#3494·napi-rs

wasm32-wasip1-threads artifacts panic in threadless hosts (workerd) when rayon lazily initializes its global pool — EAGAIN vs Unsupported error-kind mismatch

Author: BrooooooklynCreated Sep 9, 2026Updated Sep 9, 2026

Summary

I tested whether a wasm32-wasip1-threads napi artifact can serve all hosts (Node.js, browser, Cloudflare workerd) by pairing it with the CurrentThread napi-async-runtime flavor plus JS polyfills for the imports workerd lacks. Short answer: instantiation works, the scheduler works, but any lazy initialization of rayon's global thread pool panics irrecoverably, and the failure cannot be prevented from JS. The threadless wasm32-wasip1 artifact survives the same code path because of an error-kind subtlety documented below.

Context: this came out of rolldown's tokio-free runtime work (rolldown/rolldown#10268, rolldown/rolldown#10350), which consumes napi-async-runtime 0.2.x. The question under test was "build wasm32-wasip1-threads once, switch the async runtime flavor per entry, polyfill the rest for workerd."

Experiment setup

  • Artifacts: rolldown-binding.wasm32-wasi.wasm (threads target) and rolldown-binding.wasm32-wasip1.wasm (threadless), both built from rolldown feat/tokio-free-runtime tip with the shared scheduler (CurrentThread on both wasm targets).
  • Host: real workerd via Miniflare 4.20260730.0, using rolldown's own workerd behavior suite (multi-module build, error surface, lifecycle, concurrency, capabilities, fire-and-forget loads, failed-build reuse, memory slope).
  • Polyfills supplied to the threads artifact: shared WebAssembly.Memory for the env.memory import, a counting wasi.thread-spawn stub, and the existing @napi-rs/wasm-runtime WASI layer.

Import diff between the two artifacts (full diff, nothing else differs):

threads-only:   env.memory = SHARED (min 1025 / max 65536 pages),
                wasi.thread-spawn, napi.napi_remove_env_cleanup_hook,
                emnapi.emnapi_is_node_binding_available, ~18 env._emnapi_* fns
threadless-only: env.napi_create_async_work / napi_queue_async_work /
                 napi_create_threadsafe_function / ... (7 napi-level async fns)

What works in workerd (corrections to common assumptions)

  • Shared memory allocation is fine: new WebAssembly.Memory({ shared: true }) succeeds in workerd and the threads artifact instantiates and initializes. workerd's single-isolate model forbids spawning threads, not allocating shareable memory.
  • The CurrentThread host protocol (task host + timer host, contract v4) registers and runs on the threads artifact; getRuntimeCapabilities() reports flavor: "CurrentThread", threads: false, target: "wasi-threads".
  • With rayon never triggered (see below), the entire behavior suite passes: concurrent builds on one instance with correct distinct outputs, single-slot admission, error surfaces, fire-and-forget this.load(), failed-build recovery, and a flat memory slope across rebuilds with clean disposal. wasi.thread-spawn is never even called in that configuration.

Issue 1 (hard blocker): rayon global-pool init panic — not polyfillable

Every default rolldown build on the threads artifact panics in workerd. Symbolicated stack:

BindingBundler::generate → GenerateStage::render_chunk_to_assets
  → GenerateStage::minify_chunks            (crates/rolldown .../generate_stage/minify_chunks.rs:38)
  → rayon_core::current_num_threads()       (lazy-initializes the GLOBAL pool)
  → registry::default_global_registry → Registry::new
  → DefaultSpawn::spawn → std::thread::Builder::spawn → __pthread_create
  → __wasi_thread_spawn  →  host stub fails  →  ThreadPoolBuildError: WouldBlock
  → panic: "The global thread pool has not been initialized."

(minify_chunks runs on every default build because rolldown's minify option defaults to 'dce-only', and it calls real rayon::current_num_threads() to size an AllocatorPool — bypassing rolldown's serial-on-wasm rayon facade.)

Root cause — rayon-core 1.13 has an explicit wasm fallback in default_global_registry() (registry.rs:208-226):

rust
// "If we're running in an environment that doesn't support threads at all,
//  we can fall back to using the current thread alone. ... Notably, this
//  allows current WebAssembly targets to work even though their threading
//  support is stubbed out"
let unsupported = matches!(&result, Err(e) if e.is_unsupported());
if unsupported && WorkerThread::current().is_null() {
    let builder = ThreadPoolBuilder::new().num_threads(1).use_current_thread();
    ...
}

The fallback keys on the error kind of the failed spawn, and the two wasm targets produce different kinds:

target spawn path failure error kind fallback?
wasm32-wasip1 wasi-libc internal pthread_create stub (no host import) Unsupported (code 58) yes — pool becomes the current thread, current_num_threads() = 1, par_iter runs inline
wasm32-wasip1-threads real __wasi_thread_spawn host import WouldBlock (EAGAIN, code 6) no — panic

The threads-target failure kind is not shapable from JS: wasi-libc's pthread_create maps any __wasi_thread_spawn failure to a fixed EAGAIN return regardless of what the host stub returns.

Isolated repro (no rolldown involved), rustc 1.97.1, rayon 1.12:

rust
fn main() {
    println!("available_parallelism = {:?}", std::thread::available_parallelism());
    println!("current_num_threads = {}", rayon::current_num_threads());
    let sum: u32 = (0..100u32).into_par_iter().sum();
    println!("par_iter sum = {sum}");
}
  • compiled to wasm32-wasip1, run under node:wasi: prints current_num_threads = 1, par_iter sum = 4950 — survives via the fallback.
  • compiled to wasm32-wasip1-threads, instantiated with shared memory + a wasi.thread-spawn stub returning -1: panics at registry.rs:171 with the exact message above. Same binary runs fine under @emnapi/wasi-threads in Node where thread-spawn genuinely works.

Issue 2 (secondary, likely glue-level): output objects die with auto-disposed instances on the threads artifact

With Issue 1 bypassed (minify: false), one suite case still differs from the threadless control: build({ module }) auto-disposes its private instance at completion; on the threadless artifact the returned RolldownOutput getters keep working afterwards, but on the threads artifact they throw Error: This workerd Rolldown instance has been disposed. Presumably the _emnapi_* finalizer mode (threads artifact) tears down output boxes at dispose time whereas the napi-async-work mode (threadless) drains them lazily. Everything caller-owned (explicit createInstance + dispose) behaves identically on both.

Discussion points / possible mitigations

  1. Documentation: state explicitly that workerd (and any threadless host) requires the threadless wasm32-wasip1 artifact; the threads artifact's wasi.thread-spawn import makes rayon global-pool initialization a panic hazard on such hosts even when the scheduler itself is CurrentThread.
  2. Ecosystem guidance for napi-async-runtime consumers: any dependency that lazily touches rayon's global pool (a bare rayon::current_num_threads(), par_iter outside a pool, ThreadPoolBuilder::build_global) is a workerd time-bomb on the threads target. On wasm, parallel facades should be serial (as rolldown's rolldown_utils::rayon shim does); the three raw call sites found in rolldown are generate_stage/minify_chunks.rs:38 (rayon::current_num_threads()), stages/scan_stage.rs:69 (par_iter()), and rolldown_plugin_vite_reporter/src/lib.rs:144 (par_iter()).
  3. Optional runtime mitigation (if a single threads artifact for all hosts is ever desired): pre-initialize rayon's global pool with ThreadPoolBuilder::new().num_threads(1).use_current_thread().build_global() during module init on hosts that cannot spawn threads, so later current_num_threads()/par_iter calls degrade to inline execution instead of panicking. This must be host-conditional — on Node WASI-threads real rayon parallelism via @emnapi/wasi-threads is a feature, and a permanent 1-thread global pool would silently remove it.
  4. Alternatively/also, rayon could treat WouldBlock/EAGAIN from a wasi-threads spawn as "unsupported environment" for the purposes of its fallback — arguably the wasi-libc EAGAIN mapping is the real impedance mismatch here.

Related: napi-rs/napi-rs#3489 (CurrentThread waker-stack deadlocks + threadless-wasm Buffer detachment, open), napi-rs/napi-rs#3420 (the 0.2.0 scheduler line this experiment ran on), rolldown/rolldown#10350 (full tokio removal), rolldown/rolldown#10268 (the live integration branch).

Environment

  • workerd via miniflare 4.20260730.0 (compatibilityDate 2026-06-01, nodejs_als)
  • napi 3.12.x, napi-async-runtime 0.2.0, @napi-rs/wasm-runtime with emnapi 2.0.0-alpha.3
  • rayon 1.12 / rayon-core 1.13.0, rustc 1.97.1
  • macOS aarch64 host (miniflare runs the real workerd binary)