Skip to content

bun:ffi: don't re-enter JS from a JSCallback while an exception is pending - #32780

Closed
robobun wants to merge 1 commit into
mainfrom
farm/101893e4/ffi-jscallback-pending-exception
Closed

bun:ffi: don't re-enter JS from a JSCallback while an exception is pending#32780
robobun wants to merge 1 commit into
mainfrom
farm/101893e4/ffi-jscallback-pending-exception

Conversation

@robobun

@robobun robobun commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator

What

A JSCallback whose JS body throws leaves the exception installed in the VM while control returns to the foreign caller, which cannot unwind it. When that caller invokes the callback again before returning (for example qsort calling its comparator a second time), Bun re-enters JSC with the exception still pending and assertion-enabled builds abort:

ASSERTION FAILED: Unexpected exception observed on thread Thread:0x... at:
The exception was thrown from thread Thread:0x... at:
Error Exception: cb-throw

!exception()
JavaScriptCore/ExceptionScope.h(61) : void JSC::ExceptionScope::assertNoException()

Repro (debug / ASAN builds):

import { dlopen, ptr, JSCallback } from "bun:ffi";
const libc = dlopen("libc.so.6", { qsort: { args: ["ptr", "u64", "u64", "function"], returns: "void" } });
const cb = new JSCallback(() => { throw new Error("cb-throw"); }, { args: ["ptr", "ptr"], returns: "i32" });
const arr = new Int32Array([3, 1, 2]);
try { libc.symbols.qsort(ptr(arr), arr.length, 4, cb.ptr); } catch (e) { console.log("caught", String(e)); }
console.log("survived");

Cause

The FFI callback glue leaves the exception pending so it surfaces once the enclosing FFI call returns to JS, which is the normal JSC host-function contract. What was missing is the other half of that contract: while an exception is pending, nothing may start a new JS execution. The native caller has no way to know a throw happened and keeps invoking the callback; Interpreter::executeCallImpl begins with scope.assertNoException(), which is what aborts. Release builds happen to survive because VMTraps::NeedExceptionHandling short-circuits the callee before its body runs, but that is exactly the state JSC's assertion forbids.

Fix

invokeFFICallback, the shared tail of every FFI_Callback_* entry point, now returns immediately, without entering JS, when the VM already has a pending exception. Everything else is unchanged: the exception still stays pending and is raised at the enclosing FFI call site.

Verification

New test in test/js/bun/ffi/ffi.test.js spawns a child that qsorts 8 elements with a comparator that throws, and asserts the error is caught at the qsort() call site, the callback body ran exactly once, and the process exited 0.

  • unfixed bun-debug (current main): the child aborts with the assertion above and the test fails
  • fixed bun-debug: the whole test/js/bun/ffi/ directory passes, including the two tests added by bun:ffi: don't re-throw JSC's TerminationException from a JSCallback #32792; also clean under BUN_JSC_validateExceptionChecks=1
  • the assertion only exists in ASSERT_ENABLED JSC builds, so the release binary passes the test before and after; debug and ASAN lanes are where it distinguishes

Rebase note

#32792 landed on main while this was open and performed the same invokeFFICallback deduplication of the FFI_Callback_* entry points, for an independent bug (re-throwing JSC's TerminationException). This PR is rebased on top of it, so the remaining src/ change is just the pending-exception guard inside that helper. #32778 fixes a third independent JSCallback bug in the same functions.

@robobun

robobun commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 9:08 AM PT - Jun 28th, 2026

@robobun, your commit 6d754ea has some failures in Build #66372 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 32780

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

bun-32780 --bun

@coderabbitai

coderabbitai Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Centralizes FFI callback invocation in JSFFIFunction.cpp and adds a spawned Bun subprocess test that loads qsort through bun:ffi, throws from a JSCallback, and checks the resulting error handling at the FFI call site.

Changes

FFI callback exception flow

Layer / File(s) Summary
Shared callback invocation
src/jsc/bindings/JSFFIFunction.cpp
invokeFFICallback now handles ThrowScope setup and profiledCall, and the FFI callback entry points build their argument buffers and delegate to it.
qsort throw test
test/js/bun/ffi/ffi.test.js
The harness imports bunEnv and bunExe, and a new spawned-process test discovers a qsort-exporting library, loads it through bun:ffi, throws from a JSCallback, and checks the FFI call-site behavior.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main fix: preventing JSCallback re-entry while an exception is pending.
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 covers the change and verification clearly, even though it uses different section headings than the template.

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

@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: 2

🤖 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 993-997: Condense the explanatory comment near the JSCallback/FFI
test so it fits the repo’s 3-line comment limit while preserving the invariant.
Keep the essential points in the test around JSCallback and the enclosing FFI
call site: a thrown callback error must not remain pending in the VM, the error
should surface at the outer FFI call, and the callback must not be re-entered
during that native call.
- Around line 1029-1033: The subprocess test in ffi.test.js drains stderr but
never checks it, so unexpected fixture output can slip through; update the
qsort/FFI subprocess assertion to use the existing combined-object style by
capturing stdout, stderr, and exitCode together and asserting stderr explicitly
alongside the current stdout and exitCode checks. Keep the pattern consistent
with the surrounding bunEnv-based subprocess tests and the existing combined
result assertions in this file.
🪄 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: 5923a2dd-8d95-4b55-9c65-2dc6a8b90ad6

📥 Commits

Reviewing files that changed from the base of the PR and between ba72810 and 7675d76.

📒 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

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

Additional findings (outside current diff — PR may have been updated during review):

  • 🟡 test/js/bun/ffi/ffi.test.js:1029-1033 — nit: stderr is read but never appears in any assertion, so when this test fails on an unfixed debug build the JSC ASSERTION FAILED: !exception() abort message is silently dropped and the failure shows only an empty-stdout mismatch. Consider expect({ stdout, stderr, exitCode }).toMatchObject({ stdout: "caught: cb-throw\ncalls: 1\ndone\n", exitCode: 0 }) — same pattern as cc.test.ts:453 — which surfaces stderr in the failure diff without asserting it empty (debug/ASAN noise).

    Extended reasoning...

    What this is

    The new subprocess test pipes stderr, drains it concurrently into a local variable, and then never references that variable in any assertion:

    const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
    expect(stdout).toBe("caught: cb-throw\ncalls: 1\ndone\n");
    expect(exitCode).toBe(0);

    The repo's CLAUDE.md (Tests reviewers reject → Subprocess tests, line 195) says: "Never assert stderr is exactly empty (ASAN/debug builds emit benign warnings); assert a combined { stdout, stderr, exitCode } object." The neighboring test/js/bun/ffi/cc.test.ts:449-456 shows the intended pattern:

    // stderr is included in the received object so failures show it, but is not
    // asserted empty: debug builds emit benign startup warnings.
    expect({ stdout: normalizeBunSnapshot(stdout), stderr, exitCode }).toMatchObject({
      stdout: "OK 100",
      exitCode: 0,
    });

    toMatchObject only checks the keys present in the expected object, so stderr is part of the received value (and thus printed in the failure diff) without being constrained to "".

    Why it matters specifically for this test

    This test's entire purpose is to catch a debug-build JSC assertion abort. Per the PR description, on unfixed bun-debug the child dies with:

    ASSERTION FAILED: Unexpected exception observed on thread ...
    !exception()
    JavaScriptCore/ExceptionScope.h(61) : void JSC::ExceptionScope::assertNoException()
    

    That diagnostic goes to stderr. With the current assertions, a regression produces only:

    Expected: "caught: cb-throw\ncalls: 1\ndone\n"
    Received: ""
    

    …plus an exit-code mismatch — the actual reason (the JSC assertion text) is read into a dead variable and discarded. Whoever debugs the regression has to re-run the child manually to see what happened. Including stderr in the received object puts the abort message directly in the test-failure diff.

    Step-by-step proof

    1. Revert invokeFFICallback (or run on a build without this PR) and run the test under bun-debug.
    2. Child process: first qsort comparison enters JS, throws, leaves the exception pending; second comparison hits Interpreter::executeCallImplscope.assertNoException() → abort. The abort banner is written to fd 2.
    3. Parent: proc.stdout.text() resolves to "" (nothing was logged before the abort), proc.stderr.text() resolves to the multi-line ASSERTION FAILED text, proc.exited resolves to a non-zero signal/exit code.
    4. expect(stdout).toBe(...) fails first; Jest's diff shows received "" vs the expected three lines. stderr is in scope but unreferenced, so its contents never appear in the test output.
    5. With expect({ stdout, stderr, exitCode }).toMatchObject({ stdout: ..., exitCode: 0 }), the same failure prints the full received object — including the ASSERTION FAILED: !exception() text — in the diff.

    Addressing the counter-argument

    It's true that CLAUDE.md's own canonical subprocess-test example (lines 86-96) also reads stderr without asserting on it, and that the pipe-draining requirement is satisfied here — so the test is not wrong. There is genuine tension between the example and the prose rule on line 195. Two things tip this toward worth-mentioning:

    • The "obvious fix" the counter-argument warns against (expect(stderr).toBe("")) is not what's being suggested. toMatchObject with stderr only on the received side adds zero flake risk on ASAN/debug builds — it never compares stderr to anything. The sibling cc.test.ts in the same directory already does exactly this, with a comment explaining why.
    • The diagnostic gap is unusually concrete for this test: unlike most subprocess tests, its designed-for failure mode writes the only useful signal to stderr. That's precisely the case the combined-object guidance exists for.

    Fix

    Replace the two trailing assertions with:

    expect({ stdout, stderr, exitCode }).toMatchObject({
      stdout: "caught: cb-throw\ncalls: 1\ndone\n",
      exitCode: 0,
    });

    This is a test-quality nit, not a functional bug — the test still correctly passes/fails on the right builds either way.

@robobun

robobun commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator Author

Done in 7fce146: switched to the toMatchObject shape from cc.test.ts so stderr shows up in the failure diff without being asserted empty. Verified on the unfixed debug build that the diff now carries the full ASSERTION FAILED: Unexpected exception observed on thread ... assertNoException() text and exitCode: 134, and that the fixed build still passes.

@robobun

robobun commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator Author

Final CI status for build 66372 (rebased commit 6d754eac): 285 of 286 test shards passed and there are no failing-test annotations anywhere in the build. The single red shard is darwin 26 aarch64 - test-bun, and it is CI infrastructure: the job dies before running a single test.

Error: buildkite-agent artifact download timed out after 120s for step 'darwin-aarch64-build-bun'. Refusing to continue with a partial download (would silently fall back to the wrong binary).

Same agent (darwin-aarch64-26.5.1-1), same error, on all three builds of this PR (65105, 65114, 66372) and on many unrelated branches in the same window (66361, 66362, 66365, 66367, 66369, 66371, among others). The runner exits with status 1, which the pipeline's automatic retry does not cover (only -1 and 255), so the shard cannot self-heal without a manual retry. The other parallel shard of the same step passed, so the box is intermittent rather than hard down.

test/js/bun/ffi/ffi.test.js passes on every lane that runs it. The node-tls-connect.test.ts breakage that also affected the pre-rebase build was fixed on main by #32789, which this branch is now rebased past.

I already re-rolled once and it landed on the same box, so I will stop pushing. A manual retry of the one darwin 26 aarch64 - test-bun job is all that is left between this PR and green.

@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 didn't find any issues, but this changes ThrowScope placement and re-entry semantics at the JSC/FFI boundary, so it's worth a human glance before merging.

Extended reasoning...

Overview

This PR touches two files: src/jsc/bindings/JSFFIFunction.cpp and test/js/bun/ffi/ffi.test.js. The C++ change consolidates nine near-identical FFI_Callback_call* entry points into a shared invokeFFICallback helper, and adds an early-return guard that skips JS re-entry when scope.exception() is already set. The test adds a subprocess-based regression that drives qsort with a throwing comparator and asserts the error surfaces at the FFI call site with the callback body entered exactly once.

Security risks

None identified. This is exception-flow plumbing inside the FFI callback trampoline; it doesn't touch auth, permissions, parsing, or any external input surface. The new test only dlopens well-known system C libraries already used elsewhere in this test file.

Level of scrutiny

Moderate-to-high. While the diff is mostly deduplication (net -100 lines), the substantive change is to JSC exception-scope handling at the native↔JS re-entry boundary: DECLARE_THROW_SCOPE is now hoisted above profiledCall (previously declared only inside the post-call if (exception) branch), and a new pending-exception guard short-circuits the call. The reasoning in the PR description is sound and matches JSC's assertNoException() contract, but ThrowScope lifetime/placement in JSC bindings is subtle enough that someone familiar with the exception-check validator should confirm the hoisted scope doesn't introduce validator noise on other paths.

Other factors

The bug-hunting system found nothing, CodeRabbit's two style nits were addressed, and the test is well-constructed with cross-platform libc discovery. CI shows musl build failures and a node-tls-connect test failure that appear unrelated to this change. The author also flags an overlap with #32778 touching the same functions. Given the critical-path nature of JSC bindings rather than any specific concern with the implementation, I'm deferring rather than approving.

Jarred-Sumner pushed a commit that referenced this pull request Jun 28, 2026
…32792)

### 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):

```js
// 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.
…nding

A JSCallback whose body throws leaves the exception installed in the VM
while control is still inside the foreign caller, which cannot unwind
it. If that caller invokes the callback again before returning (qsort
calling its comparator), Bun entered JSC with the exception still
pending and assertion-enabled builds aborted in
ExceptionScope::assertNoException.

invokeFFICallback now returns immediately, without entering JS, when an
exception is already pending. The throw still surfaces at the enclosing
FFI call site, as before.
@robobun
robobun force-pushed the farm/101893e4/ffi-jscallback-pending-exception branch from 9b255b1 to 6d754ea Compare June 28, 2026 11:01

@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 didn't find any issues — the guard is the standard JSC ThrowScope idiom and the test is solid — but exception-scope handling at the FFI↔JS re-entry boundary is subtle enough that someone with JSC context should give it a look, especially given the noted overlap with #32778.

Extended reasoning...

Overview

This PR adds a 6-line early-return guard to invokeFFICallback in src/jsc/bindings/JSFFIFunction.cpp: if scope.exception() is already set when an FFI callback trampoline fires, return jsNull() immediately rather than calling JSC::profiledCall. This prevents debug/ASAN builds from aborting on ExceptionScope::assertNoException() when native code (e.g. qsort) re-invokes a JSCallback after the first invocation threw. A new subprocess test in test/js/bun/ffi/ffi.test.js exercises this via qsort with a throwing comparator and asserts the error is caught once at the enclosing FFI call site.

Security risks

None identified. The change only short-circuits re-entry into JS when an exception is already pending; it does not introduce new data flows, parsing, auth, or external input handling. The test dlopens the system C library, which is consistent with existing tests in this file.

Level of scrutiny

Moderate. The diff is tiny and follows the canonical JSC ThrowScope pattern (matching the existing RETURN_IF_EXCEPTION fallback value), but it sits at the native↔VM boundary where exception-scope semantics are subtle. The PR description argues release builds already effectively behave this way via VMTraps::NeedExceptionHandling, and verification under BUN_JSC_validateExceptionChecks=1 is reassuring — but a human familiar with Bun's JSC integration should confirm the approach, particularly since #32778 touches the same nine entry points and whichever lands second needs a rebase.

Other factors

CodeRabbit's two cosmetic nits (comment length, assertion shape) were addressed and resolved. The author's CI analysis attributes the red lanes to an unrelated darwin artifact-download timeout and a live-cert TLS test, with ffi.test.js itself passing where it ran. No CODEOWNERS cover these paths.

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

Closing this since #35246 (bun:ffi: use the engine-native FFI when available) merged and covers the same ground. Thank you @robobun for the PR — if there's a piece of this that #35246 didn't pick up, please say so and we'll take another look.

(This comment was written by Claude, on behalf of the Bun team.)

@robobun

robobun commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks, and confirmed: #35246 covers it. The function this PR patched (invokeFFICallback) no longer exists, and the engine-native path already has the equivalent guard in ffiCallbackDispatch (FFICallbackThunk.cpp):

if (Exception* pendingException = vm.exceptionForInspection()) [[unlikely]] {
    returnSlot = 0;
    return { encodedJSUndefined(), pendingException };
}

which is the same semantics this PR would have added, implemented more cleanly. I rebuilt main (bun-debug) and ran this PR's original repro (qsort with a comparator that throws on the first comparison) and it behaves exactly as this PR would have made it behave: calls=1, the error is caught at the FFI call site, exit 0, and clean under BUN_JSC_validateExceptionChecks=1.

One small observation, not a request: the stress suite's nearest case, cbThrowOnLast in ffi-callbacks.js, throws on the final iteration of ffi_call_cb_reentrant, so native code never calls again after the throw and that line of ffiCallbackDispatch is not exercised there. A cbThrowOnFirst variant would pin it if you want the guard covered by the stress tests, but the behavior itself is correct on main today so nothing is left over from this PR.

@robobun
robobun deleted the farm/101893e4/ffi-jscallback-pending-exception branch August 1, 2026 01:44
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