Skip to content

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

Open
robobun wants to merge 8 commits into
mainfrom
farm/24aebbb5/ffi-threadsafe-bigint
Open

fix(ffi): defer JSBigInt allocation for threadsafe JSCallback i64/u64 args to the JS thread#30165
robobun wants to merge 8 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.

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.

@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
Title check ✅ Passed The title clearly and specifically describes the main change: deferring JSBigInt allocation for threadsafe callback int64/uint64 arguments to the JS thread, which matches the core fix throughout the changeset.
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 PR description comprehensively covers the problem, fix, verification, and related context with clear examples and test results.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


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

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

robobun commented May 3, 2026

Copy link
Copy Markdown
Collaborator Author

@robobun

robobun commented May 3, 2026

Copy link
Copy Markdown
Collaborator Author

Status — ready for maintainer review/merge; remaining CI red is fleet-wide, not this PR

The diff is green. ffi-threadsafe-callback-bigint.test.ts and the full 72-test callback matrix 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 (8 findings) addressed; reviewer's post-rebase pass found nothing new and sign-off is deferred to a human FFI owner.

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

Rebases: #30412 (Rust rewrite — fix ported to the Rust codegen), #31116 (clippy), #31332 (threadsafe wrapper refcounting — merged with this PR's argTypes ABI + JS-thread BigInt decode), #31746 (TODO sweep).

CI, build 60254 (head 8363a37): all 15 red jobs are the same single test — test/cli/install/bunx.test.ts → "should handle package that requires node 24" (exit 3) — failing identically on every platform and on every other open PR's current build (60251, 60258, 60259), i.e. a fleet-wide environmental breakage in that bunx test, unrelated to this FFI-only diff. Earlier builds' reds were likewise unrelated flakes.

I've already used my single CI re-roll; not pushing further retriggers. A maintainer can merge over the fleet-wide red or re-run once the bunx test is fixed on main.

@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 and others added 7 commits June 4, 2026 03:51
… 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.
@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.

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