Skip to content

fix(ffi): multiple long-standing FFI correctness bugs - #31449

Closed
ObscuritySRL wants to merge 13 commits into
oven-sh:mainfrom
ObscuritySRL:claude/ffi-quick-fixes
Closed

ObscuritySRL wants to merge 13 commits into
oven-sh:mainfrom
ObscuritySRL:claude/ffi-quick-fixes

Conversation

@ObscuritySRL

@ObscuritySRL ObscuritySRL commented May 26, 2026 •

Copy link
Copy Markdown

Summary

Hardens Bun's FFI subsystem (bun:ffi — dlopen / cc / linkSymbols / CFunction / JSCallback / ptr / read.* / toBuffer / toArrayBuffer / CString). It began as a batch of long-standing correctness fixes, then the entire FFI surface was run through repeated rounds of independent adversarial review until a fresh full pass found nothing. Net −558 lines (+1157 / −1715 — a large amount of dead code removed).

Every behavioral change ships a regression test that fails on unmodified Bun and passes here. Debug + release builds, bun run rust:check-all (all target triples), and the full test/js/bun/ffi/** suite are green on both debug and release (133 pass / 34 skip / 1 todo / 0 fail). Hot-path benchmarks (arg marshalling, return encoding, buffer creation, read.*) show no regression.

Memory-safety fixes (the most serious)

  • Use-after-free reading a failed-compile error message. cc(), dlopen(), and linkSymbols() built their Step::Failed error via ZigString::init(msg).to_error_instance(...), which (through getErrorInstance → toString → StringImpl::createWithoutCopying) makes the JS error's .message borrow a heap Box<[u8]> owned by a Function that is freed before user JS reads it. Reachable via args:["void"] (invalid C on a resolved symbol → Step::Failed); on the unfixed build error.message was 47 bytes of freed/poisoned memory, and an ASAN build aborts. Fixed by routing all four sites (including the callback path, which had already been fixed in isolation) through one shared failed_step_error helper that copies the message.
  • TinyCC compilation-state leak. CompileC::compile obtained a TCC::State (no Drop) but installed no scopeguard, unlike its sibling Function::compile. Every failed cc() compile — invalid C, an unknown symbol, a bad include path — leaked a full TinyCC context (~216 KB). Now freed on every path; verified as 64.8 MB → 2.5 MB RSS growth over 300 failed compiles.

Crash / DoS fixes (uncatchable process aborts reachable from one public call)

  • ptr(a, -Infinity) / -(2**63) / NaN byteOffset → integer-overflow panic (-i64::MIN) → abort. Now a clean TypeError: byteOffset must be a finite number.
  • read.u8(addr, -1) / non-number byteOffset → JSValue::toInt64 ASSERT abort. Now guarded (undefined→0, non-number→error).
  • toArrayBuffer(ptr, 0, 2**33) (byteLength ≥ 2³²) → u32::try_from(len).expect() panic. Now bounded to u32::MAX on both the explicit-length and NUL-scan paths.
  • cc({ source: [] }) (empty source array) → empty-Vec index panic. Now rejected with a clear error.
  • new Array(0xFFFFFFFF) as args → ~16 GB reserve_exact → OOM abort. Reservation hint now capped.
  • toBuffer(ptr(typedArray)) → JSC installed mi_free as the deallocator and freed foreign/interior memory on GC (double-free / allocator mismatch). Now a non-owning view, matching toArrayBuffer.

Undefined behavior / platform divergence

  • Out-of-range float→int conversion of i64_fast / u64_fast / pointer args in FFI.h was C UB and platform-divergent (x86 → INT64_MIN, arm64 fcvtzs → saturates). Now clamped (NaN→0, saturate to range) — defined and identical on every target.
  • The 8/16-bit argument clamps (uint8_t / int8_t / int16_t / uint16_t) saturate instead of letting the C cast wrap (256→0, 128→−128, 32768→−32768, 65536→0).
  • Return encoding (INT64 / UINT64 / UINT32_TO_JSVALUE): MAX_INT32 was mis-named — it held 2³¹, not INT32_MAX — so a 64-bit function returning 2³¹ wrapped to −2³¹ in JS. The constant is corrected to INT32_MAX (with a distinct MIN_INT32). Separately, u64_fast compared against MAX_INT52 with a strict <, so exactly Number.MAX_SAFE_INTEGER came back as a BigInt while i64_fast returned a Number; u64_fast now uses <= so both agree. This reconciles with ffi: u32 and i64_fast returns of 2 ** 31 arrive in JS as -2147483648 #33340 (a fuzzing-driven fix of the same block), which can be closed once this lands.

API correctness

  • cc() reads symbol specs from options.symbols[key], not options[key] — so cstring returns actually become CString instances and the argument wrappers (integer clamps, pointer auto-conversion) actually install.
  • JSCallback rejects non-JSFunction callables (a callable Proxy / InternalFunction previously hit uncheckedDowncast<JSFunction> — type confusion) and throws the returned Error instead of destructuring it into ptr = undefined.
  • napi_value args are no longer coerced through val|0; returns: "napi_value" with no napi args now binds (the handle-scope env symbol was only added for napi args); DataView is accepted as a ptr/buffer arg; size_t and several string type labels (i64_fast, u64_fast, c_int, c_uint, isize, char*, void*, fn) were missing from the JS FFIType/.d.ts.
  • cc() string flags prepend the default flags instead of replacing them (a string flags silently dropped -Wl,--export-all-symbols, so symbols never resolved).
  • The threadsafe-non-void-return guard read a not-yet-assigned struct field, so it never fired; it now reads the local value.

Dead code removed

  • src/runtime/ffi/host_fns.rs (469 lines) — an entire dead second copy of the symbol-spec parser and C-trampoline emitter (the live one is on ffi_body::Function).
  • The two test/js/bun/ffi/ffi.test.fixture.*.c snapshots (~790 lines) that nothing asserted against and that were rewritten on every run.
  • mod.rs's dead Function/Step/Compiled/LIB_DIR_Z, ABIType::MAX, param_typename/param_typename_label, the c_param_type table column, EnumMapFormatter, and other orphaned fields.

Tests

A regression test for every fix, most spawned as subprocesses so a crash regression surfaces as a child abort (not a runner abort) and each verified to fail on unmodified Bun. The previously-dead dlopen round-trip suite — gated on a file produced by a Makefile target deleted in #21863, so it never ran — is resurrected by compiling the fixture with the system compiler at load time.

The 34 skips + 1 todo are all platform/build gates, never silenced failures: TinyCC is unavailable on Windows-ARM64 (isFFIUnavailable); a few subprocess-timing tests are ASAN-incompatible; the foreign-thread threadsafe-callback tests and NaN-purification test are Windows-gated (they run on POSIX CI); and the >63 symbols and int32-address tests are Linux/glibc-only.

Testing & caveats

Built and validated on Windows x64 (debug + release). Cross-platform is covered by bun run rust:check-all (all six target triples type-check); full test execution on Linux/macOS and arm64 is left to CI. DOMJIT fast paths for ptr/read.* were evaluated and deliberately left as a WONTFIX (a separate, higher-risk perf project; disabled system-wide in #14005 and the fast-path bodies don't exist in src/).

🤖 Generated with Claude Code

@ObscuritySRL
ObscuritySRL marked this pull request as ready for review May 27, 2026 00:12

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

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

@coderabbitai

coderabbitai Bot commented May 27, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

This PR corrects FFI type conversion boundaries, refactors ABI validation, improves error handling, and adjusts type conversion logic. Integer encoding boundaries prevent overflow at power-of-2 limits; ABI validation is simplified in Rust; callbacks and memory access now validate inputs explicitly; type conversions are refined; and symbol metadata lookup in cc() is corrected. Test coverage expands to validate all boundary behavior and error cases.

Changes

FFI type handling and validation fixes

Layer / File(s) Summary
Integer encoding boundary fixes
src/js/bun/ffi.ts, src/runtime/ffi/FFI.h
Integer type conversions adjust boundaries to prevent overflow: int16_t clamps to INT16_MAX (32767), UINT32_TO_JSVALUE uses strict < instead of <= for int32 encoding, and INT64_TO_JSVALUE uses strict < for positive bounds. Values at power-of-2 boundaries now follow the double encoding path.
ABI type validation refactoring
src/runtime/ffi/ffi_body.rs, src/runtime/ffi/host_fns.rs
Numeric ABI type validation in Rust is simplified by removing explicit int <= ABIType::MAX filters for both args and returns, relying instead on ABIType::from_int(int) returning Some(...).
Threadsafe validation using local flag
src/runtime/ffi/ffi_body.rs, src/runtime/ffi/host_fns.rs
The threadsafe constraint check now uses the locally parsed threadsafe boolean (from the threadsafe option) instead of reading function.threadsafe in both files.
Error handling for callbacks and memory access
src/js/bun/ffi.ts, src/runtime/ffi/FFIObject.rs
JSCallback construction validates nativeCallback(options, cb) result with Error.isError before destructuring. addr_from_args explicitly rejects negative byteOffset with a structured error, converting to i32 first and only then to usize.
Type conversion logic adjustments
src/js/bun/ffi.ts
u64_fast wrapper is removed; double conversion for bigint changes from conditional Math.abs(...) to always using Number(val) + (0.00 - 0.00); cstring/pointer converter recognizes objects with numeric ptr property.
Symbol metadata lookup in cc()
src/js/bun/ffi.ts
cc(options) reads per-symbol metadata from options.symbols instead of options[key] when rebuilding wrapped functions with FFIBuilder.
Test coverage for type conversion boundaries
test/js/bun/ffi/cc.test.ts
FFIType is imported for type constant reference; cstring tests conditionally skip on ASAN/unavailable FFI with updated assertions for wrapping and pointer preservation; strlen returns uint64_t expecting BigInt results; new regression suites verify int16_t clamping, double conversion from negative BigInt, infinity for huge values, int64 boundary at 2^31, and numeric FFIType constant acceptance.
Test coverage for error handling
test/js/bun/ffi/ffi.test.js
JSCallback validation tests verify rejection of unknown arg types and threadsafe/non-void combinations, and acceptance of valid threadsafe void callbacks; read function tests confirm rejection of negative byteOffset while working correctly for valid non-negative offsets.

Suggested reviewers

  • Jarred-Sumner
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title is concise and accurately reflects the main FFI correctness-fix theme.
Description check ✅ Passed It covers both the change summary and verification, though it doesn't use the template's exact headings.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@Nitansh

Nitansh commented Jun 17, 2026

Copy link
Copy Markdown

Hi! I'm experiencing what might be related to the "JSCallback constructor silently failed" bug mentioned in this PR.

Issue

JSCallback.ptr is undefined in Bun v1.3.14 on Windows 11, even though the JSCallback object is created successfully.

Minimal Reproduction

import { JSCallback, FFIType } from "bun:ffi";

const callback = new JSCallback(
  {
    args: [FFIType.ptr, FFIType.ptr, FFIType.i32],
    returns: FFIType.i32,
  },
  (ctx, buffer, size) => {
    console.log("Callback invoked");
    return 0;
  }
);

console.log("callback.ptr:", callback.ptr);
// Expected: A number (pointer address)
// Actual: undefined

Output:

callback.ptr: undefined

Environment

  • Bun version: v1.3.14 (latest stable)
  • Platform: Windows 11 Enterprise 10.0.26100
  • Architecture: x64

Error When Used

When trying to pass this callback to a native function expecting FFIType.function:

TypeError: Expected function to be a JSCallback or a number
    at <anonymous> (builtin://bun/ffi:25:11)

The Bun FFI code checks val && val.ptr and since .ptr is undefined, validation fails.

How This Relates to PR #31449

This seems directly related to two issues mentioned in this PR:

  1. "JSCallback constructor silently failed" - The constructor succeeds, but .ptr is not initialized
  2. "Objects with a .ptr property couldn't be passed as pointer arguments" - Our callback has .ptr but it's undefined

According to the official Bun FFI docs, JSCallback.ptr should contain a valid pointer:

⚡️ Performance tip — For a slight performance boost, directly pass JSCallback.prototype.ptr

Question

Will this PR fix the JSCallback.ptr undefined issue, or should I open a separate issue for this?

Jarred-Sumner pushed a commit that referenced this pull request Jul 1, 2026
### What

`f64`/`double` arguments are silently corrupted before the native
function sees them. With a C callee that reports what it actually
received:

```js
const lib = dlopen(path, {
  isnan_f64:   { args: ["f64"], returns: "i32" },  // x != x
  signbit_f64: { args: ["f64"], returns: "i32" },  // raw sign bit of x
  echo_f64:    { args: ["f64"], returns: "f64" },
});
lib.symbols.isnan_f64(NaN);          // 0: C received 0.0
lib.symbols.signbit_f64(-0.0);       // 0: C received +0.0
lib.symbols.echo_f64(-5n);           // 5: C received the absolute value
lib.symbols.echo_f64(2n ** 1024n);   // TypeError: Cannot mix BigInt and other types
lib.symbols.echo_f64(-(2n ** 1024n)) // +Infinity: wrong sign, no error
```

`f32` arguments and the `f64` return path are correct; the bug is
specific to the `f64` argument wrapper. These corrupt silently, and NaN,
signed zero, and negative integers are exactly the values numeric C
libraries assign meaning to.

### Cause

`ffiWrappers[FFIType.double]` in `src/js/bun/ffi.ts`:

```js
if (typeof val === "bigint") {
  if (val.valueOf() < BigInt(Number.MAX_VALUE)) {
    return Math.abs(Number(val).valueOf()) + (0.00 - 0.00);
  }
}
if (!val) {
  return 0 + (0.00 - 0.00);
}
return val + (0.00 - 0.00);
```

- `!val` is true for `NaN` and `-0.0`, so both become `+0.0`.
- The BigInt branch looks like it meant to range-check `|val| <
Number.MAX_VALUE` and return `Number(val)`, but the `Math.abs` ended up
on the return value, so every negative BigInt loses its sign.
- A BigInt at or above `Number.MAX_VALUE` falls through to `val + (0.00
- 0.00)`, which throws `TypeError: Cannot mix BigInt and other types`;
one at or below `-Number.MAX_VALUE` passes the `<` check and comes out
as `+Infinity`.
- A string argument survives the wrapper as a string (`"2.5" + 0` is
`"2.50"`), so the compiled stub reinterprets a `JSString` pointer as a
double.

The native decoder (`JSVALUE_TO_DOUBLE` in `src/runtime/ffi/FFI.h`)
already handles every JS number, including NaN, -0.0, and int32-tagged
values (covered by the existing "integral JS numbers reach C as the
exact double" test). The wrapper's only job is to guarantee the stub
receives a plain number.

### Fix

```js
if (typeof val === "number") {
  return val;
}
return Number(val);
```

Numbers pass through bit-exact. Everything else is converted with
`Number()`: BigInt keeps its sign (out-of-range values become
+/-Infinity instead of throwing, matching `Number(bigint)` everywhere
else), strings and objects go through ToNumber, and `undefined` becomes
`NaN` instead of `0.0` (matching the `f32` wrapper, which is
`Math.fround(val)`).

### Test

Added to the existing `double <-> JSValue conversions` group in
`test/js/bun/ffi/cc.test.ts`. The fixture tinycc-compiles C observers
(`x != x`, the raw sign bit via a union, an echo) and calls them through
`CFunction`, which uses the same `FFIBuilder`/`ffiWrappers` argument
path as `dlopen` (the `dlopen` suite in `ffi.test.js` is gated on a
prebuilt library that no longer gets built, and `cc()` symbols currently
skip the argument wrappers entirely, so calling them directly would not
cover this). Assertions are on the values C reports, so a JS round trip
cannot mask an argument bug.

On the unfixed build, the one assertion reports every corruption:

```
nan_isnan:             expected 1, got 0
negative_zero_signbit: expected 1, got 0
negative_bigint:       expected "-5", got "5"
huge_bigint:           expected Infinity, got a thrown TypeError
negative_huge_bigint:  expected "-Infinity", got "Infinity"
string:                expected "2.5", got "NaN" (JSString pointer read as a double)
undefined_arg:         expected "NaN", got "0"
```

With the fix, `bun bd test test/js/bun/ffi/` passes (18 pass, 0 fail,
ASAN debug build).

### Out of scope, and overlap with other open PRs

The integer argument wrappers in the same table have their own problems
(saturation vs. two's-complement wrapping is inconsistent across widths,
and `int16_t`'s clamp bound of `32768` does not fit in int16). That is a
behavior decision beyond this bug, and #31449 already proposes changes
there.

- #31449 also fixes the BigInt sign half of this wrapper, but keeps the
`!val` branch, so NaN and -0.0 arguments are still corrupted with that
patch applied.
- #33095 fixes a different bug (JSCallback return values were never
coerced). Callback returns reuse the `ffiWrappers` table, so that PR
rewrites this same `FFIType.double` entry to an equivalent conversion.
The src hunk overlaps; the subjects and most of the coverage do not
(that PR asserts what C receives from callback returns, this one asserts
what a symbol receives as an `f64` argument, including the sign bit of
`-0.0` and out-of-range BigInts). Whichever lands second is a one-hunk
rebase, and this test applies unchanged either way.
@robobun

robobun commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator

Heads up on an overlap: the UINT32_TO_JSVALUE / INT64_TO_JSVALUE part of this PR is also fixed in #33340, which was opened from a fuzzing report of the u32-return corruption.

Two differences worth folding in either way:

  • #33340 fixes the constant (MAX_INT32 -> 2147483647, plus MIN_INT32) rather than switching the two comparisons to strict <, so MAX_INT32 stops meaning INT32_MAX + 1. That mis-named constant was the root cause, and the identical fix was already written in 099d2df on the unmerged ben/fix-ffi-test branch.
  • The same constant block has a third off-by-one: MAX_INT52 holds Number.MAX_SAFE_INTEGER, and UINT64_TO_JSVALUE compares it with a strict <, so u64_fast returns a BigInt for exactly 9007199254740991 while i64_fast returns a number.

Also: the int64_t return at the int32 boundary test here is describe.skipIf(isASAN || ...) because it uses cc(), so it is skipped on the CI lanes that run with ASAN. #33340 drives the same code path through dlopen and through callback arguments instead, which both run under ASAN.

Happy to close #33340 if this one lands first.

Rebased onto current main. The earlier double/BigInt argument fix from this
branch is dropped: oven-sh#33122 already fixed that upstream (main's double wrapper
now routes through Number(val)).

JS (src/js/bun/ffi.ts):
- cc(): read symbol specs from options.symbols[key], not options[key], so
  cstring returns become CString instances and argument wrappers (integer
  clamps, pointer auto-conversion) actually install.
- int16_t arg wrapper: clamp to INT16_MAX (32767), not 32768 (which
  signed-overflowed to -32768 when the C trampoline cast to int16_t).
- cstring/pointer arg wrapper: accept any object with a numeric .ptr
  (e.g. CString), matching the function wrapper's duck typing.
- JSCallback constructor: throw the Error instance returned by
  nativeCallback() instead of destructuring it into ptr=undefined.
- FFIBuilder: resolve numeric FFIType constants (e.g. FFIType.buffer = 20)
  as well as string labels, for both argument and return types. FFIType[n]
  reverse-maps a number to its label, so the old FFIType[params[i]] lookup
  threw "Unsupported type 20" for numeric constants.
- Remove dead duplicate ffiWrappers entries (i64_fast/u64_fast/uint16_t
  early definitions overwritten by later ones).

Rust/C:
- FFI.h: INT64_TO_JSVALUE / UINT32_TO_JSVALUE use strict `< MAX_INT32`.
  MAX_INT32 is 2^31 (not INT32_MAX), so `<=` admitted 2^31 into the int32
  encoding, where it wrapped to -2^31.
- FFIObject.rs: addr_from_args returns a JS error on a negative byteOffset
  instead of panicking (read.u8(addr, -1) previously SIGABRT'd the process).
- ffi_body.rs / host_fns.rs: the threadsafe-non-void-return guard now reads
  the local `threadsafe` (it read the not-yet-assigned struct field, so the
  guard never fired); drop the redundant ABIType::MAX filter that rejected
  FFIType.buffer (from_int already range-checks and accepts Buffer = 20).

Tests: un-skip ping(cstr)/strlen(cstring) and add coverage for each fix in
cc.test.ts and ffi.test.js. Fix makeValidCase to return a live handle (it
returned undefined before its beforeAll ran; every caller was skipped until
now). The FFI.h-derived ffi.test.fixture.*.c snapshots are regenerated.

Built and tested on Windows x64 (debug): all new tests pass, and the same
tests fail on system Bun 1.4.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AxxaB1U8TSsXg7RGyAtJhX
@ObscuritySRL
ObscuritySRL force-pushed the claude/ffi-quick-fixes branch from e0a6ce3 to f52cfe9 Compare July 12, 2026 01:19
ObscuritySRL and others added 11 commits July 11, 2026 23:35
Iteration from a 7-expert adversarial review of the FFI surface (31 raw
findings, 28 verified) plus independent analysis. All fixes reproduced and
covered by tests.

Memory safety / crashes reachable from public API with trivial input:
- toBuffer(ptr) without a finalizer installed MarkedArrayBuffer_deallocator and
  mi_free'd the caller's foreign/interior pointer on GC (double-free / heap
  corruption). Now a non-owning view via a no-op deallocator, matching
  toArrayBuffer (a null deallocator asserts in debug / null-calls on GC).
- ptr()/toArrayBuffer()/toBuffer()/CString with a byteOffset of -Infinity or
  exactly -(2^63) negated i64::MIN -> integer-overflow panic -> process abort
  (panic=abort). Now uses unsigned_abs + a finiteness check and returns a clean
  error. Fixes the sibling paths the prior addr_from_args fix missed.
- get_ptr_slice lacked ptr_'s max-addressable-memory bound, so a huge finite
  byteOffset (2^63) reached the CString NUL-scan and segfaulted. Bound added.
- JSCallback accepted callable Proxies/InternalFunctions (isCallable) then
  uncheckedDowncast<JSFunction>'d them (type confusion). Now dynamicDowncast +
  reject; the Step::Failed error message no longer borrows freed heap memory.

Undefined behavior / platform divergence:
- i64_fast/u64_fast args passed out-of-range doubles straight to (int64_t)d /
  (uint64_t)d, which is C UB and differs on x86 (indefinite) vs arm64
  (saturating). FFI.h now clamps and maps NaN -> 0.

Security / DoS:
- cc({ symbols: { f: { args: new Array(0xFFFFFFFF) } } }) reserved ~16 GB up
  front (reserve_exact on the JS array length) -> OOM abort. Reservation capped.

API / correctness:
- cc() string `flags` replaced the defaults, dropping -Wl,--export-all-symbols
  so the compiled symbols never resolved. Now prepends the defaults and appends,
  matching the array form and the documented example.
- napi_value args kept the default `val|0` wrapper (silent JSValue corruption);
  DataView was rejected by the ptr/cstring/buffer wrappers though the types list
  it; size_t was in the native table but missing from FFIType. Fixed.
- read.* read byteOffset with to_int32 (wraps at 2^31) while ptr()/toBuffer()/
  toArrayBuffer() used to_int64; get_ptr_slice's non-number byteOffset handling
  was inverted (silently ignored instead of erroring). Unified.
- Poison-pointer guard compared the same 32-bit constant twice; the 64-bit fill
  was never caught. i64_fast/u64_fast wrappers allocated two BigInts per call.
- .d.ts: toBuffer/toArrayBuffer finalizer params; buffer/napi_env returns ->
  never; bool coercion doc; DataView arg types; size_t.

Idioms: deleted dead ABIType::MAX and the dead c_param_type column /
param_typename / param_typename_label (generated C is byte-identical).

Tests: resurrected the dlopen round-trip suite (make compile-ffi-test was
removed -- compile the fixture with the system compiler at load time; fix the
hardcoded .dylib -> suffix); rewrote the broken threadsafe-callback test to
await the invocation and skip on Windows (matching cc.test.ts); removed the
"ffi print" test that overwrote git-tracked .c files and deleted those dead
fixtures; un-skipped three stale skips (string args coerce, missing arg -> 0,
syntax error throws); tightened a bare toThrow and the exact-empty-stderr
assertions; arch-selected the libc path; added regression tests for every fix.

Built and green on Windows x64 (debug): test/js/bun/ffi/** = 121 pass, 0 fail,
34 skip across 5 files. Release build, rust:check-all, benchmarks, and the
dead-code deletion of host_fns.rs are follow-ups tracked in GOAL_RESULTS.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01279WZmDr9EtyQ1FG97AioG
…C error sanitizer

host_fns.rs plus mod.rs's own Function/Step/Compiled structs and LIB_DIR_Z were a
second, dead copy of the FFI symbol-spec parsers and C-trampoline emitter — the
live implementation is ffi_body::Function (used by open/cc/linkSymbols/callback).
Verified dead: the mod.rs re-exports had no external callers and nothing
references crate::ffi::{Function,Step,Compiled} (ffi_body defines its own). Delete
the file, the mod/re-export, the duplicate structs + impls, LIB_DIR_Z, and the
now-unused imports (~470 lines).

Also extract strip_leading_nonprintable() so the two TinyCC error handlers
(handle_compilation_error, handle_tcc_error) share one copy of the
"skip leading non-printable garbage" logic instead of a copy-pasted loop with
bare 0x20/0x7f magic numbers.

No runtime change. Debug build clean (-D unused-imports enforced) and
`rust:check-all` green across linux/macOS/windows x64/arm64 (0 errors, 0
warnings). cc() error reporting verified unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01279WZmDr9EtyQ1FG97AioG
…pass

A second adversarial 7-expert pass over the committed FFI code surfaced 11 real
findings — several are missed siblings of the first pass's own fixes.

Memory safety:
- JSCallback compile failure leaked the FFICallbackFunctionWrapper (and a
  Strong<GlobalObject> GC root pinning the whole realm) on every failure path
  after wrapper creation — the null-check fix only covered a null wrapper. A
  valid wrapper whose compilation then fails (e.g. `new JSCallback(fn, { args:
  ["buffer"] })`, whose callback-direction codegen emits invalid C) leaked
  unboundedly. Fixed: (1) reject buffer/napi_env as callback ARG types up front;
  (2) scopeguard the wrapper at the acquisition site, disarmed only when
  Step::Compiled adopts it.

Crashes / UB (missed siblings of the byteOffset + float->int fixes):
- read.* fed a non-number byteOffset straight to to_int64() with no is_number
  guard (the guard the ptr_/get_ptr_slice siblings have) -> JSC toInt64 ASSERT
  abort (debug) / UB (release). Added the guard.
- get_ptr_slice bounded byteLength at MAX_ADDRESSABLE_MEMORY (2^56) but
  ArrayBuffer::from_bytes does u32::try_from(len).expect(), so a byteLength
  >= 2^32 panic-aborted. Tightened to u32::MAX.
- int8_t arg wrapper had no clamp (sibling of int16_t/uint8_t): (int8_t) wrapped
  128 -> -128. Added the clamp.
- JSVALUE_TO_PTR had the same double->int UB fixed in INT64/UINT64; clamped.

Idioms:
- Removed dead EnumMapFormatter; derived compile()'s fallback flags from
  DEFAULT_TCC_OPTIONS instead of a duplicated literal.

Tests:
- Strengthened the DataView test to actually validate byteOffset (offset-2
  address == offset-0 address + 2); added the missing finalizer-callback GC test
  (deallocator fires exactly once); fixed a stray expect(stderr).toBe("");
  added regression tests for the int8_t clamp, the read.*/toArrayBuffer
  crash-guards, and the callback buffer/napi_env rejection.

Debug build green; full test/js/bun/ffi/** = 126 pass / 0 fail / 34 skip (161
tests across 5 files).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01279WZmDr9EtyQ1FG97AioG
A third adversarial pass converged to 6 low-severity findings, all fixed:

- get_ptr_slice's u32::MAX byteLength ceiling only covered the explicit-length
  branch; toArrayBuffer(ptr) with no length returned the full strlen, which
  ArrayBuffer::from_bytes (u32::try_from(len).expect) would panic on for a >=4 GiB
  NUL-free region. Applied the same ceiling to the NUL-scan branch.
- Removed two dead write-only fields: Step::Failed.allocated and
  Compiled.js_context (never read; matches use `{ msg, .. }` / field access).
- Rewrote the stale ValueOrError consumer-audit comment that still described
  toBuffer's already-fixed mi_free footgun as current behavior.
- Tests: added the missing wrapper-leak regression (loops JSCallbacks whose args
  compile to invalid C so the compile_callback scopeguard's free-on-failure path
  runs, then asserts a clean exit — the ASAN lane catches a regression); moved the
  negative read.* byteOffset case into the spawned crash-guard fixture.

Debug build green; full test/js/bun/ffi/** = 127 pass / 0 fail / 34 skip (162
tests across 5 files).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01279WZmDr9EtyQ1FG97AioG
…rray

A fourth convergence pass found one real reachable crash the earlier passes
missed: an empty `source: []` built `Source::Files(Vec::new())`, so
`Source::first()`'s `&files[0]` indexed an empty Vec and panicked -> process
abort. Reachable from the public API (ffi.ts cc() only rejects a falsy source;
`[]` is truthy).

- Reject an empty source array in bun_ffi_cc with a clear JS error before compile.
- Defensively harden Source::first() to files.first()...unwrap_or(zstr!("")) so
  no caller can index-panic.
- Fixed a stale mod.rs module-doc line (claimed the JSC offsets live there; they
  live in ffi_body).
- Added a spawned regression test asserting cc({ source: [] }) throws, not aborts.

Debug build green; full test/js/bun/ffi/** = 128 pass / 0 fail / 34 skip (163
tests). Convergence series across review passes: 28 -> 11 -> 6 -> 2 findings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01279WZmDr9EtyQ1FG97AioG
The 5th convergence review found FFITypeStringToType omitted 8 labels the runtime
accepts (abi_type.rs ABI_TYPE_LABEL): i64_fast, u64_fast, c_int, c_uint, isize,
char*, void*, fn. So e.g. `dlopen(p, { f: { args: ["i64_fast"] } })` typed the arg
as `never` (uncallable in a typed codebase) even though the call works at runtime
— a missed sibling of the size_t/usize/callback additions in the same table.

Added the eight entries mapped to their canonical members. Types-only change.
(The bun-types integration test's harness is currently failing wholesale on this
Windows dev machine — 0/13 with and without this change, all on unrelated features
like bun:bundle/markdown/lib-config — so it was verified by inspection against the
runtime table; the change is purely additive to a lookup interface.)

Convergence series across review passes: 28 -> 11 -> 6 -> 2 -> 1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01279WZmDr9EtyQ1FG97AioG
… hermetic

The 6th convergence pass found a real feature breakage: a symbol declared with
`returns: "napi_value"` and no napi_env/napi_value argument failed to bind. The
generated forward wrapper opens a NapiHandleScope (referencing
Bun__thisFFIModuleNapiEnv) whenever needs_handle_scope() is true — which includes
a napi_value return — but the env symbol was only added when needs_napi_env()
(args only). So the wrapper referenced an unresolved symbol, relocate() failed,
and the symbol never bound across dlopen()/cc()/linkSymbols().

Fixed by gating the env on needs_handle_scope() (a strict superset of
needs_napi_env, so nothing else regresses) in both make_napi_env_if_needed (the
dlopen/linkSymbols batch env) and the CompileC shared-state loop (cc()). Added a
cc() regression test that binds a napi_value-returning symbol with no napi args.

Also fixed addr32.test.ts to build its .so into a tempDir (auto-removed) instead
of writing libaddr32.so into the git-tracked test directory with no cleanup.

Debug build green; full test/js/bun/ffi/** = 129 pass / 0 fail / 34 skip (164
tests). Convergence series: 28 -> 11 -> 6 -> 2 -> 1 -> 2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01279WZmDr9EtyQ1FG97AioG
…p dead vm

The 7th convergence pass found no crash/UB/security/correctness defect — only
three quality nits, all fixed here:

- get_ptr_slice's non-finite error said "ptr must be a finite number." when it is
  the byteOffset that is bad (the ptr was already validated). Corrected to
  "byteOffset must be a finite number", matching ptr_().
- .d.ts FFITypeStringToType mapped "function"/"callback" to FFIType.pointer while
  the runtime (and the just-added "fn") map them to FFIType.function — so a
  JSCallback arg was a spurious TS error and a TypedArray type-checked but threw
  at runtime. Mapped both to FFIType.function.
- Removed a dead `let vm = jsc::VirtualMachineRef::get();` (+ its `let _ = vm;`
  discard) in FFI::open.

Debug build green; full test/js/bun/ffi/** = 129 pass / 0 fail / 34 skip (164
tests). Convergence: 28 -> 11 -> 6 -> 2 -> 1 -> 2 -> 3 (all cosmetic, 0 real bugs).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01279WZmDr9EtyQ1FG97AioG
CompileC::compile obtained a TCC::State handle (an opaque FFI object with no
Drop) but installed no scopeguard over it, unlike the sibling Function::compile.
Every error return — invalid C, an unknown exported symbol, a bad include or
library path, a relocate failure — leaked a full TinyCC context (section
buffers, symbol tables, code cache). The path is user-reachable and repeatable,
so RSS grew unbounded across repeated failed cc({...}) calls (~216 KB each).

Wrap the state in a scopeguard that destroys it on any early return, disarmed
via ScopeGuard::into_inner on the success path where ownership transfers to the
caller — mirroring the existing guard in Function::compile.

Regression test loops 300 failed compiles and asserts bounded RSS + clean exit;
reverting only the scopeguard makes it grow 64.8 MB (vs 2.5 MB fixed), and the
ASAN lane independently flags the leak.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01279WZmDr9EtyQ1FG97AioG
…ites (UAF)

When a per-symbol wrapper fails to compile, the reason is stored in
Step::Failed { msg }, where msg is a heap Box<[u8]> owned by the Function.
cc(), dlopen(), and linkSymbols() converted it with
ZigString::init(msg).to_error_instance(global), which (via getErrorInstance ->
toString -> StringImpl::createWithoutCopying) makes the JS error's message
StringImpl borrow msg's bytes with no copy. The native call then returns, the
local symbols map drops, the Function drops, and msg is freed — but user JS
reads error.message after the call returns, dereferencing freed heap: a
use-after-free (garbage bytes, or an ASAN abort).

An earlier iteration fixed this in the JSCallback path but never propagated the
fix to these three siblings. Extract one failed_step_error() helper that copies
msg via create_error_instance(format_args!) and route all four sites (the three
siblings plus the callback path) through it, so the borrow-vs-copy contract has
a single owner and a future site can't reintroduce the class.

Reachable from script: args:["void"] compiles to `void arg0` (invalid C) with a
resolved symbol, landing in Step::Failed. The new spawned regression test drives
cc()/dlopen()/linkSymbols() that way and asserts each error.message is printable
ASCII — GARBAGE on the unfixed build, OK on the fixed build.

Also scope ffi.test.js's "Failed to dlopen the ffi test fixture" log to the
fixture itself, so the intentional dlopen("nonexistent") test stops logging it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01279WZmDr9EtyQ1FG97AioG
ffi.ts saturation-clamps four 8/16-bit arg types before the no-cast trampoline
truncates the low byte(s): uint8_t/int8_t/int16_t/uint16_t. Out-of-range tests
existed for int8_t and int16_t but not the two unsigned types, so reverting
either uint clamp to a bare `val|0` would leave CI green while reintroducing
256->0 / 65536->0 wraparound.

Add uint8_t and uint16_t arg-clamping tests mirroring the existing int ones.
Verified load-bearing: reverting the clamps makes exactly these two tests fail
(identity_uint16(65536) returns 0), int8_t/int16_t unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01279WZmDr9EtyQ1FG97AioG
@ObscuritySRL
ObscuritySRL requested a review from alii as a code owner July 12, 2026 15:07
Addresses the review feedback on oven-sh#31449 and folds in the overlapping oven-sh#33340.

- MAX_INT32 held 2^31 (INT32_MAX + 1) — a mis-named constant that forced every
  comparison to a strict `<` workaround. Correct it to INT32_MAX (2147483647)
  and add a distinct MIN_INT32, so the int32-range checks read naturally with
  inclusive bounds. Behavior at every boundary is unchanged (verified by the
  existing 2^31 / -2^31 return tests).
- UINT64_TO_JSVALUE compared against MAX_INT52 with a strict `<`, so a u64_fast
  return of exactly Number.MAX_SAFE_INTEGER arrived as a BigInt while the same
  value from i64_fast arrived as a Number. Use `<=` so both agree — it is
  exactly representable as a double.

Tests: extend the cc.test.ts boundary describe to cover the u32/u64 int32
boundary and the u64_fast/i64_fast MAX_SAFE_INTEGER agreement, and add
ASan-covered coverage through dlopen (the cc() variants are ASan-skipped) via
new boundary functions in the ffi-test.c fixture.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01279WZmDr9EtyQ1FG97AioG
@ObscuritySRL

Copy link
Copy Markdown
Author

This one's ready for review whenever someone has a moment.

Since I opened it I did a thorough pass over the whole FFI surface and folded the results into this branch, so it now covers quite a bit more than the original description — I've rewritten the PR body to match. Highlights:

  • Memory safety: a use-after-free reading a failed-compile error message (cc/dlopen/linkSymbols all borrowed a heap message that was freed before JS read .message), and a TinyCC compilation-state leak (~216 KB per failed cc()).
  • Crash/DoS: several uncatchable process aborts reachable from a single public call — ptr(a, -Infinity), read.u8(addr, -1), toArrayBuffer(ptr, 0, 2**33), cc({ source: [] }), a 4-billion-length args array, and toBuffer freeing foreign memory on GC.
  • UB / correctness: the out-of-range float→int conversions, the 8/16-bit arg clamps, and the return-encoding constants.
  • A good deal of dead code removed (net −558 lines), and a regression test for every fix that fails on current main.

I also folded in the robobun note about the overlap with #33340 — the MAX_INT32 / u64_fast return-encoding fix now matches that PR's root-cause approach (correcting the mis-named constant plus the MAX_SAFE_INTEGER off-by-one), so #33340 can be closed if this lands first.

CI shows as blocked pending a maintainer trigger — could someone kick off the build when convenient? Happy to rebase, split it up, or adjust anything to make review easier.

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants