Fix Safari OOM on VFS image switching: reclaim Atomics.wait-blocked workers#863
Merged
Merged
Conversation
…orkers Repeatedly switching Kandelo VFS images in one Safari tab grew memory without bound and eventually threw "Out of Memory". Root cause: on WebKit, `Worker.terminate()` cannot free a worker parked in `Atomics.wait` on its syscall channel — the state every blocked process/thread worker sits in — so each machine teardown leaked all of its workers' threads and committed working set (WordPress ≈ 10 blocked daemons ≈ +900-1100 MiB and +~10 threads per switch, monotonic). V8 interrupts the wait on terminate; JSC does not. Fix — cooperatively drive blocked workers to a clean exit before terminating: - libc/glue/channel_syscall.c: on a queued SIGKILL (never delivered to the guest in normal operation, so unambiguous) call the `kernel_exit` import directly. This must NOT use musl `_exit()`, which issues SYS_exit_group over the channel and then spins `for(;;) SYS_exit`, re-parking the worker in Atomics.wait. The `kernel_exit` import is _Noreturn; the following `unreachable` trap is caught by worker-main as a clean exit, unwinding the wasm so the worker returns to its idle JS event loop and terminate() reclaims its thread + memory. - host/src/kernel-worker.ts: killAllBlockedForTeardown() wakes every process channel parked at CH_STATUS==CH_PENDING (main threads + pthreads) — keyed on channel status, not the per-resource wait maps, so it also catches accept()/epoll-blocked daemons — completing each syscall with -EINTR and queueing SIGKILL. - host/src/browser-kernel-worker-entry.ts (handleDestroy): wake blocked workers, drain until they exit and are reclaimed, then terminate stragglers. Node host is unchanged: V8's terminate() already frees Atomics.wait-blocked workers, and the glue change is dormant unless the kernel queues SIGKILL, which only the browser teardown does. Justified by the WebKit platform boundary. Also migrate all browser demos to kernel-owned FS and remove the legacy main-thread VFS SharedArrayBuffer APIs (a secondary, smaller Safari leak: the persistent main thread co-owned each machine's growable VFS buffer, which WebKit reclaims only under GC pressure that WASM/SAB memory rarely triggers). Demos now assemble a transient build FS, saveImage() it, and initFromImage() so the kernel worker solely owns the live VFS; destroy() drops the main thread's framebuffer/PTY buffers. Adds BrowserKernel.initFromImage() and readFileFromVfs(); removes memfs/fsSab options, init(), the fs getter, registerLazyFiles/ensureMaterialized, and the lazy-registration module. Validated (Playwright WebKit, kernel-owned WordPress boot/destroy x10, freshly relinked nginx+php-fpm+dinit): before +11 threads & +900-1100 MiB per switch (monotonic); after threads flat (slope -0.1) and RSS converges to a bounded ~3.4-4.1 GB plateau. Host vitest: 797 pass (3 pre-existing failures are missing wasm64/dlopen/fork-replay test binaries in the worktree). Committed repro harnesses: public/terminate-atomics-test.html, public/wasm-memory-reclaim-test.html. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase B-1 matrix build status —
|
| Package | Arch | Status | Sha |
|---|---|---|---|
| libcurl | wasm32 | built | 0352cfac |
| libcxx | wasm32 | built | 51570a32 |
| libcxx | wasm64 | built | ff1cf2bc |
| libpng | wasm32 | built | 612b4024 |
| libxml2 | wasm32 | built | adb341c8 |
| libxml2 | wasm64 | built | 0f24dd35 |
| openssl | wasm32 | built | af4147e0 |
| openssl | wasm64 | built | 5d0705a0 |
| sqlite | wasm32 | built | bcc5822f |
| sqlite | wasm64 | built | 1c43a0c4 |
| zlib | wasm32 | built | 94efb1ca |
| zlib | wasm64 | built | 9e57ce8b |
| bc | wasm32 | built | 70239e6c |
| bzip2 | wasm32 | built | 335c1ef6 |
| coreutils | wasm32 | built | 6be6bfcf |
| curl | wasm32 | built | 8a13c8fa |
| dash | wasm32 | built | dfe7bbdf |
| diffutils | wasm32 | built | d7506509 |
| dinit | wasm32 | built | ac72da07 |
| fbdoom | wasm32 | built | a891f021 |
| file | wasm32 | built | 9d7484eb |
| findutils | wasm32 | built | 4a31d1a0 |
| gawk | wasm32 | built | 9218c3a0 |
| git | wasm32 | built | 38ee841f |
| grep | wasm32 | built | af4adf56 |
| gzip | wasm32 | built | aba1a5ab |
| hello | wasm32 | built | 79c2823c |
| kandelo-sdk | wasm32 | built | a3c912f4 |
| kernel | wasm32 | built | 1fd2a7a9 |
| less | wasm32 | built | 2df7e50b |
| lsof | wasm32 | built | 16155a21 |
| m4 | wasm32 | built | 48f6cfea |
| make | wasm32 | built | 2e3173bf |
| mariadb | wasm32 | built | 4287f28d |
| mariadb | wasm64 | built | 97356c69 |
| modeset | wasm32 | built | 4cadc656 |
| msmtpd | wasm32 | built | 96063975 |
| nano | wasm32 | built | dda16c4a |
| ncurses | wasm32 | built | ff22bffc |
| netcat | wasm32 | built | 44b69b16 |
| nginx | wasm32 | built | 5dd2c3c0 |
| php | wasm32 | built | 2f12ee7f |
| posix-utils-lite | wasm32 | built | b818663f |
| sed | wasm32 | built | 6f17774d |
| spidermonkey | wasm32 | built | 6988045e |
| tar | wasm32 | built | 4f4942b8 |
| tcl | wasm32 | built | 5887c381 |
| unzip | wasm32 | built | 8668e002 |
| userspace | wasm32 | built | a49149da |
| vim | wasm32 | built | 9d000b71 |
| wget | wasm32 | built | 6dd06a78 |
| xz | wasm32 | built | b41441ea |
| zip | wasm32 | built | fa768da6 |
| zstd | wasm32 | built | 383fea58 |
| bash | wasm32 | built | aaa4d9f1 |
| mariadb-test | wasm32 | built | f87473d0 |
| mariadb-vfs | wasm32 | built | b63206cd |
| mariadb-vfs | wasm64 | built | af14c8cb |
| nethack | wasm32 | built | 0473fe97 |
| node | wasm32 | built | 6767bf7c |
| spidermonkey-node | wasm32 | built | 81aca532 |
| vim-browser-bundle | wasm32 | built | 0f294516 |
| nethack-browser-bundle | wasm32 | built | 4a74cf2a |
| rootfs | wasm32 | built | 235c7085 |
| shell | wasm32 | built | f7dccdb9 |
| lamp | wasm32 | built | 64104909 |
| node-vfs | wasm32 | built | 7618c9c6 |
| wordpress | wasm32 | built | 76133d30 |
Auto-generated; replaced on each push. Raw data in the publish-status workflow artifact.
…+ document removal
The engine boundary is JSC vs V8, not browser vs Node: Bun embeds JavaScriptCore,
so the Node host entry hits the same `Worker.terminate()`-can't-free-an-
Atomics.wait-blocked-worker leak under Bun. The prior "browser-only, Node
unchanged" split was wrong. Apply the cooperative wake-drain-terminate teardown in
`node-kernel-worker-entry.ts` `handleDestroy` too, unconditionally — required on
JSC (Safari, Bun), a bounded no-op on V8 (Chrome, Node) — matching the browser
host and the Host Runtime Contract's peer-hosts rule.
- kernel-worker.ts `killAllBlockedForTeardown`: skip processes the kernel already
reports Exited (`kernel_get_process_exit_status(pid) != -1`) — waking a still-
parked sibling of a thread that already `exit_group`'d would clobber the real
exit status (a `pthread` exit(0) becoming 137; fixes a pthread.test.ts
regression this change introduced). Return the set of woken pids so both
handleDestroy drains wait only for those; a not-woken straggler never posts
{exit} and is force-terminated instead of waited on.
- Reframe all comments from "WebKit"/browser to the JSC engine boundary, correct
a stale note (the glue calls the `kernel_exit` import, not `_exit`), and tag
every participating site `[JSC-TERMINATE-ATOMICS-WAIT-LEAK]`.
- Add docs/jsc-terminate-atomics-wait-workaround.md: the engine table, every
tagged site, the removal procedure, a known-limitation note, and validation
(incl. Bun). Enables removing the whole workaround when the JSC bug is fixed.
Validated: host vitest 797 pass (3 pre-existing binary-not-found), pthread.test.ts
green; browser WordPress boot/destroy ×6 still flat threads + bounded RSS plateau.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…un to the flake The workaround exists for JavaScriptCore, which Safari AND Bun share, but nothing ran the teardown path on JSC. Add `pkgs.bun` to the flake dev shell and a cross-engine test so the JSC behaviour is actually exercised, not just reasoned about. - examples/block-forever.c: a fixture that parks in a blocking nanosleep (Atomics.wait on its channel), modeling an idle daemon. It never exits on its own, so a cooperative teardown exit is observable as status 137 (128 + SIGKILL, from the glue's kernel_exit). - host/test/teardown-reclaim.test.ts: spawns that daemon, destroys the kernel, and asserts it was WOKEN into the cooperative exit (137) and the drain resolved promptly — not force-terminated while parked. Runs under both engines via `bun x vitest`. Verified locally: passes on Node (V8) and Bun (JSC); the existing pthread exit(0)-preservation test also passes on both. - host/package.json: `test:teardown` / `test:teardown:bun` / `:engines` scripts. - scripts/ci-run-test-suite.sh: the `vitest` suite now re-runs the teardown + pthread tests under Bun after the V8 run. - flake.nix: add pkgs.bun (JSC runtime) to the dev shell. The actual memory/thread reclamation is still only measured out-of-process via Playwright WebKit (the in-process tests assert the cooperative path ran, which is the engine-dependent part). Full host vitest: 798 pass (3 pre-existing binary-not-found). See docs/jsc-terminate-atomics-wait-workaround.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
prepare-merge: test-gate passed against the synthetic PR merge and |
This was referenced Jul 11, 2026
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.
Problem
Repeatedly switching between Kandelo VFS images in the same Safari tab grows memory without bound and eventually throws Out of Memory. Chrome is fine.
Root cause
On JavaScriptCore (JSC),
Worker.terminate()cannot free a worker parked inAtomics.wait— the state every blocked process/thread worker sits in on its syscall channel. So each teardown of a running machine leaks all of its workers' threads + committed working set. WordPress (~10 blocked daemons) leaked ~900–1100 MiB and ~10 threads per switch, monotonic → OOM. V8 interruptsAtomics.waiton terminate; JSC does not.The boundary is the engine, not "browser vs Node". JSC backs both Safari and Bun; V8 backs Chrome and Node. So the leak hits the browser host and the Node host when the latter runs under Bun.
Two related WebKit bugs exist but neither resolves this on current builds: #278866 (JS
Atomics.waittermination, fixed Aug 2024 — does not cover this) and #250569 (RESOLVED CONFIGURATION CHANGED, disputed). This PR is the runtime-side workaround.Fix — cooperatively exit blocked workers before terminating
libc/glue/channel_syscall.c: on a queuedSIGKILL(never delivered to the guest in normal operation → unambiguous "exit now"), call thekernel_exitimport directly. Crucially not musl_exit(), which channelsSYS_exit_groupthen spinsfor(;;) SYS_exit, re-parking the worker inAtomics.wait. Thekernel_exitimport is_Noreturn; the followingunreachabletrap is caught by worker-main as a clean exit → worker returns to its idle JS event loop →terminate()reclaims it.host/src/kernel-worker.tskillAllBlockedForTeardown(): wakes every process channel parked atCH_STATUS==CH_PENDING(main threads + pthreads) — keyed on channel status, not per-resource wait maps, so it also catchesaccept()/epoll-blocked daemons — completing each syscall with-EINTRand queueingSIGKILL. Skips processes the kernel already reportsExited(kernel_get_process_exit_status(pid) != -1) so a sibling thread's real exit status isn't clobbered (apthreadexit(0)becoming137). Returns the set of woken pids so the drain waits only for those.handleDestroy(browser-kernel-worker-entry.tsandnode-kernel-worker-entry.ts): wake, drain until the woken workers exit and are reclaimed, then terminate any stragglers. Applied unconditionally on both hosts — required on JSC (Safari, Bun), a bounded no-op on V8 (Chrome, Node) — rather than sniffing the engine.Also migrates all browser demos to kernel-owned FS and removes the legacy main-thread VFS
SharedArrayBufferAPIs (a secondary, smaller Safari leak where the persistent main thread co-owned each machine's growable VFS buffer). AddsBrowserKernel.initFromImage()/readFileFromVfs(); removesmemfs/fsSaboptions,init(), thefsgetter,registerLazyFiles/ensureMaterialized, and the lazy-registration module.Documenting the workaround for removal
Every participating site is tagged
[JSC-TERMINATE-ATOMICS-WAIT-LEAK](git grepfinds them all), anddocs/jsc-terminate-atomics-wait-workaround.mdgives the engine table, all sites, a step-by-step removal procedure, a known-limitation note, and the validation to re-run. When the JSC bug is fixed: grep the marker, follow the doc.Testing on both engines
pkgs.bunadded to the flake dev shell (JSC runtime, same engine as Safari).host/test/teardown-reclaim.test.ts(+ fixtureexamples/block-forever.c): spawns a daemon parked in a blocking syscall, destroys the kernel, and asserts it was woken into a cooperative exit (status 137 = 128 + SIGKILL) and drained promptly — not force-terminated. Runs under both V8 and JSC (bun x vitest).pthread.test.ts(exit(0) preservation with a blocked main thread) also runs on both.hostnpm scriptstest:teardown/:bun/:engines; the CIvitestsuite (scripts/ci-run-test-suite.sh) re-runs the teardown + pthread tests under Bun after the V8 run.Validation
Committed repro harnesses:
apps/browser-demos/public/terminate-atomics-test.html,apps/browser-demos/public/wasm-memory-reclaim-test.html.Notes
ABI_VERSIONbump: no struct/syscall/channel-layout/export changes; uses the existingkernel_exitimport andCH_SIGfields.libc.a); CI rebuilds all binaries/VFS images from source. Old-glue binaries degrade gracefully (still leak on JSC, no crash).exit_groups via one thread while another is stillAtomics.wait-parked is skipped by the wake (to preserve its exit status) and force-terminated instead — so that one stuck worker still leaks on JSC. Narrow/unusual case, documented in the workaround doc.Manual test
Recommend confirming in real Safari via
./run.sh browser: switch between demo images repeatedly and watch memory stay bounded.🤖 Generated with Claude Code