Skip to content

fix(js): retire dedicated Node workers and bound each worker's dead guest memory - #583

Merged
syrusakbary merged 3 commits into
mainfrom
fix/node-retire-after-task
Nov 7, 2026
Merged

syrusakbary merged 3 commits into
mainfrom
fix/node-retire-after-task

Conversation

@syrusakbary

@syrusakbary syrusakbary commented Nov 7, 2026 •

Copy link
Copy Markdown
Member

Part of the SDK fix for #547 (memory kept after Edge.js sandboxes close).

This PR now lands two changes:

  1. Retire dedicated Node workers after their task (workerPerThread): the original scope, below.
  2. Bound the dead guest memory each pool worker keeps (guestMemoryGcBudget): fix(js): bound the dead guest memory each pool worker keeps #584 was reviewed against this branch and merged into it. Its full description, tests and measurements are in fix(js): bound the dead guest memory each pool worker keeps #584; a summary is below.

Since #582 (the MarkIdle race fix) is now on main, main has been merged back into this branch to resolve the scheduler conflicts (see "Merge with main").

1. Retire dedicated Node workers after their task

Problem

globalThis.__wasmerWorkerPerThread gives every WASIX thread a dedicated worker that should stop when the thread exits. The browser worker does that. On main, the Node worker ignores retireAfterTask, so on Node:

  • every guest leaks a worker;
  • client close() waits forever for the dedicated workers, which never report retired.

On main, with the global set, 5 hello-world sandboxes leave 6 workers alive and close() never returns.

Change

  • node-worker.ts now honours retireAfterTask from init. Once its task settles, it drops its shared objects, frees its SDK state, posts retired and exits. Exiting disposes the isolate together with the guest's memory.
  • The adapter forwards retired to the pool only from the thread's exit event. Since fix(js): stop pool workers safely on kill, timeout, and close #578 the pool frees a retired worker's TLS and stack, which is safe only once the thread has stopped.
  • Pool shutdown treats a worker that retires on its own like one that exits, instead of waiting out the 2 s deadline.
  • New workerPerThread client option, so this mode no longer needs the global. The Swift WebKit SDK keeps setting the global.
  • ThreadPool::new takes PoolOptions.

Measurements

macOS arm64, Node 26; issue loop with N=60, median of 3 runs.

main workerPerThread: true
plateau RSS 1209 MB 534 MB
idle 3 s RSS 1036 MB 441 MB, no dead guest memory
CPU per sandbox 164 ms 491 ms (a worker start per guest)

This mode is deterministic but expensive, so it stays opt-in. It is also the fallback for workers that cannot run a garbage collection (part 2). With the option on, the issue loop's close() takes 2–4 ms.

2. Bound the dead guest memory each pool worker keeps (from #584)

Each Edge.js run leaves a dead 52 MB shared WebAssembly.Memory in its pool worker, and only that worker's garbage collector can free it. The pool now decides when that happens.

  • Budget. Each worker counts the guest memory of the threads it finished. On the pool's post-idle collection message it runs one full GC when one more guest could take the dead memory past guestMemoryGcBudget: default 192 MiB, 0 to disable.
  • Idle collection. Otherwise the worker collects after about 1 s idle; any blocking task cancels that collection.
  • Collector. Node gets one from Bun.gc, an exposed gc, or --expose-gc set for one throwaway vm context and reset at once, so gc never becomes visible to other code.
  • No collector (browsers, frozen V8 flags): the worker asks the pool to retire it instead.
  • Existing bug fixed. Every sandbox's main thread is PID 1 / TID 1, so overlapping guests overwrote each other's scheduler entry, and the earlier worker never got its post-idle collection. The scheduler now tracks per worker whether it ran a WASIX thread.

Measurements, with #582 (issue loop, wasmer/edgejs):

main (before #582) with #582 and this PR
plateau RSS, N=200 1190 MB 651 MB
max dead guest memory per worker 419 MB 157 MB
idle 3 s RSS (dead guest memories) 964 MB (5) 503 MB (0)
CPU per sandbox 153–164 ms 202–220 ms

Cost. With 52 MB Edge.js guests the budget collects every 3 guests, which costs about +56 ms CPU per sandbox. Most of that falls on the guest right after each GC. It should drop about 5× with the Edge.js build that keeps ICU and builtins out of linear memory (11 MB guests). Not measured: Linux, Windows, Node 24.

Merge with main

Merge commit 7a695e46, the only commit not reviewed in #582 or #584:

Tests (on the merged head 7a695e46)

  • node scripts/test-node.mjs: 16 groups, 63/63. This includes fix(js): run back-to-back guests on the worker that just finished #582's back-to-back test and the new worker-per-thread and guest-memory groups (Bun smoke included).
  • tests/edgejs-http.test.mjs: 3/3, including "guest code never sees the hook that collects guest memory".
  • npm run test:bindgen: pass.
  • Wasm size: 5.09 MB raw, under the 5.10 MB budget with little headroom left.

🤖 Generated with Claude Code

`globalThis.__wasmerWorkerPerThread` gives every WASIX thread a dedicated
worker that should stop when the thread exits. The browser worker does
that; the Node worker ignored `retireAfterTask`, so its workers never
stopped and never reported "retired". Every guest leaked a worker, and
client close() waited forever for the busy dedicated workers.

- node-worker.ts reads `retireAfterTask` from init. Once its task settles
  it drops its shared objects, frees its SDK state, posts "retired" and
  exits, which disposes the isolate together with the guest's memory.
- The adapter forwards "retired" to the pool only from the thread's exit
  event: since #578 the pool frees a retired worker's TLS and stack, which
  is safe only once the thread has stopped.
- Pool shutdown treats a worker that retires on its own like one that
  exits, instead of waiting out the 2 s deadline for it.
- New `workerPerThread` client option, so this mode no longer needs the
  global. The Swift WebKit SDK keeps setting the global.

wasmer-sdk#547: this gives deterministic release of guest memory at the
cost of a worker start per thread. It is opt-in, and the fallback for
workers that cannot run a garbage collection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@wasmer

wasmer Bot commented Nov 7, 2026 •

Copy link
Copy Markdown

The latest updates on your Wasmer apps. Learn more about Wasmer for Github.

Project Deployment Actions Commit Updated (UTC)
wasmer-examples-wasmer-sh Ready Ready Preview 7a695e4 Nov 7, 2026 8:34pm
wasmer-sh-service-worker Ready Ready Preview 7a695e4 Nov 7, 2026 8:34pm

syrusakbary and others added 2 commits November 7, 2026 12:10
Every WASIX process gets its own shared WebAssembly.Memory (52 MB for
Edge.js) inside a pooled worker's isolate. After the guest exits nothing
references it, but only that isolate's garbage collector can free it, and
it rarely runs: an idle worker never collects, Node disables V8's idle
tasks, and shared backing stores create no external-memory pressure on
V8 13.6 (Node 24) and only lazy pressure on V8 14.6 (Node 26). Dead guest
memories piled up per worker, 4 to 13 at a time, and the issue loop
plateaued at 1.1 to 1.3 GB on macOS (wasmer-sdk#547).

The pool knows when a guest's memory dies, so it now decides when that
memory is released:

- A worker records the final guest-memory size of each WASIX thread it
  runs: the attached memory for threads, and the memory WASIX hands to the
  recycle callback when a process's main thread exits.
- On the pool's post-idle collection message, the worker adds those bytes
  to its released total. When one more guest of the largest size seen
  could take that total past the budget, it runs one full garbage
  collection right away, before its next guest. Otherwise it collects once
  it has been idle for about a second; any blocking task cancels that, so
  a busy worker collects only when the budget requires it. Timer and
  async-job messages never trigger a collection.
- Node gets its collector lazily: Bun.gc, then an exposed gc, then
  --expose-gc for one throwaway vm context, switched off again at once so
  no other context, guest or worker gets a gc global.
- Without a collector (browsers, frozen V8 flags) the worker asks the pool
  to retire it: the pool stops routing work to it, and it exits once its
  jobs are done, which disposes its isolate. Such a worker asks while
  there is still room for two guests, because the request can cross one.
- New client option `guestMemoryGcBudget` (bytes, default 192 MiB; 0
  leaves memory to the engine).
- The Node worker no longer keeps a task's shared-object envelope in its
  suspended frame, as the browser worker already did: the envelope
  snapshots every shared memory the worker knew, finished guests' included.

Test-only diagnostics: `globalThis.__wasmerGuestMemoryDiagnostics` makes
Node workers count the guest memories they create and finalize, readable
through `nodeWorkerGuestMemoryStats()`; "no-collector" also simulates a
host without a collector.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Resolve the scheduler conflicts with #582 (keep both the deferred-spawn
and retire-request messages and imports). A worker that asked to retire
now no longer counts as draining: it retires at its next Idle instead of
taking the deferred spawn, which would otherwise wait for it in vain.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@syrusakbary syrusakbary changed the title fix(js): retire dedicated Node workers after their task fix(js): retire dedicated Node workers and bound each worker's dead guest memory Nov 7, 2026
@syrusakbary
syrusakbary merged commit ddbd6cd into main Nov 7, 2026
10 checks passed
@syrusakbary
syrusakbary deleted the fix/node-retire-after-task branch November 7, 2026 20:52
syrusakbary added a commit that referenced this pull request Nov 7, 2026
Brings main's SDK work (up to ddbd6cd) to the GUI/WebGPU branch: the
Wasmer 4b71ca8 and NAPI 961566e pins (#576, #585), module digests hashed
once with a caller-supplied SHA-256 and the browser's native digest
(#576), worker retirement, kill/timeout/close fixes and guest memory
budgets (#578, #579, #581, #582, #583), and one memory-buffer read per
wasm-bindgen view check (#559).

Conflicts:
- Cargo.toml: main's Wasmer and NAPI revisions; this branch's gui
  (41dac65) and webgpu (c65e1e3) pins. Cargo.lock regenerated from
  main's, keeping this branch's versions for the gui-only crates.
- packages/napi: main's 961566e (a descendant of this branch's 18209d2).
- rust/src/process.rs: force_exit closes the process's GUI scope, then
  kills it and asks the task manager to stop its executors (#556).
- rust/src/client.rs: main's progress assertion over per-package events.
- js/bindgen/Cargo.toml: keeps the js-webgpu and js-gui features.
- js/scripts/check-wasm-size.mjs: raw budget 5.45 MB; the merged
  artifact is 5.40 MB (5,400,797 bytes), 1.38 MB Brotli.
- ci.yml: one comment for both kinds of unpublished Git dependencies.

Checked: cargo +1.99.1 test --locked -p wasmer-sdk --all-targets;
cargo check --features gui,webgpu --all-targets; js npm run build
(size check and tsc) and npm test (65/65).

Co-Authored-By: Claude Opus 5.5 <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.

1 participant