From 63f49f67cf07fd6369f3e380d8ba30f88b89206a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 18 Jul 2026 15:49:38 +0000 Subject: [PATCH 1/3] bun:ffi: decode double-encoded JSValues in JSVALUE_TO_INT32 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]). --- src/runtime/ffi/FFI.h | 8 +- test/js/bun/ffi/cc.test.ts | 83 +++++++++++++++++++++ test/js/bun/ffi/ffi.test.fixture.callback.c | 8 +- test/js/bun/ffi/ffi.test.fixture.receiver.c | 8 +- 4 files changed, 104 insertions(+), 3 deletions(-) diff --git a/src/runtime/ffi/FFI.h b/src/runtime/ffi/FFI.h index 52f803d9acb6..d497390f048a 100644 --- a/src/runtime/ffi/FFI.h +++ b/src/runtime/ffi/FFI.h @@ -267,7 +267,13 @@ static EncodedJSValue DOUBLE_TO_JSVALUE(double val) { } static int32_t JSVALUE_TO_INT32(EncodedJSValue val) { - return val.asInt64; + if (JSVALUE_IS_INT32(val)) { + return (int32_t)val.asInt64; + } + // Decode a double-encoded integer (JIT tier-up, Math.* provenance, etc.); + // int64_t intermediate keeps u32 callers (uint32_t)JSVALUE_TO_INT32(...) defined. + val.asInt64 -= DoubleEncodeOffset; + return (int32_t)(int64_t)val.asDouble; } static EncodedJSValue INT32_TO_JSVALUE(int32_t val) { diff --git a/test/js/bun/ffi/cc.test.ts b/test/js/bun/ffi/cc.test.ts index 15b959aa9c18..e362429b8763 100644 --- a/test/js/bun/ffi/cc.test.ts +++ b/test/js/bun/ffi/cc.test.ts @@ -744,6 +744,89 @@ describe.skipIf(isFFIUnavailable)("double <-> JSValue conversions", () => { }); }); + // JSVALUE_TO_INT32 must decode double-encoded JSValues: whether a JS number + // is int32-tagged or double-encoded is the engine's choice (JIT tier, double + // speculation, Math.* provenance), so an int-typed JSCallback return that + // truncates the raw encoded bits hands C 0 once the callback tiers up. + it("double-encoded JS numbers returned from an int-typed JSCallback reach C as the integer", async () => { + using dir = tempDir("bun-ffi-int32-cb-return", { + "cb.c": /* c */ ` + typedef int (*cb_i32)(int); + int call_i32(cb_i32 f, int x) { return f(x); } + typedef unsigned int (*cb_u32)(int); + unsigned int call_u32(cb_u32 f, int x) { return f(x); } + typedef signed char (*cb_i8)(int); + int call_i8(cb_i8 f, int x) { return (int)f(x); } + typedef unsigned short (*cb_u16)(int); + int call_u16(cb_u16 f, int x) { return (int)f(x); } + `, + "fixture.js": /* js */ ` + import { cc, JSCallback } from "bun:ffi"; + import path from "path"; + + const { symbols } = cc({ + source: path.join(import.meta.dir, "cb.c"), + symbols: { + call_i32: { args: ["function", "i32"], returns: "i32" }, + call_u32: { args: ["function", "i32"], returns: "u32" }, + call_i8: { args: ["function", "i32"], returns: "i32" }, + call_u16: { args: ["function", "i32"], returns: "i32" }, + }, + }); + + // +0.5 then -0.5 on a runtime value: integer-valued, but the intermediate + // pins a double-represented result regardless of JIT tier or const-folding. + const asDouble = x => { + const v = x + 0.5; + return v - 0.5; + }; + const echoDouble = new JSCallback(asDouble, { args: ["i32"], returns: "i32" }); + // Plain int32-tagged return must keep working. + const echoInt = new JSCallback(x => x, { args: ["i32"], returns: "i32" }); + const fractional = new JSCallback(() => 5.7, { args: ["i32"], returns: "i32" }); + const negFractional = new JSCallback(() => -5.7, { args: ["i32"], returns: "i32" }); + const u32Double = new JSCallback(x => asDouble(x) + 3000000000, { args: ["i32"], returns: "u32" }); + const i8Double = new JSCallback(asDouble, { args: ["i32"], returns: "i8" }); + const u16Double = new JSCallback(asDouble, { args: ["i32"], returns: "u16" }); + + const results = { + echo_double: symbols.call_i32(echoDouble.ptr, 938), + echo_int: symbols.call_i32(echoInt.ptr, 938), + fractional: symbols.call_i32(fractional.ptr, 0), + neg_fractional: symbols.call_i32(negFractional.ptr, 0), + u32_double: symbols.call_u32(u32Double.ptr, 0), + i8_double: symbols.call_i8(i8Double.ptr, -7), + u16_double: symbols.call_u16(u16Double.ptr, 40000), + }; + for (const cb of [echoDouble, echoInt, fractional, negFractional, u32Double, i8Double, u16Double]) cb.close(); + console.log(JSON.stringify(results)); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "fixture.js"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + const results = stdout.startsWith("{") ? JSON.parse(stdout) : stdout; + expect({ results, stderr, exitCode }).toMatchObject({ + results: { + echo_double: 938, + echo_int: 938, + fractional: 5, + neg_fractional: -5, + u32_double: 3000000000, + i8_double: -7, + u16_double: 40000, + }, + exitCode: 0, + }); + }); + // napi_create_double and napi_create_date take the double from the addon // verbatim, so they are the same boundary. cc()-compiled C resolves napi_* // from the host process; that lookup is only exercised on POSIX today (see diff --git a/test/js/bun/ffi/ffi.test.fixture.callback.c b/test/js/bun/ffi/ffi.test.fixture.callback.c index f6d6d40cae96..d694b4102e93 100644 --- a/test/js/bun/ffi/ffi.test.fixture.callback.c +++ b/test/js/bun/ffi/ffi.test.fixture.callback.c @@ -269,7 +269,13 @@ static EncodedJSValue DOUBLE_TO_JSVALUE(double val) { } static int32_t JSVALUE_TO_INT32(EncodedJSValue val) { - return val.asInt64; + if (JSVALUE_IS_INT32(val)) { + return (int32_t)val.asInt64; + } + // Decode a double-encoded integer (JIT tier-up, Math.* provenance, etc.); + // int64_t intermediate keeps u32 callers (uint32_t)JSVALUE_TO_INT32(...) defined. + val.asInt64 -= DoubleEncodeOffset; + return (int32_t)(int64_t)val.asDouble; } static EncodedJSValue INT32_TO_JSVALUE(int32_t val) { diff --git a/test/js/bun/ffi/ffi.test.fixture.receiver.c b/test/js/bun/ffi/ffi.test.fixture.receiver.c index af6a78283716..ec18ef8b655f 100644 --- a/test/js/bun/ffi/ffi.test.fixture.receiver.c +++ b/test/js/bun/ffi/ffi.test.fixture.receiver.c @@ -269,7 +269,13 @@ static EncodedJSValue DOUBLE_TO_JSVALUE(double val) { } static int32_t JSVALUE_TO_INT32(EncodedJSValue val) { - return val.asInt64; + if (JSVALUE_IS_INT32(val)) { + return (int32_t)val.asInt64; + } + // Decode a double-encoded integer (JIT tier-up, Math.* provenance, etc.); + // int64_t intermediate keeps u32 callers (uint32_t)JSVALUE_TO_INT32(...) defined. + val.asInt64 -= DoubleEncodeOffset; + return (int32_t)(int64_t)val.asDouble; } static EncodedJSValue INT32_TO_JSVALUE(int32_t val) { From 1dc2150664a1d1963554f1661f880b67b63da7ca Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 18 Jul 2026 16:21:47 +0000 Subject: [PATCH 2/3] ci: retrigger From 70fbcd821ca95d14be542a2acc4daad3083d9303 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 18 Jul 2026 16:36:11 +0000 Subject: [PATCH 3/3] ffi: return 0 from JSVALUE_TO_INT32 for NaN / non-number immediates 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. --- src/runtime/ffi/FFI.h | 2 ++ test/js/bun/ffi/ffi.test.fixture.callback.c | 2 ++ test/js/bun/ffi/ffi.test.fixture.receiver.c | 2 ++ 3 files changed, 6 insertions(+) diff --git a/src/runtime/ffi/FFI.h b/src/runtime/ffi/FFI.h index d497390f048a..5159a50a2761 100644 --- a/src/runtime/ffi/FFI.h +++ b/src/runtime/ffi/FFI.h @@ -273,6 +273,8 @@ static int32_t JSVALUE_TO_INT32(EncodedJSValue val) { // Decode a double-encoded integer (JIT tier-up, Math.* provenance, etc.); // int64_t intermediate keeps u32 callers (uint32_t)JSVALUE_TO_INT32(...) defined. val.asInt64 -= DoubleEncodeOffset; + // NaN check also catches undefined/null/bool, whose decoded bits are all NaNs. + if (val.asDouble != val.asDouble) return 0; return (int32_t)(int64_t)val.asDouble; } diff --git a/test/js/bun/ffi/ffi.test.fixture.callback.c b/test/js/bun/ffi/ffi.test.fixture.callback.c index d694b4102e93..1a83b7dce62a 100644 --- a/test/js/bun/ffi/ffi.test.fixture.callback.c +++ b/test/js/bun/ffi/ffi.test.fixture.callback.c @@ -275,6 +275,8 @@ static int32_t JSVALUE_TO_INT32(EncodedJSValue val) { // Decode a double-encoded integer (JIT tier-up, Math.* provenance, etc.); // int64_t intermediate keeps u32 callers (uint32_t)JSVALUE_TO_INT32(...) defined. val.asInt64 -= DoubleEncodeOffset; + // NaN check also catches undefined/null/bool, whose decoded bits are all NaNs. + if (val.asDouble != val.asDouble) return 0; return (int32_t)(int64_t)val.asDouble; } diff --git a/test/js/bun/ffi/ffi.test.fixture.receiver.c b/test/js/bun/ffi/ffi.test.fixture.receiver.c index ec18ef8b655f..5ba908cb953e 100644 --- a/test/js/bun/ffi/ffi.test.fixture.receiver.c +++ b/test/js/bun/ffi/ffi.test.fixture.receiver.c @@ -275,6 +275,8 @@ static int32_t JSVALUE_TO_INT32(EncodedJSValue val) { // Decode a double-encoded integer (JIT tier-up, Math.* provenance, etc.); // int64_t intermediate keeps u32 callers (uint32_t)JSVALUE_TO_INT32(...) defined. val.asInt64 -= DoubleEncodeOffset; + // NaN check also catches undefined/null/bool, whose decoded bits are all NaNs. + if (val.asDouble != val.asDouble) return 0; return (int32_t)(int64_t)val.asDouble; }