Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/runtime/ffi.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,15 @@ The following `FFIType` values are supported.

`buffer` arguments must be a `TypedArray` or `DataView`.

### Integer argument coercion

When a JavaScript value is passed as an integer-typed argument (`i8`, `u8`, `i16`, `u16`, `i32`, `u32`, `char`, `i64`, `u64`, `i64_fast`, `u64_fast`), it is coerced with **modular wrap** semantics, identical to storing into the corresponding `TypedArray` element:

- **32-bit and narrower**: the value is converted with ECMAScript `ToInt32` (truncating any fractional part and wrapping modulo 2<sup>32</sup>), then narrowed modulo 2<sup>N</sup> to the declared width. `echo_u8(300)` reaches C as `44`, `echo_u32(-1)` reaches C as `0xFFFFFFFF`, and `echo_i32(5.7)` reaches C as `5`. `NaN` and `±Infinity` become `0`. Passing a `BigInt` throws, matching `Int32Array` assignment.
- **64-bit**: `number` inputs are truncated toward zero (with `NaN`/`±Infinity` becoming `0`) and `BigInt` inputs pass through; either is then wrapped modulo 2<sup>64</sup>. `echo_u64(-1)` reaches C as `0xFFFFFFFFFFFFFFFF`, and `echo_i64(5.7)` reaches C as `5`.

Out-of-range values are never clamped and never throw.

---

## Strings
Expand Down
129 changes: 39 additions & 90 deletions src/js/bun/ffi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,101 +160,50 @@ Object.defineProperty(globalThis, "__GlobalBunCString", {

const ffiWrappers = new Array(21);

var char = "val|0";
ffiWrappers.fill(char);
ffiWrappers[FFIType.uint8_t] = "val<0?0:val>=255?255:val|0";
ffiWrappers[FFIType.int16_t] = "val<=-32768?-32768:val>=32768?32768:val|0";
ffiWrappers[FFIType.uint16_t] = "val<=0?0:val>=65536?65536:val|0";
ffiWrappers[FFIType.int32_t] = "val|0";
// https://github.com/oven-sh/bun/issues/7007
// This cast with `|0` looks incorrect as it converts 0xffffffff into -1, but this misinterpretation
// of the integer is taken advantage of by a second misinterpretation of the bytes in the C binding
// The bitwise operator | forces a conversion to int32_t, but it will wrap to negative numbers
// when going above >0x7fffffff.
// Integer argument coercion policy: **modular wrap** at the declared width.
//
// What this |0 operatation also *seems to do* (citation needed) is convert the internal representation
// of JSC::JSValue to ALWAYS use Int32Tag, which is important as `JSValue::asInt32()` can only handle
// this encoding to properly deserialize this as an int32.
// For <=32-bit integer types (char, i8, u8, i16, u16, i32, u32) the value is
// normalized with `| 0` (ToInt32), which truncates fractions and wraps modulo
// 2^32. The generated C stub then implicitly narrows the int32-tagged low bits
// to the parameter's declared width, giving wrap modulo 2^N. The result is
// identical to storing into the corresponding TypedArray element:
// echo_u8(300) === new Uint8Array([300])[0] === 44
//
// tldr jsc internals: JSValue represents int32 as a tag value, then the int32 bytes.
// and all other integers are as tagged 64-bit floats.
// `| 0` also forces the JSValue into Int32Tag encoding (JSC boxes small ints
// as tag|int32 and everything else as an offset double), which is the only
// encoding the trampoline's raw `*argsPtr` read is valid for; see #7007.
// For unsigned types the signed int32 bit pattern is reinterpreted by the C
// cast, so `-1 | 0` reaches a `uint32_t` parameter as `0xFFFFFFFF`.
//
// The trick to fixing the bug: after using |0 to misinterpret and force the integer into Int32Tag,
// when passing the value to the C ffi code, misinterpret it again, resulting in the correct uint32_t.
//
// To do this in native code, there is a spot in zig where uint32_t just prints int32_t.
ffiWrappers[FFIType.uint32_t] = "val<0?0:val>0xFFFFFFFF?-1:val|0";
ffiWrappers[FFIType.i64_fast] = `{
if (typeof val === "bigint") {
if (val <= BigInt(Number.MAX_SAFE_INTEGER) && val >= BigInt(-Number.MAX_SAFE_INTEGER)) {
return Number(val).valueOf() || 0;
}

return val;
}

return !val ? 0 : +val || 0;
}`;
ffiWrappers[FFIType.i64_fast] = `{
if (typeof val === "bigint") {
if (val <= BigInt(Number.MAX_SAFE_INTEGER) && val >= BigInt(-Number.MAX_SAFE_INTEGER)) {
return Number(val).valueOf() || 0;
}

return val;
}

return !val ? 0 : +val || 0;
}`;

ffiWrappers[FFIType.u64_fast] = `{
if (typeof val === "bigint") {
if (val <= BigInt(Number.MAX_SAFE_INTEGER) && val >= 0) {
return Number(val).valueOf() || 0;
}

return val;
}

return !val ? 0 : +val || 0;
}`;

ffiWrappers[FFIType.int64_t] = `{
if (typeof val === "bigint") {
return val;
}

if (typeof val === "number") {
return BigInt(val || 0);
}

return BigInt(+val || 0);
}`;

ffiWrappers[FFIType.uint64_t] = `{
if (typeof val === "bigint") {
return val;
}

if (typeof val === "number") {
return val <= 0 ? BigInt(0) : BigInt(val || 0);
}

return BigInt(+val || 0);
}`;

ffiWrappers[FFIType.u64_fast] = `{
if (typeof val === "bigint") {
if (val <= BigInt(Number.MAX_SAFE_INTEGER) && val >= BigInt(0)) return Number(val);
return val;
}

return typeof val === "number" ? (val <= 0 ? 0 : +val || 0) : +val || 0;
// For 64-bit integer types the value is normalized to a BigInt. Numbers are
// truncated toward zero first; NaN/Infinity become 0. Out-of-range BigInts
// wrap modulo 2^64 on the C side (toBigInt64 / toBigUInt64), matching
// BigInt64Array / BigUint64Array assignment.
var int32 = "val|0";
ffiWrappers.fill(int32);

// JSVALUE_TO_INT64 reads int32-tagged and double-encoded Numbers directly, so
// integers in the safe range stay as Number and avoid a BigInt allocation.
// Fractional, NaN, and too-large Numbers are normalized to a BigInt; the
// C-side toBigInt64 wraps out-of-range BigInt modulo 2^64.
ffiWrappers[FFIType.int64_t] = ffiWrappers[FFIType.i64_fast] = `{
if (typeof val === "bigint") return val;
var n = typeof val === "number" ? val : Number(val);
if (Number.isSafeInteger(n)) return n;
n = Math.trunc(n);
return n > -Infinity && n < Infinity ? BigInt(n) : 0n;
}`;

ffiWrappers[FFIType.uint16_t] = `{
const ret = (typeof val === "bigint" ? Number(val) : val) | 0;
return ret <= 0 ? 0 : ret > 0xffff ? 0xffff : ret;
// JSVALUE_TO_UINT64's Number paths cannot wrap a negative value (the C cast of
// a negative double to uint64_t is undefined, and toUInt64NoTruncate clamps to
// 0), so only non-negative safe integers stay as Number. Everything else goes
// through BigInt; toBigUInt64 wraps negatives and over-width values.
ffiWrappers[FFIType.uint64_t] = ffiWrappers[FFIType.u64_fast] = `{
if (typeof val === "bigint") return val;
var n = typeof val === "number" ? val : Number(val);
if (Number.isSafeInteger(n) && n >= 0) return n;
n = Math.trunc(n);
return n > -Infinity && n < Infinity ? BigInt(n) : 0n;
}`;

// Plain numbers pass through untouched: NaN, -0.0, and every other double are
Expand Down
164 changes: 164 additions & 0 deletions test/js/bun/ffi/ffi-int-coercion.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { dlopen, FFIType } from "bun:ffi";
import { describe, expect, test } from "bun:test";
import { isWindows, tempDir } from "harness";
import { join } from "node:path";

// Verifies that JS -> C integer argument coercion follows a single, documented
// policy: modular wrap (ToInt32 for <=32-bit widths, ToBigInt64/ToBigUint64 for
// 64-bit widths), matching TypedArray element assignment and the WebAssembly JS
// API. Historically each width chose independently between wrap, saturate, and
// throw.

const echoC = /* c */ `
#include <stdint.h>
int8_t echo_i8 (int8_t v) { return v; }
uint8_t echo_u8 (uint8_t v) { return v; }
int16_t echo_i16(int16_t v) { return v; }
uint16_t echo_u16(uint16_t v){ return v; }
int32_t echo_i32(int32_t v) { return v; }
uint32_t echo_u32(uint32_t v){ return v; }
int64_t echo_i64(int64_t v) { return v; }
uint64_t echo_u64(uint64_t v){ return v; }
int64_t echo_i64f(int64_t v) { return v; }
uint64_t echo_u64f(uint64_t v){ return v; }
char echo_char(char v) { return v; }
`;

const symbols = {
echo_i8: { args: [FFIType.i8], returns: FFIType.i8 },
echo_u8: { args: [FFIType.u8], returns: FFIType.u8 },
echo_i16: { args: [FFIType.i16], returns: FFIType.i16 },
echo_u16: { args: [FFIType.u16], returns: FFIType.u16 },
echo_i32: { args: [FFIType.i32], returns: FFIType.i32 },
echo_u32: { args: [FFIType.u32], returns: FFIType.u32 },
echo_i64: { args: [FFIType.i64], returns: FFIType.i64 },
echo_u64: { args: [FFIType.u64], returns: FFIType.u64 },
echo_i64f: { args: [FFIType.i64_fast], returns: FFIType.i64_fast },
echo_u64f: { args: [FFIType.u64_fast], returns: FFIType.u64_fast },
echo_char: { args: [FFIType.char], returns: FFIType.char },
} as const;

let S: any;

function build() {
if (S) return S;
const dir = tempDir("ffi-int-coercion", { "echo.c": echoC });

Check warning on line 45 in test/js/bun/ffi/ffi-int-coercion.test.ts

View check run for this annotation

Claude / Claude Code Review

tempDir not disposed — leaves compiled .so behind

`tempDir()` returns a Disposable whose `[Symbol.dispose]` rm-rf's the directory, but assigning it to `const dir` (rather than `using dir`) means it's never disposed — `echo.c` and `libecho.so` are left in the OS temp dir after the test file completes. Change to `using dir = tempDir(...)`; `dlopen` has already mmap'd the .so before scope exit and the describe skips Windows, so unlinking is safe.
Comment thread
robobun marked this conversation as resolved.
Outdated
const out = join(String(dir), "libecho.so");
const res = Bun.spawnSync({
cmd: ["cc", "-shared", "-fPIC", "-o", out, join(String(dir), "echo.c")],
stderr: "pipe",
stdout: "pipe",
});
if (res.exitCode !== 0) {
throw new Error("cc failed: " + res.stderr.toString());
}
S = dlopen(out, symbols).symbols;
return S;
}

// Requires a system C compiler for the dlopen fixture.
describe.skipIf(isWindows)("integer argument coercion wraps modularly at every width", () => {
Comment thread
robobun marked this conversation as resolved.
Outdated
// ToInt32 reference: what a typed array would store.
const i8 = (n: number) => new Int8Array([n])[0];
const u8 = (n: number) => new Uint8Array([n])[0];
const i16 = (n: number) => new Int16Array([n])[0];
const u16 = (n: number) => new Uint16Array([n])[0];
const i32 = (n: number) => new Int32Array([n])[0];
const u32 = (n: number) => new Uint32Array([n])[0];

test("char/i8/u8 wrap like Int8Array/Uint8Array", () => {
const s = build();
expect(s.echo_i8(300)).toBe(i8(300)); // 44
expect(s.echo_i8(-200)).toBe(i8(-200)); // 56
expect(s.echo_i8(5.7)).toBe(5);
expect(s.echo_u8(300)).toBe(u8(300)); // 44, not 255
expect(s.echo_u8(-1)).toBe(u8(-1)); // 255, not 0
expect(s.echo_u8(5.7)).toBe(5);
expect(s.echo_char(300)).toBe(i8(300));
});

test("i16/u16 wrap like Int16Array/Uint16Array", () => {
const s = build();
expect(s.echo_i16(40000)).toBe(i16(40000)); // -25536, not 32767
expect(s.echo_i16(32768)).toBe(i16(32768)); // -32768 (current off-by-one gives this by accident)
expect(s.echo_i16(-40000)).toBe(i16(-40000)); // 25536, not -32768
expect(s.echo_u16(70000)).toBe(u16(70000)); // 4464, not 65535
expect(s.echo_u16(-1)).toBe(u16(-1)); // 65535, not 0
});

test("i32/u32 wrap like Int32Array/Uint32Array", () => {
const s = build();
expect(s.echo_i32(5_000_000_000)).toBe(i32(5_000_000_000)); // 705032704
expect(s.echo_i32(5.7)).toBe(5);
expect(s.echo_u32(-1)).toBe(u32(-1)); // 4294967295, not 0
expect(s.echo_u32(5_000_000_000)).toBe(u32(5_000_000_000)); // 705032704, not 4294967295
expect(s.echo_u32(5.7)).toBe(5);
});

test("i64/u64 truncate fractional numbers instead of throwing", () => {
const s = build();
expect(s.echo_i64(5.7)).toBe(5n);
expect(s.echo_i64(-5.7)).toBe(-5n);
expect(s.echo_u64(5.7)).toBe(5n);
});

test("u64 wraps negative numbers instead of saturating to 0", () => {
const s = build();
expect(s.echo_u64(-1)).toBe(0xffff_ffff_ffff_ffffn);
expect(s.echo_u64(-2)).toBe(0xffff_ffff_ffff_fffen);
});

test("i64_fast/u64_fast share the same argument coercion as i64/u64", () => {
const s = build();
// u64_fast previously saturated negatives to 0
expect(BigInt(s.echo_u64f(-1))).toBe(0xffff_ffff_ffff_ffffn);
expect(s.echo_u64f(5.7)).toBe(5);
expect(s.echo_i64f(5.7)).toBe(5);
expect(s.echo_i64f(-5.7)).toBe(-5);
expect(s.echo_i64f(NaN)).toBe(0);
expect(s.echo_u64f(NaN)).toBe(0);
expect(s.echo_i64f(42)).toBe(42);
expect(s.echo_u64f(42)).toBe(42);
});

test("i64/u64 wrap out-of-range BigInt", () => {
const s = build();
expect(s.echo_i64(1n << 64n)).toBe(0n);
expect(s.echo_i64((1n << 63n) + 1n)).toBe(-(1n << 63n) + 1n);
expect(s.echo_u64(1n << 64n)).toBe(0n);
expect(s.echo_u64(-1n)).toBe(0xffff_ffff_ffff_ffffn);
});

test("NaN and Infinity coerce to 0 at every width", () => {
const s = build();
for (const fn of [s.echo_i8, s.echo_u8, s.echo_i16, s.echo_u16, s.echo_i32, s.echo_u32]) {
expect(fn(NaN)).toBe(0);
expect(fn(Infinity)).toBe(0);
expect(fn(-Infinity)).toBe(0);
}
expect(s.echo_i64(NaN)).toBe(0n);
expect(s.echo_i64(Infinity)).toBe(0n);
expect(s.echo_u64(NaN)).toBe(0n);
expect(s.echo_u64(-Infinity)).toBe(0n);
});

test("in-range values are unchanged", () => {
const s = build();
expect(s.echo_i8(-128)).toBe(-128);
expect(s.echo_i8(127)).toBe(127);
expect(s.echo_u8(0)).toBe(0);
expect(s.echo_u8(255)).toBe(255);
expect(s.echo_i16(-32768)).toBe(-32768);
expect(s.echo_i16(32767)).toBe(32767);
expect(s.echo_u16(65535)).toBe(65535);
expect(s.echo_i32(-2147483648)).toBe(-2147483648);
expect(s.echo_i32(2147483647)).toBe(2147483647);
expect(s.echo_u32(0)).toBe(0);
expect(s.echo_u32(4294967295)).toBe(4294967295);
expect(s.echo_i64(9007199254740991)).toBe(9007199254740991n);
expect(s.echo_i64(-9007199254740991)).toBe(-9007199254740991n);
expect(s.echo_u64(9007199254740991)).toBe(9007199254740991n);
expect(s.echo_i64(0x7fff_ffff_ffff_ffffn)).toBe(0x7fff_ffff_ffff_ffffn);
expect(s.echo_u64(0xffff_ffff_ffff_ffffn)).toBe(0xffff_ffff_ffff_ffffn);
});
});
Loading