Skip to content

Reach the VM of a deferred-work job through its client data, not the ticket's realm - #43085

Open
robobun wants to merge 4 commits into
mainfrom
robobun/cf357537/deferred-work-dead-realm
Open

robobun wants to merge 4 commits into
mainfrom
robobun/cf357537/deferred-work-dead-realm

Conversation

@robobun

@robobun robobun commented Sep 17, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • bun test --isolate segfaults at exit after every test passed (bun test --isolate: intermittent segfault in Bun__runDeferredWork at exit after all tests pass (1.4.2, Linux x64, JIT off) #43056, Bun 1.4.2): Segmentation fault at address 0x9670 in JSCTaskScheduler.cpp:31: vm under Bun__runDeferredWork.
  • Cause: JSCDeferredWorkTask::vm() (src/jsc/bindings/JSCTaskScheduler.cpp:32) read ticket->scriptExecutionOwner()->vm() before the isCancelled() check. The realm that owns the ticket can be destructed by then (a global that --isolate retired, a dead node:vm context). ~JSGlobalObject cancels the ticket but leaves the owner pointer in place. Bun__deleteDeferredWorkTask had the same read.

Fix

Background

  • DeferredWorkTimer carries native completions (FinalizationRegistry cleanup, wasm compilation, Atomics.waitAsync) to the JS thread, one Ticket each. A ticket records the realm that asked for the work as its scriptExecutionOwner.
  • Bun installs the timer's hooks. onScheduleWorkSoon wraps the ticket and the task in a JSCDeferredWorkTask and posts it to Bun's event loop. The job holds a Ref<Ticket>, so the ticket outlives its realm.
  • ~JSGlobalObject calls cancelPendingWorkSafe, which sets m_isCancelled on every ticket of that realm. It does not clear the owner pointer. JSC's own scriptExecutionOwner() asserts !isCancelled() for that reason.
  • A Zig::GlobalObject (the main realm, a ShadowRealm, each --isolate file) is larger than MarkedSpace::largeCutoff, so it is a precise allocation. A collection destructs and frees it before it returns. That is why the --isolate face reads freed memory in release builds.
Notes

The fixture: a node:vm context creates a FinalizationRegistry with four dead targets. Bun.gc(true) at GC end posts the registry's cleanup job. The context is then dropped and Bun.gc(true) sweeps it before the event loop runs the job. releaseWeakRefs() clears the WeakRef that createContext() holds on the sandbox, and one extra createContext() unroots the previous context from the shared structure (#39990). Eight kept contexts fill the precise tier of the subspace so the contexts under test land in a block, and the context is created 500 frames deep so that no stale slot of the round's frame keeps it alive. The test needs at least one round to collect its context.

I could not get the --isolate timing itself to fire with a constructed fixture (0 of 30 release runs). A ShadowRealm variant of the fixture also does not post a cleanup job for a dead realm, which I did not chase.

Under a debug build the unfixed binary aborts with ASSERTION FAILED: !isCancelled() at DeferredWorkTimer.h(150) on the current WebKit pin (000c48997255) and on the previous one. That assert is the same read as the issue's HeapCellInlines.h: vm frame.


no test proof · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/node/vm/vm.test.ts

…ticket's realm

Bun__runDeferredWork and Bun__deleteDeferredWorkTask read
ticket->scriptExecutionOwner()->vm() before they checked the ticket.
The realm that owns the ticket can be destructed by then: a global that
bun test --isolate retired, or a dead node:vm context. ~JSGlobalObject
cancels the ticket but leaves the owner pointer in place, so the read
hit a destructed cell. This is the crash in #43056.

The job now carries its JSVMClientData, and JSCTaskScheduler stores the
VM it was created for. Both entry points use that. The existing
isCancelled() check then skips the job.

This is the Bun-only part of #39994. A ticket whose realm died but was
not swept yet is still not cancelled, and its job still reads the dead
target. That needs the WebKit change in #39994.
@coderabbitai

coderabbitai Bot commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

Warning

Review paused — included plan limit reached

Keep your review moving with free on-demand reviews.

  • Run this review for free

On-demand reviews are free for the next 3 days.

  • Ask an admin to make reviews automatic

Open in CodeRabbit

Reviews can continue after your included limit without a manual trigger. An admin must approve usage-based billing.

Promotion and pricing details

On-demand reviews are free for the next 3 days. After that, they cost $0.25 per reviewed file.

Review limit details

Or wait 3 minutes for your next included review.

Check out review usage here.

Limit details: You’ve used all 10 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: f246bbc5-6aa1-44f0-9d4e-d5a15a51836c

📥 Commits

Reviewing files that changed from the base of the PR and between 9d0bbca and b0e8590.

📒 Files selected for processing (3)
  • src/jsc/bindings/JSCTaskScheduler.cpp
  • src/jsc/bindings/JSCTaskScheduler.h
  • test/js/node/vm/vm.test.ts

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: 091898e5-15d7-4ef0-9f4a-e5367ade7212

📥 Commits

Reviewing files that changed from the base of the PR and between b52d513 and 9d0bbca.

📒 Files selected for processing (4)
  • src/jsc/bindings/BunClientData.cpp
  • src/jsc/bindings/JSCTaskScheduler.cpp
  • src/jsc/bindings/JSCTaskScheduler.h
  • test/js/node/vm/vm.test.ts

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


Walkthrough

Changes

The scheduler now retains its VM through JSVMClientData. Deferred work no longer depends on ticket realm ownership during scheduling, execution, or cleanup. A regression test covers finalization jobs after VM contexts are destructed.

Deferred work VM ownership

Layer / File(s) Summary
Scheduler VM ownership
src/jsc/bindings/BunClientData.cpp, src/jsc/bindings/JSCTaskScheduler.h
JSCTaskScheduler requires and stores a JSC::VM. JSVMClientData initializes DeferredWorkTimer with that VM.
Deferred task lifecycle
src/jsc/bindings/JSCTaskScheduler.cpp
Deferred tasks retain JSVMClientData. Scheduling, execution, and deletion derive VM and scheduler state from that data.
Finalization regression coverage
test/js/node/vm/vm.test.ts
The test verifies cleanup jobs for destructed contexts complete with done, empty stderr, and exit code 0.

Suggested reviewers: dylan-conway

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to 9d0bb

The deferred-work ownership update has no identified 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 and concisely describes the main fix: deferred-work jobs obtain the VM through client data instead of the ticket's realm.
Description check ✅ Passed The description explains the problem, root cause, fix, scope, limitations, and verification results. It does not use the exact template headings, but it provides the required content, including how th…

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.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline findings, I also checked the lifetime of the raw clientData pointer each job now carries: queued jobs are released via release_unrun during close_and_wait (which only publishes Closed after the ticket count hits zero and every queued task is serviced), so Bun__deleteDeferredWorkTask runs before ~JSVMClientData on the normal teardown path. The refused-post path in Bun__queueJSCDeferredWorkTaskConcurrently dereferences job->clientData the same way the old code dereferenced the ticket's owner, so it is not a new exposure.

Extended reasoning...

Two confirmed findings are posted inline (the remaining stale target()->globalObject() read at JSCTaskScheduler.cpp:127 and the regression test not failing on a release build), so a human still needs to weigh in. This note records what else was examined: the new unowned JSVMClientData* on JSCDeferredWorkTask is live for every run/release path reachable via VmHandle::close_and_wait (src/jsc/VmHandle.rs:440-479), and the Posted::Refused branch in src/jsc/JSCScheduler.rs:62-69 was already dereferencing dead-realm memory before this change, so the PR does not widen that window. The refcount balance in runPendingWork and Bun__deleteDeferredWorkTask is unchanged by the refactor (same remove/dropPendingTicketLocked + refKeepAlive(-1) pairing).

One verified lower-impact observation (a convention, logging or cleanup point) was not posted.

Comment thread test/js/node/vm/vm.test.ts
Comment thread src/jsc/bindings/JSCTaskScheduler.cpp
@robobun

robobun commented Sep 17, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 7:37 AM PT - Sep 17th, 2026

✅ @robobun, your commit b0e8590d9daa8cb7dccd762a7586244624c84e3a passed in Build #117070! 🎉


🧪   To try this PR locally:

bunx bun-pr 43085

That installs a local version of the PR into your bun-43085 executable, so you can run:

bun-43085 --bun

Comment thread src/jsc/bindings/JSCTaskScheduler.cpp Outdated
Comment thread src/jsc/bindings/JSCTaskScheduler.h Outdated
Comment thread src/jsc/bindings/JSCTaskScheduler.h Outdated

@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 the latest push and found no new bugs; the follow-up commits addressed the test-comment point from my earlier review. Since this is lifetime code in the JSC deferred-work glue and I could not build the debug binary in this environment to run the new fixture, a human look before merge is still worthwhile.

What was reviewed:

  • JSVMClientData initializer order: deferredWorkTimer(vm) sits between commonStrings and m_builtinNames, matching the member declaration order in BunClientData.h, so no reorder hazard.
  • The dropped if (clientData) guard in Bun__deleteDeferredWorkTask: the old path dereferenced job->vm() (the dead owner) before the null check ever ran, so the guard protected nothing; the new raw job->clientData pointer has the same "released before ~VM" contract the Rust release_unrun/refused-post path already relies on, so no new lifetime assumption is introduced.
  • runPendingWork now reaches the VM via scheduler.vm() before the target() read, which stays behind the !isCancelled() check as on base.
Extended reasoning...

Overview

The change replaces JSCDeferredWorkTask::vm() (which walked ticket->scriptExecutionOwner()->vm(), a pointer into a realm that ~JSGlobalObject cancels but does not clear) with two stable handles: a WebCore::JSVMClientData* stored on each job at post time, and a JSC::VM& stored on JSCTaskScheduler at construction. Bun__runDeferredWork and Bun__deleteDeferredWorkTask now derive the VM handle, scheduler and VM from those. Since my prior review, the only diffs are shortened comments in the header/cpp and the test comment stating that only a debug build observes the assertion, which is what my optional inline note asked for.

Security risks

None of the usual kind (no user-controlled parsing, auth, or network surface). The relevant risk class is memory safety: a stale pointer read on the event-loop path. The fix removes one such read; the remaining target()->globalObject() read behind the !isCancelled() gate is unchanged from base and is explicitly scoped out to #39994 in the description, matching what I noted inline last time.

Level of scrutiny

Moderate-to-high. The diff is small (~40 lines of C++) but sits in the JSC/event-loop lifetime glue, which is the repo's most-blocked category. I checked the constructor initializer order against the header, confirmed WTF_MAKE_NONCOPYABLE resolves through the existing DeferredWorkTimer.h include, and traced the Rust release_unrun and Posted::Refused paths in src/jsc/JSCScheduler.rs and src/jsc/VmHandle.rs to confirm the new raw clientData pointer carries the same lifetime contract the old vm.clientData lookup did. The removed null guard in Bun__deleteDeferredWorkTask was unreachable-as-protection because the old code dereferenced the dead owner before reaching it.

Other factors

WebKit headers and a debug build are not present in this checkout, so I could not run the new vm.test.ts fixture or verify the claimed 3/3 debug-build failure on the unfixed base; the PR description's own verification note and CI are the evidence for that. The test itself is hermetic (spawned -e fixture, concurrent pipe drain, combined {stdout, stderr, exitCode} assertion, bounded poll). The bug hunt ran dry with no findings. Given the code-owner sensitivity of the JSC bindings and the pending #39994 interplay, a human confirmation before merge is the safer call rather than an automated approve.

This branch has not been deployed

No deployments
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.

1 participant