feat(wasi-threads): load a sized pool in the background on synchronous instantiate by Brooooooklyn · Pull Request #239 · toyobayashi/emnapi · GitHub
Skip to content

feat(wasi-threads): load a sized pool in the background on synchronous instantiate - #239

Merged
toyobayashi merged 2 commits into
toyobayashi:mainfrom
Brooooooklyn:feat/wasi-threads-background-pool-preload
Oct 5, 2026
Merged

toyobayashi merged 2 commits into
toyobayashi:mainfrom
Brooooooklyn:feat/wasi-threads-background-pool-preload

Conversation

@Brooooooklyn

@Brooooooklyn Brooooooklyn commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Problem

instantiateNapiModuleSync / loadNapiModuleSync throw Synchronous loading is not supported with worker pool (reuseWorker.size > 0). They throw after they posted load to every pool worker, so the workers leak. A CommonJS consumer that must load synchronously (the @napi-rs/cli threaded WASI loader, used by rolldown) therefore cannot size the pool. Its pool starts empty, and the first async call boots one Worker per pool thread and loads the wasm into each one. On rolldown that is +21 ms on the first build.

The pool has more holes that a background load runs into:

  • A worker whose load failed was terminated but stayed in unusedWorkers, so getNewWorker could hand out a dead worker.
  • A pool worker that was never sent load (for example after terminateAllThreads re-created the pool) was popped without a whenLoaded, so threadSpawn threw Cannot read properties of undefined (reading 'catch') after it had reported success.
  • A start queued in the worker before a failed load was never answered, so a waitThreadStart spawner waited forever.
  • thread-spawn without waitThreadStart reports success before the worker has loaded. When that load failed later, the whenLoaded handler cleaned up the thread and then rethrew into a promise that nobody holds, so Node.js saw an unhandled rejection and exited with code 1.

Change

Commit 1, feat(wasi-threads): load a sized pool in the background on synchronous instantiate:

  • core: on the synchronous path, start loading every unused pool worker without waiting, then initialize right away. The async path still waits for loadWasmModuleToAllWorkers. The loads are not ref()'d, so a Node.js process can still exit while they are in flight.
  • wasi-threads: terminateWorker removes the worker from unusedWorkers. One failed load no longer affects the other pool workers.
  • wasi-threads: the terminated-worker reporter ignores a late loaded.
  • wasi-threads: getNewWorker starts the load of a popped pool worker that has no whenLoaded.
  • wasi-threads: when the load fails, ThreadMessageHandler answers every queued start with the load failure code (2) and drops it.
  • No new ThreadManager method. The Emscripten PThread surface is kept.
  • README: the synchronous APIs accept reuseWorker.size > 0; the pool is loaded in the background and is not guaranteed to be ready.
  • Versions: @emnapi/wasi-threads 2.2.0 (core pins it), and core, runtime, emnapi and ts-transform-emscripten-parse-tools 2.0.0-alpha.6 together, as the bump script does.

Commit 2, fix(wasi-threads): report a failed load of a running pool worker and keep the pool size (from the review round below):

  • wasi-threads: a load that fails after thread-spawn returned still deletes whenLoaded and cleans up the thread, then prints the error through printErr instead of rethrowing.
  • wasi-threads: the three load-failure sites call a new private dropFailedLoad. When the worker was idle in the pool (a background preload), a fresh, unloaded worker takes its slot. The pool keeps its size, the next spawn loads it, and a load error then surfaces through the normal spawn path. A strict pool (reuseWorker: { size, strict: true }) therefore no longer shrinks for good after a failed preload. No replacement after a fatal error. getNewWorker takes a newly created worker out of the pool before it starts its load, so a load that fails right away is not taken for a failed preload.

Tests

  • packages/wasi-threads/test/index.js: eight FakeWorker tests. A terminated idle worker leaves the pool. One failed background load replaces only that worker, without an unhandled rejection. A never-loaded pool worker posts ['load', 'start'] on spawn. A late loaded from a terminated worker is not reported while other commands still are. A queued start gets code 2 when the load fails. A strict pool keeps its size when both preloads fail, and the next spawn returns 0. A load that fails after a spawn is printed, with no unhandled rejection and no pthread left. Each fails on the previous code.
  • New packages/core/test/index.js: runs instantiateNapiModuleSync with reuseWorker: { size: 3 } on a 195-byte wasm that Node-API init accepts, and checks that it returns and that each worker got one load; plus the spawn-then-fail scenario with { size: 1 }. Both fail on the previous code. CI runs the file in the package-tests job.

Verification

npm run build -w packages/wasi-threads
npm run build -w packages/core
WASI_SDK_PATH=/opt/wasi-sdk node ./packages/wasi-threads/test/build.js
npm run test -w packages/wasi-threads
npm run test -w packages/core
npx tsc --noEmit -p packages/wasi-threads/tsconfig.json
npx tsc --noEmit -p packages/core/tsconfig.json
npx eslint packages/wasi-threads/src packages/core/src/load.ts

Downstream: rolldown (rolldown/rolldown#10268, commit c60d7ed61) validated its threaded WASI matrix against the packed @emnapi/core 2.0.0-alpha.6, @emnapi/runtime 2.0.0-alpha.6 and @emnapi/wasi-threads 2.2.0 from this branch (cc2f9b2), together with napi-rs/napi-rs#3558: debug and release threaded suites, lifecycle, stress, crash latch, the pool-preload cases (including a forced preload load failure, where the replacement Workers serve the builds), the single-thread suite, the browser and WebContainer consumers, and 1,500 fresh-process runs of the load-failure case at 36 concurrent children with no hang. Its CI is green on that commit.

Review round

An adversarial review of commit 1 reported three findings. Each was reproduced before it was acted on.

Finding Verified Outcome
A load failure of a claimed, still-loading pool worker ends in an unhandled rejection (exit 1) Yes. The rethrow at the spawn site is older than this branch, but a sized pool now hands out still-loading workers Fixed in commit 2
Core pins @emnapi/wasi-threads 2.2.0 exactly while the main release target publishes runtime, emnapi and core only Yes. Same pattern as the 2.1.0 bump Release note below
A failed background preload shrinks a strict pool for good Yes. Reproduced with { size: 2, strict: true }: every later spawn returned EAGAIN Fixed in commit 2

Release note

@emnapi/core 2.0.0-alpha.6 pins @emnapi/wasi-threads 2.2.0 exactly. The main release target publishes runtime, emnapi and core only. Please publish the wasi-threads target first, then main. Otherwise the new core cannot be installed until wasi-threads 2.2.0 is on the registry.

🤖 Generated with Claude Code

https://claude.ai/code/session_01T34fMrYCf2V13Mg8BKmnda

Brooooooklyn and others added 2 commits October 1, 2026 17:17
…s instantiate

instantiateNapiModuleSync / loadNapiModuleSync threw "Synchronous loading
is not supported with worker pool (reuseWorker.size > 0)", after they had
already posted 'load' to every pool worker, so the workers leaked. The
pool itself had three more holes that a background load runs into: a
worker whose load failed was terminated but stayed in unusedWorkers, so
getNewWorker could hand out a dead worker; a pool worker that was never
sent 'load' (for example after terminateAllThreads re-created the pool)
was popped without a whenLoaded, so threadSpawn threw "Cannot read
properties of undefined (reading 'catch')" after it had already reported
success; and a 'start' queued in the worker before a failed load was
never answered, so a waitThreadStart spawner waited forever.

- core: on the synchronous path, start loading every unused pool worker
  without waiting and initialize right away. The async path still waits
  for loadWasmModuleToAllWorkers. The loads are not ref()'d, so a Node.js
  process can exit while they are in flight. This applies on every
  environment: nothing blocks, and a worker queues a 'start' that arrives
  before its instance exists.
- wasi-threads: terminateWorker removes the worker from unusedWorkers.
  Every failed load and any idle worker terminated on purpose leave the
  pool; one failed load no longer affects the other pool workers.
- wasi-threads: the terminated-worker reporter ignores a late 'loaded'.
- wasi-threads: getNewWorker starts the load of a popped pool worker that
  has no whenLoaded.
- wasi-threads: when the load fails, ThreadMessageHandler answers every
  queued 'start' with the load failure code (2) and drops it.
- No new ThreadManager method; the Emscripten PThread surface is kept.
- README: the synchronous APIs accept reuseWorker.size > 0; the pool is
  loaded in the background and is not guaranteed to be ready.
- Versions: @emnapi/wasi-threads 2.2.0 (core pins it), and core, runtime,
  emnapi and ts-transform-emscripten-parse-tools 2.0.0-alpha.6 together,
  as the bump script does.

Tests: packages/wasi-threads/test/index.js gets five FakeWorker tests (a
terminated idle worker leaves the pool; one failed background load drops
only that worker without re-creating the pool or an unhandled rejection;
a never-loaded pool worker posts ['load', 'start'] on spawn; a late
'loaded' from a terminated worker is not reported while other commands
still are; a queued 'start' gets code 2 when the load fails). All five
fail on the previous code. New packages/core/test/index.js runs
instantiateNapiModuleSync with reuseWorker { size: 3 } on a 195-byte
wasm that Node-API init accepts, and checks that it returns and that
each worker got one 'load'. It fails on the previous code with the
"Synchronous loading" error. CI runs it in the package-tests job.

## Verification

    npm run build
    WASI_SDK_PATH=/opt/wasi-sdk node ./packages/wasi-threads/test/build.js
    npm run test -w packages/wasi-threads
    npm run test -w packages/core
    npx tsc --noEmit -p packages/wasi-threads/tsconfig.json
    npx tsc --noEmit -p packages/core/tsconfig.json
    npx eslint packages/wasi-threads/src packages/core/src/load.ts

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T34fMrYCf2V13Mg8BKmnda
…keep the pool size

Two problems with a pool that loads in the background:

- thread-spawn without waitThreadStart reports success before the worker
  has loaded. When that load failed later, the whenLoaded handler cleaned
  up the thread and then rethrew into a promise that nobody holds, so
  Node.js saw an unhandled rejection and exited with code 1. A sized pool
  now hands out workers that are still loading, so this happens in the
  supported setup.
- A background preload that failed was terminated and removed from the
  pool, and nothing took its place. A strict pool (reuseWorker
  { size, strict: true }, enforced outside Node.js) stayed smaller for
  good: once it ran out, every spawn printed "the thread pool is
  exhausted" and returned EAGAIN.

Changes:

- wasi-threads.ts: a load that fails after thread-spawn returned still
  deletes whenLoaded and cleans up the thread, then prints the error
  (message and stack) through printErr instead of rethrowing.
- thread-manager.ts: the three load-failure sites call a new private
  dropFailedLoad. When the worker was idle in the pool (a background
  preload), a fresh, unloaded worker takes its slot, so the pool keeps
  its size and the next spawn loads it; a load error then surfaces
  through the normal spawn path. No replacement after a fatal error. The
  Node.js once('message') / unref() setup for pool workers moved into a
  private allocatePoolWorker that preparePool and the replacement share.
  getNewWorker now takes a newly created worker out of the pool before it
  starts its load, so a load that fails right away (beforeLoad or
  postMessage throws) is not taken for a failed preload; the caller gets
  no worker, as before.
- load.ts comment and the README describe the replacement.

Tests:

- wasi-threads: testBackgroundPreloadDropsOnlyFailedWorker now expects
  the replacement (created.length 3, unusedWorkers [w1, replacement],
  the replacement has no whenLoaded and no posted messages).
- wasi-threads: new testStrictPoolKeepsSizeWhenPreloadsFail loads a
  second copy of the package with Node.js detection off,
  { size: 2, strict: true }, fails both preloads, expects two pool
  workers, then a spawn that returns 0 and posts ['load', 'start'] to a
  replacement, and no "exhausted" message.
- wasi-threads: new testLoadFailureAfterSpawnIsReported spawns on a
  still-loading pool worker, fails the load, and expects no unhandled
  rejection, the worker terminated, no pthreads, and one printErr line.
- core: the same spawn-then-fail scenario through
  instantiateNapiModuleSync with reuseWorker { size: 1 }.

All four fail on the previous build (2 vs 3 workers created; pool size 0
vs 2; one unhandled rejection in each spawn-then-fail test) and pass now.

## Verification

    npm run build -w packages/wasi-threads
    npm run build -w packages/core
    WASI_SDK_PATH=/opt/wasi-sdk node ./packages/wasi-threads/test/build.js
    npm run test -w packages/wasi-threads
    npm run test -w packages/core
    npx tsc --noEmit -p packages/wasi-threads/tsconfig.json
    npx tsc --noEmit -p packages/core/tsconfig.json
    npx eslint packages/wasi-threads/src packages/core/src/load.ts

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T34fMrYCf2V13Mg8BKmnda
Brooooooklyn added a commit to rolldown/rolldown that referenced this pull request Oct 1, 2026
Problem

Rolldown patched the generated threaded Node loader
(`rolldown-binding.wasi.cjs`) after every build to preload emnapi's Worker
pool, because neither upstream piece could do it. Upstream now owns it:

- napi-rs/napi-rs#3558: napi-async-runtime exports
  `napi_wasm_runtime_pool_workers` (MultiThread `workerThreads`, else 0, and
  0 once a backend has started, since its pool threads already exist); the
  cli loader runs `__reconcileWasiThreadPool` after the load and wraps
  `configureAsyncRuntime`, so a later configure matches the idle pool.
- toyobayashi/emnapi#239: a sized pool loads in the background on the
  synchronous instantiate; a terminated Worker leaves the pool; a load that
  fails after a spawn took the Worker is printed, not rethrown; an idle
  preload whose load failed is replaced by a fresh, unloaded Worker.

Change

- Deleted: the loader insert in build-binding.ts
  (`insertWasiPoolWorkerPreloadIntoLoader`), its anchors and code in
  binding-loader-codegen.ts (`insertWasiPoolWorkerPreload`,
  `assertWasiPoolWorkerPreload`, `__preloadWasiPoolWorkers`,
  `__matchWasiPoolWorkersToConfigure`), and their unit tests.
- Added: the assertion-only seam check `assertWasiThreadPoolPreload` (the
  cli's reconcile, export read, configure wrap and call placement, each
  exactly once); check-wasi-dist-files.mjs requires the
  `napi_wasm_runtime_pool_workers` function export on the threaded wasm only;
  pool-worker-preload.mjs gets a header describing the upstream behavior and
  an explicit FAIL when MultiThread has 0 Workers after the import.
- pool-worker-preload.mjs load-failure case: with emnapi's replacement the N
  failed preloads are replaced before the builds, so the case now expects
  N replacement Workers before the builds and 0 created by the builds
  (was: N created by the builds). Latch and disposer checks are unchanged.
- Regenerated rolldown-binding.wasi.cjs with the cli tarball below.
- Docs: async-runtime design.md Principle 1 and implementation.md section 13
  describe the upstream preload, the heads, and the expected release floors.

Temporary pins (all replaced by the releases)

- Cargo.toml [patch.crates-io]: napi, napi-sys, napi-build, napi-derive
  (=3.6.10), napi-derive-backend, napi-async-runtime at napi-rs 4d6f0a6f
  (one napi-sys in the graph, oxc_*_napi included).
- @napi-rs/cli: .napi-validation/napi-rs-cli.tgz, packed as 3.10.6 from the
  same head (cli/src unchanged since 37271331), sha256 16751886...0dc65.
- emnapi cc2f9b2 tarballs: emnapi-core.tgz 2.0.0-alpha.6 (d3a519b6...6511),
  emnapi-wasi-threads.tgz 2.2.0 (f4c934b4...e110), emnapi-runtime.tgz
  2.0.0-alpha.6 (2cbe43bb...0b55; core alpha.6 hands the env address to the
  runtime as a function, emnapi#237).
- emnapi.tgz (85c7d600...fd14): the registry 2.0.0-alpha.5 C archives with
  only `version` set to alpha.6, because `napi build` refuses an emnapi
  version that differs from @emnapi/core; the archives are byte-identical to
  alpha.5.
- The pnpm catalog stays at the registry alpha.5: @rolldown/browser declares
  it and check-workerd-packed-consumer.mjs installs from the registry.
- Expected release floors: @napi-rs/cli >= 3.11.0, napi-async-runtime
  >= 0.2.5, @emnapi/core >= 2.0.0-alpha.6, @emnapi/wasi-threads >= 2.2.0.

Measurement

Release threaded wasm (TARGET_CC=clang), same wasm in all three, only the
loader differs; 10 interleaved fresh processes, medians (ms), M5 Max under
load (load average 10-12, so absolute times are about 2x a quiet machine):

  loader                 load    first build   load+first build
  no preload             109.4   143.8         252.4
  rolldown patch (HEAD)  110.4    84.4         196.2
  upstream (this)        110.5    86.2         196.8

Paired upstream - patch: load -0.4, first build +1.5, load+first build
+0.3 ms: equal within noise. Paired upstream - no preload: first build
-57.2 ms (10/10 faster). Round-1 on a quiet machine (20 rounds): first build
73.1 -> 43.0 ms, patch vs upstream +0.4 ms.

Validation

  lane                                                     result
  codegen unit test                                        5/5
  debug test:wasi-threaded MultiThread / CurrentThread     5 + 2 skipped, each
  dist files threaded (export present) / single (absent)   ok / ok
  lifecycle MultiThread / CurrentThread                    9/9 each
  stability MultiThread / CurrentThread                    ok / ok
  stress default memory                                    7 PASS
  stress bounds-checks/no-trap/forced-growth/handoff       20/10/20/20 PASS
  stress heap-ceiling / no-growth                          1 / 2 PASS
  worker crash latch                                       12 PASS
  test:wasi-pool-preload debug default / single            16/16, 16/16
  ROLLDOWN_WASI_TEST=1 test:main (threaded)                1478 + 56 skipped
  single-thread test:wasi                                  1446 + 88 skipped
  threadless check, wasi binding deps, workerd canary      ok
  browser build, runtime contract, workerd packed          ok
  check-wasi-binding-packed-consumer (Node 24 + 20.20.2)   ok
  release: test:wasi-threaded                              5 + 2 skipped
  release: stress (bounds/no-trap/forced/handoff/ceil/no-growth)
                                                           20/10/20/20/1/2
  release: crash latch / pool preload                      12 / 16/16
  native test:main                                         1511 + 23 skipped
  just lint-node / lint-rust / lint-repo                   ok

Also checked by hand on the release dist: after the first build a configure
throws (frozen) and the loader's reconcile creates no second pool (2 Workers
before and after).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T34fMrYCf2V13Mg8BKmnda
@Brooooooklyn
Brooooooklyn marked this pull request as ready for review October 2, 2026 03:40
@Brooooooklyn

Copy link
Copy Markdown
Contributor Author

@toyobayashi
toyobayashi merged commit 64c78ae into toyobayashi:main Oct 5, 2026
23 checks passed
Brooooooklyn added a commit to napi-rs/napi-rs that referenced this pull request Oct 5, 2026
…hreads 2.2.0

emnapi 2.0.0-alpha.6 and @emnapi/wasi-threads 2.2.0 were published on
2026-10-05. Compared with 2.0.0-alpha.5 / 2.1.0 they contain:

- const property arrays in js_native_api.h (nodejs/node#65621)
- NAN V8 compatibility coverage (toyobayashi/emnapi#236)
- fix: delay env allocation (toyobayashi/emnapi#237; C changes, and
  @emnapi/core now hands the env address to @emnapi/runtime as a function)
- the wasi-threads background pool preload (toyobayashi/emnapi#239)

The threaded loader's pool preload was written against #239, and
`napi build` requires emnapi, @emnapi/core and @emnapi/runtime to be the
same version, so the root resolutions and the exact pins in
examples/wasi-heap-sync and examples/custom-async-runtime move together.
The lockfile change is only these four packages (and the two example
workspace entries). The caret ranges in cli and wasm-runtime already
admit the new versions.

Checked locally (macOS arm64, Node 24.21):
- cli templates.spec: 150 passed; full cli ava suite: 414 passed, the 11
  new.spec failures were template git fetches and new.spec alone passes
  20/20
- shared-async-runtime test:wasi 11/11, test:wasi-workerd 11/11,
  test:wasi-threads-pool 8/8
- wasi-heap-sync stress: 5/5, and 20/20 with bounds checks
- shared-async-runtime test:wasi-threads-crash: 2 passed, 1 failed.
  "dispose() after a crash releases an armed CurrentThread sleep" times
  out: its child calls dispose() from the unhandledRejection that
  @emnapi/wasi-threads 2.1.0 raised when a worker failed to load, and
  2.2.0 logs that error instead of throwing it, so dispose() never runs.
  On 2.1.0 the whole file passes 3/3, and on 2.2.0 a dispose() fired by a
  timer still rejects with the crash and releases the sleep. The test's
  trigger needs updating.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T34fMrYCf2V13Mg8BKmnda
Brooooooklyn added a commit to rolldown/rolldown that referenced this pull request Oct 5, 2026
…hreads 2.2.0

emnapi shipped the pool-preload side today: emnapi, @emnapi/core and
@emnapi/runtime 2.0.0-alpha.6, and @emnapi/wasi-threads 2.2.0
(@emnapi/core depends on exactly that version). Since alpha.5 the release
carries four upstream changes:

- const property arrays in js_native_api.h (nodejs/node#65621)
- NAN V8 compatibility (toyobayashi/emnapi#236; rolldown does not link it)
- delay env allocation (toyobayashi/emnapi#237): C changes, and core hands
  the env address to the runtime as a function
- wasi-threads background pool preload (toyobayashi/emnapi#239)

The published JS dist of @emnapi/core and @emnapi/wasi-threads is
byte-identical to the vendored tarballs this branch tested with, and
@emnapi/runtime differs only in a source map. The C package differs: the
vendored emnapi.tgz was the alpha.5 archives relabelled as alpha.6, while
the release ships rebuilt lib/**/*.a archives (#237, the const arrays). So
both .wasm files were rebuilt; the build scripts now link
node_modules/.pnpm/emnapi@2.0.0-alpha.6_*/emnapi/lib/wasm32-wasip1{,-threads}.

Change

- pnpm-workspace.yaml: back to the shape before the tarballs. Overrides
  keep only the @napi-rs/cli tarball and an exact `emnapi: 2.0.0-alpha.6`;
  the catalog pins emnapi, @emnapi/core and @emnapi/runtime at exactly
  2.0.0-alpha.6, and @emnapi/wasi-threads 2.2.0 comes from @emnapi/core's
  exact dependency. The pins stay exact because the .wasm links the C
  archives and `napi build` refuses an emnapi that reports another version
  than @emnapi/core. The comment block drops the relabelled archives and
  their sha256 list.
- .napi-validation: deleted emnapi.tgz, emnapi-core.tgz,
  emnapi-runtime.tgz, emnapi-wasi-threads.tgz.
- pnpm-lock.yaml: only emnapi entries move. With no @emnapi/* override,
  the wasm32-only optional platform packages (sharp, lzma, tar,
  oxc-resolver, wasm-tools) get their own emnapi 1.x copies back, the same
  versions as before the tarballs.
- check-workerd-packed-consumer.mjs: expects @emnapi/core and
  @emnapi/runtime 2.0.0-alpha.6 from the registry.
- browser-tests staging comments: only the cli tarball is still staged;
  the code is unchanged and still works with one override.
- async-runtime implementation.md: the emnapi side is released; the
  release floors stay.

The committed loaders under packages/rolldown/src are byte-identical after
the native -> threaded wasi -> single wasi -> native rebuild; the bundled
threaded loaders now take @emnapi/core and runtime alpha.6 and
@emnapi/wasi-threads 2.2.0 from the registry copies.

Still temporary (until the napi-rs release): the @napi-rs/cli tarball
(packed as 3.10.6 from napi-rs e6e50eb4) and the Cargo [patch.crates-io]
pins at e6e50eb4.

Validation (local, M5 Max, debug wasm)

  lane                                                   result
  test:wasi-threaded MultiThread / CurrentThread         5 + 2 skipped, each
  dist files threaded / single                           ok / ok
  stability MultiThread / CurrentThread                  ok / ok
  wasi-runtime-lifecycle MultiThread / CurrentThread     ok / ok
  stress default                                         7 PASS
  stress bounds/no-trap/forced/handoff                   20/10/20/20 PASS
  stress heap-ceiling / no-growth                        1 / 2 PASS
  worker crash latch                                     12 PASS
  test:wasi-pool-preload, 20 runs                        20/20 (320 PASS, 0 FAIL)
    load-failure: 2 after import, 4 before builds,
    0 by builds, in all 40 cases
  threadless, stage, binding deps, workerd memory        ok
  single-thread test:wasi                                1581 + 90 skipped
  loader codegen unit test                               7/7
  check-wasi-binding-packed-consumer (Node 24 + 20.20.2) ok
  runtime contract, workerd packed consumer              ok
  browser-tests webcontainer-fallback (staged fixture)   1/1
  just lint-node; typos, ls-lint, vp fmt --check         ok

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T34fMrYCf2V13Mg8BKmnda
Brooooooklyn added a commit to napi-rs/napi-rs that referenced this pull request Oct 5, 2026
…the loader (#3558)

* feat(async-runtime): export the configured pool worker count on threaded WASI

Problem: the generated threaded WASI loader starts emnapi's reuse pool
empty, so every pool thread a MultiThread runtime spawns on its first async
call boots a Worker and loads the wasm first. The loader cannot size the
pool itself: the worker count lives in the Rust runtime options, and
reading them through `configured_options()` takes the state lock, which a
crashed pool thread may still hold.

Change: `RuntimeController` keeps a lock-free `AtomicU32` mirror of the
pool size, computed by the pure helper `pool_workers_for` (MultiThread ->
worker_threads, CurrentThread -> 0). It is stored at the three sites that
write `state.options` (`new`, `configure`, `configure_partial_inner`), so a
configure that fails validation or is refused as frozen leaves it
unchanged. On wasm32-wasip1-threads only (`napi_runtime_wasi_threads`),
`napi_wasm_runtime_pool_workers() -> u32` exports it. It lives in the
scheduler layer, so `default-features = false` consumers export it too.
README: the "adds no wasm-specific API" line now describes the export.

Tests: three unit tests next to
`partial_configuration_after_backend_started_is_rejected`: helper values
(MultiThread 3 -> 3, MultiThread 1 -> clamped 2, CurrentThread -> 0);
configure / configure_partial update the mirror and a failed validation
does not; a frozen configure leaves it at 0.

Verification: `cargo test -p napi-async-runtime` (375 + 1 passed);
`cargo check -p napi-async-runtime --tests --target wasm32-wasip1-threads`
with and without default features; clippy -D warnings on the lib for
native, wasm32-wasip1-threads (both feature sets); `cargo fmt --check`.
The export is present in the shared-async-runtime example wasm
(`wasm-objdump -x` lists `napi_wasm_runtime_pool_workers`).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T34fMrYCf2V13Mg8BKmnda

* feat(cli): preload and reconcile the threaded WASI worker pool from the loader

Problem: the threaded Node WASI loader passes `reuseWorker: true`, so
emnapi's pool starts empty (its own preload, `reuseWorker.size > 0`,
throws on the synchronous CommonJS load). The first MultiThread async call
then boots one Worker per pool thread and loads the wasm into each. Addons
such as rolldown patch the generated loader to work around this.

Change (threaded Node loader only; browser, threadless and deferred
loaders unchanged):
- `__reconcileWasiThreadPool()` reads the addon's
  `napi_wasm_runtime_pool_workers` export and matches emnapi's idle pool
  to it: missing Workers are allocated through `onCreateWorker` and start
  loading without being awaited (a rejected load splices the Worker out of
  the pool, since emnapi 2.1.0 leaves it there); idle Workers above the
  count are terminated newest first and removed from the pool and from
  `__wasiWorkers`. Their `onmessage` is reset as a compatibility line for
  emnapi <= 2.1.0, whose terminated-worker reporter otherwise prints
  'received "loaded" command from terminated worker' for a queued
  'loaded' (reproduced: 8/8 runs without the reset, 0/40 with it). A
  synchronous allocate/load throw terminates that Worker and stops. It
  never throws or waits, and does nothing after a thread crash, during or
  after disposal, or for an addon without the export.
- It runs once after a successful load (after the initialization
  try/catch, before the export tail), is published as the non-enumerable,
  read-only `Symbol.for('napi.rs.wasi.reconcileThreadPool')`, and, with
  `napi.wasm.asyncRuntime`, runs after every successful
  `configureAsyncRuntime` through a same-name wrapper (matched by name,
  like the host install; a throwing configure propagates and skips it).
- docs/wasi.md: new "Thread pool preload" section, including the
  crash-latch caveat and the shared uv async-work pool.

Tests: templates.spec.ts runs the sliced reconcile and wrapper against a
stubbed instance and thread manager (preload 3, missing export / instance
/ manager, disposed / disposing / crashed, shrink with and without an
emnapi that removes the Worker itself, rejected load, synchronous throws,
symbol descriptor, wrapper `this` / args / result / name, wiring order),
and checks that no other loader contains the export name. New e2e
examples/shared-async-runtime test-wasi-threads-pool.mjs (CI step added
after the crash test) runs the real threaded artifact: export 0 by
default, MultiThread 3 -> 3 Workers and no new one on the first call,
shrink, shrink with queued 'loaded' messages, grow, frozen configure.

Verification: `yarn workspace @napi-rs/cli test` (422 passed); example
test:wasi-threads-pool 6/6, test:wasi-threads-crash 3 pass + 2 skipped,
test:wasi 11/11, test:wasi-workerd 11/11; oxlint --deny-warnings and
oxfmt --check clean.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T34fMrYCf2V13Mg8BKmnda

* fix(async-runtime): report zero pool workers once the backend has started

Problem: `napi_wasm_runtime_pool_workers` kept reporting the configured
MultiThread `worker_threads` after the backend started. Building the
backend spawns every pool thread at once (Rayon's `build()` spawns
`num_threads` and never grows), and those threads take the preloaded idle
Workers. A later loader reconcile then sees an empty pool and preloads a
second one: reproduced 3 -> 6 Workers through
`Symbol.for('napi.rs.wasi.reconcileThreadPool')`. The post-load reconcile
hits the same case when a `module_init` / `module_exports` hook calls a
runtime-backed API, which starts the backend before the reconcile runs.
`configureAsyncRuntime` is not affected: it throws once the runtime has
started, and the wrapper skips the reconcile then.

Change: the export now means "pool threads the runtime will still spawn".
`RuntimeController` stores 0 into the mirror right after each backend
build, under the state lock, at both build sites (`backend_locked` on the
first submission, and `start` from `Stopped`). The three configure-time
writes stay as they were. After the start the timer thread, uv threads and
a restart take their Workers from emnapi's on-demand allocation, so a
reconcile after first use only releases idle Workers. The loader is
unchanged. Field comment, export doc, README and cli docs/wasi.md describe
the new meaning.

docs/wasi.md also notes that the reconcile symbol lives on the CommonJS
loader object, like `napi.rs.wasi.dispose`: the generated ESM entry
re-exports named exports only, so an ESM consumer reaches it through
`createRequire(import.meta.url)('<pkg>-wasm32-wasi')` or the local
`./<name>.wasi.cjs`.

Tests: new unit test `pool_workers_drop_to_zero_once_the_backend_started`
(MultiThread 3 -> 3; first backend -> 0; stays 0 after shutdown and a
restart from `Stopped`). e2e test-wasi-threads-pool.mjs: the frozen case
now expects `poolWorkers: 0` after the first call; new case "a reconcile
after the backend started allocates no second pool" (MultiThread 3, one
async call, manual reconcile: export 0, created 3, idle 0). On the pre-fix
artifact it fails (export 3 after the call; the child then reports
created 6, idle 3 after the reconcile).

Verification: `cargo test -p napi-async-runtime` (376 passed, 1 ignored;
consumed_api 1 passed); `cargo check -p napi-async-runtime --tests
--target wasm32-wasip1-threads` with and without default features; clippy
-D warnings on the lib for native and wasm32-wasip1-threads (both feature
sets); `cargo fmt --check`. Rebuilt threaded example:
test:wasi-threads-pool 7/7, test:wasi-threads-crash 3 pass + 2 skipped,
test:wasi 11/11; cli templates.spec.ts 147 passed; oxfmt --check and
oxlint --deny-warnings clean on the touched files.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T34fMrYCf2V13Mg8BKmnda

* test(async-runtime): run the pool-worker start test only where pool threads exist

The threadless wasm32-wasip1 test run aborts when a test builds or configures
a MultiThread runtime, which that target rejects. Gate the new start test and
the configure-mirror test with `napi_runtime_os_threads`, like the other
MultiThread tests; both still run natively and on wasm32-wasip1-threads.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T34fMrYCf2V13Mg8BKmnda

* fix(cli): keep a shrunk WASI pool Worker tracked until it has exited

Problem: when `__reconcileWasiThreadPool` shrinks the idle pool, it calls
the thread manager's `terminateWorker`, which only starts Node's
asynchronous `worker.terminate()` and drops its promise, and then removed
the Worker from `__wasiWorkers` right away. A disposal started before that
Worker exited skipped it, so `dispose()` could resolve while the Worker was
still exiting. With a configure back to CurrentThread followed by an
immediate dispose, the disposer resolved before any of the three terminated
Workers had exited in 20/20 runs (order ['disposed'], exited 0).

Change: the shrink branch keeps the Worker in `__wasiWorkers` and drops it
only when its own `worker.terminate()` settles, which happens at the
Worker's exit (a second `terminate()` on an exiting Worker settles at that
exit). A disposal that starts first finds the Worker and waits for it the
way `__terminateWasiWorkers` waits for every other one. A rejected
terminate leaves it tracked, so disposal retries and reports it. A
non-thenable result drops it at once, as `__terminateWasiWorkers` does.
The `onmessage` reset and the pool splice are unchanged.

Tests:
- templates.spec.ts: the reconcile harness slices the real
  `__terminateWasiWorkers`; the pool Worker stub gets a `terminate()` that
  can settle only at a later exit. New case: after a shrink the Worker is
  still tracked, a disposal does not resolve before its exit, and the exit
  alone removes it.
- test-wasi-threads-pool.mjs: new `shrink-dispose` case (configure
  MultiThread 3, wait for loads, configure CurrentThread, dispose with no
  turn in between): order is exit x3 then disposed, all 3 exited.

Verification:
- templates.spec.ts: 148 passed; with the template reverted the new case
  fails (1 failed).
- test-wasi-threads-pool.mjs on the artifact built before the fix: 7 pass,
  1 fail (order ['disposed']); rebuilt artifact: 8 pass, 0 fail.
- shrink-dispose child, 20 runs: before ['disposed'] 20/20, after
  ['exit','exit','exit','disposed'] 20/20.
- test:wasi-threads-crash 3 pass / 2 skipped (manual hang checks) / 0 fail;
  test:wasi 11 pass / 0 fail.
- oxfmt --check and oxlint --deny-warnings clean on the touched files.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T34fMrYCf2V13Mg8BKmnda

* fix(cli): track a WASI pool Worker whose load threw until it has exited

Problem: when `loadWasmModuleToWorker` throws synchronously during a pool
reconcile, the loader terminated the Worker through the thread manager and
removed it from `__wasiWorkers` at once, while its asynchronous termination
was still under way. A disposal started in that window did not wait for it,
the same gap e0c2193 closed for the shrink branch.

Change: factor "terminate, then drop from `__wasiWorkers` when the promise
settles" into `__untrackWasiWorkerOnExit` and use it in both the shrink
branch and the sync-failure branch.

Tests: templates.spec.ts case for the sync-throw branch (the Worker stays
tracked until its `terminate()` settles; a disposal in between waits for it).

Verification:
- templates.spec.ts: 149 passed; with the e0c2193 template the new case
  fails (1 failed).
- rebuilt threaded artifact: test-wasi-threads-pool 8/8 pass,
  test:wasi-threads-crash 3 pass / 2 skipped / 0 fail, test:wasi 11/11.
- oxfmt --check and oxlint --deny-warnings clean on the touched files.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T34fMrYCf2V13Mg8BKmnda

* fix(cli): drop emnapi's deferred calls into wasm after a thread-crash disposal

After a worker crash is latched, the public disposer takes
__disposeWasiBindingAfterThreadCrash(), which terminates the pool workers
while they may still be working. A pool thread terminated while it holds
napi's heap-sync allocator lock (spin + sched_yield, never gives up) leaves
that lock set. A callback emnapi queued earlier through the context's
features.setImmediate then runs on the main thread:
_emnapi_set_immediate -> uv__finish_close -> _emnapi_tsfn_do_finalize ->
thread_finalize_cb -> __rust_dealloc -> __wrap_free -> lock_slow, and spins
there forever. The loader already says nothing may re-enter wasm after the
crash path stops the workers, but emnapi's deferred calls still did.

Fix: the threaded Node loader creates its emnapi context with
features: { setImmediate: __wasiSetImmediate }. __wasiSetImmediate schedules
the callback with the host setImmediate and drops it once
__wasiReentryClosed is set. The crash disposal sets that flag after it
unrefs the waiting-request port and releases the host timers, right before
it terminates the workers. The single-thread loaders are unchanged (their
rendered output is byte-identical to before).

Residual paths emnapi gives no hook for, documented on __wasiSetImmediate:
the FinalizationRegistry callbacks that free external memory on GC,
threadsafe-function dispatch of async-send type 1 and _emnapi_next_tick
(both Promise.resolve().then), and every deferred call under emnapi 1.x,
whose createContext ignores its options.

Tests: the context-creation spec now expects the features object for the
threaded Node loader and the plain options elsewhere; the two crash
disposal ordering specs expect the gate before the termination. A new spec
runs the generated crash disposal with a stubbed setImmediate: a callback
that fires before the disposal still runs, ones that fire after it
(queued before or after the disposal started) are dropped, the gate is
closed when the workers are terminated, and the threadless loaders have no
gate. Without the gate the spec fails. No e2e case: the example addon has
no threadsafe function and nothing that holds the heap lock on a pool
thread at crash time, so it would need new Rust.

Verification:
- templates.spec.ts: 150 passed
- build:wasi-threads, test:wasi-threads-pool 8/8, test:wasi-threads-crash
  3 pass + 2 manual skips
- build:wasi, test:wasi 11/11
- oxfmt --check and oxlint --deny-warnings on the touched files: clean
- rolldown harness (investigation): hang 1/1500 at 36 children and 19/480
  widened before, 0/1500 and 0/480 with the fix

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T34fMrYCf2V13Mg8BKmnda

* test(async-runtime): tolerate a std that never runs worker TLS destructors on wasm32-wasip1-threads

rustc 1.99.0 changed how std handles thread_local! destructors on
wasm32-wasip1-threads. rust-lang/rust#160868 ("std: Adjust cfgs again
for TLS on WASI") rewrote the guard selection in
library/std/src/sys/thread_local/mod.rs so the "leak everything" branch
now covers all(target_family = "wasm", not(target_env = "p3")). That
includes the threaded WASI target, which on 1.98 and earlier used a
pthread key whose destructor pthread_join waits for. On 1.99.0 std
never runs TLS destructors at thread exit on this target, not even
late.

multi_thread_shutdown_waits_for_worker_tls_destructors asserts that
every worker TLS destructor ran before shutdown() returned, so on 1.99.0
it aborts the whole wasm test binary:

  assertion `left == right` failed: every worker TLS destructor must
  have completed before shutdown() returned ...
    left: 0
   right: 2

The CI runners picked up rustc 1.99.0 on 2026-10-02. napi-rs main run
36961186116 and this branch's run 37034095113 both fail the same way;
rustc 1.98.1 passes.

The runtime is unchanged and needs no change: it does not rely on worker
TLS destructors, and the join barrier still joins every worker. Only the
test's premise moved. On wasm32-wasip1-threads the test now asks a plain
std thread whether its TLS destructor ran before join() returned, and
checks the final count only when it did. The rest of the test (probe
install, zero count before shutdown, shutdown and join) still runs. The
assert turns itself back on once std runs these destructors again.
Native targets still assert unconditionally.

Local results (wasmtime 35.0.0, as CI):
- rustc 1.99.0, wasm32-wasip1-threads, full suite:
  test result: ok. 280 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 16.93s
  test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
- rustc 1.99.0, wasm32-wasip1:
  test result: ok. 56 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s
  test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
- native (rustc 1.98.0), the changed test:
  test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 376 filtered out; finished in 0.02s

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T34fMrYCf2V13Mg8BKmnda

* build(deps): pin the released emnapi 2.0.0-alpha.6 and @emnapi/wasi-threads 2.2.0

emnapi 2.0.0-alpha.6 and @emnapi/wasi-threads 2.2.0 were published on
2026-10-05. Compared with 2.0.0-alpha.5 / 2.1.0 they contain:

- const property arrays in js_native_api.h (nodejs/node#65621)
- NAN V8 compatibility coverage (toyobayashi/emnapi#236)
- fix: delay env allocation (toyobayashi/emnapi#237; C changes, and
  @emnapi/core now hands the env address to @emnapi/runtime as a function)
- the wasi-threads background pool preload (toyobayashi/emnapi#239)

The threaded loader's pool preload was written against #239, and
`napi build` requires emnapi, @emnapi/core and @emnapi/runtime to be the
same version, so the root resolutions and the exact pins in
examples/wasi-heap-sync and examples/custom-async-runtime move together.
The lockfile change is only these four packages (and the two example
workspace entries). The caret ranges in cli and wasm-runtime already
admit the new versions.

Checked locally (macOS arm64, Node 24.21):
- cli templates.spec: 150 passed; full cli ava suite: 414 passed, the 11
  new.spec failures were template git fetches and new.spec alone passes
  20/20
- shared-async-runtime test:wasi 11/11, test:wasi-workerd 11/11,
  test:wasi-threads-pool 8/8
- wasi-heap-sync stress: 5/5, and 20/20 with bounds checks
- shared-async-runtime test:wasi-threads-crash: 2 passed, 1 failed.
  "dispose() after a crash releases an armed CurrentThread sleep" times
  out: its child calls dispose() from the unhandledRejection that
  @emnapi/wasi-threads 2.1.0 raised when a worker failed to load, and
  2.2.0 logs that error instead of throwing it, so dispose() never runs.
  On 2.1.0 the whole file passes 3/3, and on 2.2.0 a dispose() fired by a
  timer still rejects with the crash and releases the sleep. The test's
  trigger needs updating.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T34fMrYCf2V13Mg8BKmnda

* test(shared-async-runtime): detect the pool worker crash through the loader, not an unhandled rejection

The "dispose-sleep" case of test-wasi-threads-crash.mjs must call dispose()
after the pool worker's failed load has latched the binding as crashed, so
that the crash disposal (not a normal disposal the crash later overtakes)
releases the armed 60 s CurrentThread sleep. The child used the process
'unhandledRejection' event as that signal: @emnapi/wasi-threads 2.1.0
rethrew the failed load at the thread spawn site, and nothing handled it.

2.2.0 (emnapi#239) prints that failure through PThread.printErr instead and
drops the failed Worker (dropFailedLoad). No rejection comes, dispose()
never ran, and the sleep kept the child alive past the 30 s timeout.

The child now disposes on the failed Worker's 'error' event, reached through
Node's public process 'worker' event. The loader's onCreateWorker listener
latches the crash on that same event, and wasi-worker.mjs raises the
shared crash flag before it posts the error, so dispose() sees the crash
when it starts. It is the only signal a host gets on both versions: the
loader exposes no crash flag, binding calls keep resolving after the crash
(plus100 and sleepThenAdd resolved in a probe), and only dispose() reports
it. The unhandledRejection listener stays, only to log: on 2.1.0 the
"exit" and "dispose" modes still get the rejection and must not die on it.
The test now also asserts that the trigger fired.

Results (test:wasi-threads-crash; the dispose-sleep child exits in ~530 ms):
- @emnapi/wasi-threads 2.2.0 / core alpha.6: 3/3 passed in 5 of 5 runs, and
  again after restoring and relinking
- @emnapi/wasi-threads 2.1.0 / core alpha.5, artifact relinked: 3/3 passed
  in 3 of 3 runs
- test:wasi-threads-pool: 8/8

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T34fMrYCf2V13Mg8BKmnda

* test(custom-async-runtime): pass the real napi_env when re-registering over emnapi 2.0.0-alpha.6

emnapi 2.0.0-alpha.6 (toyobayashi/emnapi#237, "delay env allocation")
turned `EnvNativeBridge.address` into a lazy allocator function and moved
the pointer to `Env.address`. The two abandoned-cleanup cases still
passed `env.bridge.address` to `napi_register_wasm_v1`, so the wasm i32
parameter received ToNumber(function) = NaN = 0: a null env.

napi-rs still refused the registration (the owed phase 2 stayed standing,
so `..._finish` joined the backend afterwards), but its
`napi_throw_error(env, ...)` hit emnapi's `if (!env) return
napi_invalid_arg`, so no exception reached JavaScript and the threaded
case read `ABANDONED_PENDING_ERROR NONE`.

Read the pointer from `Env.address`, falling back to `bridge.address` for
emnapi releases that predate the change.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T34fMrYCf2V13Mg8BKmnda

* test(wasm-runtime): create the fixture's dist directory before writing into it

createRuntimeFixture put mkdir(dist) in the same Promise.all as three
writes into dist/ (cp emnapi-plugins.cjs, cp emnapi-plugins.js,
writeFile fs-proxy.cjs). Nothing ordered the mkdir first, so under slow
I/O a write could open dist/<file> before dist existed and fail with
ENOENT. CI hit this twice on the v2 case: ASAN run 37041725283 and the
aarch64-pc-windows-msvc node@24 job 111639107194 of run 37271378422
(both reruns passed).

Await the mkdir before the Promise.all of the writes; the rest of the
fixture is unchanged. writeModulePackage already awaits its mkdir.

Local check (macOS): yarn test x10 = 10 pass / 0 fail;
node --test runtime.test.js x20 = 20 pass / 0 fail. A scratch script
running the same 8 ops in the old order 200 times hit ENOENT 52, 37
and 40 times in three runs; the fixed order hit it 0 times.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T34fMrYCf2V13Mg8BKmnda

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants