Repository navigation
fix(js): retire dedicated Node workers and bound each worker's dead guest memory - #583
Merged
Merged
Conversation
`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>
This was referenced Nov 7, 2026
|
The latest updates on your Wasmer apps. Learn more about Wasmer for Github.
|
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>
This was referenced Nov 7, 2026
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Part of the SDK fix for #547 (memory kept after Edge.js sandboxes close).
This PR now lands two changes:
workerPerThread): the original scope, below.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,mainhas 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.__wasmerWorkerPerThreadgives every WASIX thread a dedicated worker that should stop when the thread exits. The browser worker does that. Onmain, the Node worker ignoresretireAfterTask, so on Node:close()waits forever for the dedicated workers, which never reportretired.On
main, with the global set, 5 hello-world sandboxes leave 6 workers alive andclose()never returns.Change
node-worker.tsnow honoursretireAfterTaskfrom init. Once its task settles, it drops its shared objects, frees its SDK state, postsretiredand exits. Exiting disposes the isolate together with the guest's memory.retiredto the pool only from the thread'sexitevent. 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.workerPerThreadclient option, so this mode no longer needs the global. The Swift WebKit SDK keeps setting the global.ThreadPool::newtakesPoolOptions.Measurements
macOS arm64, Node 26; issue loop with N=60, median of 3 runs.
workerPerThread: trueThis 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.Memoryin its pool worker, and only that worker's garbage collector can free it. The pool now decides when that happens.guestMemoryGcBudget: default 192 MiB, 0 to disable.Bun.gc, an exposedgc, or--expose-gcset for one throwawayvmcontext and reset at once, sogcnever becomes visible to other code.Measurements, with #582 (issue loop,
wasmer/edgejs):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:DrainWaitElapsed(fix(js): run back-to-back guests on the worker that just finished #582) andWorkerRetireRequested(fix(js): bound the dead guest memory each pool worker keeps #584).draining_workers()now also skips workers inretire_when_idle. Such a worker retires at its next Idle instead of taking the deferred spawn, which would otherwise wait up to 250 ms for it in vain.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 newworker-per-threadandguest-memorygroups (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.🤖 Generated with Claude Code