Repository navigation
Cancel a dead realm's deferred work at the end of the collection that found it dead - #44279
Conversation
|
Navigate logical layers of code changes, visualize relationships, and explore their blast radius. No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Repository: oven-sh/bun/.coderabbit.yaml Review profile: ASSERTIVE Plan: Essentials Run ID: 📒 Files selected for processing (1)
Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 7 remain after this review. WalkthroughThe change adds end-of-GC cancellation of deferred work for unmarked realms. Deferred-work jobs now retain JSVMClientData for VM and scheduler access. Two subprocess tests cover FinalizationRegistry cleanup and Atomics.waitAsync behavior across VM contexts. ChangesDead-realm deferred work
Suggested reviewers: Priority: ⬆️ High Merge Risk: ⚪ Minimal · up to The reviewed source shows dead-realm cancellation is serialized with event-loop execution, and the added regression tests cover live and dead VM contexts. No actionable merge-blocking risk remains. 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
There was a problem hiding this comment.
Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.
Beyond the inline test nits, I also looked at whether vm.heap.isMarked(realm) in cancelWorkOfDeadRealms could report a live old-generation realm as dead during an eden collection (it mirrors what upstream DeferredWorkTimer::cancelPendingWork(VM&) asks at the same GC-end boundary), whether the hook can race the mutator when the collector is concurrent (the end phase runs with the world stopped, and clearWeakTickets runs no JS), and whether the now-unconditional job->clientData dereference in Bun__deleteDeferredWorkTask can outlive JSVMClientData (~VM deletes it last, after the handle has drained or refused queued jobs). None of those turned into a bug, but since this touches GC-phase and lock-ordering code, a human look is still worthwhile.
Extended reasoning...
The change adds a GC-end hook on JSVMClientData that cancels DeferredWorkTimer tickets of unmarked vm.createContext realms, moves the scheduler lookup for deferred-work jobs from the (possibly freed) ticket owner cell to a stored JSVMClientData pointer, and drops a null check in the delete path; it touches no injection, auth, or data-exposure surface. It does touch GC-phase ordering, m_lock drop-then-reenter, and cross-thread job lifetime, and two verified test-quality findings are posted inline, so this is not a change to approve without a human reading it.
…h tests concurrently
There was a problem hiding this comment.
I reviewed the updated PR and didn't find any bugs; the follow-up commit addresses both earlier nits (the dead contexts' cleanups are now counted and asserted as 0, and both tests run concurrently). Because the fix hooks the GC end phase and its safety rests on JSC ordering that isn't verifiable from this checkout, a human look from someone with the WebKit tree at hand would still be worthwhile.
What was reviewed:
- Retainer reasoning in
cancelWorkOfDeadRealms: a ticket's target is marked only through its realm'svisitChildren, so!heap.isMarked(realm)is the right discriminator andtarget()->globalObject()is only read on unswept cells as long as the hook precedessweepInFinalize(the eager sweep of lower-tier precise allocations) — that ordering is the one claim I could not confirm here, since the vendored JavaScriptCore sources are not in the checkout. clearWeakTickets→onCancelPendingWork→Bun__VmHandle__refKeepAlivefrom a possibly-collector-thread end phase: the Rust side is documented as any-thread and the mutator is stopped, so no new thread-affinity problem.- Dropping the null guard in
Bun__deleteDeferredWorkTask: the old path already dereferenced the ticket's owner cell, so it assumed a live VM/clientData too; no weakening. The queued job for a cancelled ticket now falls throughrunPendingWorkwithwasPending == falseand never touches the freed target.
Extended reasoning...
The change adds a JSC::VM::ClientData::reconcileWeakReferencesAtGCEnd override in src/jsc/bindings/BunClientData.{h,cpp} that calls a new JSCTaskScheduler::cancelWorkOfDeadRealms, which scans both pending-ticket maps for tickets whose global object is unmarked and calls JSGlobalObject::clearWeakTickets on each, and it reroutes the deferred-work job's VM/clientData access away from the ticket's possibly-freed owner cell. It touches no auth, injection, or data-exposure surface; the sensitive surface is GC lifetime and cross-thread event-loop keep-alive counting. The bug hunt exited on dry_streak, no third-party objections are recorded, no CODEOWNERS entry covers the changed files, and the follow-up commit addressed both of my earlier inline nits. I did not approve because the correctness hinges on JSC's end-phase ordering (hook before the eager sweep of the first eight precise cells per IsoSubspace) and on clearWeakTickets semantics that I could not verify without the WebKit sources, and a GC-end-phase hook is not a simple or mechanical change.
|
The two tests this PR added require |
per m2081: reuse unchanged complete test-file proofs and identical worktree closures. Current dev/test canary fixes retired-realm crashes through oven-sh/bun#44279, but lacks spawnSync fix #44581. TLS absence uses the precise handshake probe, independent of fetch error labels. The CLI user runtime and devbox tools stay on their stable versions; remove the obsolete test coupling users to development tooling.
…lizers (#1323) TL;DR: the docx editor closes its display-list store handles when they stop being used, so `bun test --isolate` no longer leaves hundreds of finalizer registrations behind per test file. ## Why CI's Test step crashes now and then on Bun 1.4.2 with a segfault in `JSFinalizationRegistry::runFinalizationCleanup` (bun.report traces of three CI crashes). Cleanup jobs for registrations that are still live when an `--isolate` file's realm is torn down run against a freed callback. A standalone repro on Bun 1.4.2 crashed 5 of 5 runs when registrations were left live as their context died, and 0 of 5 when they were unregistered first. The upstream fix (oven-sh/bun#44279) landed after 1.4.2. The crash depends on timing and never reproduced on our test VMs, so this PR measures live finalizer registrations at the end of each file instead. The largest source was the docx display-list query facade. Every facade registered with a `FinalizationRegistry` when it was created, even if it never opened a store handle. Nothing disposed facades when the editor unmounted. ## What changed - `displayListQueries.ts`: a facade registers for finalization only while it holds a store handle, and the handle cell is the unregister token. `endDisplayListQueriesLine` closes every handle held by that line's facades. `useDisplayList` already ends its open lines on unmount, release, layout reset and worker fallback, so those paths now close their handles. A query method called after its facade has been garbage collected answers over the JSON path and opens no handle. - `residentEngineWorker.ts` / `residentCommandSelection.ts`: the worker releases its selection queries and per-engine selection reads when the command host is rebound and when the session is destroyed. - Tests that created facades, sessions, hosts or workbooks without releasing them now release them in teardown. No assertion was removed or weakened. ## Tests New (red on main, green here): - `displayListQueries.finalizer.test.ts`: only a facade that holds a handle is registered; adoption moves the registration; a failed by-handle query drops it; ending a line closes and unregisters every handle; a method kept past its collected facade opens no handle. - `sessionLifetime.test.tsx`: unmounting the editor closes every display-list store handle it opened. - `residentEngineWorker.navigation.test.ts`: rebinding the command host disposes its selection queries. Changed (teardown only): `displayListQueries.test.ts`, `frameDelta.test.ts`, `showHiddenText.test.ts`, `useYrsCoreSession.workerOpen.test.tsx`, `residentCommandSelection.test.ts`, `residentEngineWorker.navigation.test.ts`, `XlsxEditorPlugins.test.tsx`, `commandSwap.test.tsx`, `commandHost.shared.test.ts`, `commandSwap.test.ts`, `recovery.test.ts`. ## Verification Measured in one run on an x86_64 Linux VM with Bun 1.4.2, at d70fe7d. That VM was loaded: CI's test command took 2765 s there. Pure main 7166688 took 1001 s on a quiet VM. - CI's typecheck passes. The `docx`, `docx-react`, `xlsx` and `xlsx-react` builds pass. - `displayListQueries.finalizer.test.ts`, 3 runs per source: - main's `displayListQueries.ts`: 0 pass / 5 fail - this branch before the collected-facade guard: 4 / 1 (only "a query method kept past its collected facade opens no handle" fails) - head: 5 / 0 - `residentEngineWorker.navigation.test.ts` with main's worker sources: 11 pass / 1 fail ("rebinding_command_host_disposes_selection_queries"). Head: 12 / 0. - CI's `bun run test`: 9336 pass / 65 fail / 120 skip / 10 todo across 658 files, and no segfault. Pure main on the quiet VM: 9391 pass / 3 fail, with no unhandled errors. - 3 failures also fail on main on these VMs: native package public names, standalone Cargo workspaces and the S3 PNG upload. - The other 62 match the pattern main shows on loaded VMs. They are: - 52 in `DocxEditorPlugins.test.tsx` - 4 "the editor Save writes ..." cases in `useFileIO.comments.test.tsx` - 2 reply-mode cases in `useDisplayList.workerLayout.test.ts` - 1 each in `viewportFrames.parity.test.ts`, `previewChain.parity.test.ts`, `useYrsCoreSession.workerOpen.test.tsx` ("a successful worker-recovery main open ...") and `VsdxEditor.lifecycle.test.tsx` (font face restore, which takes 2.4 to 3.1 s even on GitHub's runners) - Run alone on the same VM, the plugins file gives 1 pass / 51 fail in 164.84 s. Pure main cd042ba gave the same on a loaded VM: 1 / 51 in 164.91 s. In both runs the first test times out at 5 s, and every later mount times out at about 3.1 s. - The run reports 3 unhandled errors between tests. Each comes from a test body that kept running after its test had failed: a wasm `version()` call on a freed session in `DocxEditorPlugins.test.tsx`, `window is not defined` in a React act flush in `useFileIO.comments.test.tsx`, and a `waitFor` in `useDisplayList.workerLayout.test.ts`. None comes from a finalization callback. - On one VM, main's versions of the three changed source files against this head, 2 rounds of the six docx files above: only `DocxEditorPlugins.test.tsx` differed. Main's sources gave 52 / 0 twice. Head gave 1 / 51, then 51 / 1. In both head runs the in-thread first test timed out on its own, at 5147 and 5050 ms, and in the 1 / 51 run every later mount timed out after it (see Deferred). In the other five files head had no more failures than main's sources in either round. - That first test runs close to Bun's 5 s default on both sides. Two more runs counted the display-list query paths, with the same three files swapped: - A VM at load average about 4, test 1 alone with a 60 s timeout, 3 runs per side: main's sources took 5082 to 5804 ms on the in-thread engine, head 4599 to 5152 ms. - A VM at load average about 2.5, sides interleaved, 4 runs per side: the whole file at the default timeout passed 52 / 0 in all 8 runs, with test 1 at 3383 to 4527 ms on main's sources and 2344 to 4784 ms on head. Test 1 alone with a 60 s timeout took 2462 to 2915 ms on main's sources and 2357 to 3156 ms on head. - In all 22 counted runs, no query used the JSON path on either side. Test 1, run on both engines, opened 7 store handles on both sides, and a whole-file run opened 62 to 66 on both sides. ## Live finalizer registrations at file end (measured) Each test file runs on its own, with a preload that counts live `FinalizationRegistry` registrations when the file ends. "Handles" counts display-list store handle registrations. "Other" counts edit sessions and xlsx documents. Before = pure main 7166688; after = this head, from the run above. The crash never reproduced on our VMs (no segfault in either run), so this count is the measure. | File | Handles before | Other before | Handles after | Other after | | --- | ---: | ---: | ---: | ---: | | `useYrsCoreSession.workerOpen.test.tsx` * | 461 | 0 | 1 | 0 | | `useDisplayList.lazyPages.test.ts` | 108 | 0 | 0 | 0 | | `DocxEditorPlugins.test.tsx` * | 72 | 0 | 0 | 0 | | `useDisplayList.workerLayout.test.ts` * | 61 | 0 | 0 | 1 | | `useFileIO.untouchedParts.test.tsx` | 58 | 0 | 1 | 0 | | `useDisplayList.provenance.test.tsx` | 33 | 0 | 0 | 0 | | `previewFailure.test.tsx` | 31 | 0 | 0 | 0 | | `useDisplayList.outOfMemory.test.ts` | 30 | 0 | 0 | 0 | | `displayListQueries.test.ts` | 29 | 0 | 1 | 0 | | `residentEngineWorker.navigation.test.ts` | 28 | 0 | 0 | 0 | | `whenLayoutComplete.test.tsx` | 24 | 0 | 0 | 0 | | `useDisplayList.handoff.test.ts` | 22 | 0 | 0 | 0 | | `useDisplayList.fallback.test.ts` | 20 | 0 | 0 | 0 | | `geometry.stories.test.ts` | 15 | 0 | 0 | 0 | | `firstPagePainted.test.tsx` * | 13 | 0 | 0 | 0 | | `EditorToolbarProp.test.tsx` * | 12 | 0 | 0 | 0 | | `hostProposals.test.tsx` | 10 | 0 | 0 | 0 | | `saveCycles.test.tsx` * | 10 | 0 | 0 | 0 | | `sessionLifetime.test.tsx` | 8 | 0 | 0 | 0 | | `viewerSelectionPublication.test.tsx` | 8 | 0 | 0 | 0 | | `frameDelta.test.ts` | 1 | 5 | 0 | 0 | | `residentCommandSelection.test.ts` | 6 | 0 | 0 | 0 | | `proposalToggle.geometry.test.tsx` * | 5 | 0 | 0 | 0 | | `useDocxPluginHost.lazyPages.test.tsx` | 4 | 0 | 0 | 0 | | `useDocxEditorRefApi.worker.test.tsx` | 3 | 0 | 0 | 0 | | `useFileIO.pickedImage.test.tsx` | 3 | 0 | 0 | 0 | | `loadingIndicator.test.tsx` | 2 | 0 | 0 | 0 | | `residentCommandWorker.test.ts` | 2 | 0 | 2 | 0 | | `XlsxEditorPlugins.test.tsx` | 0 | 1 | 0 | 0 | | `commandHost.shared.test.ts` | 0 | 1 | 0 | 0 | | `commandSwap.test.ts` | 0 | 1 | 0 | 0 | | `commandSwap.test.tsx` | 0 | 1 | 0 | 0 | | `recovery.test.ts` | 0 | 1 | 0 | 0 | | `showHiddenText.test.ts` | 0 | 1 | 0 | 0 | | **Total** | **1079** | **11** | **5** | **1** | Files at 0 on both sides are left out. A file marked * had failures in the after run, so its count only covers the tests that completed. With this branch merged into #1318's head, every marked file passes and holds 0 handles. Across 147 files, #1318's head alone holds 1119 + 11 and the merge holds 13 + 0; 9 of the 13 are in `workerPluginGeometry.test.ts` (see Deferred). ## Deferred - `workerPluginGeometry.test.ts` (from #1319) never disposes its plugin harness and leaves 9 handle registrations live. #1318's next head fixes it. - 5 handle registrations stay live at file end on this head: 2 in `residentCommandWorker.test.ts` from its fixture, and 1 each in `useYrsCoreSession.workerOpen.test.tsx`, `useFileIO.untouchedParts.test.tsx` and `displayListQueries.test.ts`. One edit session also stays live in `useDisplayList.workerLayout.test.ts`. That count comes from a run where two of the file's tests failed; in the merge with #1318 the file passes and holds 0. - Upgrade Bun once a release includes oven-sh/bun#44279. Until then, any registration still live when a file's realm is torn down can still hit the crash, including the ones above and those other libraries create. A handle's close callback was also seen once running against a torn-down realm, as `deadSources.has is not a function` at `displayListQueries.ts:339`, on a main-based branch. - Pre-existing flakes on main, found while measuring this (follow-ups): - `saveCycles.test.tsx`: on loaded VMs, all 4 tests fail together in about 1 of 3 runs of the file on main, and they failed on this head too. Each is a mount or startup timeout: the in-thread arm hits the 5 s test timeout while it waits for Save to enable. No comment-range or paragraph assertion fails. - `useYrsCoreSession.workerOpen.test.tsx`, "the editor state prefetch waits for a sliced background page-build reply with metadata bootstrap": run alone with a name filter, it fails 3 of 3 runs on main 89a5631. It passes in full-file runs. - `DocxEditorPlugins.test.tsx` on loaded VMs: the first test times out at 5 s and its body keeps running. Every later mount in the file then times out, giving 1 pass / 51 fail on main and on this head. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: GPT 6.1 Sol <codex@openai.com>
TL;DR: every workflow runs Bun 1.4.3 instead of 1.4.2. - 1.4.3 fixes the `bun test --isolate` segfault when a finished test file's global is collected while FinalizationRegistry callbacks are still queued (oven-sh/bun#44279). Our CI crash has the same stack; #1323 avoids it on 1.4.2 by disposing wasm handles eagerly. - The node:vm repro from oven-sh/bun#44279 segfaults 10/10 on 1.4.2 and 0/10 on 1.4.3 (Linux x64). - CI's test step on main, 4 vCPU Linux VMs, arms interleaved: no segfault on either; failed tests per run 16, 4, 7, 1, 5, 6 on 1.4.2 vs 7, 2, 0, 1, 0, 0, 0 on 1.4.3; median wall time 1189 s vs 1157 s. - `scripts/native-bindings.test.ts` expects the new pin. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Production serves Bun 1.4.4 (dev-manager toolchain-posture); 1.4.4 carries oven-sh/bun#44279. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
What does this PR do?
Fixes a segfault in
WTF::Vector::takeLastunderJSFinalizationRegistry::takeDeadHoldingsValue←runFinalizationCleanup←Bun__runDeferredWork(Sentry BUN-4SGV). It happens withnode:vmcontexts and withbun test --isolate/--parallel, on every platform.Cause
DeferredWorkTimerticket's cells (the registry, awaitAsyncpromise, a wasm promise) are marked only by their realm, so the sweep frees them once the realm is dead.DeferredWorkTimer::cancelPendingWork(VM&). It walksm_pendingTickets, which is empty in Bun becauseonAddPendingWorktakes each ticket intoJSCTaskScheduler's maps.~JSGlobalObjectcancels them too, but a lazily swept global is destroyed later. A cleanup job that was already queued runs in between, on a registry that has been destroyed.Fix
JSVMClientDataimplementsVM::ClientData::reconcileWeakReferencesAtGCEnd, the embedder hook JSC calls after marking and before sweeping.JSCTaskScheduler::cancelWorkOfDeadRealmsfinds the unmarked realms that still have pending tickets and callsJSGlobalObject::clearWeakTickets()on each. This is the fixing line. It is what~JSGlobalObjectcalls: it unregisters the realm'sAtomics.waitAsyncwaiters, then cancels its tickets through the existingonCancelPendingWorkhook, which drops the map entry and releases the event loop ref.JSCDeferredWorkTaskstores itsJSVMClientDatainstead of reaching the VM throughticket->scriptExecutionOwner(), which is a freed cell for exactly these jobs. The null check inBun__deleteDeferredWorkTaskgoes with it:~VMdeletesclientDatalast and never nulls it.A second effect:
Atomics.notifyno longer counts or wakes theAtomics.waitAsyncof a dead context.No WebKit change. Supersedes #39994.
How did you verify your code works?
Two new tests in
test/js/node/vm/vm.test.ts. Each also keeps one context alive and requires its callback to still run.woken: 5woken: 1, passThe first test also segfaults on canary
11c41c645. TheFinalizationRegistrytests intest/cli/test/isolation.test.tspass with the change.