From 663b8380fc2cc131d298c3f6e2f016f10f87bd62 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 13 Aug 2026 03:25:03 +0000 Subject: [PATCH 1/6] bun:ffi: make cc() wrappers treat arguments that were not passed as undefined The C wrapper compiled for each cc() symbol loaded one call-frame slot per declared parameter without consulting the frame's argument count, so a call with fewer arguments than the symbol declares handed C whatever the caller's stack held past the last argument (garbage integers, and a segfault for pointer parameters while the stale slot was inspected as a JSValue). The wrapper now reads argumentCountIncludingThis from the call frame and loads each parameter with ARGUMENT(i), which yields undefined for slots past the count, the same thing CallFrame::argument(i) does for the dlopen() FFI. Every argument goes through its type's conversion (the int32-family types used to be passed as raw JSValue bits), and JSVALUE_TO_PTR maps undefined to NULL like the engine's pointer conversion does. Both call-frame slot indices now come from JSC::CallFrameSlot through Bun__FFI__offsets instead of the hardcoded copy in src/jsc/sizes.rs, whose only user this was. --- src/jsc/bindings/ffi.cpp | 7 ++ src/jsc/lib.rs | 2 - src/jsc/sizes.rs | 8 -- src/runtime/ffi/FFI.h | 21 ++-- src/runtime/ffi/abi_type.rs | 14 --- src/runtime/ffi/ffi_body.rs | 53 +++----- test/js/bun/ffi/cc.test.ts | 130 +++++++++++++++++++- test/js/bun/ffi/ffi.test.fixture.receiver.c | 24 ++-- 8 files changed, 177 insertions(+), 82 deletions(-) delete mode 100644 src/jsc/sizes.rs diff --git a/src/jsc/bindings/ffi.cpp b/src/jsc/bindings/ffi.cpp index 2f4b6f561cee..f62fb0657c19 100644 --- a/src/jsc/bindings/ffi.cpp +++ b/src/jsc/bindings/ffi.cpp @@ -5,13 +5,20 @@ typedef struct FFIFields { uint32_t JSArrayBufferView__offsetOfByteOffset; uint32_t JSArrayBufferView__offsetOfVector; uint32_t JSCell__offsetOfType; + // Register (8-byte slot) indices into a CallFrame, not byte offsets. + uint32_t CallFrame__argumentCountIncludingThisSlot; + uint32_t CallFrame__firstArgumentSlot; } FFIFields; extern "C" FFIFields Bun__FFI__offsets = { 0 }; +static_assert(sizeof(JSC::Register) == sizeof(void*), "the cc() wrapper indexes the CallFrame as an array of pointer-sized slots"); + extern "C" void Bun__FFI__ensureOffsetsAreLoaded() { Bun__FFI__offsets.JSArrayBufferView__offsetOfLength = JSC::JSArrayBufferView::offsetOfLength(); Bun__FFI__offsets.JSArrayBufferView__offsetOfByteOffset = JSC::JSArrayBufferView::offsetOfByteOffset(); Bun__FFI__offsets.JSArrayBufferView__offsetOfVector = JSC::JSArrayBufferView::offsetOfVector(); Bun__FFI__offsets.JSCell__offsetOfType = JSC::JSCell::typeInfoTypeOffset(); + Bun__FFI__offsets.CallFrame__argumentCountIncludingThisSlot = static_cast(JSC::CallFrameSlot::argumentCountIncludingThis); + Bun__FFI__offsets.CallFrame__firstArgumentSlot = static_cast(JSC::CallFrame::argumentOffset(0)); } diff --git a/src/jsc/lib.rs b/src/jsc/lib.rs index 377b657f5ab7..8e0654edbad4 100644 --- a/src/jsc/lib.rs +++ b/src/jsc/lib.rs @@ -76,8 +76,6 @@ pub mod marked_argument_buffer; pub mod regular_expression; #[path = "ScriptExecutionStatus.rs"] pub mod script_execution_status; -#[path = "sizes.rs"] -pub mod sizes; #[path = "SourceProvider.rs"] pub mod source_provider; #[path = "TextCodec.rs"] diff --git a/src/jsc/sizes.rs b/src/jsc/sizes.rs deleted file mode 100644 index d5b83de50d55..000000000000 --- a/src/jsc/sizes.rs +++ /dev/null @@ -1,8 +0,0 @@ -//! This namespace contains JSC C++ type sizes/alignments exported from a code -//! generator. Do not rely on any of these values in new code. If possible, -//! rewrite old ones to use another approach. -//! -//! It is not reliable to interpret C++ classes as raw bytes, since the -//! memory layout is not guaranteed by the compiler. - -pub const BUN_FFI_POINTER_OFFSET_TO_ARGUMENTS_LIST: usize = 6; diff --git a/src/runtime/ffi/FFI.h b/src/runtime/ffi/FFI.h index 6a4f733c3e17..d2fa9b78627b 100644 --- a/src/runtime/ffi/FFI.h +++ b/src/runtime/ffi/FFI.h @@ -128,13 +128,18 @@ EncodedJSValue ValueTrue = { TagValueTrue }; typedef void* JSContext; -// Bun_FFI_PointerOffsetToArgumentsList is injected into the build -// The value is generated in `make sizegen` -// The value is 6. -// On ARM64_32, the value is something else but it really doesn't matter for our case -// However, I don't want this to subtly break amidst future upgrades to JavaScriptCore +// JSFunctionCall is installed as a JSC host function, so `callFrame` is a +// JSC::CallFrame: an array of 8-byte slots. The two Bun_FFI_PointerOffsetTo* +// slot indices are defined by the runtime from JSC::CallFrameSlot when it +// compiles this file (src/jsc/bindings/ffi.cpp). #define LOAD_ARGUMENTS_FROM_CALL_FRAME \ - int64_t *argsPtr = (int64_t*)((size_t*)callFrame + Bun_FFI_PointerOffsetToArgumentsList) + EncodedJSValue *argsPtr = (EncodedJSValue*)((size_t*)callFrame + Bun_FFI_PointerOffsetToArgumentsList); \ + int32_t argsCount = ((EncodedJSValue*)((size_t*)callFrame + Bun_FFI_PointerOffsetToArgumentCountIncludingThis))->asBits.payload - 1 + +// JSC::CallFrame::argument(i): slots past argsCount are not arguments (they are +// whatever the caller's stack holds there), so a missing argument is undefined, +// like it is for a JS function and for the dlopen()/linkSymbols() FFI. +#define ARGUMENT(i) ((i) < argsCount ? argsPtr[i] : ValueUndefined) @@ -208,7 +213,9 @@ static uint64_t JSVALUE_TO_TYPED_ARRAY_LENGTH(EncodedJSValue val) { // This behavior change enables the JIT to handle it better // It also is better readability when console.log(myPtr) static void* JSVALUE_TO_PTR(EncodedJSValue val) { - if (val.asInt64 == TagValueNull) + // Same as the engine FFI (FFIConversions.cpp writePointerSlot): both null and + // undefined, including an argument that was not passed at all, are NULL. + if (val.asInt64 == TagValueNull || val.asInt64 == TagValueUndefined) return 0; if (JSCELL_IS_TYPED_ARRAY(val)) { diff --git a/src/runtime/ffi/abi_type.rs b/src/runtime/ffi/abi_type.rs index 32dc51c65896..a57fd65c0357 100644 --- a/src/runtime/ffi/abi_type.rs +++ b/src/runtime/ffi/abi_type.rs @@ -191,20 +191,6 @@ impl ABIType { }) } - /// Types that we can directly pass through as an `int64_t` - pub(crate) fn needs_a_cast_in_c(self) -> bool { - !matches!( - self, - ABIType::Char - | ABIType::Int8T - | ABIType::Uint8T - | ABIType::Int16T - | ABIType::Uint16T - | ABIType::Int32T - | ABIType::Uint32T - ) - } - pub(crate) fn is_floating_point(self) -> bool { matches!(self, ABIType::Double | ABIType::Float) } diff --git a/src/runtime/ffi/ffi_body.rs b/src/runtime/ffi/ffi_body.rs index 679a87b1e36a..d230d70877ec 100644 --- a/src/runtime/ffi/ffi_body.rs +++ b/src/runtime/ffi/ffi_body.rs @@ -72,12 +72,15 @@ fn dangerously_run_without_jit_protections(func: impl FnOnce() -> R) -> R { func() } +/// Mirrors `FFIFields` in `src/jsc/bindings/ffi.cpp`. #[repr(C)] struct Offsets { js_array_buffer_view_offset_of_length: u32, js_array_buffer_view_offset_of_byte_offset: u32, js_array_buffer_view_offset_of_vector: u32, js_cell_offset_of_type: u32, + call_frame_argument_count_including_this_slot: u32, + call_frame_first_argument_slot: u32, } unsafe extern "C" { @@ -2151,41 +2154,13 @@ impl Function { )?; } - if !self.arg_types.is_empty() { + // A napi_env parameter is filled in by `to_c` below but still takes up + // its position in the JS argument list, so `i` is the JS index too. + if self.arg_types.iter().any(|arg| *arg != ABIType::NapiEnv) { writer.write_all(b" LOAD_ARGUMENTS_FROM_CALL_FRAME;\n")?; for (i, arg) in self.arg_types.iter().enumerate() { - if *arg == ABIType::NapiEnv { - write!( - writer, - " napi_env arg{} = (napi_env)&Bun__thisFFIModuleNapiEnv;\n argsPtr++;\n", - i - )?; - } else if *arg == ABIType::NapiValue { - writeln!( - writer, - " EncodedJSValue arg{} = {{ .asInt64 = *argsPtr++ }};", - i - )?; - } else if arg.needs_a_cast_in_c() { - if i < self.arg_types.len() - 1 { - writeln!( - writer, - " EncodedJSValue arg{} = {{ .asInt64 = *argsPtr++ }};", - i - )?; - } else { - write!( - writer, - " EncodedJSValue arg{};\n arg{}.asInt64 = *argsPtr;\n", - i, i - )?; - } - } else { - if i < self.arg_types.len() - 1 { - writeln!(writer, " int64_t arg{} = *argsPtr++;", i)?; - } else { - writeln!(writer, " int64_t arg{} = *argsPtr;", i)?; - } + if *arg != ABIType::NapiEnv { + writeln!(writer, " EncodedJSValue arg{i} = ARGUMENT({i});")?; } } } @@ -2213,11 +2188,7 @@ impl Function { let length_buf = bun_core::fmt::print_int(&mut arg_buf[3..], i); let arg_name = &arg_buf[0..3 + length_buf]; - if arg.needs_a_cast_in_c() { - write!(writer, "{}", arg.to_c(arg_name))?; - } else { - writer.write_all(arg_name)?; - } + write!(writer, "{}", arg.to_c(arg_name))?; } writer.write_all(b");\n")?; @@ -2542,7 +2513,11 @@ impl CompilerRT { state.define_symbols(&[ ( "Bun_FFI_PointerOffsetToArgumentsList", - bun_jsc::sizes::BUN_FFI_POINTER_OFFSET_TO_ARGUMENTS_LIST as i64, + offsets.call_frame_first_argument_slot as i64, + ), + ( + "Bun_FFI_PointerOffsetToArgumentCountIncludingThis", + offsets.call_frame_argument_count_including_this_slot as i64, ), ( "JSArrayBufferView__offsetOfLength", diff --git a/test/js/bun/ffi/cc.test.ts b/test/js/bun/ffi/cc.test.ts index 0726df91e629..e2aea0b71fdd 100644 --- a/test/js/bun/ffi/cc.test.ts +++ b/test/js/bun/ffi/cc.test.ts @@ -75,10 +75,13 @@ describe.skipIf(isASAN)("given an add(a, b) function", () => { expect(() => res.symbols.add("1", "2")).toThrow(); }); - // looks like `b` defaults to `0`, is this U.B. or expected? - it.skip("when passed too few arguments, throws an error", () => { + // A missing argument is undefined, which converts to 0, exactly as it does + // for a dlopen() symbol. + it("when passed too few arguments, the missing ones are 0", () => { // @ts-expect-error - expect(() => res.symbols.add(1)).toThrow(); + expect(res.symbols.add(1)).toBe(1); + // @ts-expect-error + expect(res.symbols.add()).toBe(0); }); it("when passed too many arguments, still works", () => { @@ -101,6 +104,127 @@ describe.skipIf(isASAN)("given an add(a, b) function", () => { }); }); // +// The wrapper cc() compiles for a symbol used to load one call-frame slot per +// declared parameter without looking at how many arguments were passed, so a +// call with fewer arguments handed C whatever the caller's frame held past the +// last one: garbage integers, and for pointer parameters a segfault while the +// stale slot was being inspected as a JSValue. A missing argument is undefined, +// which converts the way it does for dlopen() symbols: 0 for integers and +// pointers, NaN for floating point, false for bool, undefined for napi_value. +// Runs in a subprocess because the unfixed wrapper crashes. +describe("calling a symbol with fewer arguments than it declares", () => { + it("converts the missing arguments as undefined instead of reading past the call frame", async () => { + using dir = tempDir("bun-ffi-cc-missing-args", { + // Every function reports what C received as an int, so a bad argument + // decode cannot be masked by the matching return conversion. + "arity.c": /* c */ ` + int digits(int a, int b, int c) { return a * 100 + b * 10 + c; } + int u8_value(unsigned char x) { return x; } + int bool_value(_Bool x) { return x; } + int f64_is_nan(double x) { return x != x; } + int f32_is_nan(float x) { return x != x; } + int ptr_is_null(void* p) { return p == 0; } + int cstring_is_null(const char* s) { return s == 0; } + /* bit i is set when parameter i arrived as the value undefined converts to */ + int missing_mask(int a, double b, void* c, _Bool d) { + return (a == 0) | ((b != b) << 1) | ((c == 0) << 2) | ((d == 0) << 3); + } + typedef struct bun_test_env* env_t; + typedef struct bun_test_value* value_t; + value_t echo_napi_value(env_t env, value_t value) { return value; } + `, + "fixture.js": /* js */ ` + import { cc, ptr } from "bun:ffi"; + import path from "path"; + + const { symbols } = cc({ + source: path.join(import.meta.dir, "arity.c"), + symbols: { + digits: { args: ["i32", "i32", "i32"], returns: "i32" }, + u8_value: { args: ["u8"], returns: "i32" }, + bool_value: { args: ["bool"], returns: "i32" }, + f64_is_nan: { args: ["f64"], returns: "i32" }, + f32_is_nan: { args: ["f32"], returns: "i32" }, + ptr_is_null: { args: ["ptr"], returns: "i32" }, + cstring_is_null: { args: ["cstring"], returns: "i32" }, + missing_mask: { args: ["i32", "f64", "ptr", "bool"], returns: "i32" }, + echo_napi_value: { args: ["napi_env", "napi_value"], returns: "napi_value" }, + }, + }); + + const bytes = new Uint8Array(8); + + // Repeated calls from one call site walk through the JIT tiers, which + // is where the stale slot past the arguments tends to hold a pointer. + let nullPointers = 0; + for (let i = 0; i < 500; i++) nullPointers += symbols.ptr_is_null(); + + const results = { + digits: { + none: symbols.digits(), + one: symbols.digits(1), + two: symbols.digits(1, 2), + all: symbols.digits(1, 2, 3), + extra: symbols.digits(1, 2, 3, 4), + }, + u8: { missing: symbols.u8_value(), passed: symbols.u8_value(200) }, + bool: { missing: symbols.bool_value(), passed: symbols.bool_value(true) }, + f64_is_nan: { missing: symbols.f64_is_nan(), passed: symbols.f64_is_nan(1.5) }, + f32_is_nan: { missing: symbols.f32_is_nan(), passed: symbols.f32_is_nan(1.5) }, + ptr_is_null: { + missing: symbols.ptr_is_null(), + missing_500_times: nullPointers, + undefined: symbols.ptr_is_null(undefined), + null: symbols.ptr_is_null(null), + typed_array: symbols.ptr_is_null(bytes), + address: symbols.ptr_is_null(ptr(bytes)), + }, + cstring_is_null: { missing: symbols.cstring_is_null(), undefined: symbols.cstring_is_null(undefined) }, + missing_mask: { + none: symbols.missing_mask(), + first_only: symbols.missing_mask(5), + all: symbols.missing_mask(5, 2.5, bytes, true), + }, + // napi_env takes up position 0 of the JS argument list but is filled in by the wrapper. + echo_napi_value: { + none: typeof symbols.echo_napi_value(), + placeholder_only: typeof symbols.echo_napi_value(null), + passed: symbols.echo_napi_value(undefined, 42), + }, + }; + 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]); + + // stderr is included in the received object so failures show it, but is not + // asserted empty: debug builds emit benign startup warnings. + const results = stdout.startsWith("{") ? JSON.parse(stdout) : stdout; + expect({ results, stderr, exitCode }).toMatchObject({ + results: { + digits: { none: 0, one: 100, two: 120, all: 123, extra: 123 }, + u8: { missing: 0, passed: 200 }, + bool: { missing: 0, passed: 1 }, + f64_is_nan: { missing: 1, passed: 0 }, + f32_is_nan: { missing: 1, passed: 0 }, + ptr_is_null: { missing: 1, missing_500_times: 500, undefined: 1, null: 1, typed_array: 0, address: 0 }, + cstring_is_null: { missing: 1, undefined: 1 }, + missing_mask: { none: 0b1111, first_only: 0b1110, all: 0 }, + echo_napi_value: { none: "undefined", placeholder_only: "undefined", passed: 42 }, + }, + exitCode: 0, + }); + }); +}); + describe("given a source file with syntax errors", () => { const source = /* c */ ` int add(int a, int b) { diff --git a/test/js/bun/ffi/ffi.test.fixture.receiver.c b/test/js/bun/ffi/ffi.test.fixture.receiver.c index 03c89180e442..2c36c789e33e 100644 --- a/test/js/bun/ffi/ffi.test.fixture.receiver.c +++ b/test/js/bun/ffi/ffi.test.fixture.receiver.c @@ -130,13 +130,18 @@ EncodedJSValue ValueTrue = { TagValueTrue }; typedef void* JSContext; -// Bun_FFI_PointerOffsetToArgumentsList is injected into the build -// The value is generated in `make sizegen` -// The value is 6. -// On ARM64_32, the value is something else but it really doesn't matter for our case -// However, I don't want this to subtly break amidst future upgrades to JavaScriptCore +// JSFunctionCall is installed as a JSC host function, so `callFrame` is a +// JSC::CallFrame: an array of 8-byte slots. The two Bun_FFI_PointerOffsetTo* +// slot indices are defined by the runtime from JSC::CallFrameSlot when it +// compiles this file (src/jsc/bindings/ffi.cpp). #define LOAD_ARGUMENTS_FROM_CALL_FRAME \ - int64_t *argsPtr = (int64_t*)((size_t*)callFrame + Bun_FFI_PointerOffsetToArgumentsList) + EncodedJSValue *argsPtr = (EncodedJSValue*)((size_t*)callFrame + Bun_FFI_PointerOffsetToArgumentsList); \ + int32_t argsCount = ((EncodedJSValue*)((size_t*)callFrame + Bun_FFI_PointerOffsetToArgumentCountIncludingThis))->asBits.payload - 1 + +// JSC::CallFrame::argument(i): slots past argsCount are not arguments (they are +// whatever the caller's stack holds there), so a missing argument is undefined, +// like it is for a JS function and for the dlopen()/linkSymbols() FFI. +#define ARGUMENT(i) ((i) < argsCount ? argsPtr[i] : ValueUndefined) @@ -210,7 +215,9 @@ static uint64_t JSVALUE_TO_TYPED_ARRAY_LENGTH(EncodedJSValue val) { // This behavior change enables the JIT to handle it better // It also is better readability when console.log(myPtr) static void* JSVALUE_TO_PTR(EncodedJSValue val) { - if (val.asInt64 == TagValueNull) + // Same as the engine FFI (FFIConversions.cpp writePointerSlot): both null and + // undefined, including an argument that was not passed at all, are NULL. + if (val.asInt64 == TagValueNull || val.asInt64 == TagValueUndefined) return 0; if (JSCELL_IS_TYPED_ARRAY(val)) { @@ -374,8 +381,7 @@ float not_a_callback(float arg0); /* ---- Your Wrapper Function ---- */ ZIG_REPR_TYPE JSFunctionCall(void* JS_GLOBAL_OBJECT, void* callFrame) { LOAD_ARGUMENTS_FROM_CALL_FRAME; - EncodedJSValue arg0; - arg0.asInt64 = *argsPtr; + EncodedJSValue arg0 = ARGUMENT(0); float return_value = not_a_callback( JSVALUE_TO_FLOAT(arg0)); return FLOAT_TO_JSVALUE(return_value).asZigRepr; From 80c7532572b1c9d2b7e91cee1352c477f0cd7881 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 13 Aug 2026 04:05:47 +0000 Subject: [PATCH 2/6] bun:ffi: keep the cc() wrapper's argument loads scalar so they link on arm64 ARGUMENT(i) produced an EncodedJSValue, and initializing the argN locals from it is a union copy. TinyCC only open-codes struct copies on x86_64; on every other target it emits a call to memmove, which the wrapper cannot resolve because it is linked with -nostdlib, so every cc() symbol failed to compile on aarch64 with "unresolved reference to 'memmove'". ARGUMENT(i) now yields the raw int64_t bits and the generated code stores them through a designated initializer, the same shape the wrapper used before. --- src/runtime/ffi/FFI.h | 13 ++++++++----- src/runtime/ffi/ffi_body.rs | 5 ++++- test/js/bun/ffi/ffi.test.fixture.receiver.c | 15 +++++++++------ 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/src/runtime/ffi/FFI.h b/src/runtime/ffi/FFI.h index d2fa9b78627b..8d7cd5efe0a0 100644 --- a/src/runtime/ffi/FFI.h +++ b/src/runtime/ffi/FFI.h @@ -133,13 +133,16 @@ typedef void* JSContext; // slot indices are defined by the runtime from JSC::CallFrameSlot when it // compiles this file (src/jsc/bindings/ffi.cpp). #define LOAD_ARGUMENTS_FROM_CALL_FRAME \ - EncodedJSValue *argsPtr = (EncodedJSValue*)((size_t*)callFrame + Bun_FFI_PointerOffsetToArgumentsList); \ + int64_t *argsPtr = (int64_t*)((size_t*)callFrame + Bun_FFI_PointerOffsetToArgumentsList); \ int32_t argsCount = ((EncodedJSValue*)((size_t*)callFrame + Bun_FFI_PointerOffsetToArgumentCountIncludingThis))->asBits.payload - 1 -// JSC::CallFrame::argument(i): slots past argsCount are not arguments (they are -// whatever the caller's stack holds there), so a missing argument is undefined, -// like it is for a JS function and for the dlopen()/linkSymbols() FFI. -#define ARGUMENT(i) ((i) < argsCount ? argsPtr[i] : ValueUndefined) +// JSC::CallFrame::argument(i) as encoded bits: slots past argsCount are not +// arguments (they are whatever the caller's stack holds there), so a missing +// argument is undefined, like it is for a JS function and for the +// dlopen()/linkSymbols() FFI. Yields an int64_t rather than an EncodedJSValue +// because this file is linked with -nostdlib and, on every target but x86_64, +// TinyCC compiles a struct/union copy into a call to memmove. +#define ARGUMENT(i) ((i) < argsCount ? argsPtr[i] : TagValueUndefined) diff --git a/src/runtime/ffi/ffi_body.rs b/src/runtime/ffi/ffi_body.rs index d230d70877ec..98e213f0e535 100644 --- a/src/runtime/ffi/ffi_body.rs +++ b/src/runtime/ffi/ffi_body.rs @@ -2160,7 +2160,10 @@ impl Function { writer.write_all(b" LOAD_ARGUMENTS_FROM_CALL_FRAME;\n")?; for (i, arg) in self.arg_types.iter().enumerate() { if *arg != ABIType::NapiEnv { - writeln!(writer, " EncodedJSValue arg{i} = ARGUMENT({i});")?; + writeln!( + writer, + " EncodedJSValue arg{i} = {{ .asInt64 = ARGUMENT({i}) }};" + )?; } } } diff --git a/test/js/bun/ffi/ffi.test.fixture.receiver.c b/test/js/bun/ffi/ffi.test.fixture.receiver.c index 2c36c789e33e..bb540b5620c3 100644 --- a/test/js/bun/ffi/ffi.test.fixture.receiver.c +++ b/test/js/bun/ffi/ffi.test.fixture.receiver.c @@ -135,13 +135,16 @@ typedef void* JSContext; // slot indices are defined by the runtime from JSC::CallFrameSlot when it // compiles this file (src/jsc/bindings/ffi.cpp). #define LOAD_ARGUMENTS_FROM_CALL_FRAME \ - EncodedJSValue *argsPtr = (EncodedJSValue*)((size_t*)callFrame + Bun_FFI_PointerOffsetToArgumentsList); \ + int64_t *argsPtr = (int64_t*)((size_t*)callFrame + Bun_FFI_PointerOffsetToArgumentsList); \ int32_t argsCount = ((EncodedJSValue*)((size_t*)callFrame + Bun_FFI_PointerOffsetToArgumentCountIncludingThis))->asBits.payload - 1 -// JSC::CallFrame::argument(i): slots past argsCount are not arguments (they are -// whatever the caller's stack holds there), so a missing argument is undefined, -// like it is for a JS function and for the dlopen()/linkSymbols() FFI. -#define ARGUMENT(i) ((i) < argsCount ? argsPtr[i] : ValueUndefined) +// JSC::CallFrame::argument(i) as encoded bits: slots past argsCount are not +// arguments (they are whatever the caller's stack holds there), so a missing +// argument is undefined, like it is for a JS function and for the +// dlopen()/linkSymbols() FFI. Yields an int64_t rather than an EncodedJSValue +// because this file is linked with -nostdlib and, on every target but x86_64, +// TinyCC compiles a struct/union copy into a call to memmove. +#define ARGUMENT(i) ((i) < argsCount ? argsPtr[i] : TagValueUndefined) @@ -381,7 +384,7 @@ float not_a_callback(float arg0); /* ---- Your Wrapper Function ---- */ ZIG_REPR_TYPE JSFunctionCall(void* JS_GLOBAL_OBJECT, void* callFrame) { LOAD_ARGUMENTS_FROM_CALL_FRAME; - EncodedJSValue arg0 = ARGUMENT(0); + EncodedJSValue arg0 = { .asInt64 = ARGUMENT(0) }; float return_value = not_a_callback( JSVALUE_TO_FLOAT(arg0)); return FLOAT_TO_JSVALUE(return_value).asZigRepr; From 991b2c23fa32b0ddac076a8a1d4cc476de9f89de Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 13 Aug 2026 04:16:35 +0000 Subject: [PATCH 3/6] bun:ffi: shorten the comments on the cc() wrapper's argument loading --- src/runtime/ffi/FFI.h | 18 ++++++------------ src/runtime/ffi/ffi_body.rs | 3 +-- test/js/bun/ffi/ffi.test.fixture.receiver.c | 18 ++++++------------ 3 files changed, 13 insertions(+), 26 deletions(-) diff --git a/src/runtime/ffi/FFI.h b/src/runtime/ffi/FFI.h index 8d7cd5efe0a0..31cd0f5f1103 100644 --- a/src/runtime/ffi/FFI.h +++ b/src/runtime/ffi/FFI.h @@ -128,20 +128,15 @@ EncodedJSValue ValueTrue = { TagValueTrue }; typedef void* JSContext; -// JSFunctionCall is installed as a JSC host function, so `callFrame` is a -// JSC::CallFrame: an array of 8-byte slots. The two Bun_FFI_PointerOffsetTo* -// slot indices are defined by the runtime from JSC::CallFrameSlot when it -// compiles this file (src/jsc/bindings/ffi.cpp). +// callFrame is the JSC::CallFrame (an array of 8-byte slots); the two slot indices +// are defined from JSC::CallFrameSlot by src/jsc/bindings/ffi.cpp. #define LOAD_ARGUMENTS_FROM_CALL_FRAME \ int64_t *argsPtr = (int64_t*)((size_t*)callFrame + Bun_FFI_PointerOffsetToArgumentsList); \ int32_t argsCount = ((EncodedJSValue*)((size_t*)callFrame + Bun_FFI_PointerOffsetToArgumentCountIncludingThis))->asBits.payload - 1 -// JSC::CallFrame::argument(i) as encoded bits: slots past argsCount are not -// arguments (they are whatever the caller's stack holds there), so a missing -// argument is undefined, like it is for a JS function and for the -// dlopen()/linkSymbols() FFI. Yields an int64_t rather than an EncodedJSValue -// because this file is linked with -nostdlib and, on every target but x86_64, -// TinyCC compiles a struct/union copy into a call to memmove. +// Bits of JSC::CallFrame::argument(i): a slot past argsCount is stale stack, so an +// argument that was not passed reads as undefined. int64_t rather than a union copy: +// TinyCC emits a memmove call for those on non-x86_64 and this file is built -nostdlib. #define ARGUMENT(i) ((i) < argsCount ? argsPtr[i] : TagValueUndefined) @@ -216,8 +211,7 @@ static uint64_t JSVALUE_TO_TYPED_ARRAY_LENGTH(EncodedJSValue val) { // This behavior change enables the JIT to handle it better // It also is better readability when console.log(myPtr) static void* JSVALUE_TO_PTR(EncodedJSValue val) { - // Same as the engine FFI (FFIConversions.cpp writePointerSlot): both null and - // undefined, including an argument that was not passed at all, are NULL. + // undefined (e.g. an argument that was not passed) is NULL, as in the engine's writePointerSlot. if (val.asInt64 == TagValueNull || val.asInt64 == TagValueUndefined) return 0; diff --git a/src/runtime/ffi/ffi_body.rs b/src/runtime/ffi/ffi_body.rs index 98e213f0e535..a25d4e920a97 100644 --- a/src/runtime/ffi/ffi_body.rs +++ b/src/runtime/ffi/ffi_body.rs @@ -2154,8 +2154,7 @@ impl Function { )?; } - // A napi_env parameter is filled in by `to_c` below but still takes up - // its position in the JS argument list, so `i` is the JS index too. + // napi_env comes from `to_c` but still occupies its JS argument position, so `i` is the JS index. if self.arg_types.iter().any(|arg| *arg != ABIType::NapiEnv) { writer.write_all(b" LOAD_ARGUMENTS_FROM_CALL_FRAME;\n")?; for (i, arg) in self.arg_types.iter().enumerate() { diff --git a/test/js/bun/ffi/ffi.test.fixture.receiver.c b/test/js/bun/ffi/ffi.test.fixture.receiver.c index bb540b5620c3..32d1d4bdc25e 100644 --- a/test/js/bun/ffi/ffi.test.fixture.receiver.c +++ b/test/js/bun/ffi/ffi.test.fixture.receiver.c @@ -130,20 +130,15 @@ EncodedJSValue ValueTrue = { TagValueTrue }; typedef void* JSContext; -// JSFunctionCall is installed as a JSC host function, so `callFrame` is a -// JSC::CallFrame: an array of 8-byte slots. The two Bun_FFI_PointerOffsetTo* -// slot indices are defined by the runtime from JSC::CallFrameSlot when it -// compiles this file (src/jsc/bindings/ffi.cpp). +// callFrame is the JSC::CallFrame (an array of 8-byte slots); the two slot indices +// are defined from JSC::CallFrameSlot by src/jsc/bindings/ffi.cpp. #define LOAD_ARGUMENTS_FROM_CALL_FRAME \ int64_t *argsPtr = (int64_t*)((size_t*)callFrame + Bun_FFI_PointerOffsetToArgumentsList); \ int32_t argsCount = ((EncodedJSValue*)((size_t*)callFrame + Bun_FFI_PointerOffsetToArgumentCountIncludingThis))->asBits.payload - 1 -// JSC::CallFrame::argument(i) as encoded bits: slots past argsCount are not -// arguments (they are whatever the caller's stack holds there), so a missing -// argument is undefined, like it is for a JS function and for the -// dlopen()/linkSymbols() FFI. Yields an int64_t rather than an EncodedJSValue -// because this file is linked with -nostdlib and, on every target but x86_64, -// TinyCC compiles a struct/union copy into a call to memmove. +// Bits of JSC::CallFrame::argument(i): a slot past argsCount is stale stack, so an +// argument that was not passed reads as undefined. int64_t rather than a union copy: +// TinyCC emits a memmove call for those on non-x86_64 and this file is built -nostdlib. #define ARGUMENT(i) ((i) < argsCount ? argsPtr[i] : TagValueUndefined) @@ -218,8 +213,7 @@ static uint64_t JSVALUE_TO_TYPED_ARRAY_LENGTH(EncodedJSValue val) { // This behavior change enables the JIT to handle it better // It also is better readability when console.log(myPtr) static void* JSVALUE_TO_PTR(EncodedJSValue val) { - // Same as the engine FFI (FFIConversions.cpp writePointerSlot): both null and - // undefined, including an argument that was not passed at all, are NULL. + // undefined (e.g. an argument that was not passed) is NULL, as in the engine's writePointerSlot. if (val.asInt64 == TagValueNull || val.asInt64 == TagValueUndefined) return 0; From 6dae8ea503ea21aad48d70d5cb61965940303887 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 13 Aug 2026 04:27:38 +0000 Subject: [PATCH 4/6] bun:ffi: one-line comments on the cc() wrapper's argument loading --- src/runtime/ffi/FFI.h | 7 ++----- src/runtime/ffi/ffi_body.rs | 1 + test/js/bun/ffi/ffi.test.fixture.receiver.c | 7 ++----- 3 files changed, 5 insertions(+), 10 deletions(-) diff --git a/src/runtime/ffi/FFI.h b/src/runtime/ffi/FFI.h index 31cd0f5f1103..ec15923e426f 100644 --- a/src/runtime/ffi/FFI.h +++ b/src/runtime/ffi/FFI.h @@ -128,15 +128,12 @@ EncodedJSValue ValueTrue = { TagValueTrue }; typedef void* JSContext; -// callFrame is the JSC::CallFrame (an array of 8-byte slots); the two slot indices -// are defined from JSC::CallFrameSlot by src/jsc/bindings/ffi.cpp. +// The Bun_FFI_PointerOffsetTo* slot indices into the JSC::CallFrame are defined from JSC::CallFrameSlot by src/jsc/bindings/ffi.cpp. #define LOAD_ARGUMENTS_FROM_CALL_FRAME \ int64_t *argsPtr = (int64_t*)((size_t*)callFrame + Bun_FFI_PointerOffsetToArgumentsList); \ int32_t argsCount = ((EncodedJSValue*)((size_t*)callFrame + Bun_FFI_PointerOffsetToArgumentCountIncludingThis))->asBits.payload - 1 -// Bits of JSC::CallFrame::argument(i): a slot past argsCount is stale stack, so an -// argument that was not passed reads as undefined. int64_t rather than a union copy: -// TinyCC emits a memmove call for those on non-x86_64 and this file is built -nostdlib. +// JSC::CallFrame::argument(i) as encoded bits: an argument the caller did not pass reads as undefined, not as the stale slot. #define ARGUMENT(i) ((i) < argsCount ? argsPtr[i] : TagValueUndefined) diff --git a/src/runtime/ffi/ffi_body.rs b/src/runtime/ffi/ffi_body.rs index a25d4e920a97..b600e35c83f7 100644 --- a/src/runtime/ffi/ffi_body.rs +++ b/src/runtime/ffi/ffi_body.rs @@ -2159,6 +2159,7 @@ impl Function { writer.write_all(b" LOAD_ARGUMENTS_FROM_CALL_FRAME;\n")?; for (i, arg) in self.arg_types.iter().enumerate() { if *arg != ABIType::NapiEnv { + // Initialized from the bits, never copied as a union: TinyCC lowers union copies to memmove on non-x86_64 and the wrapper is -nostdlib. writeln!( writer, " EncodedJSValue arg{i} = {{ .asInt64 = ARGUMENT({i}) }};" diff --git a/test/js/bun/ffi/ffi.test.fixture.receiver.c b/test/js/bun/ffi/ffi.test.fixture.receiver.c index 32d1d4bdc25e..dec32b7798a0 100644 --- a/test/js/bun/ffi/ffi.test.fixture.receiver.c +++ b/test/js/bun/ffi/ffi.test.fixture.receiver.c @@ -130,15 +130,12 @@ EncodedJSValue ValueTrue = { TagValueTrue }; typedef void* JSContext; -// callFrame is the JSC::CallFrame (an array of 8-byte slots); the two slot indices -// are defined from JSC::CallFrameSlot by src/jsc/bindings/ffi.cpp. +// The Bun_FFI_PointerOffsetTo* slot indices into the JSC::CallFrame are defined from JSC::CallFrameSlot by src/jsc/bindings/ffi.cpp. #define LOAD_ARGUMENTS_FROM_CALL_FRAME \ int64_t *argsPtr = (int64_t*)((size_t*)callFrame + Bun_FFI_PointerOffsetToArgumentsList); \ int32_t argsCount = ((EncodedJSValue*)((size_t*)callFrame + Bun_FFI_PointerOffsetToArgumentCountIncludingThis))->asBits.payload - 1 -// Bits of JSC::CallFrame::argument(i): a slot past argsCount is stale stack, so an -// argument that was not passed reads as undefined. int64_t rather than a union copy: -// TinyCC emits a memmove call for those on non-x86_64 and this file is built -nostdlib. +// JSC::CallFrame::argument(i) as encoded bits: an argument the caller did not pass reads as undefined, not as the stale slot. #define ARGUMENT(i) ((i) < argsCount ? argsPtr[i] : TagValueUndefined) From c556efbc8523e477210be791d4b2fdb9dc94b9ae Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 13 Aug 2026 10:05:05 +0000 Subject: [PATCH 5/6] bun:ffi: store cc() wrapper arguments through a member store; cover function and double-encoded arguments TinyCC zero-fills every brace-initialized local through a call to memset, so the designated initializer cost one memset call per argument on every target. Declaring the local and assigning the member, the shape the generator already used for the last argument, references neither memset nor memmove (checked by compiling the wrapper with the vendored TinyCC for x86_64 and arm64). The arity fixture now also covers a "function" parameter, which shares JSVALUE_TO_PTR with ptr/cstring and therefore receives NULL for a missing or undefined callback, and one double-encoded integer argument, which the removed raw pass-through decoded wrongly. --- src/runtime/ffi/ffi_body.rs | 4 ++-- test/js/bun/ffi/cc.test.ts | 11 +++++++++-- test/js/bun/ffi/ffi.test.fixture.receiver.c | 3 ++- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/runtime/ffi/ffi_body.rs b/src/runtime/ffi/ffi_body.rs index b600e35c83f7..14b5ec6455c7 100644 --- a/src/runtime/ffi/ffi_body.rs +++ b/src/runtime/ffi/ffi_body.rs @@ -2159,10 +2159,10 @@ impl Function { writer.write_all(b" LOAD_ARGUMENTS_FROM_CALL_FRAME;\n")?; for (i, arg) in self.arg_types.iter().enumerate() { if *arg != ABIType::NapiEnv { - // Initialized from the bits, never copied as a union: TinyCC lowers union copies to memmove on non-x86_64 and the wrapper is -nostdlib. + // Member store: TinyCC emits a memset call for a brace-initialized local and a memmove call (unresolvable under -nostdlib) for a union copy. writeln!( writer, - " EncodedJSValue arg{i} = {{ .asInt64 = ARGUMENT({i}) }};" + " EncodedJSValue arg{i};\n arg{i}.asInt64 = ARGUMENT({i});" )?; } } diff --git a/test/js/bun/ffi/cc.test.ts b/test/js/bun/ffi/cc.test.ts index e2aea0b71fdd..1f37bf4ba634 100644 --- a/test/js/bun/ffi/cc.test.ts +++ b/test/js/bun/ffi/cc.test.ts @@ -125,6 +125,7 @@ describe("calling a symbol with fewer arguments than it declares", () => { int f32_is_nan(float x) { return x != x; } int ptr_is_null(void* p) { return p == 0; } int cstring_is_null(const char* s) { return s == 0; } + int function_is_null(void (*f)(void)) { return f == 0; } /* bit i is set when parameter i arrived as the value undefined converts to */ int missing_mask(int a, double b, void* c, _Bool d) { return (a == 0) | ((b != b) << 1) | ((c == 0) << 2) | ((d == 0) << 3); @@ -147,6 +148,7 @@ describe("calling a symbol with fewer arguments than it declares", () => { f32_is_nan: { args: ["f32"], returns: "i32" }, ptr_is_null: { args: ["ptr"], returns: "i32" }, cstring_is_null: { args: ["cstring"], returns: "i32" }, + function_is_null: { args: ["function"], returns: "i32" }, missing_mask: { args: ["i32", "f64", "ptr", "bool"], returns: "i32" }, echo_napi_value: { args: ["napi_env", "napi_value"], returns: "napi_value" }, }, @@ -167,7 +169,8 @@ describe("calling a symbol with fewer arguments than it declares", () => { all: symbols.digits(1, 2, 3), extra: symbols.digits(1, 2, 3, 4), }, - u8: { missing: symbols.u8_value(), passed: symbols.u8_value(200) }, + // 200.5 is a double-encoded JSValue; integer parameters used to receive the raw slot bits. + u8: { missing: symbols.u8_value(), passed: symbols.u8_value(200), double_encoded: symbols.u8_value(200.5) }, bool: { missing: symbols.bool_value(), passed: symbols.bool_value(true) }, f64_is_nan: { missing: symbols.f64_is_nan(), passed: symbols.f64_is_nan(1.5) }, f32_is_nan: { missing: symbols.f32_is_nan(), passed: symbols.f32_is_nan(1.5) }, @@ -180,6 +183,9 @@ describe("calling a symbol with fewer arguments than it declares", () => { address: symbols.ptr_is_null(ptr(bytes)), }, cstring_is_null: { missing: symbols.cstring_is_null(), undefined: symbols.cstring_is_null(undefined) }, + // The engine's FFI throws a TypeError for an undefined callback; the cc() wrapper + // has no throwing conversion yet (#37989 adds it), so C receives NULL until then. + function_is_null: { missing: symbols.function_is_null(), undefined: symbols.function_is_null(undefined) }, missing_mask: { none: symbols.missing_mask(), first_only: symbols.missing_mask(5), @@ -211,12 +217,13 @@ describe("calling a symbol with fewer arguments than it declares", () => { expect({ results, stderr, exitCode }).toMatchObject({ results: { digits: { none: 0, one: 100, two: 120, all: 123, extra: 123 }, - u8: { missing: 0, passed: 200 }, + u8: { missing: 0, passed: 200, double_encoded: 200 }, bool: { missing: 0, passed: 1 }, f64_is_nan: { missing: 1, passed: 0 }, f32_is_nan: { missing: 1, passed: 0 }, ptr_is_null: { missing: 1, missing_500_times: 500, undefined: 1, null: 1, typed_array: 0, address: 0 }, cstring_is_null: { missing: 1, undefined: 1 }, + function_is_null: { missing: 1, undefined: 1 }, missing_mask: { none: 0b1111, first_only: 0b1110, all: 0 }, echo_napi_value: { none: "undefined", placeholder_only: "undefined", passed: 42 }, }, diff --git a/test/js/bun/ffi/ffi.test.fixture.receiver.c b/test/js/bun/ffi/ffi.test.fixture.receiver.c index dec32b7798a0..f33c302bd4d0 100644 --- a/test/js/bun/ffi/ffi.test.fixture.receiver.c +++ b/test/js/bun/ffi/ffi.test.fixture.receiver.c @@ -375,7 +375,8 @@ float not_a_callback(float arg0); /* ---- Your Wrapper Function ---- */ ZIG_REPR_TYPE JSFunctionCall(void* JS_GLOBAL_OBJECT, void* callFrame) { LOAD_ARGUMENTS_FROM_CALL_FRAME; - EncodedJSValue arg0 = { .asInt64 = ARGUMENT(0) }; + EncodedJSValue arg0; + arg0.asInt64 = ARGUMENT(0); float return_value = not_a_callback( JSVALUE_TO_FLOAT(arg0)); return FLOAT_TO_JSVALUE(return_value).asZigRepr; From 7e738e1cd186e1901c3647d43a58b1031948f0c7 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 13 Aug 2026 17:45:26 +0000 Subject: [PATCH 6/6] ci: retrigger