Skip to content

fix(ffi): defer JSBigInt allocation for threadsafe JSCallback i64/u64 args to the JS thread - #30165

Closed
robobun wants to merge 14 commits into
mainfrom
farm/24aebbb5/ffi-threadsafe-bigint
Closed

fix(ffi): defer JSBigInt allocation for threadsafe JSCallback i64/u64 args to the JS thread#30165
robobun wants to merge 14 commits into
mainfrom
farm/24aebbb5/ffi-threadsafe-bigint

Conversation

@robobun

@robobun robobun commented May 3, 2026

Copy link
Copy Markdown
Collaborator

Problem

new JSCallback(fn, { args: ['int64_t'], threadsafe: true }) generates a TCC trampoline that converts each native argument to a JSValue and then calls FFI_Callback_threadsafe_call, which posts a task to the JS thread. For int64_t / uint64_t / i64_fast / u64_fast arguments, the conversion goes through INT64_TO_JSVALUE_SLOW / UINT64_TO_JSVALUE_SLOW, which call JSBigInt::createFrom — a heap allocation on the JSC GC heap.

The trampoline runs on whatever OS thread invoked the callback. When that is not the JS thread, JSBigInt::createFrom allocates without holding the JS lock, corrupting MarkedBlock free lists / GC state.

Repro

test/js/bun/ffi/ffi-threadsafe-callback-bigint.test.ts compiles a tiny shared library that invokes a threadsafe JSCallback with a 64-bit integer argument from a real pthread. On a debug build without this fix:

ASSERTION FAILED: isSymbol() || isHeapBigInt()
vendor/WebKit/Source/JavaScriptCore/runtime/JSCell.cpp(268) : JSString *JSC::JSCell::toStringSlowCase(JSGlobalObject *) const

— the off-thread allocation produced an invalid cell.

Fix

For threadsafe: true callbacks only, the generated trampoline now:

  • passes the raw 64-bit bits through for int64_t / uint64_t / i64_fast / u64_fast arguments instead of calling *_TO_JSVALUE_SLOW, and
  • emits a static const uint8_t argTypes[N] table and passes it as a fourth argument to FFI_Callback_call.

FFI_Callback_threadsafe_call copies the raw values and the type table into the posted task and performs the JSBigInt / jsNumber conversion there, on the JS thread. All other argument types (whose *_TO_JSVALUE encodings are immediate and never heap-allocate) are unchanged. Non-threadsafe callbacks are unchanged.

Example of the new trampoline for { args: ['int32_t','int64_t','uint64_t','double'], threadsafe: true }:

 ZIG_REPR_TYPE arguments[4];
arguments[0] = INT32_TO_JSVALUE((int32_t)arg0).asZigRepr;
arguments[1] = (ZIG_REPR_TYPE)(int64_t)arg1;
arguments[2] = (ZIG_REPR_TYPE)(int64_t)arg2;
arguments[3] = DOUBLE_TO_JSVALUE(arg3).asZigRepr;
static const uint8_t argTypes[4] = {5, 7, 8, 9};
  FFI_Callback_call((void*)0x...ULL, 4, arguments, argTypes);

Verification

bun bd test test/js/bun/ffi/ffi-threadsafe-callback-bigint.test.ts
before ASSERTION FAILED: isSymbol() || isHeapBigInt() in JSCell::toStringSlowCase
after passes; all of int64_t / uint64_t / i64_fast / u64_fast (big & small) and a mixed-arg callback deliver the expected values

Existing run ffi threadsafe-callback and non-threadsafe-callback tests (all arg types, both normal and fast-int variants) pass unchanged.

The ffi.test.fixture.{callback,receiver}.c snapshot files are regenerated by ffi.test.js from the current FFI.h; most of their diff is unrelated drift that had accumulated on main.


Rebase after #31332 (ffi: avoid copying the threadsafe callback wrapper on the calling thread): that commit rewrote the same FFI_Callback_threadsafe_call this PR extends (refcounted wrapper, cached ScriptExecutionContextIdentifier, Ref{wrapper} capture). The merged version keeps main's wrapper-lifetime handling and adds this PR's argTypes marshalling, throw scope, and per-argument BigInt decode inside the posted task. The new foreign-thread cc.test.ts coverage added by #31332 passes together with this PR's tests.


Rebase (June): main deleted the Zig sources and refactored callback exception handling.


Rebase after #34140 (threadsafe JSCallback destroys JSC::Strong handles off the JS thread on teardown): that commit rewrote the same postTaskTo call this PR extends, moving the keep-alive ref inside postTaskTo's found-live window (via a between-lookup-and-enqueue lambda) and releasing it via adoptRef inside the task so a failed enqueue is ref-neutral on the foreign thread. The merged FFI_Callback_threadsafe_call keeps that ref-handling shape and adds this PR's argTypesVec capture, per-argument BigInt decode, and ThrowScope / scope.release() inside the task body; adoptRef stays first so the ref releases on any early return. The worker.terminate()-during-callback test from #34140 passes together with this PR's tests.

Also rebased cleanly through #33909 (per-crate thiserror enums, touched ffi_body.rs/host_fns.rs/abi_type.rs), #33122 (f64 NaN/-0.0/BigInt conversion, touched ffi.ts), #33712, and #33731; all auto-merged with no semantic overlap, and their new FFI tests pass.

Related

#28115 fixes a separate off-thread access inside FFI_Callback_threadsafe_call itself (copying JSC::Strong<>). This PR fixes the step that happens in the TCC trampoline before that function is called. There will be a small textual conflict in JSFFIFunction.cpp when both land.


While here, also fixed two pre-existing bugs this change would have otherwise exposed:

  • The "Threadsafe functions must return void" guard in generateSymbolForFunction read function.threadsafe (the not-yet-assigned out-param, always false) instead of the local threadsafe, so it never fired.
  • JSCallback's constructor destructured { ctx, ptr } directly out of nativeCallback's result without checking for an error return, so every validation error from the native side was silently swallowed into ptr: undefined. It now throws, matching dlopen / linkSymbols.

Fixes #24529


Rebase after #30412 (Rewrite Bun in Rust): the FFI callback codegen was rewritten in Rust (src/runtime/ffi/{ffi_body.rs,host_fns.rs,abi_type.rs}) as a faithful port of the original Zig — including both bugs this PR fixes. Commit 9409be4 applies the same fix to the Rust codegen: the dead threadsafe && return_type != Void guard, the IS_THREADSAFE define, raw-bit marshalling for 64-bit int args, the argTypes[] table, and the 4-arg FFI_Callback_call signature. The Zig changes are retained (the file is still compiled) for parity.


no test proof · iteration 25 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/bun/ffi/ffi-threadsafe-callback-bigint.test.ts

@coderabbitai

coderabbitai Bot commented May 3, 2026

Copy link
Copy Markdown
Contributor

Walkthrough

This PR extends Bun's FFI system to support thread-safe callbacks with proper JavaScript BigInt handling. Thread-safe callbacks now receive an additional ABI type descriptor array, allowing the JavaScript thread to decode 64-bit integer arguments as BigInt values rather than allocating them on the OS thread, and includes test coverage for mixed-type callback scenarios.

Changes

Threadsafe FFI Callbacks with BigInt Support

Layer / File(s) Summary
ABI Interface
src/bun.js/api/FFI.h
FFI_Callback_call signature becomes conditional: IS_THREADSAFE builds add const uint8_t* argTypes parameter to pass per-argument type tags; non-threadsafe builds retain the original 3-parameter form.
Core Callback Compilation
src/bun.js/api/ffi.zig
Threadsafe callback registration routes through FFI_Callback_threadsafe_call with ABI-aware argument handling. New mayAllocateBigIntWhenConvertedToJS() method identifies int64/uint64 types. Generated C code emits IS_THREADSAFE define, an argTypes lookup table, and raw int64 bit-passing for types requiring BigInt conversion.
Callback Binding & Decoding
src/bun.js/bindings/JSFFIFunction.cpp
FFI_Callback_threadsafe_call signature updated to accept argTypes array. New decodeThreadsafeCallbackArgument() function converts encoded argument payloads to JS values using JSBigInt for 64-bit integers and defers conversion to the JS thread instead of the OS thread.
Test Fixtures
test/js/bun/ffi/ffi.test.fixture.callback.c, test/js/bun/ffi/ffi.test.fixture.receiver.c
Added platform-specific BUN_FFI_IMPORT macro for Windows DLL imports. Updated N-API environment type from napi_env__ to NapiEnv. Conditional FFI_Callback_call declarations match the new threadsafe ABI. Pointer decoding simplified via direct (uintptr_t) casting.
Threadsafe Callback Test Module
test/js/bun/ffi/threadsafe-callback-bigint.c
New C module exposing three thread-spawning test entrypoints: call_i64_from_thread, call_u64_from_thread, and call_mixed_from_thread. Each creates an OS thread that repeatedly invokes a callback with fixed or parameterized 64-bit values.
Test Case
test/js/bun/ffi/ffi-threadsafe-callback-bigint.test.ts
New integration test that compiles the threadsafe callback module, loads it via bun:ffi, invokes callbacks from OS threads for int64/uint64 cases, validates returned BigInt/number values, forces full GC, and asserts clean process exit with OK status.
🚥 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 summarizes the main fix: deferring BigInt allocation for threadsafe JSCallback i64/u64 args to the JS thread.
Description check ✅ Passed The description includes the PR purpose, repro, fix, and verification details, even though it uses different section headings than the template.

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

@github-actions github-actions Bot added the claude label May 3, 2026
@robobun

robobun commented May 3, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 11:41 PM PT - Jul 19th, 2026

@robobun, your commit 7264787 has 1 failures in Build #76026 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 30165

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

bun-30165 --bun

@robobun

robobun commented May 3, 2026

Copy link
Copy Markdown
Collaborator Author

Status: ready for maintainer review/merge

The diff is green. ffi-threadsafe-callback-bigint.test.ts (now 3 tests) and every FFI test file pass locally under debug+ASAN with BUN_JSC_validateExceptionChecks=1. No FFI test has failed on any CI lane in any build of this PR. All review feedback addressed (10 findings, all threads resolved); three reviewer sign-offs on record, deferring merge to a human FFI owner.

CI: every failed job in the latest build (#76026, 73 lanes green) is a fleet-wide flake or infrastructure, not this diff.

  • windows-2019-x64-baseline: test/js/workerd/html-rewriter-leak.test.ts (expect(deltaMB).toBeLessThan(25) got 30.6). This FFI-only PR does not touch HTMLRewriter, and the same test is red in unrelated recent PR builds 75996 / 75974 / 75934. Reported so a separate session owns the fix.
  • darwin-26-aarch64-test-bun: the tart VM boot failed with guest never up after retry ... The number of VMs exceeds the system limit before running a single test.

Not re-triggering CI for either.

Gate: without the fix, off-thread JSBigInt allocation corrupts the GC heap (ASSERT / ASAN SEGV); with it, all 7 arg-type variants deliver correct values from a real pthread. The napi_env/napi_value guard and the now-working threadsafe + non-void return guard both fire on the released binary.

Rebases: #30412 (Rust rewrite: fix ported to the Rust codegen), #31116, #31332 (threadsafe wrapper refcounting), #31746, #32621 (Zig sources deleted, so this PR is Rust/C++/FFI.h only), #32792 (invokeFFICallback refactor: decode scope released before the tail call), #34140 (threadsafe-callback teardown ref-handling: merged that fix's adoptRef-inside-the-task shape with this PR's argTypes decode in the same postTaskTo body), #34396 (ffi.ts already throws on native validation errors, so that change dropped out of this diff), plus clean auto-merges through #33909 / #33122 / #33712 / #33731. Details in the PR description.

Current head: 7264787. Fixes #28113, #24529.

@github-actions

github-actions Bot commented May 3, 2026

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 }) from worker threads — this PR defers the JSBigInt allocation to the JS thread, fixing the off-thread GC heap corruption that causes these crashes
  2. bun:ffi JSCallback invoked from different thread crashing after a while #24529 - bun:ffi JSCallback invoked from different thread crashing after a while — the reporter's callback uses u64 args with threadsafe: true, which is exactly the codepath this PR fixes

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

Comment thread src/runtime/ffi/FFI.h
Comment thread test/js/bun/ffi/ffi-threadsafe-callback-bigint.test.ts Outdated
Comment thread src/jsc/bindings/JSFFIFunction.cpp
Comment thread test/js/bun/ffi/ffi-threadsafe-callback-bigint.test.ts
@robobun
robobun force-pushed the farm/24aebbb5/ffi-threadsafe-bigint branch 2 times, most recently from 1b6dd85 to d4121bd Compare May 3, 2026 03:17
Comment thread src/bun.js/api/FFI.h Outdated
@robobun
robobun force-pushed the farm/24aebbb5/ffi-threadsafe-bigint branch 2 times, most recently from 8ad2195 to 493b57a Compare May 3, 2026 05:15

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

All my earlier feedback has been addressed and I have nothing further to flag, but given this changes the threadsafe-callback ABI and touches off-thread → JS-thread GC-heap handoff (plus the user-visible JSCallback constructor now throwing on validation errors), it'd be good to have a human who owns bun:ffi sign off.

Extended reasoning...

Overview

This PR fixes a real crash: threadsafe JSCallback trampolines with int64_t/uint64_t/i64_fast/u64_fast args were calling JSBigInt::createFrom on whatever OS thread invoked the callback, corrupting the JSC GC heap. The fix defers the BigInt allocation to the posted task on the JS thread by passing raw 64-bit bits plus a per-arg ABIType table through a new 4-arg FFI_Callback_threadsafe_call signature. It spans FFI.h (TCC header), ffi.zig (codegen + symbol binding), JSFFIFunction.cpp (decode on JS thread), bindings/FFI.zig (translated-C mirror), ffi.ts (JSCallback ctor), two regenerated fixture snapshots, and a new pthread-driven integration test.

Along the way it also fixes two pre-existing bugs: the dead "Threadsafe functions must return void" guard (read function.threadsafe instead of the local) and JSCallback's constructor swallowing native validation errors. It also tightens UINT64_TO_JSVALUE to use <= MAX_INT52 so u64_fast matches i64_fast at the boundary.

Security risks

None identified. No new attack surface; inputs are the same FFI arg types as before, just marshalled differently. The argTypes array is generated by Bun's own codegen (a static const baked into the TCC trampoline), not user-controlled at the C++ boundary.

Level of scrutiny

High. This is cross-thread code interacting with the JSC GC heap, with an ABI change coordinated across Zig codegen, a TCC-compiled C header, a C++ extern, and a Zig extern decl — four places that must agree on the new 4-arg signature. The FFIABIType enum values in C++ are hand-mirrored from Zig's ABIType (verified: 7/8/15/16 match). The JSCallback constructor now throws where it previously returned { ptr: undefined }, which is a user-visible behavior change (correct, and matches dlopen/linkSymbols, but still a change). There's also a known textual conflict with #28115 in JSFFIFunction.cpp.

Other factors

I reviewed this across three prior passes; each round surfaced an issue (the dead guard → undeclared _FFI_Callback_call, the u64_fast </<= divergence, the void-vs-ZIG_REPR_TYPE decl mismatch, two test-convention nits) and the author addressed all of them. The new test compiles a real shared library and invokes callbacks from a pthread, which is exactly the right shape to gate this regression. The fixture-snapshot diffs are mostly unrelated drift from main per the description. Nothing left to flag from my side, but the combination of ABI change + cross-thread GC + behavior change puts this outside what I'd shadow-approve.

@Jarred-Sumner
Jarred-Sumner force-pushed the farm/24aebbb5/ffi-threadsafe-bigint branch from 493b57a to 6e853fb Compare May 4, 2026 10:23
Comment thread src/jsc/bindings/JSFFIFunction.cpp Outdated
@robobun
robobun force-pushed the farm/24aebbb5/ffi-threadsafe-bigint branch from 944a5db to 94f2c62 Compare May 4, 2026 10:52

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

  • 🔴 src/bun.js/bindings/GeneratedBindings.zig:1-6 — Commit 944a5db accidentally adds two stale codegen artifacts — src/bun.js/bindings/GeneratedBindings.zig and src/bun.js/bindings/GeneratedJS2Native.zig (~780 lines) — at the pre-restructure path that .gitignore doesn't cover. The build only imports these from src/jsc/bindings/ (where they're gitignored and regenerated), so these copies are dead code unrelated to the FFI fix; please git rm both files and optionally extend .gitignore to also cover src/bun.js/bindings/Generated*.zig.

    Extended reasoning...

    What

    Commit 944a5db ("doc: update ABIType path reference after src/ restructure") was meant to be a one-line comment fix in JSFFIFunction.cpp, but it also adds two brand-new files:

    • src/bun.js/bindings/GeneratedBindings.zig (391 lines)
    • src/bun.js/bindings/GeneratedJS2Native.zig (388 lines)

    Both diff hunks start with --- /dev/null, i.e. these files did not exist on main before this PR.

    Why they're stale and at the wrong path

    These are codegen outputs. The codegen writes them under the post-restructure path src/jsc/bindings/:

    • src/codegen/bindgen.ts:1591 writes src/jsc/bindings/GeneratedBindings.zig
    • src/codegen/bundle-modules.ts:484 writes ../jsc/bindings/GeneratedJS2Native.zig

    …and the build imports them from there: bun.zig:209,214 does @import("./jsc/bindings/GeneratedBindings.zig") / @import("./jsc/bindings/GeneratedJS2Native.zig").

    Accordingly, .gitignore:128-129 ignores exactly:

    src/jsc/bindings/GeneratedJS2Native.zig
    src/jsc/bindings/GeneratedBindings.zig
    

    The two files committed here live under the old src/bun.js/bindings/ path, which is not in .gitignore. git check-ignore confirms: src/jsc/bindings/GeneratedBindings.zig is ignored, src/bun.js/bindings/GeneratedBindings.zig is not. So when the author ran git add, the stray copies at the legacy path were picked up.

    Why nothing catches this

    A repo-wide grep finds zero references to src/bun.js/bindings/GeneratedBindings.zig or src/bun.js/bindings/GeneratedJS2Native.zig — nothing imports them, nothing reads them, the build never touches them. They are pure dead code that will silently rot as the canonical generated files (under src/jsc/bindings/) evolve, while bloating an FFI threadsafe-callback PR with ~780 lines of unrelated bindgen output.

    Step-by-step proof

    1. .gitignore:128-129 lists src/jsc/bindings/Generated{JS2Native,Bindings}.zig only.
    2. git check-ignore -v src/bun.js/bindings/GeneratedBindings.zig → no match (not ignored).
    3. git log --diff-filter=A -- src/bun.js/bindings/GeneratedBindings.zig → added in 944a5db (this PR's HEAD).
    4. bun.zig:209pub const GeneratedBindings = @import("./jsc/bindings/GeneratedBindings.zig"); — the build looks at src/jsc/bindings/, not src/bun.js/bindings/.
    5. rg -l 'bun.js/bindings/Generated' → no hits anywhere in the tree.
    6. Therefore the two new files at src/bun.js/bindings/ are unreferenced codegen artifacts accidentally staged because the legacy path isn't gitignored.

    Impact

    No runtime effect — the files are never compiled or read. But they (a) add ~780 lines of noise to a focused FFI fix, (b) will diverge from the real generated bindings over time and confuse anyone who greps for the symbols they define, and (c) leave a trap for the next person whose checkout has stale codegen at the old path.

    Fix

    git rm src/bun.js/bindings/GeneratedBindings.zig src/bun.js/bindings/GeneratedJS2Native.zig

    Optionally also append to .gitignore so this can't recur:

    src/bun.js/bindings/GeneratedJS2Native.zig
    src/bun.js/bindings/GeneratedBindings.zig
    

@robobun
robobun force-pushed the farm/24aebbb5/ffi-threadsafe-bigint branch from 94f2c62 to 9409be4 Compare May 17, 2026 17:34
Comment thread src/runtime/ffi/ffi_body.rs
@robobun
robobun force-pushed the farm/24aebbb5/ffi-threadsafe-bigint branch from 4bc5ad0 to 26dd8b6 Compare May 23, 2026 20:11
Comment thread src/jsc/bindings/JSFFIFunction.cpp

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

All of my prior feedback has been addressed (including the INT64_TO_JSVALUE int32 bound in 1ab0231) and I have nothing further to flag — deferring final sign-off to a human given the cross-language ABI change to FFI_Callback_threadsafe_call and the thread-safety-critical codegen it touches.

Extended reasoning...

Overview

This PR defers JSBigInt allocation for int64_t/uint64_t/i64_fast/u64_fast arguments of threadsafe: true JSCallbacks from the TCC-generated trampoline (which may run on an arbitrary OS thread) to the task posted to the JS thread. It does so by changing the FFI_Callback_threadsafe_call ABI to accept a per-argument uint8_t argTypes[] table, emitting that table from the codegen, and decoding raw 64-bit bits in a new decodeThreadsafeCallbackArgument on the JS thread. The change is mirrored across four languages: the embedded C header (FFI.h), the Zig codegen (ffi.zig), two Rust codegen modules (ffi_body.rs, host_fns.rs, abi_type.rs), the C++ task runner (JSFFIFunction.cpp), and the translated-C Zig mirror (src/jsc/FFI.zig). It also fixes two pre-existing bugs (the dead threadsafe && return_type != Void guard, and JSCallback swallowing native-side errors) and tightens two off-by-one boundary comparisons in {U,}INT64_TO_JSVALUE. New tests compile a shared library that invokes callbacks from a real pthread.

Security risks

None identified. The change is internal to bun:ffi's callback marshalling; it does not touch auth, crypto, permissions, or untrusted-input parsing. The new codegen only runs for user-constructed JSCallbacks and the argTypes table is emitted from already-validated ABIType enum values.

Level of scrutiny

High. This is thread-safety-critical code at the JSC GC boundary, and it changes an extern "C" function signature that must agree across TCC-emitted C, the C++ definition, a Zig extern, and two Rust extern blocks. It also introduces a user-visible behavioral change (new JSCallback now throws on validation errors instead of silently producing ptr: undefined). The fix is well-reasoned and well-tested, but the blast radius of a mistake here is heap corruption, so a human should own the merge.

Other factors

This PR has been through six review iterations with the bug-hunting system; every finding (the _FFI_Callback_call regression, the u64_fast/MAX_INT52 and i64_fast/MAX_INT32 boundary divergences, the void return-type mismatch, the stale path comment, the clashing_extern_declarations lint, and two test-convention nits) has been addressed by the author, the most recent in commit 1ab0231. CI shows only unrelated package-manager flakes. The PR description notes an expected textual conflict with #28115 in JSFFIFunction.cpp, which is one more reason for a human to coordinate the merge.

@robobun
robobun force-pushed the farm/24aebbb5/ffi-threadsafe-bigint branch from 6a30613 to 93896c5 Compare May 26, 2026 07:25
@robobun
robobun force-pushed the farm/24aebbb5/ffi-threadsafe-bigint branch from 93896c5 to 8363a37 Compare June 4, 2026 03:57

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

All 8 prior findings are addressed and this pass found nothing new — looks correct to me, but given it changes the threadsafe-callback ABI and coordinates cross-thread JSC heap allocation across the C++/Zig/Rust codegens, I'd like a human FFI owner to give the final sign-off.

Extended reasoning...

Overview

This PR fixes off-thread JSBigInt allocation in bun:ffi threadsafe JSCallback trampolines by deferring the 64-bit-int → JSValue conversion to the JS thread. It touches 12 files across four language layers: the TCC-compiled C header (FFI.h), the C++ task-posting path (JSFFIFunction.cpp), and the parallel Zig (ffi.zig) and Rust (ffi_body.rs / host_fns.rs / abi_type.rs) callback codegens, plus the JS wrapper (ffi.ts) and a new pthread-based integration test. It also changes the FFI_Callback_threadsafe_call extern signature (3→4 args, EncodedJSValuevoid), fixes a dead validation guard, makes JSCallback throw on native validation errors, and tightens two pre-existing boundary off-by-ones in {U,}INT64_TO_JSVALUE.

Security risks

None identified. The change is internal to the FFI marshalling path; no new untrusted input parsing, auth, or privilege surfaces. The user-facing behavior change (new JSCallback now throws on invalid options instead of silently yielding ptr: undefined) is a strict tightening.

Level of scrutiny

High. This is concurrency-correctness code at the JSC-GC / native-thread boundary, with an ABI change that must stay in lockstep across C++, Zig, two Rust codegens, and the TCC-emitted C. A mismatch in any one of those would corrupt memory or break callback dispatch. The PR has been rebased across three substantial upstream rewrites (#30412 Rust port, #31332 wrapper refcounting, #31116 clippy), so the merge surface is non-trivial. That's beyond what I'm comfortable shadow-approving without a human FFI/JSC owner confirming the cross-language wiring and the #31332 merge.

Other factors

All eight inline findings I raised over previous revisions (the _FFI_Callback_call regression, dead function.threadsafe guard, u64_fast/i64_fast boundary divergences, void return-type decl, stale path comment, mod.rs extern clash, test-convention nits) have been fixed and resolved; the current bug-hunting pass found nothing. The new test exercises real pthreads with all four 64-bit ABI types plus a mixed-arg case and forces a full GC, and CI is reported green. I have not previously posted a top-level review verdict on this PR — only inline findings — so this is my first summary.

@robobun

robobun commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator Author

Heads up for the next rebase: #32782 lands the "Threadsafe functions must return void" guard fix separately (plus the JSCallback constructor error-throw from this PR's "while here" list). Once that merges, the overlapping commit here can be dropped and this PR can stay focused on the off-thread JSBigInt allocation. #32782 also changes Zig::getErrorInstance in helpers.h to copy the message string, which may add a small textual conflict.

@mintlify

mintlify Bot commented Jun 29, 2026

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
bun 🟢 Ready View Preview Jun 29, 2026, 2:11 PM

💡 Tip: Enable Workflows to automatically generate PRs for you.

Comment thread test/js/bun/ffi/ffi-threadsafe-callback-bigint.test.ts Outdated
robobun and others added 10 commits July 15, 2026 02:36
… args to the JS thread

The TCC-generated trampoline for `new JSCallback(fn, { threadsafe: true })`
converted int64_t / uint64_t / i64_fast / u64_fast arguments to JSValues
via INT64_TO_JSVALUE_SLOW / UINT64_TO_JSVALUE_SLOW before handing them to
FFI_Callback_threadsafe_call. Those helpers call JSBigInt::createFrom,
which heap-allocates a JS cell. Since the trampoline runs on whatever OS
thread invoked the callback, this allocated on a non-JS thread without the
JS lock, corrupting the GC heap.

For threadsafe callbacks the trampoline now passes the raw 64-bit value
for these argument types along with a per-argument ABIType table, and
FFI_Callback_threadsafe_call performs the BigInt / number conversion
inside the task it posts to the JS thread.
The "Threadsafe functions must return void" validation in
generateSymbolForFunction read function.threadsafe (the out-param,
still at its default false) instead of the local threadsafe variable,
so the guard never fired. Combined with the previous commit moving
_FFI_Callback_call behind an #else branch, this would have turned
`new JSCallback(fn, { threadsafe: true, returns: 'int32_t' })` from
silently-broken into a TCC compile error.

Also make the JSCallback constructor surface validation errors from
nativeCallback by throwing them (matching dlopen/linkSymbols), instead
of destructuring `{ctx, ptr}` out of the returned Error instance and
leaving both undefined.
…e callback task

JSBigInt::createFrom declares a throw scope and can throw on OOM. The
posted task in FFI_Callback_threadsafe_call called it in a loop without
an enclosing scope or exception check, tripping JSC's exception-scope
validator on the asan lane before profiledCall.
…eadsafe decl

UINT64_TO_JSVALUE in FFI.h used strict < MAX_INT52 where INT64_TO_JSVALUE
used <=, so a u64_fast callback received 9007199254740991 as a BigInt
while an i64_fast callback received it as a number. Since that value is
Number.MAX_SAFE_INTEGER it should be a number in both. This also brings
the non-threadsafe u64_fast path in line with the threadsafe decode path
added in this PR.

Also:
- declare the IS_THREADSAFE FFI_Callback_call prototype as void to match
  FFI_Callback_threadsafe_call's actual signature (threadsafe callbacks
  always return void so the trampoline never reads a return value).
- hoist the bun:ffi import in the test to module scope per test/CLAUDE.md.
host_fns.rs). The trampoline generator there was a faithful port of
the original Zig, including the two bugs this PR fixes:

- generate_symbol_for_function read function.threadsafe (the
  not-yet-assigned out-param, always false) instead of the local,
  so the 'Threadsafe functions must return void' guard never fired.
- print_callback_source_code converted int64_t / uint64_t / i64_fast /
  u64_fast arguments via *_TO_JSVALUE_SLOW in the trampoline, allocating
  a JSBigInt off the JS thread.

Apply the same fix as the Zig/C++ side: for threadsafe callbacks emit
IS_THREADSAFE, pass raw 64-bit bits for those arg types plus a
static argTypes[] table, and call the 4-arg FFI_Callback_call so
FFI_Callback_threadsafe_call can do the BigInt conversion on the JS
thread. Also add ABIType::may_allocate_bigint_when_converted_to_js
and update the Rust extern decl to match the C++ signature.
MAX_INT32 is 2^31, so 'val <= MAX_INT32' let exactly 2147483648 through
the (int32_t) cast and wrap to -2147483648. Use a strict < like
UINT64_TO_JSVALUE already does, so the non-threadsafe i64_fast path
agrees with the threadsafe decode added in this PR.
… 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.
@robobun
robobun force-pushed the farm/24aebbb5/ffi-threadsafe-bigint branch from 12f989b to 237e485 Compare July 15, 2026 02:40
Comment thread test/js/bun/ffi/ffi-threadsafe-callback-bigint.test.ts 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.

No new issues found this pass; all 10 prior findings are resolved. Deferring to a human FFI owner given the scope — this changes the FFI_Callback_threadsafe_call extern-C ABI across Rust/C++/TCC, reworks argument marshalling inside a cross-thread postTaskTo body that also manages JSC::Strong lifetime via adoptRef, and has been rebased through several overlapping rewrites of that same function.

What was reviewed:

  • decodeThreadsafeCallbackArgument boundary semantics vs FFI.h's {U,}INT64_TO_JSVALUE — now consistent at both MAX_INT32 and MAX_INT52.
  • ThrowScope handling around JSBigInt::createFrom: RETURN_IF_EXCEPTION after each decode, scope.release() before the invokeFFICallback tail call, adoptRef first so the wrapper ref releases on the early-return path.
  • The argTypes table is copied into argTypesVec on the foreign thread before postTaskTo, so the TCC static const lifetime isn't relied on across the task boundary.
  • Both Rust codegen paths (ffi_body.rs and host_fns.rs) emit the same IS_THREADSAFE define, raw-bit marshalling, argTypes[] table, and 4-arg call; the now-working threadsafe && return_type != Void guard makes the non-void codegen path unreachable.
Extended reasoning...

Overview

This PR fixes off-thread JSC GC-heap allocation in bun:ffi's threadsafe JSCallback path. It touches:

  • Rust FFI codegen (src/runtime/ffi/{ffi_body.rs,host_fns.rs,abi_type.rs}): emits #define IS_THREADSAFE, marshals 64-bit int args as raw bits + a static const uint8_t argTypes[N] table, calls the new 4-arg FFI_Callback_call, and fixes a dead threadsafe && return_type != Void guard.
  • C++ JSC bindings (src/jsc/bindings/JSFFIFunction.cpp): FFI_Callback_threadsafe_call gains a const uint8_t* argTypes parameter, copies args+types on the foreign thread, and decodes them to JSValue (allocating JSBigInt as needed) inside the posted task under a ThrowScope.
  • TCC header (src/runtime/ffi/FFI.h): conditional 4-arg vs 3-arg FFI_Callback_call declaration under IS_THREADSAFE, plus two boundary-comparison fixes in {U,}INT64_TO_JSVALUE.
  • Built-in JS (src/js/bun/ffi.ts): JSCallback constructor now throws on native-side validation errors instead of silently destructuring undefined.
  • New test (ffi-threadsafe-callback-bigint.test.ts) + C fixture that spawns real pthreads, plus regenerated ffi.test.fixture.*.c snapshots.

Security risks

None identified. bun:ffi is inherently unsafe by design (arbitrary native code execution); this change doesn't widen that surface. The argTypes array comes from Bun's own codegen (not user data), the argCount bound is codegen-controlled, and the raw-bit reinterpretation is between fixed-width integer types.

Level of scrutiny

High. This is squarely in CLAUDE.md's most-blocked category: cross-thread JSC heap access, JSC::Strong lifetime across postTaskTo, ThrowScope/RETURN_IF_EXCEPTION discipline, and an extern-C ABI change that must agree across three languages (Rust extern block, C++ definition, TCC-compiled C prototype). The function being modified was independently rewritten by #31332, #32792, and #34140 while this PR was open, and the merged result has to preserve each of those PRs' invariants (refcounted wrapper, adoptRef-first for early-return safety, no re-throw of retired TerminationException) alongside the new decode logic. That composition is exactly the kind of thing a human FFI owner should eyeball once.

Other factors

  • The bug-hunting system found nothing this run. All 10 findings from earlier passes (one 🔴, the rest nits/🟡) have been addressed and their threads resolved.
  • Test coverage is solid: real-pthread invocation for all four 64-bit types (big and small values), a mixed-type callback verifying non-64-bit args still decode as ordinary JSValues, a Bun.gc(true) to surface heap corruption, and a separate test that the previously-dead threadsafe + non-void return guard now fires.
  • The robobun status comment itself says "reviewer sign-off on record, deferring merge to a human FFI owner" — matching this call.
  • The ffi.ts change (throw on Error.isError(result)) is a minor user-facing behavior change: previously-silent validation failures now throw. That's clearly correct and matches dlopen/linkSymbols, but is another reason for a human glance.

robobun added 2 commits July 20, 2026 05:35
ffi.ts: take main's version (#34396 already added the JSCallback
error-throwing this PR originally carried).
A napi_env or napi_value is only valid on the JS thread that created it;
accepting one from a foreign thread via a threadsafe callback is
inherently unsafe regardless of how the value is marshalled.
@robobun

robobun commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator Author

Issue #35406 is another manifestation of this bug, reproducible single-threaded: with a threadsafe JSCallback taking int64_t, the BigInt allocated by the trampoline is not rooted between enqueue and drain, so a Bun.gc(true) after the JSCallback goes out of scope frees it and the callback receives whatever JSValue reuses the cell. This PR fixes it since the conversion moves into the posted task and the value goes straight into the MarkedArgumentBuffer. Needs a rebase (currently conflicting with main).

@robobun

robobun commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator Author

Superseded by #31332. #24529 is now closed as fixed on main.

@robobun robobun closed this Jul 24, 2026
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.

bun:ffi JSCallback invoked from different thread crashing after a while

1 participant