Skip to content

ffi: reject non-void return types on threadsafe JSCallbacks - #32782

Closed
robobun wants to merge 3 commits into
mainfrom
farm/f14c1a51/ffi-threadsafe-return-guard
Closed

ffi: reject non-void return types on threadsafe JSCallbacks#32782
robobun wants to merge 3 commits into
mainfrom
farm/f14c1a51/ffi-threadsafe-return-guard

Conversation

@robobun

@robobun robobun commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator

Reproduction

import { CFunction, JSCallback } from "bun:ffi";
for (let i = 0; i < 200; i++) {
  const cb = new JSCallback((a, b) => Number(a) + Number(b ?? 0), { args: ["i64"], returns: "u64", threadsafe: true });
  const f = new CFunction({ ptr: cb.ptr, args: ["int"], returns: "int" });
  try { f(i); } catch {}
  cb.close();
}

On main this SEGVs (panic(main thread): Segmentation fault at address 0x4F048506), with the top frame in JSC__JSValue__toUInt64NoTruncate decoding garbage bits as a JSCell*. With an integer return type instead (returns: "int"), there is no crash, only a silently wrong result: a callback that returns 7 hands the native caller a stale value like 1325696257.

Cause

A threadsafe JSCallback binds FFI_Callback_threadsafe_call as the TCC trampoline's FFI_Callback_call symbol. That function is void in C++: it posts a task to the JS thread and returns before the JS function runs, so there is no return-value channel. But the generated C stub declares FFI_Callback_call as returning an EncodedJSValue, so for any non-void return type it reads an uninitialized return register.

Bun already has a guard for exactly this, with the message Threadsafe functions must return void, but it never fires. In generate_symbol_for_function it tests function.threadsafe (the out-param, still at its Default of false) instead of the local threadsafe parsed a few lines earlier; the field is only assigned below the check.

Even with a working guard, JSCallback's constructor swallows the error. Native validation errors are returned (not thrown), and the constructor destructures { ctx, ptr } straight out of nativeCallback's result, so an Error return becomes ptr: undefined. dlopen, cc, and linkSymbols all do if (Error.isError(result)) throw result; JSCallback is the only entry point that does not.

Fix

  1. Reject the configuration in Function::compile_callback (src/runtime/ffi/ffi_body.rs), the layer that owns the trampoline, via the existing self.fail() / Step::Failed path, before any allocation. The dead checks in both copies of generate_symbol_for_function (ffi_body.rs, host_fns.rs) are deleted rather than fixed in place: that parser also serves dlopen, cc, CFunction, and linkSymbols, where threadsafe is documented as JSCallback-only and is a parsed-but-unread no-op (self.threadsafe has exactly one reader: callback() passing it to compile_callback). Firing the check there would newly reject valid native-function specs.

  2. Throw native validation errors from the JSCallback constructor (src/js/bun/ffi.ts), matching dlopen / cc / linkSymbols. This also unswallows the other construction errors (returns: "buffer", returns: "napi_env", unknown types), which previously succeeded with ptr: undefined.

  3. Copy the error message in Zig::getErrorInstance (src/jsc/bindings/helpers.h). Running the new test under bun bd (ASAN) tripped a pre-existing heap-use-after-free:

    ==18225==ERROR: AddressSanitizer: heap-use-after-free ... READ of size 1
      #0 simdutf::icelake::validate_ascii
      ...
      #9 bun_runtime::test_runner::expect::to_throw::to_throw   toThrow.rs:236
    freed by thread T0 here:
      #7 <alloc::boxed::Box<[u8]> as core::ops::drop::Drop>::drop
      #9 core::ptr::drop_in_place::<bun_runtime::ffi::ffi_body::Step>
      #10 core::ptr::drop_in_place::<bun_runtime::ffi::ffi_body::Function>
      #11 <bun_runtime::ffi::ffi_body::FFI>::callback   ffi_body.rs:1320
    previously allocated by thread T0 here:
      #11 <bun_runtime::ffi::ffi_body::Function>::fail
    

    getErrorInstance builds the Error's message with toString(*str), which uses WTF::StringImpl::createWithoutCopying: the JS string borrows the caller's bytes for the lifetime of the Error. Its three siblings (getTypeErrorInstance, getSyntaxErrorInstance, getRangeErrorInstance) all use toStringCopy(*str). The new guard's message is a Box<[u8]> inside Step::Failed, owned by a local Function that drops when callback() returns, so reading .message on the thrown error reads freed memory.

    Switching getErrorInstance to toStringCopy matches its siblings. I audited every ZigString::to_error_instance caller in the tree: none relies on the non-copying or external-adoption behavior (so nothing leaks), and six more latent UAFs are closed by the same line: JSGlobalObject::create_error's interpolated slow path (every create_error_instance(format_args!("...{}...")) in the codebase formats into a local Vec<u8>; the // it alwayas clones comment there was the author's assumption, and this change makes it true), two sites in AsyncModule.rs, and the other three FFI Step::Failed arms. None of those had fired because the freed bytes usually survive intact under mimalloc, and the FFI ones additionally had the error swallowed by (2).

Also fixed the docs/runtime/ffi.mdx example, which paired threadsafe: true with returns: "bool" (a configuration that has never worked), documented the constraint on FFIFunction.threadsafe in bun-types, and declared [Symbol.dispose]() on the JSCallback type (the runtime already implements it).

Verification

test/js/bun/ffi/ffi-error-messages.test.ts:

result
USE_SYSTEM_BUN=1 bun test (unfixed) 8 pass, 11 fail (none of the new toThrow assertions throw)
bun bd test (fixed, ASAN) 19 pass, 0 fail

With only the Rust + JS fixes and (3) reverted, bun bd test aborts with the ASAN report above.

The existing JSCallback round-trip (48) and threadsafe callback (24) matrices in ffi.test.js, the rest of test/js/bun/ffi/, test/js/web/html/FormData.test.ts (129), and test/integration/bun-types/bun-types.test.ts all pass unchanged.

Related

@robobun
robobun requested a review from alii as a code owner June 26, 2026 19:25
@coderabbitai

coderabbitai Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

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

More reviews will be available in 1 minute and 57 seconds. Learn how PR review limits work.

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

⌛ How to resolve this issue?

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

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

🚦 How do rate limits work?

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

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

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 6124b63a-46a4-4437-931f-c220d6116288

📥 Commits

Reviewing files that changed from the base of the PR and between a051989 and 7e37720.

📒 Files selected for processing (7)
  • docs/runtime/ffi.mdx
  • packages/bun-types/ffi.d.ts
  • src/js/bun/ffi.ts
  • src/jsc/bindings/helpers.h
  • src/runtime/ffi/ffi_body.rs
  • src/runtime/ffi/host_fns.rs
  • test/js/bun/ffi/ffi-error-messages.test.ts

Walkthrough

The PR updates bun:ffi callback docs, types, and runtime validation, adds JSCallback disposal support and constructor error handling, refreshes FFI validation tests, and adjusts one TLS peer-certificate assertion.

Changes

FFI callback contract and validation

Layer / File(s) Summary
Thread-safe callback contract
docs/runtime/ffi.mdx, packages/bun-types/ffi.d.ts
The bun:ffi docs and type comments now state that thread-safe callbacks dispatch asynchronously on the JS thread, cannot return a native result, and require returns: "void"; the search example uses an explicit zero byte offset and byte length.
JSCallback disposal and error handling
packages/bun-types/ffi.d.ts, src/js/bun/ffi.ts, src/jsc/bindings/helpers.h
JSCallback gains [Symbol.dispose]() in the type declarations, the constructor throws when nativeCallback returns an error, and error messages copy their string backing before being surfaced.
Thread-safe return validation
src/runtime/ffi/host_fns.rs, src/runtime/ffi/ffi_body.rs, test/js/bun/ffi/ffi-error-messages.test.ts
The threadsafe return-type check moves out of symbol parsing and into callback compilation, and the FFI error-message tests cover the updated JSCallback validation paths.

TLS peer certificate assertion

Layer / File(s) Summary
OCSP entry removal
test/js/node/tls/node-tls-connect.test.ts
The bun.sh peer-certificate test no longer checks for an OCSP - URI entry in infoAccess and continues checking CA Issuers - URI.

Suggested reviewers

  • Jarred-Sumner
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately summarizes the main FFI change to threadsafe JSCallbacks.
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 includes the needed purpose and verification details, though it uses custom headings instead of the template's exact section names.

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

@robobun

robobun commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator Author
Updated by robobun

Status: diff is green across all test lanes; the only CI red is one Mac agent's artifact download

Build #65562 on 7e37720 finished with 284 jobs passed, zero error-level test annotations, and 2 hard-failed jobs. Both hard failures are the same:

:darwin: 26 aarch64 - test-bun | exit 1
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).

on agent darwin-aarch64-26-5-1-1, for the original attempt and its retry. No tests ran on that shard. This same agent has failed identically on every build of this branch (65109, 65117, 65169, 65562) and is not specific to this PR.

All warning entries (retried-then-passed) are known unrelated flakes: the dev-and-prod.test.ts HMR race, hot.test.ts, bun-install.test.ts, spawn-pipe-leak.test.ts, 30205.test.ts, napi.test.ts, spawn-streaming-stdout.test.ts, in-process-cron.test.ts. This PR's own ffi-error-messages.test.ts (19 assertions) and the rest of test/js/bun/ffi/ passed on every lane that ran, including the Linux x64 ASAN lane that exercises both the getErrorInstance UAF fix and the new ffi_wrapper scopeguard.

My one retrigger (9e3f439, dropped in the rebase) is spent; the diff is complete and I'm not pushing further. Three commits, 7 files:

Commit What
e0601f7 The core fix: the threadsafe/non-void guard in compile_callback, JSCallback throws native validation errors, getErrorInstance copies its message.
238f2ff docs/runtime/ffi.mdx: both CString(ptr, length) examples corrected to CString(ptr, 0, length).
7e37720 Pre-existing FFICallbackFunctionWrapper leak on every compile_callback failure exit, closed with a scopeguard.

All automated review findings are addressed (0 unresolved threads). claude[bot] explicitly defers to a human on the Zig::getErrorInstance audit (covered in the description) and the #31779 overlap (Co-authored-by offered). Ready for a maintainer.

@github-actions

Copy link
Copy Markdown
Contributor

Found 2 issues this PR may fix:

  1. Segfault when native code repeatedly invokes JSCallback({ threadsafe: true }) #28113 - Segfault on repeated threadsafe JSCallback invocation is the canonical repro of the dead-guard bug: the TCC trampoline reads an uninitialized return register because the function.threadsafe check never fired
  2. bun:ffi silently fails #12237 - "silently fails" behavior matches Fix Fix calling #private() functions in classes #2: JSCallback constructor destructured { ctx, ptr } from a returned Error without checking, producing ptr: undefined and silently doing nothing

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

Fixes #28113
Fixes #12237

🤖 Generated with Claude Code

@robobun

robobun commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator Author

Neither of those is fixed by this PR, so I'm not adding the Fixes lines.

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. ffi: reject non-void return types for threadsafe JSCallback #31779 - Fixes the same two core bugs: the dead function.threadsafe guard in generate_symbol_for_function and JSCallback constructor silently swallowing native validation errors

🤖 Generated with Claude Code

@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 bugs, but this touches a CODEOWNER-owned path (packages/bun-types/) and changes Zig::getErrorInstance in helpers.h — a helper with ~150 call sites across the tree — so it's worth a human look.

Extended reasoning...

Overview

This PR fixes a SEGV/garbage-return when a threadsafe JSCallback is given a non-void return type. It spans seven files across four layers: the Rust FFI runtime (ffi_body.rs, host_fns.rs) moves the dead guard into Function::compile_callback and deletes the unreachable copies; the JS glue (src/js/bun/ffi.ts) makes JSCallback's constructor throw returned native errors like dlopen/cc/linkSymbols already do; the C++ bindings (src/jsc/bindings/helpers.h) switch getErrorInstance from toString (no-copy) to toStringCopy to fix an ASAN-confirmed UAF; and docs/types/tests are updated to match.

Security risks

None identified. FFI is inherently memory-unsafe by design, but this change strictly tightens validation (rejecting a configuration that previously read uninitialized memory) and fixes a heap-use-after-free in error-message construction. No new attack surface is introduced.

Level of scrutiny

Moderate-to-high. The FFI and JS changes are narrow and well-justified. However, getErrorInstance in helpers.h backs ZigString::to_error_instance, which has ~150 call sites across ~48 files (sockets, S3, fetch, DNS, shell, server, crypto, etc.). The author audited callers and the change matches the three sibling helpers (getTypeErrorInstance/getSyntaxErrorInstance/getRangeErrorInstance), so it looks correct — but a one-line change with that blast radius merits a human sanity check, particularly around any callers that might have relied on the external-string adoption path (the author says none do).

Other factors

  • packages/bun-types/ffi.d.ts is covered by CODEOWNERS (*.d.ts / /packages/bun-types/), so a codeowner sign-off is required regardless.
  • JSCallback now throws on invalid options where it previously yielded ptr: undefined; this is a (correct) behavior change that could surface in user code that was silently misconfigured.
  • Good test coverage is added (positive and negative cases), and the PR description reports the existing FFI test matrices pass unchanged. The bug-hunting system found no issues.

@robobun

robobun commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator Author

The bot is partly right. #31779 (from @EffortlessSteven, opened June 3) independently found and fixed two of the three bugs here, three weeks before this PR. My duplicate search was scoped to robobun-authored PRs, so I opened this without seeing it. That is on me, and credit for the dead-guard find belongs there; I have added #31779 to the description.

The two PRs are not identical, so I am leaving both open for a maintainer to pick rather than closing either unilaterally. The concrete differences:

1. Where the guard lives. #31779 fixes the check in place, in generate_symbol_for_function. That parser is shared: generate_symbols calls it for every dlopen, cc, CFunction, and linkSymbols symbol spec, where threadsafe is documented as JSCallback-only (ffi.d.ts: "Only supported with {@link JSCallback}") and is parsed but never read (Function::threadsafe has exactly one consumer: callback() passing it to compile_callback). So a working check there starts rejecting specs that every released Bun accepts:

dlopen("libc.so.6", { strlen: { args: ["cstring"], returns: "u64", threadsafe: true } });
// today: works. under #31779: throws "Threadsafe functions must return void"

This PR puts the guard in compile_callback, so only JSCallback (the one API where the flag means something) is affected. The check was already dead in the original Zig, so neither placement has precedent from working code; this is a judgment call for a maintainer.

2. How far the error-surfacing reaches. #31779 throws the generate_symbol_for_function validation errors from native. The Step::Failed path (compile_callback failures: TinyCC compile errors, "Out of memory") still returns an Error that the constructor's { ctx, ptr } destructure swallows into ptr: undefined. This PR fixes the swallow once in ffi.ts with the same if (Error.isError(result)) throw result that dlopen, cc, and linkSymbols already use, which covers both classes.

3. Where the tests live. #31779's tests sit inside ffiRunner(fast) in ffi.test.js, whose enclosing block is gated on existsSync("/tmp/bun-ffi-test." + suffix) and falls through to it.skip("run ffi") otherwise. Nothing in the repo or CI builds that library (make compile-ffi-test has no rule), so they never run in CI. This PR's tests are in ffi-error-messages.test.ts, which is not gated.

4. Not in #31779. The Zig::getErrorInstance use-after-free in helpers.h (the toString to toStringCopy one-liner closing seven latent UAFs, including create_error's interpolated slow path), the docs/runtime/ffi.mdx example that pairs threadsafe: true with returns: "bool", and the bun-types updates. #31779 never hits the UAF because its error message is a 'static literal and Step::Failed stays returned-and-swallowed, so the UAF is not a gap in that PR, just an adjacent bug this one happened to trip.

Either outcome is fine by me:

@mintlify

mintlify Bot commented Jun 26, 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 26, 2026, 8:16 PM

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

Comment thread docs/runtime/ffi.mdx

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

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

Inline comments:
In `@test/js/node/tls/node-tls-connect.test.ts`:
- Around line 313-314: The TLS certificate test is still pinned to bun.sh’s live
certificate fields, which makes it fragile when AIA contents change. Update the
assertions in the node-tls-connect test to avoid depending on specific live
`infoAccess` entries from `translatePeerCertificate`; use a fixture/local
certificate instead, or assert only the parser output shape and general presence
of parsed fields rather than `CA Issuers - URI` from the live cert.
🪄 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: 1641b32a-9624-4d40-b8c4-88a617837c8e

📥 Commits

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

📒 Files selected for processing (8)
  • docs/runtime/ffi.mdx
  • packages/bun-types/ffi.d.ts
  • src/js/bun/ffi.ts
  • src/jsc/bindings/helpers.h
  • src/runtime/ffi/ffi_body.rs
  • src/runtime/ffi/host_fns.rs
  • test/js/bun/ffi/ffi-error-messages.test.ts
  • test/js/node/tls/node-tls-connect.test.ts
💤 Files with no reviewable changes (1)
  • src/runtime/ffi/host_fns.rs

Comment thread test/js/node/tls/node-tls-connect.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.

Thanks for fixing the docs nit. I'm deferring to a human here: the getErrorInstancetoStringCopy change in helpers.h reaches every to_error_instance caller (the audit looks careful, but it's broad), and you've explicitly left the #31779 overlap / Co-authored-by credit as a maintainer call.

Extended reasoning...

Overview

The PR fixes a threadsafe JSCallback segfault by (1) moving the "Threadsafe functions must return void" guard from the shared symbol parser into Function::compile_callback (and deleting the dead checks in both generate_symbol_for_function copies), (2) making the JSCallback constructor throw returned-Error results instead of destructuring them into ptr: undefined, and (3) switching Zig::getErrorInstance from toString (createWithoutCopying) to toStringCopy to close a heap-use-after-free hit under ASAN. It also updates docs/types, adds 11 JSCallback validation tests, and carries an unrelated CI-unblock commit dropping the OCSP - URI assertion from the live bun.sh TLS test.

Security risks

None introduced. The changes are defensive (rejecting an unsound configuration earlier, copying error-message bytes that were previously borrowed). The helpers.h change closes latent UAFs rather than opening any. No auth/crypto/permissions surface.

Level of scrutiny

Medium-high. The FFI guard relocation and the ffi.ts error-throw are small and well-tested. But getErrorInstancetoStringCopy is a one-line change with wide blast radius: per the description it affects JSGlobalObject::create_error's interpolated slow path and every ZigString::to_error_instance caller in the tree. The author's audit (no caller relies on non-copying/external-adoption, six more latent UAFs closed) reads correct and matches the three sibling get*ErrorInstance helpers that already use toStringCopy, but a maintainer should sanity-check that claim since it touches a core JSC binding header.

Other factors

  • The author explicitly flagged the overlap with #31779 (and #30165, #31892) as a maintainer decision — which PR lands, where the guard lives, and Co-authored-by credit. That alone warrants human review rather than bot approval.
  • An unrelated TLS test change is bundled in to unblock CI; the author offered to split it out.
  • My prior inline nit (CString(ptr, length)CString(ptr, 0, length) in the docs example) was addressed in a051989; both call sites in docs/runtime/ffi.mdx are now correct.
  • The bug-hunting system found no issues on the current revision; tests added are ungated and exercise both the new guard and the previously-swallowed validation errors.

robobun added 2 commits June 27, 2026 15:50
A threadsafe JSCallback's trampoline binds FFI_Callback_threadsafe_call,
which is void in C++: it posts a task to the JS thread and returns before
the JS function runs, so there is no return-value channel. The generated
TCC stub declares the bound symbol as returning an EncodedJSValue, so any
non-void return type reads a stale register. Integer return types produce
silent garbage, and pointer-shaped ones (u64, ptr, cstring) dereference
the garbage as a JSCell and SEGV.

Bun already had a "Threadsafe functions must return void" validation, but
it read function.threadsafe (the out-param, still at its default false)
instead of the local `threadsafe` parsed a few lines earlier, so it never
fired. Move the guard into Function::compile_callback, the layer that
owns the trampoline, instead of restoring it in
generate_symbol_for_function: that parser also serves dlopen, cc,
CFunction, and linkSymbols, where threadsafe is documented as
JSCallback-only and is a parsed-but-unread no-op, and firing it there
would newly reject valid native-function specs. Delete the dead check
from both copies of the shared parser.

Two more bugs had to be fixed for the guard to be visible:

- JSCallback's constructor destructured { ctx, ptr } straight out of
  nativeCallback's return value. Validation errors are returned, not
  thrown, from the native side, so every one of them was silently
  swallowed into ptr: undefined. dlopen, cc, and linkSymbols all already
  do `if (Error.isError(result)) throw result`; do the same here.

- Zig::getErrorInstance in helpers.h used toString(*str), which builds
  the Error's message with StringImpl::createWithoutCopying: the JS
  string borrows the caller's bytes for the lifetime of the Error. Its
  three siblings (getTypeErrorInstance, getSyntaxErrorInstance,
  getRangeErrorInstance) all use toStringCopy. The new guard's message
  is a Box<[u8]> inside Step::Failed, owned by a local Function that
  drops when callback() returns, so reading the thrown error's .message
  is a heap-use-after-free under ASAN. Switch getErrorInstance to
  toStringCopy. An audit of every ZigString::to_error_instance caller
  found none relying on the non-copying or external-adoption path, and
  found six more latent UAFs this closes: JSGlobalObject::create_error's
  interpolated slow path, two sites in AsyncModule, and the other three
  FFI Step::Failed arms.

Also fix the docs/runtime/ffi.mdx example, which paired threadsafe: true
with returns: "bool" (a configuration that has never worked), document
the constraint on FFIFunction.threadsafe in bun-types, and declare
[Symbol.dispose] on the JSCallback type (the runtime already implements
it).
The CString constructor signature is (ptr, byteOffset?, byteLength?), so
`new CString(ptr, length)` treats `length` as a byte offset and reads a
NUL-terminated string starting at `ptr + length`, past the buffer the
native caller handed over. The Strings section in the same document
already shows the correct call, `new CString(ptr, 0, byteLength)`. Fix
both JSCallback examples, the pre-existing non-threadsafe one and the
thread-safe one this PR rewrote, to `new CString(ptr, 0, length)`.
Comment thread src/runtime/ffi/ffi_body.rs
Bun__createFFICallbackFunction heap-allocates an FFICallbackFunctionWrapper
holding two JSC::Strong roots (the user's callback closure and the global
object). compile_callback only stores that pointer into
Step::Compiled.ffi_callback_function_wrapper on the success path, and
Function::Drop only calls FFICallbackFunctionWrapper_destroy when step is
Compiled. Every earlier exit (the TCCMissing return and the five
self.fail() arms for TinyCC compile/relocate/symbol failures) leaked the
wrapper and permanently rooted the user's closure.

Wrap the wrapper in a scopeguard at the allocation site, mirroring the
existing scopeguard for the TinyCC state a few lines below, and disarm it
with ScopeGuard::into_inner when ownership transfers into Step::Compiled.

Pre-existing; the new threadsafe/non-void guard added in the parent commit
is placed before this allocation, so it did not add a new leak instance.
The failure paths in question require TinyCC to fail on Bun-generated
trampoline source, which is an internal-error condition not
deterministically reachable from a test; the success path (into_inner) is
exercised by the existing 72-test JSCallback matrix under ASAN.

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

Both prior review findings are addressed and this pass found nothing new. Not auto-approving: the getErrorInstancetoStringCopy change in helpers.h is a cross-cutting fix to a shared helper (audited per the description, but worth a human glance), and the #31779 overlap / credit question you already flagged is a maintainer call.

Extended reasoning...

Overview

Seven files: the threadsafe/non-void guard moves from the (dead) parser check in generate_symbol_for_function (both ffi_body.rs and host_fns.rs copies) into Function::compile_callback; JSCallback's JS constructor now throws returned Errors like dlopen/cc/linkSymbols; Zig::getErrorInstance switches toStringtoStringCopy to match its three siblings and close a UAF; compile_callback wraps ffi_wrapper in a scopeguard (added in 7e37720 in response to my last comment); plus docs, bun-types, and new validation tests in ffi-error-messages.test.ts.

Security risks

None identified. bun:ffi is inherently an unsafe-by-design module; the changes here strictly reduce unsafety (reject a config that read uninitialized memory, copy error-message bytes instead of borrowing freed ones, deref a leaked GC root on failure). No new attacker-reachable surface.

Level of scrutiny

Medium-high. The Rust/TS/test/docs changes are localized and well-covered by the new tests, but two pieces warrant human eyes:

  • helpers.h: a one-word change to getErrorInstance, but that helper backs ZigString::to_error_instance and create_error_instance(format_args!(...)) across the whole codebase. The PR description says every caller was audited and none depends on the non-copying / external-adoption semantics; that audit is plausible (the three sibling get*ErrorInstance helpers already use toStringCopy) but is exactly the kind of cross-cutting claim a maintainer should sanity-check.
  • compile_callback scopeguard (7e37720): correct by inspection — ScopeGuard::into_inner disarms before Step::Compiled takes ownership, and every earlier exit derefs the wrapper — and ASAN-verified per the author, but it's new resource-lifetime code in a function with five failure exits.

Other factors

  • The author explicitly left the #31779 overlap (where the guard lives, which PR lands, Co-authored-by credit) as a maintainer decision; that alone makes this not auto-approvable.
  • Both of my earlier inline findings (the CString(ptr, length) docs bug and the pre-existing ffi_wrapper leak) were fixed in a051989 and 7e37720 respectively; all review threads are resolved.
  • CodeRabbit's TLS-test concern was discussed and deferred to a follow-up; that file is no longer in the changed-files set for this PR.
  • Test coverage for the new behavior is good (19 assertions, including the previously-swallowed returns: buffer / napi_env / unknown-type errors).

@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. Checked each piece against main after #35246:

Two small leftovers that are not in any open PR I can see:

  • docs/runtime/ffi.mdx still has two new CString(ptr, length) calls (lines 321, 347), which treat length as a byte offset and read NUL-terminated from ptr + length. Should be new CString(ptr, 0, length).
  • packages/bun-types/ffi.d.ts: the runtime JSCallback implements [Symbol.dispose], but the type declaration still does not.

Happy to send a tiny PR for those two if useful.

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