Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
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
165 changes: 165 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,165 @@
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;

const cc = Bun.which("cc") || Bun.which("gcc") || Bun.which("clang");
let S: any;

function build() {
if (S) return S;
using dir = tempDir("ffi-int-coercion", { "echo.c": echoC });
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 || !cc)("integer argument coercion wraps modularly at every width", () => {
// 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