Skip to content

bun:ffi: stop cc() from turning non-numeric pointer arguments into garbage pointers - #37989

Open
robobun wants to merge 5 commits into
mainfrom
farm/f2631f05/ffi-cc-jscallback-arg
Open

robobun wants to merge 5 commits into
mainfrom
farm/f2631f05/ffi-cc-jscallback-arg

Conversation

@robobun

@robobun robobun commented Aug 13, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • A symbol compiled with cc() that declares a "function" argument crashes the process when it is given a JSCallback object instead of callback.ptr: panic(main thread): Segmentation fault at address 0xFFFFFFFFFFFFFFFF. The same declaration through dlopen() accepts either form, and the docs present passing the object as the normal form (.ptr is described as a performance tip).
  • Cause: the C wrapper cc() compiles converts ptr, cstring and function arguments with JSVALUE_TO_PTR (src/runtime/ffi/FFI.h:210 on main), which handles null, typed arrays and int32 and then treats every other value as a NaN-boxed double. A JSCallback, an ArrayBuffer, a BigInt, an object with a ptr property, undefined, a plain object or a string all decode to the pointer -1. For a function argument the C code calls it; for ptr/cstring it is handed to C as a valid-looking pointer.
  • The buffer row (src/runtime/ffi/abi_type.rs:146 on main) has the same shape of bug: it reads the typed-array vector slot out of whatever it is given, so a number or null segfaults and an ArrayBuffer or plain object passes a garbage pointer.
  • Only cc() is affected. Since bun:ffi: use the engine-native FFI when available #35246, dlopen()/linkSymbols()/CFunction convert arguments in the engine (JSC::FFI::writeSlotFromJSValue), which handles all of these values and throws a TypeError for the rest. cc() still calls through the TinyCC-compiled wrapper built from FFI.h. ffi: wrap JSCallback objects passed as cc() function arguments #31776 addressed this crash in the JS wrapper layer and was closed when bun:ffi: use the engine-native FFI when available #35246 removed that layer; the wrapper path it did not cover is the one fixed here.

Fix

  • FFI.h: JSVALUE_TO_PTR now takes the argument's type tag and decides the unambiguous inputs itself: typed arrays and DataViews for all four types; int32, double, and null/undefined (as NULL) for ptr/cstring; int32 and double for function. Everything else goes to a new JSVALUE_TO_PTR_SLOW, which is Bun__FFI__jsValueToPointerSlow (JSCFFIBridge.cpp) calling the engine's writeSlotFromJSValue for that type: that is where a JSCallback, ArrayBuffer, BigInt or { ptr } object is converted, where a null/undefined callback and a non-view buffer get the engine's TypeErrors, and where junk is rejected.
  • TinyCC ignores always_inline, so the existing JSVALUE_IS_* / JSCELL_IS_TYPED_ARRAY helpers are real calls in the compiled wrapper. The tag tests in JSVALUE_TO_PTR are therefore written out on the raw bits, and every accepted input costs the one JSVALUE_TO_PTR call. On main the same inputs cost 1 call for null, 5 or 6 for a number and 6 for a view, and a buffer argument was one unchecked vector read; only values the engine has to look at leave the wrapper now. The double path converts through int64 like the engine's doubleToInt64 (the old unsigned conversion was a libtcc1 helper call, and undefined for negative values).
  • The slow path can throw, so print_source_code now converts these arguments into locals before the call and returns the empty JSValue (the host-function convention after a throw) as soon as one of them threw; the native function is never entered with an exception pending. Every napi wrapper now opens its handle scope after the argument loads and these conversions instead of before them, so the bail-out has nothing to unwind (the only change to wrappers without pointer-typed arguments; the argument loads need no scope). Wrappers with neither pointer-typed nor napi arguments generate the same C as before, which is why the regenerated ffi.test.fixture.receiver.c only changes in its FFI.h half.
  • The argument type tags (ABI_TYPE_PTR etc.) reach the C side through define_symbols, the mechanism FFI.h already uses for the JSC offsets, so the values come from the ABIType enum rather than being repeated in the header. They are defined only when the wrapper is compiled (Function::compile), not for the user's own C file, which the matrix fixture checks by declaring an enum with those names.
  • Why this is correct: the engine converter is the definition of what bun:ffi accepts for a pointer-typed argument (it is what dlopen() has run since bun:ffi: use the engine-native FFI when available #35246), so delegating to it makes cc() accept the same values (JSCallback, ArrayBuffer, BigInt, objects with a numeric ptr, undefined as NULL for ptr/cstring) and reject the same values with the same TypeErrors, including null/undefined for a function argument, which dlopen() rejects and which cc() previously passed on as NULL (a fall-out of the untyped routine; 0 still passes NULL explicitly on both paths). Every input that worked before is still converted inside the wrapper to the same pointer, so the engine only ever sees inputs that previously produced a garbage pointer.
  • One deliberate difference remains: a JavaScript string for a cstring argument throws in cc() (the engine's "a JavaScript string is not valid here" TypeError) where dlopen() transcodes it. The engine frees the copy through an arena bracketed around the call, which the cc() wrapper does not have; cc() never accepted strings here (they became -1), so this turns a garbage pointer into an error rather than removing anything. The test pins the difference.
  • Verified with test/js/bun/ffi/cc.test.ts ("pointer-typed arguments"), three tests that all fail on the unfixed binary:
    • an inline snapshot of viewSource() for a napi_env/function/f64/cstring/buffer/ptr signature pins the wrapper shape: the tagged conversions into locals, the bail-out after each one, the inline conversion of the f64, and the handle scope opened only after the conversions (on the unfixed binary it shows the old inline JSVALUE_TO_PTR(argN) calls);
    • a fixture runs an input matrix through a cc() wrapper and through a CFunction over the same C functions and requires the two result tables to be equal (on the unfixed binary the process segfaults at the first row). A counting ptr getter shows each argument is converted exactly once and that conversion stops at the first argument that fails (the getter behind it never runs); a C-side counter shows the rejected calls never reached C; a throwing getter's exception propagates as-is;
    • a second fixture covers a napi_env wrapper: a rejected argument does not reach C, and the inline (view, null) and slow-path ({ ptr }) inputs still work afterwards (on the unfixed binary the rejected argument reaches C as -1).
    • bun bd test test/js/bun/ffi/ (debug ASAN): everything passes except the pre-existing 5s timeouts of the dlopen "integer identities" tests on this container, which my change does not touch.
    • The same tests pass with BUN_JSC_validateExceptionChecks=1.
    • Cost check: since debug builds read FFI.h at runtime, one debug binary was timed calling a one-argument cc() symbol in a loop with three headers swapped in (the call structure of main, the first version of this PR, and the final one); numbers are in the details block. The final header is at or below main's structure for every input class; the first version of this PR was slower than main for null, views and buffer and sent undefined through the engine, which is what prompted the rewrite.
    • test/js/bun/ffi/ffi.test.fixture.receiver.c is the checked-in copy of the generated source and is regenerated by the ffi print test, as in bun:ffi: decode double-encoded JSValues in JSVALUE_TO_INT32 #34653 and ffi, napi: purify NaNs before NaN-boxing doubles from native code #32787.
  • Overlaps textually with bun:ffi: fix cc() integer argument decoding and the int32 boundary in FFI.h #37978 (integer argument decoding in the same generator loop); the two changes are independent and either can be rebased on the other.

Background

  • cc() compiles the user's C with TinyCC and, per symbol, also compiles a small C wrapper generated by Function::print_source_code (src/runtime/ffi/ffi_body.rs) with FFI.h prepended. JSC calls that wrapper directly as a host function with (globalObject, callFrame); the wrapper reads the raw NaN-boxed argument slots, converts them, calls the user's function and boxes the result. FFI.h is therefore C compiled at runtime, and the conversion helpers in it have to be given any runtime symbols (add_symbol) and constants (define_symbols) they use; CompilerRT::inject/define in ffi_body.rs do that.
  • ABI_TABLE in abi_type.rs holds, per argument type, the prefix of the C expression the generator emits to convert an argument; INT64_TO_JSVALUE_SLOW(JS_GLOBAL_OBJECT, is an existing example of a prefix that names the wrapper's own parameter, which the new pointer prefixes also do (JS_GLOBAL_OBJECT, &threw).
  • A host function signals a throw in JSC by leaving the exception on the VM and returning; the caller checks the VM's exception slot after the call and ignores the returned value, which by convention is the empty JSValue (ValueEmpty here, encoded as 0). Running further JS (such as another argument's ptr getter) with an exception pending is not allowed, which is why the wrapper checks after each conversion rather than once at the end.
  • JSC::FFI::writeSlotFromJSValue is the engine-side converter added for bun:ffi: use the engine-native FFI when available #35246; for pointer types it accepts null/undefined (NULL, except for function), int32/double, BigInt, typed arrays and DataViews, ArrayBuffers, JSFFICallback cells (what a JSCallback is) and objects whose ptr property is a number or BigInt, and throws a TypeError otherwise. Its cstring string transcoding needs the caller to pass a string arena; passing none makes a JS string throw.
  • TinyCC has no inliner: the static ... __attribute__((always_inline)) helpers in FFI.h are compiled as ordinary functions and every use is a call, which is why the hot checks in JSVALUE_TO_PTR test the NaN-boxing tag bits directly. The constants it uses (NumberTag, NotCellMask, UndefinedTag, the JSC type byte and vector offsets) are the same ones the helpers use.
Cost check of the three headers (debug build, loaded shared host)

One-argument cc() symbols called in a loop from JS, best of 5 x 1M calls per run, ns per call including the JS-to-host call itself; minimum over 4 runs for the first and last columns, a single run for the middle one (taken on the binary built at that commit, before the header swap setup existed). The host had a load average above 200, so only the ordering is meaningful; the structural difference (number of calls made per argument, listed in the Fix section) is deterministic.

input main's check structure first version of this PR final
ptr, number 62 62 48
ptr, Uint8Array 62 64 47
ptr, null 50 55 46
ptr, undefined 66 (garbage pointer) 858 (engine round trip) 55
buffer, Uint8Array 60 (unchecked) 108 54
function, number 69 73 54

"main's check structure" is main's JSVALUE_TO_PTR body behind this PR's signature (plus main's unchecked vector read for buffer), swapped into the source tree for the current debug binary, which reads FFI.h from there at startup; the final column is the same binary with the real header.

Before/after on the repro from the report
// t2.c
#include <stdint.h>
int32_t call_cb(int32_t (*cb)(int32_t)) { return cb ? cb(21) * 2 : -1; }
import { cc, dlopen, JSCallback } from "bun:ffi";
const def = { call_cb: { args: ["function"], returns: "i32" } } as const;
const cb = new JSCallback((x: number) => x + 1, { args: ["i32"], returns: "i32" });
const c = cc({ source: "./t2.c", symbols: def }).symbols;
console.log(c.call_cb(cb.ptr)); // 44 before and after
console.log(c.call_cb(cb));     // before: panic(main thread): Segmentation fault at address 0xFFFFFFFFFFFFFFFF
                                // after: 44

Other inputs on the unfixed binary, ptr argument echoed back by C: undefined, {}, "hello", true, an ArrayBuffer, a BigInt, { ptr } and a JSCallback all arrive in C as -1. buffer argument: a number or null segfaults, an ArrayBuffer arrives as an unrelated pointer read out of the JSArrayBuffer cell. After the fix each of these behaves as it does through dlopen().

…nstead of assuming a double

The C wrapper cc() compiles converted ptr, cstring and function arguments
with JSVALUE_TO_PTR, which handled null, typed arrays and int32 and then
treated anything else as a double. A JSCallback object, an ArrayBuffer, a
BigInt, an object with a `ptr` property, undefined, or a plain object became
a garbage pointer (-1); for a function argument that is an immediate call
through address -1. The buffer row read the typed array vector offset off
whatever it was given.

The inline paths now cover numbers, views and null (for ptr/cstring); every
other value is handed to JSC::FFI::writeSlotFromJSValue through a new
JSVALUE_TO_PTR_SLOW export, so cc() wrappers accept and reject the same
values dlopen()'d symbols do. The generated wrapper converts these arguments
into locals before the call and returns the empty value when a conversion
threw, so the native function is not called with a pending exception; the
napi handle scope is opened after the conversions for the same reason.
@coderabbitai

coderabbitai Bot commented Aug 13, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

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

Next review available in: 14 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

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

How do review 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 refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: f4d0a8e2-cba4-457c-ac20-e9ca5db2aad7

📥 Commits

Reviewing files that changed from the base of the PR and between b7a777e and 366b833.

📒 Files selected for processing (6)
  • src/jsc/bindings/JSCFFIBridge.cpp
  • src/runtime/ffi/FFI.h
  • src/runtime/ffi/abi_type.rs
  • src/runtime/ffi/ffi_body.rs
  • test/js/bun/ffi/cc.test.ts
  • test/js/bun/ffi/ffi.test.fixture.receiver.c

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

@robobun

robobun commented Aug 13, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 5:05 AM PT - Aug 13th, 2026

⏳ @robobun, your commit 366b833 is still building in Build #94710, but has 1 failures so far (All Failures):

@robobun

robobun commented Aug 13, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: ready for review at head 366b833. CI build 94710 passed on every lane that ran (Linux glibc and musl, x64 and aarch64, the ASAN lane, Windows x64 and aarch64); the one red test there, test/js/node/worker_threads/worker_threads.test.ts on the ASAN lane, is unrelated to this change and has been reported separately. The four macOS test lanes expired waiting for agents, as they did for every PR build in that window; they can be retried on the build directly, or I will push a retrigger on request.

Reproduced on the released binary (1.4.0-canary.1, Linux x64) with the snippet in the description: a cc() symbol with a "function" argument, called with the JSCallback object, crashes with Segmentation fault at address 0xFFFFFFFFFFFFFFFF; the same call through dlopen() returns 44. The three tests under "pointer-typed arguments" in test/js/bun/ffi/cc.test.ts fail on the unfixed binary (the matrix fixture with that segfault, the viewSource snapshot with the old wrapper shape, the napi fixture with the rejected argument reaching C) and pass with this branch, also under ASAN and BUN_JSC_validateExceptionChecks=1.

Self-review follow-ups since the first push: the generated wrapper shape is now pinned by a viewSource inline snapshot, the matrix fixture checks that each argument is converted exactly once and that conversion stops at the first failing argument, the description no longer claims napi wrappers are unchanged (they open the handle scope after the conversions now), the ABI_TYPE_* defines are scoped to the wrapper compilation instead of also being visible to the user's C, and, since TinyCC does not inline, the fast paths in JSVALUE_TO_PTR test the tag bits directly instead of calling helpers (the first version of this branch made null, view and buffer arguments a few calls slower than main and sent undefined through the engine; numbers are in the description).

Two pre-existing problems found while probing are not part of this change and have been filed separately: the .ptr property of cc() symbols is a denormal rather than an address (JSFFIFunction.cpp), and calling a cc() symbol with fewer arguments than declared reads uninitialized frame slots.

@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 reviewed this PR and didn't find any bugs. Because it touches FFI pointer conversion and JSC exception propagation in TinyCC-generated host functions, and includes a deliberate behavior change (null/undefined for a function argument now throw where they previously passed NULL), a human look would still be worthwhile.

What was reviewed:

  • to_c is only called from print_source_code, so the new JS_GLOBAL_OBJECT/&threw prefixes are always in scope; both TCC compilation paths (~808 and ~2073 in ffi_body.rs) call CompilerRT::define/inject so the new ABI_TYPE_* defines and JSVALUE_TO_PTR_SLOW symbol are available.
  • The napi handle scope move: pointer conversions run before NapiHandleScope__open, so the early return ValueEmpty has nothing to close.
  • JSVALUE_TO_PTR has no other callers (checked repo-wide); the callback-direction fixture doesn't use it.
  • Bun__FFI__jsValueToPointerSlow uses a throw scope and only writes *threw on exception; slot is zero-initialized.
Extended reasoning...

Overview

The PR routes cc()'s pointer/cstring/function/buffer argument conversion through the same engine converter (JSC::FFI::writeSlotFromJSValue) that dlopen()/CFunction use. It touches the TinyCC-compiled FFI header (FFI.h), the Rust code generator (print_source_code in ffi_body.rs, ABI_TABLE in abi_type.rs), a new C++ bridge (Bun__FFI__jsValueToPointerSlow), and adds ~290 lines of table-driven tests comparing cc() against CFunction over the same C functions.

Security risks

The unfixed code decoded arbitrary JSValues as garbage pointers and called them, so the change is a strict hardening. The new slow path delegates to the existing engine converter and throws for unrecognized inputs rather than passing through. No new attack surface identified; the null-callback case is now a TypeError instead of a NULL call.

Level of scrutiny

High. This is memory-safety-adjacent FFI code: raw NaN-boxed JSValue decoding, exception propagation from a TinyCC-compiled host function back into JSC, and a code generator that emits C compiled at runtime. Errors here manifest as segfaults or silent pointer corruption. It also carries a small but real user-visible behavior change (null/undefined function args now throw to match dlopen()).

Other factors

The change is well-scoped, thoroughly documented, and the test compares cc() output against CFunction output over a full input matrix, plus a napi_env bail-out case. The regenerated fixture and static_assert guards for enum drift look correct. I verified the two TCC compilation paths both receive the new defines/symbol, that to_c has no callers outside print_source_code, and that no other consumers of JSVALUE_TO_PTR exist. Nonetheless, given the surface area (codegen + runtime-compiled C + JSC exception discipline) and the deliberate behavior change, this warrants a maintainer's eyes rather than automated approval.

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

On the one point the review raises: null/undefined for a function argument throwing is intentional. It is what dlopen() does today (ffi.test.js "an omitted callback argument throws instead of calling through NULL") and what the pre-#35246 JS wrappers did as well; cc() only passed NULL because the untyped routine happened to map null to 0 for every pointer type. Passing 0 still hands C a NULL callback on both paths. The call_callback rows of the new test pin this, and the "why" bullets in the description cover it.

The red "Source lints" check is dead-code-escapes.test.ts failing on main itself (reproduced on a clean checkout of current main, nothing in this diff touches it); it has been reported separately.

Comment thread src/jsc/bindings/JSCFFIBridge.cpp Outdated
Comment thread src/runtime/ffi/FFI.h Outdated
Comment thread src/runtime/ffi/abi_type.rs
Comment thread src/runtime/ffi/abi_type.rs Outdated
Comment thread src/runtime/ffi/abi_type.rs Outdated
Comment thread src/runtime/ffi/ffi_body.rs Outdated
Comment thread src/runtime/ffi/ffi_body.rs Outdated
Comment thread src/runtime/ffi/ffi_body.rs 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 addressing the ABI_TYPE_* namespace-leak concern in 06b076a — the defines are now scoped to Function::compile() only, and the enum { ABI_TYPE_PTR, ... } line in the test fixture's user C pins that. This run found no further issues. Given this touches FFI pointer conversion, JSC exception propagation from TinyCC-compiled C, and reorders the napi handle-scope open, a human look is still worthwhile.

What was reviewed:

  • to_c_macro call sites — only print_source_code uses it, so the new JS_GLOBAL_OBJECT/&threw prefixes have no other consumers.
  • CompilerRT::inject/define call sites — POINTER_TAG_DEFINES is now wrapper-only; JSVALUE_TO_PTR_SLOW symbol injection into the user's TCC state is a linker symbol, not a macro, so harmless.
  • JSVALUE_TO_PTR reordering — int32/double checks precede the cell check; JSCELL_IS_TYPED_ARRAY(null) is false (OtherTag hits NotCellMask), so no null deref before the explicit null branch.
  • Bun__FFI__jsValueToPointerSlow — throw-scope declared, exception checked before returning, *threw set on the exception path only.
Extended reasoning...

Overview

The PR fixes cc()-compiled FFI wrappers so that pointer-typed arguments (ptr, cstring, function, buffer) that aren't a number/view/null are routed through the engine's writeSlotFromJSValue instead of being blindly decoded as a NaN-boxed double (which produced garbage pointer -1 and a segfault for function args). Files: FFI.h (new slow-path fallback in JSVALUE_TO_PTR, new JSVALUE_TO_BUFFER), JSCFFIBridge.cpp (new Bun__FFI__jsValueToPointerSlow extern calling writeSlotFromJSValue), abi_type.rs (updated to_c_macro prefixes, new POINTER_TAG_DEFINES, new arg_conversion_can_throw), ffi_body.rs (codegen now emits per-argument conversion locals with bail-out on throw, moves napi handle-scope open after conversions, injects the new symbol/defines), plus ~350 lines of tests and the regenerated fixture.

Prior feedback addressed

My previous review flagged that ABI_TYPE_* macros were being #defined into the user's C via the shared CompilerRT::define(). Commit 06b076a moved state.define_symbols(ABIType::POINTER_TAG_DEFINES) out of CompilerRT::define and into Function::compile() right after the wrapper's CompilerRT::define(state) call (option 2 from my suggestion), and added enum { ABI_TYPE_PTR, ABI_TYPE_CSTRING, ABI_TYPE_FUNCTION, ABI_TYPE_BUFFER }; to the test fixture's user-side pointers.c so the test would fail if the leak reappears. Fully resolved.

Security risks

FFI pointer conversion is memory-safety-adjacent, but this change strictly narrows the failure surface: inputs that previously produced a garbage pointer now either take the same inline path they always did (int32/double/typed-array/null) or delegate to the engine converter that dlopen() already runs, which throws a TypeError for anything it doesn't recognize. The one deliberate divergence (JS strings for cstring throw instead of transcoding, because there's no arena to free the copy) is a safety improvement over the prior garbage pointer. No new attack surface identified.

Level of scrutiny

High — this is generated C compiled at runtime by TinyCC, calling back into JSC with a raw JSGlobalObject*, propagating exceptions via a bool* out-param and the empty-JSValue return convention, and reordering when the napi handle scope opens. A mistake here is a process crash or an exception-scope violation. The change looks correct (throw-scope declared, exception checked, bail-out returns ValueEmpty.asZigRepr before entering C, handle scope opened only after all conversions succeed), the tests are thorough (matrix comparison against CFunction over the same C, per-argument conversion counting via a .ptr getter, native-call counter proving rejected calls never reach C, viewSource snapshot pinning wrapper shape, napi bail-out test), and the author reports it passes under BUN_JSC_validateExceptionChecks=1 and ASAN. But the surface area and the JSC-integration subtlety warrant a maintainer's eyes.

Other factors

  • to_c()/ToCFormatter has no other call sites beyond print_source_code, so the new JS_GLOBAL_OBJECT, ABI_TYPE_*, &threw, prefixes can't appear in a context lacking those identifiers.
  • CompilerRT::inject (which now adds the JSVALUE_TO_PTR_SLOW linker symbol) runs for both the user's C and the wrapper — that's fine; it's a symbol, not a macro.
  • The comment-cop bot's flags were addressed in a6a512b (comments shortened to one line each).
  • The PR description notes textual overlap with #37978 in the same generator loop; that's a merge-order concern for a maintainer, not a correctness one.

…lling helpers

TinyCC ignores always_inline, so every JSVALUE_IS_* / JSCELL_IS_TYPED_ARRAY
helper used by JSVALUE_TO_PTR was a real call. Test the tag bits directly in
the one function, fold the buffer variant into it, and handle undefined like
null for ptr/cstring inline (the engine's isUndefinedOrNull test) so only
values the inline code cannot convert leave the wrapper. Accepted inputs now
cost the JSVALUE_TO_PTR call alone. The double path converts through int64
like the engine's doubleToInt64 instead of an unsigned conversion.
Comment thread src/runtime/ffi/abi_type.rs

@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 re-reviewed after 06b076a and 366b833 and didn't find any bugs. My earlier concern (the ABI_TYPE_* defines leaking into the user's C) is addressed — the define_symbols call now sits in Function::compile only, and the matrix fixture's enum guards it. Because this touches hand-decoded JSC tag bits in TinyCC-compiled C plus a new exception-propagation path through the generated wrapper, a human look is still worthwhile.

What was checked this run:

  • to_c / ToCFormatter has no other call site, so the new &threw/JS_GLOBAL_OBJECT prefix is only emitted where those names are in scope.
  • The (bits & ~UndefinedTag) == TagValueNull fast path matches only null/undefined; true/false fall through to the slow path (test covers true).
  • CompilerRT::inject (which now adds JSVALUE_TO_PTR_SLOW) runs on both the wrapper and user-C states, so the symbol resolves; the tag defines run only for the wrapper.
Extended reasoning...

Overview

The PR fixes cc()-compiled FFI wrappers turning non-numeric pointer/cstring/function/buffer arguments into garbage pointers (segfault on a JSCallback object, -1 for anything else). It adds a slow path (Bun__FFI__jsValueToPointerSlow in JSCFFIBridge.cpp) that delegates to the engine's writeSlotFromJSValue — the same converter dlopen() uses since #35246 — and rewires the generated C wrapper (print_source_code in ffi_body.rs) to convert pointer-typed arguments into locals before the call, bailing out with ValueEmpty after each one if it threw. The napi handle scope now opens after the conversions so a bail-out has nothing to unwind. FFI.h's JSVALUE_TO_PTR keeps inline fast paths for int32/double/typed-array-view/null-undefined and hands everything else to the slow path.

Security risks

None new. The change strictly narrows what reaches C: inputs that previously became a garbage pointer now either take the same inline path or throw a TypeError. The new slow path re-enters JS (a .ptr getter), and the wrapper correctly checks threw after each conversion before continuing — the test verifies conversion stops at the first failing argument and the getter behind it never runs.

Level of scrutiny

High. This is native FFI code that hand-decodes JSC's NaN-boxed EncodedJSValue in TinyCC-compiled C (cell mask, int32 tag, double offset, immediate tags), dereferences cell pointers to read the JSType byte, and adds a new C++ bridge and a new exception-return convention through generated C. Per the repo's review guidelines, native memory safety is the most-blocked category, and this sits squarely in it.

Other factors

  • My earlier inline finding (the ABI_TYPE_* defines being injected via CompilerRT::define and thus visible in the user's own .c) was fixed in 06b076a by moving the define_symbols call into Function::compile, and the fixture now declares an enum with those names to pin it.
  • The one still-open inline comment is the comment-cop bot re-flagging abi_type.rs:147, which is the ABI_TABLE row-label alignment (not a comment block); the author already explained this on the earlier identical flag.
  • Test coverage is thorough: a viewSource inline snapshot pins the wrapper shape, a matrix fixture runs the same input set through cc() and CFunction and requires the tables to match, per-argument conversion count and stop-on-first-failure are asserted via a counting getter, and a separate napi_env fixture checks the handle-scope ordering.
  • I confirmed to_c() has no other callers (the callback-direction generator uses to_js), so the widened macro prefix cannot land in a scope that lacks threw/JS_GLOBAL_OBJECT.

This branch has not been deployed

No deployments
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.

1 participant