Skip to content

[JSC] bun:ffi: a threadsafe JSFFICallback's entry thunk stays callable after its cell and VM are gone - #437

Merged
dylan-conway merged 5 commits into
mainfrom
claude/ffi-threadsafe-callback-outlives-vm
Aug 14, 2026
Merged

dylan-conway merged 5 commits into
mainfrom
claude/ffi-threadsafe-callback-outlives-vm

Conversation

@dylan-conway

@dylan-conway dylan-conway commented Aug 14, 2026 •

Copy link
Copy Markdown
Member

Native code may call a threadsafe: true JSCallback's entrypoint from any thread at any time — including after the global object that created it has been destroyed (a terminated worker, or process.exit() inside one), which the embedder cannot always order against close(). Everything that entrypoint touched lived in the GC cell: the JIT'd thunk (released by ~JSFFICallback), the Signature, the closed/pending word. A call arriving after the worker's VM teardown executed freed thunk memory or read a dead cell — in Bun a null Ref<Signature> assert on debug builds and a segfault on release:

// worker: hand a threadsafe JSCallback to a native thread that keeps calling it, then terminate the worker
const cb = new JSCallback(() => {}, { args: ["int"], returns: "void", threadsafe: true });
lib.symbols.spawn_calls(cb.ptr, 3000);   // pthread calling cb every 1ms
// parent: worker.terminate()  →  segfault at 0x4 on the native thread

Change. The threadsafe thunk targets a ThreadSafeRefCounted FFI::ThreadsafeCallbackHandle instead of the cell. The handle owns the thunk code, a ref to the signature, the closed-bit/pending-count word (moved from JSFFICallback) and the embedder's routing token; the cell and each ThreadsafeInvocation hold refs to it, and only the owning thread reads handle->callback(). Once an entrypoint has been handed out the handle — and with it the thunk — is never freed, close()d or not: there is no happens-before between a foreign thread leaving the thunk and the owning thread freeing it, so a call arriving after close(), or after the cell, global object and VM are gone, counts itself out and returns zero (logged under verboseFFI) rather than jumping into freed code. When the cell is destroyed it clears the handle's cell pointer and sets the closed bit; threadsafe callbacks are additionally Heap::protect()ed until closed and drained, so a cell cannot die (and sit unswept, still reachable from a delivered invocation) while its VM lives on. runThreadsafeInvocation() returns early for a handle whose cell is gone and retires under the API lock, and retireThreadsafeInvocation() is exported for invocations an embedder accepted but will not run on the owning thread ($vm's drain uses it). Non-threadsafe callbacks are unchanged (callable only on the owning thread, inside the FFI call that handed them out). ThreadsafeInvocation::embedderContext() is preserved, so Bun's dispatch hook needs no change.

Cost. One small heap object per threadsafe callback; the thunk and dispatch fast path are otherwise identical (the baked pointer is the handle instead of the cell). Every threadsafe callback that handed out an entrypoint keeps its handle + thunk (~200–250 bytes: a 64–96-byte thunk at 32-byte granularity, the handle, the executable-memory handle) for the rest of the process by design — that memory is what a late native call lands in. A per-signature freelist that re-arms retired thunks would bound this by the peak live count and is a possible follow-up.

Verified with a Bun debug+ASAN build against this branch: the repro above (both terminate() and in-worker process.exit(), native thread calling for 1.5 s after the worker is gone) runs clean where it asserted 4/4 before, and calling a worker's threadsafe callback from the parent after the worker exited — closed before exit or not — returns 0; ordinary threadsafe delivery and close() behave as before (closed callbacks are still collected, unclosed ones stay rooted); Bun's test/js/bun/ffi/ffi.test.js (146 tests) and cc.test.ts pass. New testFFI case testThreadsafeCallbackOutlivesVM (creates the callback in a VM of its own, destroys that VM unclosed / closed / with an invocation pending, then calls the entrypoint and retires the pending invocation): passes; with the previous "free on close()" policy its closed variant is a heap-use-after-free under ASAN. All 12 testFFI cases pass (9552 checks).

…e after its cell and VM are gone

Native code may call a `threadsafe` callback's entrypoint from any thread
at any time -- including after the global object that created it has been
destroyed (a terminated worker), which the embedder cannot always order
against close(). Everything that entrypoint touched lived in the GC cell:
the JIT'd thunk (freed by ~JSFFICallback), the signature, the
closed/pending word. A call arriving after the worker's VM teardown (or
after GC of a closed callback) executed freed thunk memory or read a dead
cell; in Bun that is a null Ref<Signature> assert on debug and a segfault
on release.

The threadsafe thunk now targets a ThreadSafeRefCounted
FFI::ThreadsafeCallbackHandle instead of the cell. The handle owns the
thunk code, a ref to the signature, the closed-bit/pending-count word and
the embedder's routing token; the cell and each ThreadsafeInvocation hold
refs to it, and only the owning thread reads handle->callback(). When a
threadsafe callback's cell is destroyed it clears that pointer and sets the
closed bit; if the callback was never close()d -- native code was never
told to stop calling -- the handle is additionally kept alive for the rest
of the process, so late calls count themselves out and return instead of
jumping into freed code. close()d callbacks are released as before.
runThreadsafeInvocation() returns early for a handle whose cell is gone,
and retireThreadsafeInvocation() is exported for invocations an embedder
accepted but will not run ($vm's drain uses it).

Non-threadsafe callbacks are unchanged: they may only be called on the
owning thread while it is inside the FFI call that handed them out.
@coderabbitai

coderabbitai Bot commented Aug 14, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 53 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 194e295b-af79-4fd0-bca4-1f75b0c4182a

📥 Commits

Reviewing files that changed from the base of the PR and between f0f60fd and 963b501.

📒 Files selected for processing (9)
  • Source/JavaScriptCore/ffi/BunFFI.h
  • Source/JavaScriptCore/ffi/FFICallbackThunk.cpp
  • Source/JavaScriptCore/ffi/FFICallbackThunk.h
  • Source/JavaScriptCore/ffi/FFIContext.cpp
  • Source/JavaScriptCore/ffi/FFIContext.h
  • Source/JavaScriptCore/ffi/JSFFICallback.cpp
  • Source/JavaScriptCore/ffi/JSFFICallback.h
  • Source/JavaScriptCore/ffi/tests/testFFI.cpp
  • Source/JavaScriptCore/tools/JSDollarVM.cpp

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

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Beyond the two inline nits, I also checked: Signature is ThreadSafeRefCounted so the handle's Ref<Signature> is safe to drop from whichever thread releases the last invocation; the closed-bit/pending-count CAS logic is byte-identical to what previously lived in the cell; and retireThreadsafeInvocation() / runThreadsafeInvocation() both guard handle.callback() before touching the cell, so an invocation drained after cellDestroyed() doesn't dereference the dead cell.

Extended reasoning...

This is a non-trivial cross-thread lifetime restructuring in JIT-adjacent code, so I'm not auto-approving. The two inline findings are minor (an error-path leak and a header-hygiene nit); the note above records the deeper thread-safety concerns that were examined and found sound, so a human reviewer needn't re-derive them.

Comment thread Source/JavaScriptCore/ffi/JSFFICallback.cpp
Comment thread Source/JavaScriptCore/ffi/FFICallbackThunk.h
…orward-declare ThreadsafeCallbackHandle in FFICallbackThunk.h
…ot); root threadsafe callbacks VM-wide; retire under the API lock; testFFI second-VM regression test

- ~JSFFICallback keeps the handle (and thunk) alive whenever an entrypoint
  was handed out, closed or not: there is no happens-before between a
  foreign thread leaving the thunk and this thread freeing it, and a
  close()-then-worker-exit-then-late-call reproduced the original crash.
  ~200-250 bytes per threadsafe callback ever created.
- Threadsafe callbacks are Heap::protect()ed until closed and drained, so
  the cell cannot die (and sit unswept, still reachable through
  handle->callback() from a delivered invocation) while its VM lives on,
  as it could when rooted only through a collectable global object.
- runThreadsafeInvocation retires (and possibly unroots) under the API
  lock; a call into a closed/dead handle is logged under verboseFFI.
- testFFI: testThreadsafeCallbackOutlivesVM creates a threadsafe callback
  in a VM of its own, destroys that VM (unclosed / closed / with an
  invocation pending) and checks the entrypoint still drops calls and
  returns zero, and that the pending invocation retires harmlessly.
  Heap-use-after-free under ASAN with the previous close() policy.
@github-actions

github-actions Bot commented Aug 14, 2026 •

Copy link
Copy Markdown

Preview Builds

Commit Release Date
963b5017 autobuild-preview-pr-437-963b5017 2026-08-14 19:46:16 UTC
a9c6383a autobuild-preview-pr-437-a9c6383a 2026-08-14 14:57:17 UTC

Comment thread Source/JavaScriptCore/ffi/FFICallbackThunk.cpp
Comment thread Source/JavaScriptCore/ffi/tests/testFFI.cpp Outdated
Comment thread Source/JavaScriptCore/ffi/FFIContext.h

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I've reviewed this PR across four rounds and the current revision is clean — all five earlier findings (handle leak on the OOM path, missing forward-declare, retireThreadsafeInvocation lock requirement, testFFI defensive-path VM leak, missing <atomic> include) are addressed and no new issues were found. Given the scope — cross-thread lifetime management against GC/VM teardown, JIT thunk ownership, and a deliberate process-lifetime retention policy for executable memory — a human look is still warranted.

What was reviewed:

  • The handle/cell/VM lifetime ordering: cellDestroyed() runs before the RefPtr deref, heap.protect keeps the cell live until closed+drained, and the intentional ref() in the destructor is now gated on an entrypoint existing.
  • retireThreadsafeInvocation and runThreadsafeInvocation both reach unroot() under the API lock; the !callback early-return in runThreadsafeInvocation correctly retires without touching the dead VM.
  • The atomic state word (tryBeginInvocation/endInvocation/markClosedAndReportPending) is a straight move from JSFFICallback — semantics unchanged.
  • The new testThreadsafeCallbackOutlivesVM now unconditionally destroys its per-iteration VM.
Extended reasoning...

Overview

This PR fixes a use-after-free where a threadsafe: true JSCallback's JIT'd entry thunk could be called by native code after the owning cell, global object, and VM had been destroyed (e.g. a terminated worker). The fix introduces FFI::ThreadsafeCallbackHandle, a ThreadSafeRefCounted heap object that owns everything the foreign-thread entry path touches (thunk code, signature, closed/pending state, embedder token). The thunk bakes in the handle pointer instead of the cell pointer; once an entrypoint is handed out the handle is kept alive for the process lifetime so late calls land in valid code and return zero. Threadsafe callbacks are additionally heap.protect()ed VM-wide until closed and drained. Nine files touched: the FFI runtime (FFIContext.h/cpp, JSFFICallback.h/cpp, FFICallbackThunk.h/cpp, BunFFI.h), the $vm test hook, and a new testFFI case.

Security risks

The change is in the direction of more safety — it eliminates a UAF on JIT'd executable memory. The new intentional-leak policy means each threadsafe callback that handed out an entrypoint retains ~200–250 bytes of executable + heap memory forever; this is documented and bounded by user callback creation, not attacker-controlled. No new untrusted-input parsing, no auth/crypto/permission surface. The atomic state machine is moved verbatim from the cell to the handle, so no new memory-ordering logic was introduced.

Level of scrutiny

High. This is JavaScriptCore runtime code with cross-thread lifetime reasoning against the GC and VM teardown, changes to JIT thunk generation, and a deliberate policy decision (never free a handed-out threadsafe thunk). The correctness argument depends on subtle ordering: ~JSFFICallback running during the VM's last sweep before anything else can observe handle.callback(), the heap.protect preventing the cell from dying while its VM lives, and the acq_rel on the state word ensuring foreign threads see the closed bit. These are the kinds of invariants a human familiar with JSC's heap and threading model should sign off on.

Other factors

  • The author has been responsive: all five automated findings across three prior review rounds were fixed in follow-up commits (f6dfc99, d6c2d08, 963b501), and each fix is confirmed present in the current diff.
  • The PR description documents ASAN verification against the original repro, Bun's FFI test suite (146 tests), and the new testFFI case covering unclosed / closed / pending-at-teardown VM destruction.
  • The only change since the last automated review is the one-line #include <atomic> addition — trivially correct.
  • This run of the bug-hunting system found no issues.

I am not approving because the guidelines reserve automated approval for simple/mechanical changes, and this is neither; the design tradeoff (permanent per-callback executable-memory retention vs. a freelist follow-up the description mentions) is also worth a human acknowledging.

dylan-conway added a commit to oven-sh/bun that referenced this pull request Aug 14, 2026
…tlives its VM) — to be replaced by the merge commit's autobuild
@dylan-conway
dylan-conway merged commit 8b6b046 into main Aug 14, 2026
91 of 92 checks passed
Jarred-Sumner added a commit that referenced this pull request Aug 15, 2026
… callable after its cell and VM are gone (#437)"

This reverts commit 8b6b046.

A JSCallback's entrypoint is a C function pointer whose backing state
belongs to a VM. Calling it after close() or after that VM has been torn
down is use-after-free by the native caller, the same as any C callback,
and should crash rather than silently return zero. #437 made that misuse
"work" by leaking a handle, Signature and JIT thunk for every threadsafe
callback ever created (out of the fixed executable pool), Heap::protect()ing
every threadsafe callback, and dropping late calls on the floor. None of it
is needed by a correct program. Document the contract on nativeEntrypoint()
instead.

No-Verification-Needed: straight revert of a merged commit plus a header comment
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant