Skip to content

bun test --isolate: drop a retired file's deferred work - #42110

Merged
Jarred-Sumner merged 1 commit into
mainfrom
robobun/a2fc0f13/isolate-finalization-registry-fence
Sep 9, 2026
Merged

Jarred-Sumner merged 1 commit into
mainfrom
robobun/a2fc0f13/isolate-finalization-registry-fence

Conversation

@robobun

@robobun robobun commented Sep 9, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • bun test --isolate: a FinalizationRegistry cleanup that a FINISHED file created runs while a later file executes. A throw from it is charged to the later file, which then loses its remaining tests to Cannot call test() after the test run has completed. In the constructed repro (fuzz-found, no user report) 7 of 25 tests ran, exit 1.
  • The fence of bun test --isolate: fence a finished file's realm against late completions #41831 does not reach it: JSC schedules it as a DeferredWorkTimer ticket and calls the callback directly, so no microtask, Job or run_callback check applies. Bun::runPendingWork (JSCTaskScheduler.cpp:122) ran it with no realm check.

Fix

  • Zig::GlobalObject::scriptExecutionStatus reports Stopped for a retired realm, from the fence's own predicate (microtaskRunnability() == Discard, now Bun::isRetiredTestIsolationRealm).
  • runPendingWork asks that status before it runs a task, as JSC's DeferredWorkTimer::doWork does, and drops the work, like the file's timers at the swap.
  • Correct because the swap is that file's process exit: its script never runs again. WebCore answers Stopped for a detached document.
  • Verified: a new isolation.test.ts case fails on main 20 of 20, passes 6 of 6 here. Self-reviewed: 5 concerns, 4 addressed, 1 deferred (Notes).

Background

  • --isolate gives each file a fresh Zig::GlobalObject on one JSC::VM. The old global lives until the GC takes it.
  • DeferredWorkTimer carries native completions (registry cleanups, wasm compiles, Atomics.waitAsync wake-ups) to the JS thread, one ticket each. Bun installs onScheduleWorkSoon, so they run from its event loop, not JSC's doWork.
  • ScriptExecutionStatus is how JSC asks the embedder whether a realm may run script. Bun answered from the VM alone, so a retired realm said Running.
Notes
  • Reported internally (fuzz ledger Bun.markdown: table column alignment is mis-attributed when a document has more than one table #45175) against main d745f03. There is no user report. The repro is constructed: a.test.ts creates a FinalizationRegistry whose callback throws and registers 500 objects, then six files each run Bun.gc(true) and a short sleep per test, all under BUN_JSC_collectContinuously=1. On main, 27 of 30 runs lose 15 to 19 of the 25 tests to the Cannot call test() path. The organic reach is lower than that: it needs --isolate or --parallel, a collection between the finished file's last loop drain and the swap, and a cleanup callback that throws or acts on shared state.
  • The new test sets BUN_JSC_collectContinuously=1 in the child for the same reason: natural GC timing queues the cleanup in that window only sometimes.
  • What is still not fenced, unchanged from the list bun test --isolate: fence a finished file's realm against late completions #41831 gave: calls into JS through an FFI JSCallback, a napi threadsafe function or napi async completion, functions of a node:vm context or ShadowRealm that the finished file created (those globals are not retired, so their own deferred work also still runs), and microtasks while a debugger is attached. The review of this change also pointed at the Worker close event: WorkerMessagingProxy::workerGlobalScopeDestroyedInternal dispatches it through Worker::dispatchCloseEvent (src/jsc/bindings/webcore/Worker.cpp:139), which has no realm check, so a finished file's worker.on("close") handler may run under the next file when the terminated worker's thread exits late. I have not reproduced that one and left it out of this PR.
  • Scope: this PR is the realm fence only. The separate CRASH face of the same area (a queued job reads ticket->scriptExecutionOwner()->vm() after the realm's global is collected and the ticket cancelled: ASSERTION FAILED: !isCancelled() on debug, a stale read on release) belongs to Let JSC's DeferredWorkTimer own the deferred-work tickets so a collection cancels a dead realm's work (WebKit bump) #39994, which moves ticket ownership into JSC through DeferredWorkTimer: keep the embedder's tickets in m_pendingTickets so the end of a collection cancels them WebKit#487. The status gate here is orthogonal to that: JSC's doWork performs the same check, so it survives the ownership move. The two touch adjacent lines in runPendingWork and will need a trivial rebase, nothing more.
  • An earlier version of this change also cancelled pending tickets of unmarked realms at GC end, from a VM::ClientData::reconcileWeakReferencesAtGCEnd hook. That is the shape Let JSC's DeferredWorkTimer own the deferred-work tickets so a collection cancels a dead realm's work (WebKit bump) #39994 already tried and review rejected, so it is not here.
  • runPendingWork is the only caller of scriptExecutionStatus this adds. JSC's doWork is the only other caller, and with Bun's hooks installed its task queue and ticket set are both empty, so the new Stopped answer changes nothing else. Bun__VM__scriptExecutionStatus (the VM-wide answer that timers use) is untouched. A debug ASSERT records that no Bun realm reports Suspended, which doWork would re-queue rather than drop.
  • During process exit (is_shutting_down), deferred work that is still dispatched is now dropped as well, which is what timers already do and what Node does for a FinalizationRegistry callback queued from process.on("exit") (test-finalization-registry-shutdown.js).
  • bun test: keep running a file's tests after an unhandled error during collection #41998 is complementary. It stops an unhandled error during collection from deleting the active file's tests, which is the sink this bug reached. With both, a stray error neither runs in a dead realm nor deletes a live file's tests.
  • The new test's fixture re-registers fresh garbage from inside the cleanup, so the registry has dead entries at every collection for as long as its realm is alive. The cleanup writes its marker only once the second file says it is running, so the assertion cannot pass by accident.

[human-review] gate passed · iteration 0 · 4 files touched

fails on main (without fix)
ASAN without fix: 1 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/cli/test/isolation.test.ts
bun test v1.4.3 (f42e98025)

test/cli/test/isolation.test.ts:
(pass) bun test --isolate > without --isolate, leaked global is visible to next file [345.17ms]
(pass) bun test --isolate > with --isolate, each file gets a fresh global [407.40ms]
(pass) bun test --isolate > with --isolate, --preload re-runs in each file's fresh global [399.11ms]
(pass) bun test --isolate > without --isolate, --preload still runs once (regression) [351.21ms]
(pass) bun test --isolate > with --isolate, module state is not shared between files [378.48ms]
(pass) bun test --isolate > with --isolate, a file's process.chdir() is undone before the next file [1167.65ms]
(pass) bun test --isolate > with --isolate, a file's process.env writes with native side effects are undone before the next file [1516.25ms]
(pass) bun test --isolate > with --isolate, cached module records keep short, Latin-1 and UTF-16 names [1226.39ms]
(pass) bun test --isolate > with --isolate, leaked outbound socket is closed before next file [1306.31ms]
(pass) bu
... (truncated)

release without fix: 3 FAILED
bun test v1.4.3-canary.1 (f42e98025)

test/cli/test/isolation.test.ts:
(pass) bun test --isolate > with --isolate, each file gets a fresh global [40.57ms]
(pass) bun test --isolate > without --isolate, leaked global is visible to next file [44.40ms]
(pass) bun test --isolate > with --isolate, --preload re-runs in each file's fresh global [40.08ms]
(pass) bun test --isolate > without --isolate, --preload still runs once (regression) [41.71ms]
(pass) bun test --isolate > with --isolate, module state is not shared between files [38.39ms]
(pass) bun test --isolate > with --isolate, cached module records keep short, Latin-1 and UTF-16 names [41.16ms]
(pass) --isolate: JSC options survive a bunfig.toml with an install hoist pattern [27.69ms]
(pass) bun test --isolate > leaked subprocesses are killed for every isolated file, not just the first [40.26ms]
(pass) --isolate: delete require.cache evicts the SourceProvider cache [33.68ms]
(pass) bun test --isolate > module-scope subprocesses are killed for every isolated file, not just the first (--isolate) [42.58ms]
(pass) --isolate: SourceProvider cache covers node_modules .mjs and type:commonjs packages [32.06ms]
1146 |   con
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/cli/test/isolation.test.ts
bun test v1.4.3 (f42e98025)

test/cli/test/isolation.test.ts:
(pass) bun test --isolate > without --isolate, leaked global is visible to next file [337.75ms]
(pass) bun test --isolate > with --isolate, each file gets a fresh global [330.21ms]
(pass) bun test --isolate > with --isolate, --preload re-runs in each file's fresh global [321.80ms]
(pass) bun test --isolate > without --isolate, --preload still runs once (regression) [292.32ms]
(pass) bun test --isolate > with --isolate, module state is not shared between files [375.60ms]
(pass) bun test --isolate > with --isolate, a file's process.chdir() is undone before the next file [1223.25ms]
(pass) bun test --isolate > with --isolate, a file's process.env writes with native side effects are undone before the next file [1406.88ms]
(pass) bun test --isolate > with --isolate, cached module records keep short, Latin-1 and UTF-16 names [1240.66ms]
(pass) bun test --isolate > with --isolate, leaked outbound socket is closed before next file [1301.54ms]
(pass) bu
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     dc8adebbee
  features     baseline

23 deps, 131 codegen, 1172 objects in 622ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1244] install /workspace/bun
bun install v1.4.3-canary.1 (f42e98025)

Checked 22 installs across 61 packages (no changes) [9.00ms]
[2/1244] install /workspace/bun/packages/bun-error
bun install v1.4.3-canary.1 (f42e98025)

Checked 1 install across 2 packages (no changes) [1.00ms]
[3/1244] gen ErrorCode+*.h
[4/1244] gen bindgenv2
[5/1244] install /workspace/bun/src/node-fallbacks
bun install v1.4.3-canary.1 (f42e98025)

Checked 111 installs across 104 packages (no changes) [7.00ms]
[6/1244] gen node-fallbacks/react-refresh.js
Bundled 1 module in 5ms

  react-refresh.js  4.81 KB  (entry point)

[7/1244] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[8/1217] fetch zlib
[zlib] up to date
[9/1217] fetch tinycc
[tinycc] up to date
[10/1216] gen .bind.ts → GeneratedBindings.cpp
[11/1216] gen bake.{client,server,error}.js
-> bake.client.js, bake.server.
... (truncated)
diff hotspot
src/jsc/bindings/JSCTaskScheduler.cpp | 19 ++++++++---
 src/jsc/bindings/ZigGlobalObject.cpp  |  9 +++--
 src/jsc/bindings/ZigGlobalObject.h    |  7 ++++
 test/cli/test/isolation.test.ts       | 62 +++++++++++++++++++++++++++++++++++
 4 files changed, 90 insertions(+), 7 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                   reads  edits  tests
src/jsc/bindings/JSCTaskScheduler.cpp      5     10     13
src/jsc/bindings/ZigGlobalObject.cpp       2      2     12
src/jsc/bindings/ZigGlobalObject.h         1      1     12
test/cli/test/isolation.test.ts            4      2     12

A FinalizationRegistry cleanup that a finished file created reaches none
of the realm fence's checks. JSC schedules it as a DeferredWorkTimer
ticket, and runFinalizationCleanup calls the user callback directly, so
neither the microtask fence, nor the Job generation check, nor
run_callback sees it. It ran in the retired realm while a later file
executed.

Zig::GlobalObject::scriptExecutionStatus now reports Stopped for a
retired realm, and runPendingWork asks the realm's status before it runs
a task, as DeferredWorkTimer::doWork does. A retired realm's deferred
work is dropped, like the file's timers are dropped at the swap.

Still not fenced (unchanged from #41831): calls into JS through an FFI
JSCallback, a napi threadsafe function or async completion, functions
of a node:vm context or ShadowRealm the finished file created, and
microtasks while a debugger is attached.
@robobun

robobun commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator Author

Status: reproduced, fixed, PR open. Waiting on CI.

Reproduction: the new case in test/cli/test/isolation.test.ts ("a finished file's FinalizationRegistry cleanup does not run in the next file"). a-registry.test.ts creates a FinalizationRegistry whose cleanup writes a marker once b-registry.test.ts is running, and b-registry.test.ts asserts the marker never appears. The child runs with BUN_JSC_collectContinuously=1 so the cleanup is queued between the finished file's last drain and the swap.

  • USE_SYSTEM_BUN=1 bun test test/cli/test/isolation.test.ts -t FinalizationRegistry (1.4.3): 1 pass, 1 fail in the child, 20 of 20 runs.
  • Debug ASAN build of d745f03 without the src/ change: same failure, 3 of 3.
  • bun bd test test/cli/test/isolation.test.ts -t FinalizationRegistry with the change: passes 6 of 6. Full isolation.test.ts 36 of 36, parallel.test.ts, test/js/node/vm/vm.test.ts, atomics.test.ts, wasi.test.js, shadow.test.js green.

Scope note: the crash face of the same area (ASSERTION FAILED: !isCancelled() in DeferredWorkTimer::Ticket::scriptExecutionOwner(), release SEGV at 0x9670) is #39994's. This PR does not touch ticket ownership.

@coderabbitai

coderabbitai Bot commented Sep 9, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: c2c89f66-fff1-4f1b-8a15-768d0f20d49a

📥 Commits

Reviewing files that changed from the base of the PR and between d508ad8 and dc8adeb.

📒 Files selected for processing (4)
  • src/jsc/bindings/JSCTaskScheduler.cpp
  • src/jsc/bindings/ZigGlobalObject.cpp
  • src/jsc/bindings/ZigGlobalObject.h
  • test/cli/test/isolation.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 0 remain after this review.


Walkthrough

Changes

The change marks retired test-isolation realms as stopped and prevents deferred tasks from executing in those realms. It adds an isolation regression test for FinalizationRegistry cleanup callbacks.

Retired realm status and detection

Layer / File(s) Summary
Realm retirement status
src/jsc/bindings/ZigGlobalObject.h, src/jsc/bindings/ZigGlobalObject.cpp
Adds isRetiredTestIsolationRealm. Retired realms now report ScriptExecutionStatus::Stopped.
Deferred task execution and regression coverage
src/jsc/bindings/JSCTaskScheduler.cpp, test/cli/test/isolation.test.ts
runPendingWork discards tasks for stopped realms and asserts for suspended realms. The isolation test verifies that cleanup from a completed file does not run in a later file.

Suggested reviewers: cirospaciari, dylan-conway, jarred-sumner

Priority: ⬇️ Low

Merge Risk: ⚪ Minimal · up to dc8ad

Retired isolated test realms now discard deferred cleanup work, preventing callbacks and errors from leaking into later test files. The change is covered by an isolation regression scenario and has no remaining merge-blocking risk.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main change: deferred work from retired test-isolation files is dropped.
Description check ✅ Passed The description explains the problem, fix, scope, limitations, and verification results. It provides the required change summary and verification information, although it uses different headings from …

Comment @coderabbitai help to get the list of available commands.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed this PR and didn't find any bugs. Because it changes Zig::GlobalObject::scriptExecutionStatus and gates all DeferredWorkTimer job execution in runPendingWork — paths that affect every FinalizationRegistry cleanup, wasm completion, and Atomics.waitAsync wake-up in the runtime, not just --isolate — a human familiar with the JSC scheduler should also look.

What was reviewed:

  • runPendingWork: confirmed delete job and the event-loop keepalive unref still run on the skipped path; the status check goes through the method table so non-Zig realms (ShadowRealm, node:vm) use their own entry.
  • scriptExecutionStatus: the new Stopped branch is only reachable when microtaskRunnability() == Discard, which is set exclusively by retireForTestIsolation, so the predicate is inert outside bun test --isolate. Noted the secondary behavior change the PR calls out: when Bun__VM__scriptExecutionStatus returns non-Running during process exit, deferred work is now dropped even if Bun__VmHandle__scriptAllowed would have allowed it.
  • Test: follows the file's runTests/describe.concurrent conventions, spreads bunEnv, uses a bounded 20-iteration poll with an fs sentinel rather than sleeps, and re-registers garbage from the cleanup so the failure window persists.
Extended reasoning...

Overview

This PR extends the bun test --isolate realm-fencing work of #41831 to cover JSC DeferredWorkTimer jobs (FinalizationRegistry cleanups, wasm compile completions, Atomics.waitAsync wake-ups), which bypass the microtask/Job/run_callback checks that fence already covers. Four files change: ZigGlobalObject.h gains an ALWAYS_INLINE isRetiredTestIsolationRealm() helper wrapping the existing microtaskRunnability() == Discard predicate; ZigGlobalObject.cpp's scriptExecutionStatus() returns Stopped when that predicate holds and Bun__JSValue__isFromRetiredTestIsolationRealm is refactored to call the helper; JSCTaskScheduler.cpp's runPendingWork now consults the ticket target's scriptExecutionStatus via the method table and only runs the task when Running, with a debug ASSERT that Suspended never occurs; and test/cli/test/isolation.test.ts gains a two-file fixture proving a finished file's registry cleanup no longer fires while the next file runs.

Security risks

None identified. The change narrows when deferred JS callbacks execute; it does not widen any attack surface, parse untrusted input, or touch auth/crypto paths. The one input the new code reads (microtaskRunnability()) is set only by Bun's own retireForTestIsolation.

Level of scrutiny

Moderate-to-high. The diff is small (~30 native lines + a test) and mirrors JSC's own DeferredWorkTimer::doWork gating, but the two functions it touches sit on the critical path for every deferred-work callback in the runtime. scriptExecutionStatus is a method-table entry JSC itself may consult, and runPendingWork is the sole dispatch point for Bun's DeferredWorkTimer hook. A regression here would affect all FinalizationRegistry/wasm/Atomics.waitAsync users, not just --isolate. The PR description also flags a known adjacent crash (#39994) touching the same lines and a pending rebase, and a secondary behavior change during process shutdown — both are reasons a maintainer with context on the JSC scheduler ownership work should sign off.

Other factors

I traced the skipped-job path in runPendingWork: the pending-set removal, keepalive unref, and delete job all execute regardless of status, so no leak is introduced when a retired realm's job is dropped. The isRetiredTestIsolationRealm predicate is called on JSC::JSGlobalObject* (base-class microtaskRunnability()), so it is safe for any global type, and the method-table dispatch in runPendingWork means non-Zig globals reach their own scriptExecutionStatus implementation rather than the modified one. The new test lives inside the existing describe.concurrent block for --isolate late-completion cases, uses the file's runTests helper with { ...bunEnv, BUN_JSC_collectContinuously: "1" }, polls a bounded window with an fs sentinel (no raw sleeps), asserts stderr before exit code, and the fixture's self-re-registering cleanup keeps the registry live across every collection so the pre-fix failure is deterministic. The bug hunt exited on dry_streak with no findings and no ruled-out candidates.

@Jarred-Sumner
Jarred-Sumner merged commit d797369 into main Sep 9, 2026
8 of 9 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the robobun/a2fc0f13/isolate-finalization-registry-fence branch September 9, 2026 06:06
7nohe added a commit to gurenjs/guren that referenced this pull request Sep 23, 2026
JSC queues a FinalizationRegistry cleanup as a DeferredWorkTimer ticket,
and Bun 1.4.0-1.4.2 run it with no realm check, so it lands after the
test file that created the registry is retired. In a retired realm the
cleanup surfaces as "Unhandled error between tests" (exit 1 with 0
fail); once the retired global has been collected it reads a freed
registry and the process dies with a segfault at address 0x70 (exit
139). The sqlite-smoke job hit the crash twice on main in one day, once
right after the plugin-lambda CDK test (aws-cdk-lib's wasm-bindgen
registries) and once while loading a CLI test file, where the registry
is one of Bun's own builtins.

The first face is fixed upstream in oven-sh/bun#42110 (unreleased); the
second is oven-sh/bun#39994 with oven-sh/WebKit#487, still open. Until a
release carries both, a test:bun preload replaces the global
FinalizationRegistry with one that registers nothing on Bun 1.4.x, so no
cleanup is ever queued. It runs first, before the other preloads import
node:fs. A registered cleanup only ever released memory the test process
gives back at exit. Bun 1.3.14 is left untouched.

Fixes: #969
7nohe added a commit to gurenjs/guren that referenced this pull request Sep 23, 2026
JSC queues a FinalizationRegistry cleanup as a DeferredWorkTimer ticket,
and Bun 1.4.0-1.4.2 run it with no realm check, so it lands after the
test file that created the registry is retired. In a retired realm the
cleanup surfaces as "Unhandled error between tests" (exit 1 with 0
fail); once the retired global has been collected it reads a freed
registry and the process dies with a segfault at address 0x70 (exit
139). The sqlite-smoke job hit the crash twice on main in one day, once
right after the plugin-lambda CDK test (aws-cdk-lib's wasm-bindgen
registries) and once while loading a CLI test file, where the registry
is one of Bun's own builtins.

The first face is fixed upstream in oven-sh/bun#42110 (unreleased); the
second is oven-sh/bun#39994 with oven-sh/WebKit#487, still open. Until a
release carries both, a test:bun preload replaces the global
FinalizationRegistry with one that registers nothing on Bun 1.4.x, so no
cleanup is ever queued. It runs first, before the other preloads import
node:fs. A registered cleanup only ever released memory the test process
gives back at exit. Bun 1.3.14 is left untouched.

Fixes: #969
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants