bun:ffi: fix f64 argument conversion of NaN, -0.0, and BigInts - #33122
Conversation
The double argument wrapper rewrote every falsy value to +0.0, which destroyed NaN and -0.0, and its BigInt branch returned Math.abs() of the value, so negative BigInts arrived in C with the sign stripped. BigInts at or above Number.MAX_VALUE fell through to 'val + 0.0' and threw an unrelated TypeError, and string arguments reached the compiled stub as a JSString pointer. Pass numbers through unchanged and convert everything else with Number(). The compiled stub's JSVALUE_TO_DOUBLE already decodes every JS number correctly, including NaN, -0.0, and int32-tagged values.
|
Warning Review limit reached
Next review available in: 2 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 (2)
Comment |
|
Updated 10:00 PM PT - Jun 29th, 2026
❌ @robobun, your commit 56c70c4 has 3 failures in
🧪 To try this PR locally: bunx bun-pr 33122That installs a local version of the PR into your bun-33122 --bun |
|
This PR may be a duplicate of:
🤖 Generated with Claude Code |
|
Looked at both. #31449 fixes the BigInt sign and overflow in this wrapper but keeps the #33095 fixes a different bug (JSCallback return values were never coerced). Callback returns reuse the Keeping this PR scoped to the f64 argument path; I updated the description to point at both. |
There was a problem hiding this comment.
LGTM — the new wrapper is strictly simpler and more correct than what it replaces, and the test pins the exact edge cases (NaN, -0.0, BigInt sign, huge BigInt, string) that the old code corrupted.
Extended reasoning...
Overview
This PR replaces the ffiWrappers[FFIType.double] argument coercion in src/js/bun/ffi.ts (~10 lines of buggy logic involving !val, Math.abs, and + (0.00 - 0.00)) with a 4-line wrapper: pass numbers through untouched, otherwise Number(val). A new test in test/js/bun/ffi/cc.test.ts compiles C observers (isnan, signbit, echo) via cc() and calls them through CFunction so the FFIBuilder/ffiWrappers path is exercised, asserting on what C actually received.
Security risks
None. This is JS-side coercion of arguments before they reach the compiled FFI stub. The old code could let a JSString survive the wrapper and be reinterpreted as a double in native code; the new code closes that off by always producing a plain number. No auth, crypto, or permission surfaces are touched.
Level of scrutiny
Low-to-moderate. The functional change is a single, small wrapper string whose new behavior is trivially analyzable: typeof val === "number" ? val : Number(val). I verified that JSVALUE_TO_DOUBLE in src/runtime/ffi/FFI.h already handles both int32-tagged and double-encoded JSValues, so passing the number through unchanged is correct (NaN and -0.0 included). The only intentional behavior change beyond the bug fixes is undefined → NaN instead of 0.0, which the PR calls out explicitly and which matches the existing f32 wrapper (Math.fround(undefined)).
Other factors
- No CODEOWNERS apply to the changed paths.
- No prior human review comments to address.
- The added test follows the established subprocess-fixture pattern in the same
describeblock, includes positive controls (1.5 is not NaN, +0 has signbit 0), and reports thrown conversions as values so one failure cannot mask another. - The PR description documents the interaction with #31449 and scopes out the unrelated integer-wrapper issues.
|
CI status for build 67064 (56c70c4), now finished: the change and its test are green on every lane that ran them, and each red lane is unrelated to this PR.
|
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
What
f64/doublearguments are silently corrupted before the native function sees them. With a C callee that reports what it actually received:f32arguments and thef64return path are correct; the bug is specific to thef64argument 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]insrc/js/bun/ffi.ts:!valis true forNaNand-0.0, so both become+0.0.|val| < Number.MAX_VALUEand returnNumber(val), but theMath.absended up on the return value, so every negative BigInt loses its sign.Number.MAX_VALUEfalls through toval + (0.00 - 0.00), which throwsTypeError: Cannot mix BigInt and other types; one at or below-Number.MAX_VALUEpasses the<check and comes out as+Infinity."2.5" + 0is"2.50"), so the compiled stub reinterprets aJSStringpointer as a double.The native decoder (
JSVALUE_TO_DOUBLEinsrc/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
Numbers pass through bit-exact. Everything else is converted with
Number(): BigInt keeps its sign (out-of-range values become +/-Infinity instead of throwing, matchingNumber(bigint)everywhere else), strings and objects go through ToNumber, andundefinedbecomesNaNinstead of0.0(matching thef32wrapper, which isMath.fround(val)).Test
Added to the existing
double <-> JSValue conversionsgroup intest/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 throughCFunction, which uses the sameFFIBuilder/ffiWrappersargument path asdlopen(thedlopensuite inffi.test.jsis gated on a prebuilt library that no longer gets built, andcc()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:
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 of32768does not fit in int16). That is a behavior decision beyond this bug, and #31449 already proposes changes there.!valbranch, so NaN and -0.0 arguments are still corrupted with that patch applied.ffiWrapperstable, so that PR rewrites this sameFFIType.doubleentry 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 anf64argument, including the sign bit of-0.0and out-of-range BigInts). Whichever lands second is a one-hunk rebase, and this test applies unchanged either way.