Skip to content

bun:ffi: don't re-throw JSC's TerminationException from a JSCallback - #32792

Merged
Jarred-Sumner merged 8 commits into
mainfrom
farm/a29fd24e/ffi-callback-termination
Jun 28, 2026
Merged

bun:ffi: don't re-throw JSC's TerminationException from a JSCallback#32792
Jarred-Sumner merged 8 commits into
mainfrom
farm/a29fd24e/ffi-callback-termination

Conversation

@robobun

@robobun robobun commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator

What

Calling worker.terminate() while the worker thread is inside a bun:ffi JSCallback makes the FFI callback glue re-throw JSC's own TerminationException. Assertion-enabled builds abort on the worker thread:

ASSERTION FAILED: !isTerminationException(exception) || hasTerminationRequest()
vendor/WebKit/Source/JavaScriptCore/runtime/VM.cpp(1072) : void JSC::VM::setException(Exception *)

Reproduces 5/5 with a debug build (no native library needed, CFunction over cb.ptr is enough):

// worker.js: a threadsafe JSCallback that signals the parent, then spins
const callback = new JSCallback(() => { Atomics.store(flag, 0, 1); while (true) {} },
  { returns: "void", args: [], threadsafe: true });
const fire = new CFunction({ ptr: callback.ptr, returns: "void", args: [] });
fire();
// main.js: wait for flag[0], then worker.terminate()

The racier sibling of the same bug shows up as ASSERTION FAILED: !exception() in ExceptionScope.h(61) when the next queued callback task enters Interpreter::executeCallImpl with the re-thrown exception still pending.

Cause

Every FFI_Callback_* entry point in src/jsc/bindings/JSFFIFunction.cpp called the JS function through the NakedPtr<Exception> overload of profiledCall (which clears the VM's exception) and then unconditionally re-threw it with scope.throwException().

A threadsafe JSCallback runs from a posted event-loop task, so its profiledCall is the outermost VM entry. When worker.terminate() lands inside that callback, JSC throws the TerminationException, and the outermost VMEntryScope teardown retires the termination request (VM::clearHasTerminationRequest). Re-installing the engine's own TerminationException after that violates VM::setException's precondition, and in a no-assert build it leaves a half-terminated worker VM with a stale pending TerminationException that the next JS entry trips over.

Fix

The clear-and-rethrow round trip was an inlined, incorrect re-implementation of "leave the exception pending". Do that directly: all ten FFI_Callback_* entry points now share one invokeFFICallback() helper that uses the plain profiledCall overload and RETURN_IF_EXCEPTION, leaving whatever JSC threw (user exception or TerminationException) pending on the VM like any other host function.

  • Nested case (JS -> native -> callback): unchanged, the pending exception still surfaces at the enclosing FFI call site.
  • Top-of-event-loop case (threadsafe task): the task dispatcher already recognizes a pending TerminationException and stops the tick loop, which is the path every other task-based JS entry uses.

Verification

Two tests in test/js/bun/ffi/ffi.test.js:

  • JSCallback tolerates worker.terminate() arriving inside the callback: spawns the repro above as a child. Unfixed bun-debug aborts with the assertion (SIGABRT); fixed passes 10/10.
  • JSCallback exceptions propagate out of the native call: pins the normal-exception contract (a throwing callback still raises at the FFI call site), identical before and after.

The assertion only exists in ASSERT_ENABLED JSC builds, so the release binary passes the first test before and after; debug/ASAN is where it distinguishes. Both tests are skipped on Windows ARM64, where TinyCC (and with it JSCallback/CFunction) is compiled out. The full existing JSCallback/CFunction/threadsafe-callback matrix in ffi.test.js passes with the change.

Related: #32780 and #32778 fix independent JSCallback bugs in the same functions (re-entering JS while a user exception is pending; trampoline freed under a native caller). This one is about the TerminationException specifically and composes with both, but whichever lands later needs a small rebase.

FFI_Callback_* called the JS function through the NakedPtr profiledCall
overload, then cleared and re-threw the returned exception. For the
TerminationException raised by worker.terminate() that re-throw happens
after the outermost VMEntryScope has already retired the termination
request, which violates VM::setException's precondition and re-enters
JS on the terminated worker VM:

  ASSERTION FAILED: !isTerminationException(exception) || hasTerminationRequest()
  JavaScriptCore/runtime/VM.cpp(1072) : void JSC::VM::setException(Exception *)

Leave the exception pending on the VM instead, like any other host
function, and skip callback tasks posted to a global whose script
execution has already stopped.
@coderabbitai

coderabbitai Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@robobun, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 2 minutes and 22 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

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 credits.

🚦 How do rate 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 see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: a1b19263-a0ba-413b-b207-84b1415894db

📥 Commits

Reviewing files that changed from the base of the PR and between 1e8263d and ed2c62e.

📒 Files selected for processing (2)
  • src/jsc/bindings/JSFFIFunction.cpp
  • test/js/bun/ffi/ffi.test.js

Walkthrough

JS FFI callback invocation now routes through a shared helper, and threadsafe callbacks skip JS re-entry when the target global is no longer running. The test suite adds coverage for exception propagation through CFunction and worker termination during an active threadsafe callback.

Changes

FFI callback invocation and shutdown behavior

Layer / File(s) Summary
Shared callback helper
src/jsc/bindings/JSFFIFunction.cpp
invokeFFICallback centralizes the profiled JSFunction call and pending-exception handling, and FFI_Callback_call delegates to it after decoding arguments.
Threadsafe reentry guard
src/jsc/bindings/JSFFIFunction.cpp
The threadsafe callback task checks the global's scriptExecutionStatus before re-entering JS, and the arity-specific callbacks build a MarkedArgumentBuffer before delegating to invokeFFICallback.
Callback regression tests
test/js/bun/ffi/ffi.test.js
The ffi tests import additional harness utilities, assert exception propagation through CFunction, and exercise worker termination while a threadsafe JSCallback is active.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: fixing JSCallback handling of JSC TerminationException.
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.
Description check ✅ Passed The PR description is detailed and covers the change and verification, though it uses custom headings instead of the repository template.

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

@robobun

robobun commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 10:34 PM PT - Jun 26th, 2026

@robobun, your commit ed2c62e50fee65d64ca1c57a988487db74ba3a4d passed in Build #65269! 🎉


🧪   To try this PR locally:

bunx bun-pr 32792

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

bun-32792 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 2 issues this PR may fix:

  1. Segfault when native code repeatedly invokes JSCallback({ threadsafe: true }) #28113 - Segfault when native code repeatedly invokes JSCallback({ threadsafe: true }) — the stack trace goes directly through FFI_Callback_threadsafe_call in JSFFIFunction.cpp, which this PR refactors with proper RETURN_IF_EXCEPTION and the scriptExecutionStatus guard
  2. bun:ffi JSCallback invoked from different thread crashing after a while #24529 - bun:ffi JSCallback invoked from different thread crashing after a while — same threadsafe callback crash pattern, reporter notes it crashes "even when removing the entire function body"

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #28113
Fixes #24529

🤖 Generated with Claude Code

@robobun

robobun commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator Author

Looked at both before adding Fixes tags, and I don't think either is this bug, so I'm not claiming them.

This PR only changes what happens when the callback's profiledCall comes back with an exception (specifically JSC's TerminationException from worker.terminate()), plus a guard against dispatching a queued callback task on a global whose script execution has already stopped. Both issues crash in steady state, on a live main-thread VM, with nothing terminating and callbacks that don't throw, so neither change is on their crash path.

If either still reproduces with #30165 applied, that would be a distinct third bug in this path.

Comment thread src/jsc/bindings/JSFFIFunction.cpp Outdated

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/bun/ffi/ffi.test.js`:
- Around line 721-724: Replace the time-based polling in the ffi test with an
Atomics-based signal: in the worker path that stores to the shared flag, call
Atomics.notify after writing the value, and in the parent path wait on the same
shared location instead of looping with Bun.sleep. Update the wait logic around
Atomics.load/flag in the ffi test so it blocks on the observable condition and
resumes when the worker notifies, using the existing worker callback/frame
synchronization points.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 49fb5c1c-e010-4d62-90e7-f286d53161af

📥 Commits

Reviewing files that changed from the base of the PR and between be99161 and 1c818ac.

📒 Files selected for processing (2)
  • src/jsc/bindings/JSFFIFunction.cpp
  • test/js/bun/ffi/ffi.test.js

Comment thread test/js/bun/ffi/ffi.test.js Outdated
Comment thread test/js/bun/ffi/ffi.test.js Outdated
Comment thread test/js/bun/ffi/ffi.test.js Outdated
- skip both new tests on Windows ARM64, where TinyCC (and with it
  JSCallback/CFunction) is compiled out and the constructors throw
- run the throwing-callback test in a spawned script: bun test's exit
  path does not finalize the CFunction's native handle, which the ASan
  lane's leak checker reports against the test process
- enqueue a single callback task: a worker terminated with a task still
  queued re-buffers it in EventLoop::deinit, and that buffer is
  unreachable to LSan once the worker arena is freed
@robobun

robobun commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator Author

Two pre-existing issues surfaced while getting the new tests green on the x64-asan lane (it runs with ASAN_OPTIONS=detect_leaks=1:abort_on_error=1). Neither is caused by or addressed in this PR; recording them so they aren't lost in a resolved review thread.

  1. bun test's exit path does not finalize native handles owned by live JS objects, so LeakSanitizer reports them against whichever test file happened to create one. For an in-process new CFunction(...) that is the link_symbols symbol table:
Direct leak of 210 byte(s) in 4 allocation(s)
    ...
    #13 reserve<Box<[u8]>, bun_runtime::ffi::ffi_body::Function, ...>
    #14 bun_runtime::ffi::ffi_body::generate_symbols src/runtime/ffi/ffi_body.rs:1864
    #15 FFI::link_symbols src/runtime/ffi/ffi_body.rs:1636

A plain bun script.js exit finalizes everything and reports nothing, which is why the test now spawns a child instead of constructing the CFunction inside the test runner process. It is also consistent with cc.test.ts gating its in-process FFI blocks on isASAN.

  1. Terminating a worker that still has a queued event-loop task leaks the re-queue buffer allocated inside EventLoop::deinit:
Direct leak of 16 byte(s) in 1 object(s)
    ...
    #14 LinearFifo::write_item src/collections/linear_fifo.rs:573
    #15 bun_jsc::event_loop::EventLoop::deinit src/jsc/event_loop.rs:767
    #16 bun_jsc::virtual_machine::VirtualMachine::destroy src/jsc/VirtualMachine.rs:4387
    #17 bun_jsc::web_worker::WebWorker::shutdown src/jsc/web_worker.rs:1299

deinit deliberately re-queues tasks it cannot safely drop so they stay reachable through the never-freed main-thread VirtualMachine, but a worker's VM does not outlive the worker, so the freshly allocated buffer becomes unreachable. Bounded (one small allocation per terminated worker), but any future asan-lane test that terminates a worker with a task still queued will trip it.

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/bun/ffi/ffi.test.js`:
- Around line 712-715: The subprocess regression in the ffi test is too loose
because it allows any stderr output while still expecting exitCode 0. Update the
combined-object assertion in the ffi.test.js case around the subprocess check to
pin stderr to an empty string, matching the bunEnv subprocess test pattern and
keeping the strongest invariant.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: e9f52c4d-1564-4458-8bc9-7b4341729c11

📥 Commits

Reviewing files that changed from the base of the PR and between 1c818ac and d0b152b.

📒 Files selected for processing (1)
  • test/js/bun/ffi/ffi.test.js

Comment thread test/js/bun/ffi/ffi.test.js
Comment thread test/js/bun/ffi/ffi.test.js Outdated
Comment thread src/jsc/bindings/JSFFIFunction.cpp Outdated
Comment thread src/jsc/bindings/JSFFIFunction.cpp
Zig::GlobalObject::scriptExecutionStatus always reports Running from C++:
its extern, Bun__VM__scriptExecutionStatus, resolves to the phase_c_exports
stub, so the branch could never fire. Entry traps already turn a
post-termination JS entry into the TerminationException this change handles.
Comment thread src/jsc/bindings/JSFFIFunction.cpp
@Jarred-Sumner
Jarred-Sumner merged commit 12ce04f into main Jun 28, 2026
77 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/a29fd24e/ffi-callback-termination branch June 28, 2026 05:13
robobun added a commit that referenced this pull request Jun 28, 2026
…s inside it

JSCallback.close() drops the Function synchronously, and Function::drop
calls TCC::State::destroy, which unmaps the page holding the compiled
trampoline. When close() is called from inside the callback itself, the
native caller (e.g. qsort) is still on the stack: the in-flight
invocation returns into the unmapped page and the caller keeps jumping
to the freed function pointer for the rest of the native call.

There is no safe point anywhere on that native stack. Even after the
last FFI_Callback_call_* frame returns, the trampoline still has to run
its own return-value conversion and ret out of the page that was just
unmapped.

Fix:

- FFICallbackFunctionWrapper counts how many FFI_Callback_call_* frames
  are on the JS thread's stack (FFICallbackCallScope, an RAII guard in
  each of the nine non-threadsafe entry points). The threadsafe entry
  point runs on a foreign thread and only posts a task, so it is not
  touched.
- close() from inside the callback no longer drops the Function. It arms
  a pending-close slot on the wrapper. Closing with no call in flight
  (the normal case) still drops immediately, so a tight create/close
  loop does not accumulate TinyCC states.
- The outermost FFICallbackCallScope destructor (call depth reaching 0)
  is the only place that enqueues the deferred drop as an event-loop
  task. No JS can execute between that point and the event loop draining
  its queue, so nothing can free the trampoline while a frame still has
  to return into it. Enqueuing from close() itself would leave a hole:
  a full EventLoop::tick() triggered from inside the callback (bun:jsc's
  drainMicrotasks, bun:test's resolves/rejects via wait_for_promise)
  would run the deferred drop early.
- The enqueued task routes back through the same check instead of
  dropping unconditionally. A nested tick from a later native invocation
  of the same callback can still run it while a trampoline frame is
  live, and it then re-arms the wrapper so the new outermost scope
  reschedules it. A task that re-enqueues itself would hang instead:
  tick() drains the queue until it is empty.

Deferring the drop also keeps the JSC::Strong on the JS function alive
for the remaining invocations the native caller makes before it returns,
so a callback that closes itself keeps working until the caller is done.

Rebased on top of the invokeFFICallback() refactor from #32792: the
per-arity entry points now delegate to a shared helper, so the RAII
guard moves to the top of each (now one-line) entry-point body.
robobun added a commit that referenced this pull request Jun 29, 2026
… call

invokeFFICallback (added on main in #32792) declares its own ThrowScope,
so the lambda's scope that guards decodeThreadsafeCallbackArgument must
be released before that call or JSC's exception-scope validator reports
the simulated throw as unchecked.
@coderabbitai coderabbitai Bot mentioned this pull request Jul 7, 2026
6 tasks
robobun added a commit that referenced this pull request Jul 15, 2026
… call

invokeFFICallback (added on main in #32792) declares its own ThrowScope,
so the lambda's scope that guards decodeThreadsafeCallbackArgument must
be released before that call or JSC's exception-scope validator reports
the simulated throw as unchecked.
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