Skip to content

FFI: convert Number arguments of i64/u64 parameters modulo 2^64 - #421

Merged
Jarred-Sumner merged 1 commit into
mainfrom
farm/85fac143/ffi-int64-args-modular
Aug 14, 2026
Merged

Jarred-Sumner merged 1 commit into
mainfrom
farm/85fac143/ffi-int64-args-modular

Conversation

@robobun

@robobun robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • A u64 / u64_fast argument passed as a JS Number in [2^63, 2^64) reaches the callee as 9223372036854775808 on x86-64 and as 9223372036854775807 on arm64, whatever the Number was (bun:ffi dlopen() / linkSymbols() / CFunction; 2 ** 63 + 2 ** 62 arrives as 2 ** 63). BigInts of the same values are exact.
  • NaN and Numbers outside the int64 range reach i64 / u64 parameters as INT64_MIN on x86-64 and as 0 / the saturated value on arm64.
  • Cause: writeInt64Slot (ffi/FFIConversions.cpp) converts a double-encoded Number with FFI::doubleToInt64, which is cvttsd2si on x86-64 and fcvtzs on arm64. A signed truncating instruction has no result for [2^63, 2^64), and the two CPUs define the out-of-range result differently. doubleToUInt64 only bit_cast the signed result.

Fix

  • writeInt64Slot converts a double with JSC::toInt64 (runtime/MathCommon.h): truncate toward zero, keep the low 64 bits of the two's complement result, NaN / infinities become 0.
  • This is the conversion the function's other two branches already perform: the int32 encoding sign-extends (so a Number -1 already gave a u64 all ones) and the BigInt branch uses toBigUInt64 (modulo 2^64). It is also writeIntegerSlot's ToInt32 conversion for the 8/16/32-bit types, widened to 64 bits, and the pairing JSC's own C API uses (JSValueToInt64 / JSValueToUInt64 in API/JSValueRef.cpp: JSBigInt::toBigInt64 for BigInts, JSC::toInt64 for Numbers). A Number therefore reaches the callee with the same bits however the engine boxed it and as the BigInt of the same mathematical value, on every CPU.
  • The signed and unsigned 64-bit types read the same bit pattern, so the isUnsigned split in writeInt64Slot and doubleToUInt64 (no other users) are removed. doubleToInt64 stays as the pointer conversion, where FFIICStub.cpp and FFIDFGCodegen.cpp inline the same instruction (truncateDoubleToInt64); pointer semantics are unchanged.
  • Every consumer funnels through writeSlotFromJSValue -> writeInt64Slot: the host call path (FFICallHost.cpp), operationFFIWriteSlot (the IC stub and the DFG send every non-int32, non-BigInt value there), and callback returns (FFICallbackThunk.cpp), so one change covers every tier and both directions.
  • Only Numbers that are negative, fractional-negative, >= 2^63, or non-finite change behavior; in-range values, the int32 encoding and BigInts produce the same bits as before.

Tests

  • testFFI.cpp: testConversions pins the slot bits for the new cases (double-encoded -1.0 and -1.5 into u64, 2^63 + 2^62, the largest double below 2^64, 2^64 wrapping to 0, NaN / infinities to 0, the same for i64 and the _fast variants) instead of deriving the expectations from doubleToInt64; the doubleToUInt64 corpus loop goes with the function. testDoubleToInt64 still pins the CPU-specific pointer conversion.
  • JSTests/stress/ffi-types-echo.js, ffi-host-path.js, ffi-tier-differential.js: the batteries gain the Numbers that used to be CPU-specific, so the host path and every JIT tier are checked against the same literals.
  • JSTests/stress/ffi-fuzz-signatures.js: the reference model for i64 / u64 is now the modular conversion and the generator feeds it every double edge (it previously had to exclude |d| >= 2^63 and non-finite values).
  • FFIConversions.cpp, testFFI.cpp and the other includers of FFIConversions.h compile (-fsyntax-only) against the headers of the current prebuilt; the binaries and the stress files run in this PR's CI and in the Bun bump PR (linked below), whose test/js/bun/ffi tests compare this path against cc() on the same inputs.

Background

  • bun:ffi has two argument converters: cc() compiles its own C glue with TinyCC (Bun's src/runtime/ffi/FFI.h), everything else goes through this engine FFI. The Bun PR fixes the cc() side of the same conversion (it dropped the sign of double-encoded negatives) to the definition used here.
  • Whether a JS Number is int32-encoded or double-encoded is the engine's choice (array-of-doubles storage, JIT double speculation, Math.* results), so -1 can arrive either way; the two encodings have to convert identically or the callee sees different values for the same program depending on JIT state.

writeInt64Slot converted a double-encoded Number with the CPU's truncating
double -> int64 instruction (FFI::doubleToInt64). For u64 / u64_fast that
has no representation for values in [2^63, 2^64): every such Number arrived
in the callee as 9223372036854775808 on x86-64 (cvttsd2si's indefinite
value) or 9223372036854775807 on arm64 (fcvtzs saturates). NaN and values
outside the int64 range likewise got a different answer on each CPU.

Use JSC::toInt64 instead: truncate toward zero and keep the low 64 bits of
the two's complement result, with NaN and the infinities becoming 0. That is
the ToInt32 conversion writeIntegerSlot already applies to the narrower
integer types, widened to 64 bits, and it is what the int32 encoding and the
BigInt branch of the same function already produce, so a Number now reaches
an i64 / u64 parameter (or leaves an i64 / u64 callback) with the same bits
however the engine happened to box it and as the BigInt of the same value.
The signed and unsigned types share that bit pattern, so the isUnsigned
split goes away, and doubleToUInt64 has no remaining user. doubleToInt64
itself stays the pointer conversion, where the IC stub and DFG inline the
same instruction.

testFFI pins the slot bits for the new cases; the JSTests batteries gain
the Numbers that used to be CPU-specific, and the fuzzer's reference model
now covers every double edge instead of excluding |d| >= 2^63.
@coderabbitai

coderabbitai Bot commented Aug 13, 2026

Copy link
Copy Markdown

Warning

Review limit reached

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

Next review available in: 6 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: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: a52bc403-41b0-4695-a150-f348cc7ba531

📥 Commits

Reviewing files that changed from the base of the PR and between caad865 and 0855ba4.

📒 Files selected for processing (7)
  • JSTests/stress/ffi-fuzz-signatures.js
  • JSTests/stress/ffi-host-path.js
  • JSTests/stress/ffi-tier-differential.js
  • JSTests/stress/ffi-types-echo.js
  • Source/JavaScriptCore/ffi/FFIConversions.cpp
  • Source/JavaScriptCore/ffi/FFIConversions.h
  • Source/JavaScriptCore/ffi/tests/testFFI.cpp

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

@claude claude Bot left a comment

Copy link
Copy Markdown

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. Since it changes the user-observable semantics of bun:ffi's Number→i64/u64 argument conversion and is coordinated with a companion Bun-side change, a human sign-off on the chosen semantics would still be worthwhile.

What was reviewed:

  • writeInt64Slot now uses JSC::toInt64 (modular, CPU-independent); confirmed MathCommon.h provides it and the include was added.
  • Verified the IC stub and DFG codegen send double-encoded i64/u64 args to the slow path (operationFFIWriteSlot), so this one change covers every tier as claimed; truncateDoubleToInt64 remains only on the pointer path.
  • doubleToUInt64 removal — grep confirms no remaining references; <bit> header drop from FFIConversions.h is safe (only user was the removed inline).
  • Collapsing the isUnsigned split — toBigInt64/toBigUInt64 and signed/unsigned toInt64 yield identical bit patterns, so the simplification is sound.
Extended reasoning...

Overview

The PR changes writeInt64Slot in Source/JavaScriptCore/ffi/FFIConversions.cpp to convert double-encoded JS Numbers via JSC::toInt64 (ECMAScript-style modular conversion: truncate toward zero, keep the low 64 bits, NaN/±∞ → 0) instead of FFI::doubleToInt64 (raw cvttsd2si/fcvtzs, whose out-of-range result differs between x86-64 and arm64). It removes the now-redundant signed/unsigned split and the doubleToUInt64 helper, adds the MathCommon.h include, and expands C++ (testFFI.cpp) and JS stress tests with the previously CPU-specific edge values pinned to literal expectations.

Security risks

None identified. The change is a numeric conversion policy inside the FFI argument marshalling path; it does not touch pointer handling, buffer bounds, auth, or anything that could widen an attack surface. The pointer conversion (writePointerSlot, doubleToInt64, inline truncateDoubleToInt64) is explicitly left unchanged.

Level of scrutiny

Moderate-to-high. The code diff itself is small and mechanically clean, and I verified that (a) toInt64 exists in MathCommon.h with the described semantics, (b) both FFIICStub.cpp and FFIDFGCodegen.cpp route double-encoded values for the 64-bit integer types through the slow path into operationFFIWriteSlot → writeInt64Slot, so no JIT tier is left on the old hardware truncation, and (c) doubleToUInt64 has no remaining callers. However, this is a deliberate semantic change to a public bun:ffi conversion (how out-of-range/non-finite Numbers land in i64/u64 parameters), coordinated with a companion Bun repo PR for the cc() path. That is a design decision — modular wrap vs. saturate vs. error — that a maintainer should ratify even though the chosen behavior (matching JSValueToInt64/ToBigInt64 and the existing int32/BigInt branches) is well-argued.

Other factors

  • Test coverage is thorough: hardcoded literals in testFFI.cpp and four stress files exercise the host path, IC stub, and DFG/FTL tiers against identical expected values, plus the fuzzer's reference model was updated to the new semantics and its input generator un-restricted.
  • The isUnsigned collapse is correct because JSBigInt::toBigInt64 and toBigUInt64 produce the same 64-bit pattern (only the interpretation differs), and static_cast<uint64_t>(toInt64(d)) is exactly toUInt64(d).
  • Removing <bit> from FFIConversions.h is safe — its only user was the deleted std::bit_cast in doubleToUInt64; FFIConversions.cpp still includes <bit> for its own uses.
  • No prior human reviews or unresolved comments on the PR; only a CodeRabbit rate-limit notice.

@github-actions

Copy link
Copy Markdown

Preview Builds

Commit Release Date
0855ba4b autobuild-preview-pr-421-0855ba4b 2026-08-13 06:11:02 UTC

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.

2 participants