bun:ffi: decode double-encoded JSValues in JSVALUE_TO_INT32 - #34653
Conversation
JSVALUE_TO_INT32 truncated the raw NaN-boxed int64, which is only correct when the JSValue is int32-tagged. Whether an integer-valued JS number is int32-tagged or double-encoded is the engine's choice (JIT tier, DFG double speculation, Math.* provenance), so an int-typed JSCallback could return 938 for the first N iterations and 0 once the callback body tiered up. The fix mirrors JSVALUE_TO_DOUBLE: check the tag, otherwise subtract DoubleEncodeOffset and cast (via int64_t so (uint32_t)JSVALUE_TO_INT32(...) stays defined for values in (INT32_MAX, UINT32_MAX]).
|
Reproduced on main, fails-before / passes-after verified locally with Ready for review. No
|
|
Updated 11:28 AM PT - Jul 18th, 2026
❌ @robobun, your commit 70fbcd8 has 4 failures in
🧪 To try this PR locally: bunx bun-pr 34653That installs a local version of the PR into your bun-34653 --bun |
WalkthroughChangesFFI integer decoding
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
|
This PR may be a duplicate of:
🤖 Generated with Claude Code |
|
Not duplicates:
|
There was a problem hiding this comment.
LGTM — mirrors the existing JSVALUE_TO_DOUBLE int32-tag check for the symmetric case.
What was reviewed:
- Traced all
JSVALUE_TO_INT32consumers inabi_type.rs(char/i8/u8/i16/u16/i32/u32) — the(int64_t)intermediate keeps the(uint32_t)cast on the u32 path defined for values in (INT32_MAX, UINT32_MAX], and narrower types truncate correctly. - Confirmed the in-header callers (
JSVALUE_TO_PTR/_DOUBLE/_INT64/_UINT64) all pre-guard withJSVALUE_IS_INT32, so the new branch is a no-op for them. - Test follows the neighboring spawned-fixture pattern in the same describe block; covers int32-tagged control, double-encoded integer, fractional ±, and the u32/i8/u16 codegen variants.
Extended reasoning...
Overview
Six-line change to JSVALUE_TO_INT32 in src/runtime/ffi/FFI.h (plus the two regenerated fixture .c files that embed a copy of that header) so it decodes double-encoded JSValues instead of truncating the raw NaN-boxed bits. This is the exact mirror of the fix already present in JSVALUE_TO_DOUBLE a few lines below. A new spawned-subprocess test in cc.test.ts covers i32/u32/i8/u16 JSCallback return types with a callback body that forces a double-represented integer.
Security risks
None. This is a numeric decode of a value the JS engine already produced; no untrusted input parsing, no memory dereference, no new pointer arithmetic. The change strictly narrows the set of inputs that produce garbage.
Level of scrutiny
Low-medium. The FFI header is compiled by TinyCC into per-call trampolines, so correctness matters, but the change is mechanical: it adds the same JSVALUE_IS_INT32 guard + DoubleEncodeOffset subtraction pattern used by four sibling helpers in the same file. I verified the (int32_t)(int64_t)double chain: the int64 step avoids the direct double→int32 out-of-range UB for the u32 path (3e9 fits in int64, then narrows modulo 2³² under TinyCC's 2's-complement implementation-defined behavior, then the codegen's (uint32_t) cast recovers the value). Values outside int64 range or NaN remain garbage-in-garbage-out, which is unchanged from before and out of contract for an int-typed return.
Other factors
The test is placed alongside the existing "integral JS numbers reach C as the exact double" test, uses identical harness conventions (tempDir, Promise.all pipe drain, toMatchObject on {results, stderr, exitCode}), and includes an echo_int control so the int32-tagged fast path is still asserted. The fixture .c updates are pure regeneration from FFI.h. PR description explains why this supersedes the earlier construction-time-wrapper approach (#33095).
There was a problem hiding this comment.
LGTM — the decode now mirrors JSVALUE_TO_DOUBLE and every generated JSVALUE_TO_INT32 caller in abi_type.rs (char/i8/u8/i16/u16/i32/u32) benefits. The inline nit about NaN/non-number UB is real but matches the file's existing JSVALUE_TO_PTR/_INT64/_UINT64 convention under TinyCC and isn't a regression, so not blocking.
Extended reasoning...
Overview
Fixes JSVALUE_TO_INT32 in src/runtime/ffi/FFI.h to handle double-encoded JSValues instead of blindly truncating the raw NaN-boxed asInt64. The two ffi.test.fixture.*.c files are regenerated copies of the same header (they embed FFI.h verbatim), and a new spawned-subprocess test in cc.test.ts covers i32/u32/i8/u16 JSCallback returns with double-encoded integers plus fractional/negative cases.
Correctness
The new branch mirrors the existing JSVALUE_TO_DOUBLE / JSVALUE_TO_PTR shape in the same file: check JSVALUE_IS_INT32, else subtract DoubleEncodeOffset and read asDouble. I verified via abi_type.rs:158-164 that every small-integer ABI type funnels through JSVALUE_TO_INT32 with an outer C cast, so the fix covers the whole class. The four internal FFI.h callers (lines 237/314/332/347) all pre-guard with JSVALUE_IS_INT32, so the added branch is a no-op for them — the PR body's claim checks out. Traced the u32 case: 3000000000.0 → (int64_t)3e9 → (int32_t) low-32-bits → (uint32_t) → 3000000000, and neg-fractional -5.7 → -5 via C truncate-toward-zero — both match the test expectations.
Security risks
None. This is a JSValue bit-pattern decode in a TinyCC-compiled trampoline; no untrusted-length arithmetic, no allocation, no pointer dereference introduced. The nit about C11 6.3.1.4 UB on NaN/out-of-range only fires when the user violates their own returns: 'i32' contract, is deterministic under TinyCC's cvttsd2si/fcvtzs lowering, and the pre-fix behavior on those inputs was already garbage — so no new attack surface.
Level of scrutiny
Low-medium. A 7-line change to one static inline function, following an established pattern two functions away in the same file. FFI is opt-in surface and the change strictly widens the set of correctly-decoded inputs without altering the int32-tagged fast path.
Other factors
Test follows the neighboring cc.test.ts conventions exactly (tempDir, bunEnv/bunExe, concurrent stdout/stderr/exited drain, toMatchObject on the combined object with stderr included but not asserted-empty, cb.close() cleanup). It pins double-encoding via x + 0.5 - 0.5 on a runtime arg so it fails-before regardless of JIT tier, and covers both the int32-tagged path (echoInt) and the new double path across four ABI widths. The one inline nit is explicitly non-blocking and applies equally to pre-existing code in the file.
Avoids the C11 6.3.1.4 double-to-int UB when an i32-typed callback returns undefined/null/NaN. The decoded bit patterns of every non-number immediate are NaNs, so one self-compare covers them.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/runtime/ffi/FFI.h`:
- Around line 276-280: Update JSVALUE_TO_INT32 in
src/runtime/ffi/FFI.h#L276-L280 to reject non-finite and values outside the
int32 range before narrowing, preserving the existing NaN fallback. Apply the
identical conversion guard in
test/js/bun/ffi/ffi.test.fixture.callback.c#L278-L280 and
test/js/bun/ffi/ffi.test.fixture.receiver.c#L278-L280 so both fixture copies
remain synchronized.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 7060a4c8-876b-49b8-95f6-0f72a4cfcdb7
📒 Files selected for processing (3)
src/runtime/ffi/FFI.htest/js/bun/ffi/ffi.test.fixture.callback.ctest/js/bun/ffi/ffi.test.fixture.receiver.c
There was a problem hiding this comment.
LGTM — the NaN guard from the earlier nit is in (70fbcd8), and the rest is unchanged.
What was reviewed:
JSVALUE_TO_INT32decode mirrorsJSVALUE_TO_DOUBLE; verified all internal callers inFFI.hand theabi_type.rstable (i8/u8/i16/u16/i32/u32 all funnel here via a cast prefix, so the int64_t intermediate for the u32 path is load-bearing).- Confirmed the post-subtraction NaN check catches
undefined/null/true/false/cell returns (their decoded bits all have exponent all-ones, non-zero mantissa) as the comment claims. - Test follows the sibling subprocess-fixture pattern in the same describe block; covers int32-tagged, double-encoded, fractional ±, and u32-above-INT32_MAX.
Extended reasoning...
Overview
Eight-line change to JSVALUE_TO_INT32 in src/runtime/ffi/FFI.h (plus two mechanically-regenerated .fixture.c snapshots and one new test in cc.test.ts). The function previously returned val.asInt64 truncated, which is only correct for int32-tagged JSValues; it now checks the tag, and for double-encoded values subtracts DoubleEncodeOffset, guards NaN, and casts through int64_t. This is the exact pattern already used by JSVALUE_TO_DOUBLE a few lines down.
Security risks
None. FFI.h is compiled only by the in-process TinyCC for user-declared FFI trampolines; the change narrows the set of inputs that produce garbage rather than widening any attack surface. No new allocation, no new pointer arithmetic, no external data parsing.
Level of scrutiny
Moderate — this is a JSValue-encoding boundary, so it needs to be bit-exact against JSC's NaN-boxing scheme. I traced each case: int32-tagged (NumberTag mask) → low-32-bit truncation unchanged; double-encoded → offset subtraction matches JSVALUE_TO_DOUBLE/JSVALUE_TO_PTR; the u32 path ((uint32_t)JSVALUE_TO_INT32(...) per abi_type.rs:164) round-trips 3e9 correctly via the int64_t intermediate (float→int64 in-range, then implementation-defined int64→int32 truncation, then uint32 reinterpretation). Existing internal callers (JSVALUE_TO_PTR/_DOUBLE/_INT64/_UINT64) all pre-guard with JSVALUE_IS_INT32, so the new branch is dead for them.
Other factors
My earlier nit about NaN/non-number UB in the fallthrough was applied in 70fbcd8 and the thread is resolved; the remaining ±Infinity / |d|≥2⁶³ case is explicitly deferred to match the file's existing convention in _INT64/_UINT64/_PTR, which is reasonable scope. The test is placed alongside the three sibling encoding tests in the same describe, uses the same subprocess-fixture harness pattern (concurrent pipe drain, toMatchObject on {results, stderr, exitCode}, no stderr-empty assertion), and covers the variant matrix the review guide asks for (i32/u32/i8/u16, tagged vs double-encoded, ± fractional truncation, u32 > INT32_MAX). The duplicate-PR bot's hits were addressed by the author and don't overlap.
What does this PR do?
JSVALUE_TO_INT32insrc/runtime/ffi/FFI.htruncated the raw NaN-boxedasInt64, which only works when the JSValue is int32-tagged. Whether an integer-valued JS number is int32-tagged or double-encoded is the engine's choice (JIT tier, DFG double speculation,Math.round/Math.floorprovenance), so an int-typedJSCallbackcould return the correct value for a loop's first ~12 iterations and then0once the callback body tiered up.Repro
Fix
Mirror
JSVALUE_TO_DOUBLE: checkJSVALUE_IS_INT32first, otherwise subtractDoubleEncodeOffsetand readasDouble. The(int64_t)intermediate cast keeps(uint32_t)JSVALUE_TO_INT32(...)defined for values in(INT32_MAX, UINT32_MAX](theu32return type shares this macro).All existing callers (
JSVALUE_TO_PTR,JSVALUE_TO_DOUBLE,JSVALUE_TO_INT64,JSVALUE_TO_UINT64) already guard withJSVALUE_IS_INT32before calling this, so the added branch is a no-op for them.A prior attempt at this area (#33095) wrapped the JS callback at construction time to coerce the return value; this change fixes the TinyCC-side decode instead, which costs nothing at call time and also covers any path that reaches
JSVALUE_TO_INT32without going throughFFIBuilder.How did you verify your code works?
New test in
test/js/bun/ffi/cc.test.tsexercisesi32/u32/i8/u16JSCallback returns with double-encoded integer values (pinned viax + 0.5 - 0.5on a runtime arg) and a fractional return. Fails on main withecho_double: 0,fractional: -858993459, etc.; passes with the fix.no test proof · iteration 1 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/bun/ffi/cc.test.ts