wasm32-wasip1-threads artifacts panic in threadless hosts (workerd) when rayon lazily initializes its global pool — EAGAIN vs Unsupported error-kind mismatch
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) androlldown-binding.wasm32-wasip1.wasm(threadless), both built from rolldownfeat/tokio-free-runtimetip with the shared scheduler (CurrentThreadon 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.Memoryfor theenv.memoryimport, a countingwasi.thread-spawnstub, and the existing@napi-rs/wasm-runtimeWASI 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()reportsflavor: "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-spawnis 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):
// "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:
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 undernode:wasi: printscurrent_num_threads = 1,par_iter sum = 4950— survives via the fallback. - compiled to
wasm32-wasip1-threads, instantiated with shared memory + awasi.thread-spawnstub returning-1: panics atregistry.rs:171with the exact message above. Same binary runs fine under@emnapi/wasi-threadsin 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
- Documentation: state explicitly that workerd (and any threadless host) requires the threadless
wasm32-wasip1artifact; the threads artifact'swasi.thread-spawnimport makes rayon global-pool initialization a panic hazard on such hosts even when the scheduler itself is CurrentThread. - Ecosystem guidance for
napi-async-runtimeconsumers: any dependency that lazily touches rayon's global pool (a barerayon::current_num_threads(),par_iteroutside a pool,ThreadPoolBuilder::build_global) is a workerd time-bomb on the threads target. On wasm, parallel facades should be serial (as rolldown'srolldown_utils::rayonshim does); the three raw call sites found in rolldown aregenerate_stage/minify_chunks.rs:38(rayon::current_num_threads()),stages/scan_stage.rs:69(par_iter()), androlldown_plugin_vite_reporter/src/lib.rs:144(par_iter()). - 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 latercurrent_num_threads()/par_itercalls degrade to inline execution instead of panicking. This must be host-conditional — on Node WASI-threads real rayon parallelism via@emnapi/wasi-threadsis a feature, and a permanent 1-thread global pool would silently remove it. - Alternatively/also, rayon could treat
WouldBlock/EAGAINfrom 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)
Source: napi-rs/napi-rs