bun:ffi: unify integer argument coercion on modular wrap - #35180
Conversation
The JS-side argument wrappers picked a different out-of-range policy per width: char/i8/i32 wrapped (ToInt32), u8/i16/u16/u32 saturated (with an off-by-one upper bound on i16), i64/u64 threw RangeError on a fractional number, and u64 saturated negative numbers to 0. None of this was documented, so which value reached C depended on the declared type. Apply one policy everywhere: modular wrap, the same conversion a TypedArray element store performs. Sub-32-bit and 32-bit types normalize with ToInt32 (truncating fractions, NaN/Infinity to 0) and the generated stub narrows the int32-tagged low bits to the declared width. 64-bit types truncate a Number toward zero, treat NaN/Infinity as 0, and wrap BigInt modulo 2^64 via toBigInt64/toBigUInt64. Safe-integer Numbers still pass through without a BigInt allocation. Documented under 'FFI types' in docs/runtime/ffi.mdx.
|
Warning Review limit reached
Next review available in: 7 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the 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 configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (3)
Comment |
|
Updated 5:41 PM PT - Jul 22nd, 2026
❌ @robobun, your commit 5ff99ec has 2 failures in
🧪 To try this PR locally: bunx bun-pr 35180That installs a local version of the PR into your bun-35180 --bun |
There was a problem hiding this comment.
This changes user-facing coercion semantics for every integer FFI argument type (saturate/throw → modular wrap), which is an API design decision a maintainer should sign off on — especially since bun:ffi values reach native code directly. The implementation and test coverage look solid; deferring for the policy call rather than a correctness concern.
Beyond the inline nit, I checked that the new 64-bit wrapper can't hit BigInt()'s RangeError on non-integers (Math.trunc runs first, and NaN/±Infinity are gated to 0n), and that ffiWrappers.fill(int32) still leaves the later double/float/bool/ptr/function/buffer overrides intact.
Extended reasoning...
Overview
The PR rewrites the ffiWrappers table in src/js/bun/ffi.ts so all integer argument types use a single coercion policy (modular wrap, matching TypedArray element stores) instead of the previous per-type mix of wrap/saturate/throw. It removes the duplicate i64_fast/u64_fast/uint16_t overrides, adds a docs section under FFI types, and adds a new dlopen-based test file that compiles echo functions per width and asserts round-trip equals the corresponding TypedArray store.
Security risks
FFI is inherently safety-sensitive: the coerced value is written raw into the C call frame. The change doesn't widen the set of values that can reach C (everything was already reachable via the wrap paths on i8/i32), but it does change which value reaches C for out-of-range inputs — e.g. u32(-1) now becomes 0xFFFFFFFF instead of 0. Any user code that relied (knowingly or not) on the old saturation to clamp a size/index argument will now pass a very different value to native code. That's a legitimate compat/API concern for a maintainer to weigh, not a bug in the PR itself.
Level of scrutiny
High. This is a deliberate behavior change to a user-facing API surface (bun:ffi argument coercion), which the repo guidance explicitly flags as needing maintainer agreement. The module is marked experimental, which lowers the compat bar, but the choice of wrap vs. saturate vs. throw is exactly the kind of design call that shouldn't land on bot approval alone.
Other factors
- The PR description notes the new test file was not run locally ("no test proof · deferring to CI"), so CI needs to be green before merge.
- One nit was filed inline (
const dir = tempDir(...)should beusing dirso the compiled.sois cleaned up). - I verified the 64-bit wrapper's
BigInt(n)call cannot throw:Math.truncguarantees an integer double, and the> -Infinity && < Infinityguard routes NaN/±Infinity to0n. I also confirmed thefill(int32)baseline is followed by all the same non-integer overrides (double,float,bool,cstring/pointer,buffer,function) as before, so no wrapper was accidentally dropped.
There was a problem hiding this comment.
Beyond the inline nit, I also checked: the ffiWrappers.fill(int32) still runs before the later double/float/bool/ptr/function overrides so none are clobbered; JSVALUE_TO_INT64 in src/runtime/ffi/FFI.h handles both int32-tagged and double Numbers, so the new i64 wrapper returning Number for safe integers (where it previously always returned BigInt) is safe and covered by the in-range test; and the generated stub's (uintN_t)JSVALUE_TO_INT32(...) cast in abi_type.rs provides the modulo-2^N narrowing the val|0 path relies on. Note this is a deliberate user-facing coercion change (saturate → wrap) to an experimental API — worth a maintainer sign-off on the policy itself.
Extended reasoning...
Verified the wrapper table ordering, the native-side Number handling for the new i64/u64 fast path, and the C-stub narrowing cast that backs the val|0 simplification. The one inline finding is a test-harness convention nit; the substantive change is a policy decision on out-of-range coercion semantics.
There was a problem hiding this comment.
No bugs found; both earlier nits (tempDir disposal, cc/gcc/clang probe) are addressed. Deferring to a maintainer because this is a user-visible behavior change to bun:ffi argument coercion — swapping saturation/throw for modular wrap is a policy call on public API surface, and worth a human sign-off even with the module marked experimental.
What was reviewed:
ffiWrappersrewrite:val|0fill for ≤32-bit widths still produces the Int32Tag encoding the trampoline requires; unsigned reinterpretation via the C cast is sound.- 64-bit wrappers: traced NaN/±Infinity →
0n, negative numbers tou64→ BigInt path (avoids the UBtoUInt64NoTruncateclamp), andBigInt(Math.trunc(n))cannot throw on any finite double. - Test file: cc probe + skipIf,
using dir, and per-width TypedArray reference values look correct.
Extended reasoning...
Overview
This PR rewrites the ffiWrappers table in src/js/bun/ffi.ts to apply a single coercion policy (modular wrap, matching TypedArray element assignment) to every integer argument width, replacing the previous per-width mix of saturate / wrap / throw. It adds a docs section under FFI types and a new test/js/bun/ffi/ffi-int-coercion.test.ts that compiles a C echo fixture and asserts each width matches the corresponding TypedArray store.
Security risks
None identified. The wrappers run in JS before the trampoline; | 0 and the BigInt paths are pure value coercions with no allocation-size or bounds implications. FFI itself is inherently unsafe, but this change doesn't widen the attack surface.
Level of scrutiny
Medium-high. bun:ffi is flagged experimental in the docs, so breaking changes are permissible, but this is still a deliberate API-semantics change: echo_u8(300) moves from 255 to 44, echo_u32(-1) from 0 to 4294967295, and echo_i64(5.7) from throwing to 5n. That's exactly the kind of "API design" decision REVIEW.md says needs maintainer agreement. The implementation is small and well-reasoned (the #7007 Int32Tag invariant is preserved, and the u64 path correctly routes negatives through BigInt to avoid the undefined C cast / toUInt64NoTruncate clamp), but whether wrap-over-saturate is the right policy is a maintainer call, not a mechanical fix.
Other factors
- The two nit-level findings from earlier review passes (
using dirfor tempDir disposal,Bun.which("cc")||gcc||clangprobe withskipIf) have both been applied and the threads are resolved. - Test coverage is thorough across widths, boundaries, fractions, NaN/Infinity, and out-of-range BigInt, with an in-range identity sweep. The PR body states 7/9 tests fail on the unfixed build.
- I checked that the arrow-wrapper codegen shape
(val=>${wrapper})(pN)still works for both the expression-body (val|0) and block-body (64-bit) wrapper strings. - CI build #78115 for the latest commit is still in flight per the robobun status comment.
|
CI build #78115: the new
The change is confined to the |
|
(Claude wrote this comment.) Superseded by #35246: the per-type argument-coercion wrappers in |
What
The JS-side argument wrappers in
bun:ffiapplied a different out-of-range policy for each integer width:char/i8/i32wrapped (ToInt32),u8/i16/u16/u32saturated (with an off-by-one upper bound oni16:>=32768?32768),i64/u64threwRangeErroron a fractionalnumber, andu64saturated negative numbers to0n. Which value reached C depended on the declared type and none of it was documented.This unifies on one policy: modular wrap, matching the conversion a
TypedArrayelement store performs (ToInt32/ToUint32/ToBigInt64/ToBigUint64) and what N-API and the WebAssembly JS API do.Repro
Fix
ffiWrappersinsrc/js/bun/ffi.ts:char,i8,u8,i16,u16,i32,u32: plainval | 0. ToInt32 truncates fractions and wraps modulo 2^32; the generated stub's implicit narrow to the C parameter width then wraps modulo 2^N.| 0also guarantees the Int32Tag encoding the trampoline's raw*argsPtrread relies on (FFIType.u32 misbehavior #7007), and for unsigned widths the signed int32 bit pattern is reinterpreted by the C cast (-1 | 0reachesuint32_tas0xFFFFFFFF). The per-type saturation clamps and the seconduint16_toverride are removed.i64/i64_fastandu64/u64_fast:BigIntpasses through; anumberis truncated toward zero (NaN/Infinity become0). Safe integers (non-negative only foru64, becausetoUInt64NoTruncateclamps negative doubles) stay asNumberto avoid a BigInt allocation; everything else becomes aBigInt, whichtoBigInt64/toBigUInt64wrap modulo 2^64.Documented under FFI types in
docs/runtime/ffi.mdx.Not in this PR
The two other low-severity cells from the same round are tracked by existing PRs and left alone here:
bool-typed JSCallback returns of a truthy non-truevalue reaching C asfalse: bun:ffi: coerce JSCallback return values to the declared return type #33095 addresses JSCallback return coercion.cc()skippingFFIBuilderentirely (so none of these wrappers run undercc): bun:ffi: infer exact signatures from symbol definitions, fix cc argument/return conversions #32075.How did you verify your code works?
New
test/js/bun/ffi/ffi-int-coercion.test.tscompiles a smalldlopenfixture with an echo function per width and asserts the wrapped result equals the correspondingTypedArrayelement store for out-of-range, fractional, negative-to-unsigned, and NaN/Infinity inputs, plus an in-range identity sweep. 7 of the 9 tests fail on the unfixed build (saturation/throw cases above); all pass with the fix. Existingtest/js/bun/ffi/suite is unchanged.no test proof · iteration 0 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/bun/ffi/ffi-int-coercion.test.ts