From 25706afe49404cd445443a990a288b4f594dcbf9 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 13 Aug 2026 01:52:06 +0000 Subject: [PATCH 1/5] bun:ffi: convert cc() pointer arguments with the engine's converter instead of assuming a double The C wrapper cc() compiles converted ptr, cstring and function arguments with JSVALUE_TO_PTR, which handled null, typed arrays and int32 and then treated anything else as a double. A JSCallback object, an ArrayBuffer, a BigInt, an object with a `ptr` property, undefined, or a plain object became a garbage pointer (-1); for a function argument that is an immediate call through address -1. The buffer row read the typed array vector offset off whatever it was given. The inline paths now cover numbers, views and null (for ptr/cstring); every other value is handed to JSC::FFI::writeSlotFromJSValue through a new JSVALUE_TO_PTR_SLOW export, so cc() wrappers accept and reject the same values dlopen()'d symbols do. The generated wrapper converts these arguments into locals before the call and returns the empty value when a conversion threw, so the native function is not called with a pending exception; the napi handle scope is opened after the conversions for the same reason. --- src/jsc/bindings/JSCFFIBridge.cpp | 22 ++ src/runtime/ffi/FFI.h | 40 ++- src/runtime/ffi/abi_type.rs | 62 +++-- src/runtime/ffi/ffi_body.rs | 57 +++- test/js/bun/ffi/cc.test.ts | 284 ++++++++++++++++++++ test/js/bun/ffi/ffi.test.fixture.receiver.c | 40 ++- 6 files changed, 456 insertions(+), 49 deletions(-) diff --git a/src/jsc/bindings/JSCFFIBridge.cpp b/src/jsc/bindings/JSCFFIBridge.cpp index 1e9ff0b37c66..28a4d2cac07a 100644 --- a/src/jsc/bindings/JSCFFIBridge.cpp +++ b/src/jsc/bindings/JSCFFIBridge.cpp @@ -2,6 +2,7 @@ #include "root.h" #include +#include #include #include #include @@ -17,6 +18,8 @@ static_assert(static_cast(JSC::FFI::Type::Char) == 0, "FFI::Type tag drift"); static_assert(static_cast(JSC::FFI::Type::Pointer) == 12, "FFI::Type tag drift"); +static_assert(static_cast(JSC::FFI::Type::CString) == 14, "FFI::Type tag drift"); +static_assert(static_cast(JSC::FFI::Type::Function) == 17, "FFI::Type tag drift"); static_assert(static_cast(JSC::FFI::Type::JSValue) == 19, "FFI::Type tag drift"); static_assert(static_cast(JSC::FFI::Type::Buffer) == 20, "FFI::Type tag drift"); static_assert(static_cast(JSC::FFI::Type::BufferLength) == 21, "FFI::Type tag drift"); @@ -119,3 +122,22 @@ extern "C" void Bun__JSCFFICallbackClose(JSC::EncodedJSValue callbackValue) if (auto* callback = dynamicDowncast(JSC::JSValue::decode(callbackValue))) callback->close(); } + +// JSVALUE_TO_PTR_SLOW for the wrappers cc() compiles (src/runtime/ffi/FFI.h). The wrapper converts +// numbers, typed arrays and null inline; everything else (JSCallback, ArrayBuffer, BigInt, an object +// with a numeric `ptr`, junk) gets the same conversion dlopen()'d symbols get, TypeErrors included. +// No string arena is passed: nothing would free a transcoded `cstring` after the native call, so a +// JavaScript string throws here instead. +extern "C" void* Bun__FFI__jsValueToPointerSlow(JSC::JSGlobalObject* globalObject, int32_t abiType, bool* threw, JSC::EncodedJSValue encodedValue) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + + uint64_t slot = 0; + JSC::FFI::writeSlotFromJSValue(globalObject, globalObject->ffiContext(), static_cast(abiType), JSC::JSValue::decode(encodedValue), slot, nullptr); + if (scope.exception()) [[unlikely]] { + *threw = true; + return nullptr; + } + return reinterpret_cast(static_cast(slot)); +} diff --git a/src/runtime/ffi/FFI.h b/src/runtime/ffi/FFI.h index 6a4f733c3e17..0c33c79a9d96 100644 --- a/src/runtime/ffi/FFI.h +++ b/src/runtime/ffi/FFI.h @@ -125,6 +125,8 @@ napi_value asNapiValue; EncodedJSValue ValueUndefined = { TagValueUndefined }; EncodedJSValue ValueTrue = { TagValueTrue }; +// What a host function returns after throwing; JSC unwinds to the pending exception and ignores it. +EncodedJSValue ValueEmpty = { 0 }; typedef void* JSContext; @@ -159,7 +161,13 @@ static EncodedJSValue FLOAT_TO_JSVALUE(float val) __attribute__((__always_inline static EncodedJSValue BOOLEAN_TO_JSVALUE(bool val) __attribute__((__always_inline__)); static EncodedJSValue PTR_TO_JSVALUE(void* ptr) __attribute__((__always_inline__)); -static void* JSVALUE_TO_PTR(EncodedJSValue val) __attribute__((__always_inline__)); +// The engine's pointer-argument conversion, shared with dlopen()'d symbols. `abiType` is one of +// the ABI_TYPE_* tags the runtime defines when it compiles this file. Throws a TypeError for values +// that cannot become a pointer and sets `*threw`, in which case the generated wrapper returns +// without calling the native function. +void* JSVALUE_TO_PTR_SLOW(void* jsGlobalObject, int32_t abiType, bool* threw, int64_t val); +static void* JSVALUE_TO_PTR(void* jsGlobalObject, int32_t abiType, bool* threw, EncodedJSValue val) __attribute__((__always_inline__)); +static void* JSVALUE_TO_BUFFER(void* jsGlobalObject, bool* threw, EncodedJSValue val) __attribute__((__always_inline__)); static int32_t JSVALUE_TO_INT32(EncodedJSValue val) __attribute__((__always_inline__)); static float JSVALUE_TO_FLOAT(EncodedJSValue val) __attribute__((__always_inline__)); static double JSVALUE_TO_DOUBLE(EncodedJSValue val) __attribute__((__always_inline__)); @@ -207,21 +215,35 @@ static uint64_t JSVALUE_TO_TYPED_ARRAY_LENGTH(EncodedJSValue val) { // Now, they're stored at the beginning of the 64-bit value // 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) - return 0; +static void* JSVALUE_TO_PTR(void* jsGlobalObject, int32_t abiType, bool* threw, EncodedJSValue val) { + if (JSVALUE_IS_INT32(val)) { + return (void*)(uintptr_t)JSVALUE_TO_INT32(val); + } + + if (JSVALUE_IS_NUMBER(val)) { + val.asInt64 -= DoubleEncodeOffset; + return (void*)(uintptr_t)val.asDouble; + } if (JSCELL_IS_TYPED_ARRAY(val)) { return JSVALUE_TO_TYPED_ARRAY_VECTOR(val); } - if (JSVALUE_IS_INT32(val)) { - return (void*)(uintptr_t)JSVALUE_TO_INT32(val); + // A null callback is a TypeError (decided by the slow path), like it is for dlopen(). + if (val.asInt64 == TagValueNull && abiType != ABI_TYPE_FUNCTION) + return 0; + + // JSCallback, ArrayBuffer, BigInt, objects with a numeric `ptr`, undefined, strings, ... + return JSVALUE_TO_PTR_SLOW(jsGlobalObject, abiType, threw, val.asInt64); +} + +static void* JSVALUE_TO_BUFFER(void* jsGlobalObject, bool* threw, EncodedJSValue val) { + if (JSCELL_IS_TYPED_ARRAY(val)) { + return JSVALUE_TO_TYPED_ARRAY_VECTOR(val); } - // Assume the JSValue is a double - val.asInt64 -= DoubleEncodeOffset; - return (void*)(uintptr_t)val.asDouble; + // Only views are accepted; the slow path throws for everything else. + return JSVALUE_TO_PTR_SLOW(jsGlobalObject, ABI_TYPE_BUFFER, threw, val.asInt64); } static EncodedJSValue PTR_TO_JSVALUE(void* ptr) { diff --git a/src/runtime/ffi/abi_type.rs b/src/runtime/ffi/abi_type.rs index 32dc51c65896..32a8e3e90b88 100644 --- a/src/runtime/ffi/abi_type.rs +++ b/src/runtime/ffi/abi_type.rs @@ -123,28 +123,28 @@ static ABI_TABLE: [AbiRow; 22] = { AbiRow { c_type, to_c_macro, to_js } } [ - /* Char */ r(b"char", Some("JSVALUE_TO_INT32("), Some(("INT32_TO_JSVALUE((int32_t)", ")"))), - /* Int8T */ r(b"int8_t", Some("JSVALUE_TO_INT32("), Some(("INT32_TO_JSVALUE((int32_t)", ")"))), - /* Uint8T */ r(b"uint8_t", Some("JSVALUE_TO_INT32("), Some(("INT32_TO_JSVALUE((int32_t)", ")"))), - /* Int16T */ r(b"int16_t", Some("JSVALUE_TO_INT32("), Some(("INT32_TO_JSVALUE((int32_t)", ")"))), - /* Uint16T */ r(b"uint16_t", Some("JSVALUE_TO_INT32("), Some(("INT32_TO_JSVALUE((int32_t)", ")"))), - /* Int32T */ r(b"int32_t", Some("JSVALUE_TO_INT32("), Some(("INT32_TO_JSVALUE((int32_t)", ")"))), - /* Uint32T */ r(b"uint32_t", Some("JSVALUE_TO_INT32("), Some(("UINT32_TO_JSVALUE(", ")"))), - /* Int64T */ r(b"int64_t", Some("JSVALUE_TO_INT64("), Some(("INT64_TO_JSVALUE_SLOW(JS_GLOBAL_OBJECT, ", ")"))), - /* Uint64T */ r(b"uint64_t", Some("JSVALUE_TO_UINT64("), Some(("UINT64_TO_JSVALUE_SLOW(JS_GLOBAL_OBJECT, ", ")"))), - /* Double */ r(b"double", Some("JSVALUE_TO_DOUBLE("), Some(("DOUBLE_TO_JSVALUE(", ")"))), - /* Float */ r(b"float", Some("JSVALUE_TO_FLOAT("), Some(("FLOAT_TO_JSVALUE(", ")"))), - /* Bool */ r(b"bool", Some("JSVALUE_TO_BOOL("), Some(("BOOLEAN_TO_JSVALUE(", ")"))), - /* Ptr */ r(b"void*", Some("JSVALUE_TO_PTR("), Some(("PTR_TO_JSVALUE(", ")"))), - /* Void */ r(b"void", None, None), - /* CString */ r(b"void*", Some("JSVALUE_TO_PTR("), Some(("PTR_TO_JSVALUE(", ")"))), - /* I64Fast */ r(b"int64_t", Some("JSVALUE_TO_INT64("), Some(("INT64_TO_JSVALUE(JS_GLOBAL_OBJECT, (int64_t)", ")"))), - /* U64Fast */ r(b"uint64_t", Some("JSVALUE_TO_UINT64("), Some(("UINT64_TO_JSVALUE(JS_GLOBAL_OBJECT, ", ")"))), - /* Function */ r(b"void*", Some("JSVALUE_TO_PTR("), Some(("PTR_TO_JSVALUE(", ")"))), - /* NapiEnv */ r(b"napi_env", None, None), - /* NapiValue */ r(b"napi_value", None, Some(("((EncodedJSValue) {.asNapiValue = ", " } )"))), - /* Buffer */ r(b"void*", Some("JSVALUE_TO_TYPED_ARRAY_VECTOR("), None), - /* BufferLen */ r(b"uint64_t", None, None), + /* Char */ r(b"char", Some("JSVALUE_TO_INT32("), Some(("INT32_TO_JSVALUE((int32_t)", ")"))), + /* Int8T */ r(b"int8_t", Some("JSVALUE_TO_INT32("), Some(("INT32_TO_JSVALUE((int32_t)", ")"))), + /* Uint8T */ r(b"uint8_t", Some("JSVALUE_TO_INT32("), Some(("INT32_TO_JSVALUE((int32_t)", ")"))), + /* Int16T */ r(b"int16_t", Some("JSVALUE_TO_INT32("), Some(("INT32_TO_JSVALUE((int32_t)", ")"))), + /* Uint16T */ r(b"uint16_t", Some("JSVALUE_TO_INT32("), Some(("INT32_TO_JSVALUE((int32_t)", ")"))), + /* Int32T */ r(b"int32_t", Some("JSVALUE_TO_INT32("), Some(("INT32_TO_JSVALUE((int32_t)", ")"))), + /* Uint32T */ r(b"uint32_t", Some("JSVALUE_TO_INT32("), Some(("UINT32_TO_JSVALUE(", ")"))), + /* Int64T */ r(b"int64_t", Some("JSVALUE_TO_INT64("), Some(("INT64_TO_JSVALUE_SLOW(JS_GLOBAL_OBJECT, ", ")"))), + /* Uint64T */ r(b"uint64_t", Some("JSVALUE_TO_UINT64("), Some(("UINT64_TO_JSVALUE_SLOW(JS_GLOBAL_OBJECT, ", ")"))), + /* Double */ r(b"double", Some("JSVALUE_TO_DOUBLE("), Some(("DOUBLE_TO_JSVALUE(", ")"))), + /* Float */ r(b"float", Some("JSVALUE_TO_FLOAT("), Some(("FLOAT_TO_JSVALUE(", ")"))), + /* Bool */ r(b"bool", Some("JSVALUE_TO_BOOL("), Some(("BOOLEAN_TO_JSVALUE(", ")"))), + /* Ptr */ r(b"void*", Some("JSVALUE_TO_PTR(JS_GLOBAL_OBJECT, ABI_TYPE_PTR, &threw, "), Some(("PTR_TO_JSVALUE(", ")"))), + /* Void */ r(b"void", None, None), + /* CString */ r(b"void*", Some("JSVALUE_TO_PTR(JS_GLOBAL_OBJECT, ABI_TYPE_CSTRING, &threw, "), Some(("PTR_TO_JSVALUE(", ")"))), + /* I64Fast */ r(b"int64_t", Some("JSVALUE_TO_INT64("), Some(("INT64_TO_JSVALUE(JS_GLOBAL_OBJECT, (int64_t)", ")"))), + /* U64Fast */ r(b"uint64_t", Some("JSVALUE_TO_UINT64("), Some(("UINT64_TO_JSVALUE(JS_GLOBAL_OBJECT, ", ")"))), + /* Function */ r(b"void*", Some("JSVALUE_TO_PTR(JS_GLOBAL_OBJECT, ABI_TYPE_FUNCTION, &threw, "), Some(("PTR_TO_JSVALUE(", ")"))), + /* NapiEnv */ r(b"napi_env", None, None), + /* NapiValue */ r(b"napi_value", None, Some(("((EncodedJSValue) {.asNapiValue = ", " } )"))), + /* Buffer */ r(b"void*", Some("JSVALUE_TO_BUFFER(JS_GLOBAL_OBJECT, &threw, "), None), + /* BufferLen */ r(b"uint64_t", None, None), ] }; @@ -161,6 +161,15 @@ impl ABIType { /// See [`ABI_TYPE_LABEL`]. pub(crate) const LABEL: &'static __ComptimeStringMap_ABI_TYPE_LABEL = &ABI_TYPE_LABEL; + /// Preprocessor definitions every generated wrapper is compiled with: the tags `FFI.h` and the + /// `to_c` conversions in [`ABI_TABLE`] hand to `JSVALUE_TO_PTR_SLOW`. + pub(crate) const POINTER_TAG_DEFINES: &'static [(&'static str, i64)] = &[ + ("ABI_TYPE_PTR", ABIType::Ptr as i64), + ("ABI_TYPE_CSTRING", ABIType::CString as i64), + ("ABI_TYPE_FUNCTION", ABIType::Function as i64), + ("ABI_TYPE_BUFFER", ABIType::Buffer as i64), + ]; + /// Returns `None` for out-of-range discriminants. #[inline] pub(crate) const fn from_int(n: i32) -> Option { @@ -209,6 +218,15 @@ impl ABIType { matches!(self, ABIType::Double | ABIType::Float) } + /// Argument conversions that may throw (see `JSVALUE_TO_PTR_SLOW` in `FFI.h`). The generated + /// wrapper converts these into locals and stops before the native call if one of them threw. + pub(crate) fn arg_conversion_can_throw(self) -> bool { + matches!( + self, + ABIType::Ptr | ABIType::CString | ABIType::Function | ABIType::Buffer + ) + } + pub(crate) fn to_c(self, symbol: &[u8]) -> ToCFormatter<'_> { ToCFormatter { tag: self, symbol } } diff --git a/src/runtime/ffi/ffi_body.rs b/src/runtime/ffi/ffi_body.rs index 679a87b1e36a..76516f12eda8 100644 --- a/src/runtime/ffi/ffi_body.rs +++ b/src/runtime/ffi/ffi_body.rs @@ -151,7 +151,7 @@ fn create_jsc_ffi_function( /// Raw extern fn pointers fed to the TCC-JIT'd C trampolines via `add_symbol`. mod exposed_to_ffi { - use super::{JSGlobalObject, JSValue}; + use super::{JSGlobalObject, JSValue, c_void}; unsafe extern "C" { #[link_name = "JSC__JSValue__toInt64"] pub(super) fn JSVALUE_TO_INT64(value: JSValue) -> i64; @@ -161,6 +161,15 @@ mod exposed_to_ffi { pub(super) fn INT64_TO_JSVALUE(global: *mut JSGlobalObject, i: i64) -> JSValue; #[link_name = "JSC__JSValue__fromUInt64NoTruncate"] pub(super) fn UINT64_TO_JSVALUE(global: *mut JSGlobalObject, i: u64) -> JSValue; + /// `JSCFFIBridge.cpp`: converts a `ptr`/`cstring`/`function`/`buffer` argument the inline + /// fast paths in `FFI.h` don't handle, using the same conversion as dlopen()'d symbols. + #[link_name = "Bun__FFI__jsValueToPointerSlow"] + pub(super) fn JSVALUE_TO_PTR_SLOW( + global: *mut JSGlobalObject, + abi_type: i32, + threw: *mut bool, + value: JSValue, + ) -> *mut c_void; } } @@ -2145,12 +2154,6 @@ impl Function { ZIG_REPR_TYPE JSFunctionCall(void* JS_GLOBAL_OBJECT, void* callFrame) {\n", )?; - if self.needs_handle_scope() { - writer.write_all( - b" void* handleScope = NapiHandleScope__open(&Bun__thisFFIModuleNapiEnv, false);\n", - )?; - } - if !self.arg_types.is_empty() { writer.write_all(b" LOAD_ARGUMENTS_FROM_CALL_FRAME;\n")?; for (i, arg) in self.arg_types.iter().enumerate() { @@ -2195,6 +2198,34 @@ impl Function { // ); let mut arg_buf = [0u8; 512]; + arg_buf[0..3].copy_from_slice(b"arg"); + + // Pointer arguments are converted up front because the conversion can run JS (an object's + // `ptr` getter) and throw; the native function must not be called once something threw. + let mut declared_threw = false; + for (i, arg) in self.arg_types.iter().enumerate() { + if !arg.arg_conversion_can_throw() { + continue; + } + if !declared_threw { + declared_threw = true; + writer.write_all(b" bool threw = false;\n")?; + } + let length_buf = bun_core::fmt::print_int(&mut arg_buf[3..], i); + let arg_name = &arg_buf[0..3 + length_buf]; + write!( + writer, + " void* ptr{} = {};\n if (threw) return ValueEmpty.asZigRepr;\n", + i, + arg.to_c(arg_name) + )?; + } + + if self.needs_handle_scope() { + writer.write_all( + b" void* handleScope = NapiHandleScope__open(&Bun__thisFFIModuleNapiEnv, false);\n", + )?; + } writer.write_all(b" ")?; if self.return_type != ABIType::Void { @@ -2203,7 +2234,6 @@ impl Function { } write!(writer, "{}(", BStr::new(self.base_name.as_bytes()))?; first = true; - arg_buf[0..3].copy_from_slice(b"arg"); for (i, arg) in self.arg_types.iter().enumerate() { if !first { writer.write_all(b", ")?; @@ -2213,7 +2243,9 @@ 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() { + if arg.arg_conversion_can_throw() { + write!(writer, "ptr{}", i)?; + } else if arg.needs_a_cast_in_c() { write!(writer, "{}", arg.to_c(arg_name))?; } else { writer.write_all(arg_name)?; @@ -2565,6 +2597,7 @@ impl CompilerRT { jsc::JSType::MAX_TYPED_ARRAY.0 as i64, ), ]); + state.define_symbols(ABIType::POINTER_TAG_DEFINES); } pub(crate) fn inject(state: &mut TCC::State) { @@ -2621,6 +2654,12 @@ impl CompilerRT { WORKAROUND.uint64_to_jsvalue as *const c_void, ) .expect("unreachable"); + state + .add_symbol( + zstr!("JSVALUE_TO_PTR_SLOW"), + exposed_to_ffi::JSVALUE_TO_PTR_SLOW as *const c_void, + ) + .expect("unreachable"); } } diff --git a/test/js/bun/ffi/cc.test.ts b/test/js/bun/ffi/cc.test.ts index 0726df91e629..bbb4e778a944 100644 --- a/test/js/bun/ffi/cc.test.ts +++ b/test/js/bun/ffi/cc.test.ts @@ -1087,6 +1087,290 @@ describe("double <-> JSValue conversions", () => { }); }); +describe.concurrent("pointer-typed arguments (function, ptr, cstring, buffer)", () => { + // The wrapper cc() compiles used to treat anything that was not a number, a view, or null as a + // double, so a JSCallback object (or any other object) became a garbage pointer and a `function` + // argument called address -1. Everything the inline paths don't handle now goes through the + // engine's conversion, the one dlopen()/CFunction symbols use, so the fixture runs one input + // matrix through a cc() wrapper and through a CFunction over the very same C function and the + // two tables must agree. Runs in a subprocess because the unfixed path is a segfault. + it("accepts what dlopen accepts and throws a TypeError for the rest", async () => { + using dir = tempDir("bun-ffi-cc-pointer-args", { + "pointers.c": /* c */ ` + typedef int (*callback_t)(int); + int call_callback(callback_t callback) { return callback(21) * 2; } + void* echo_ptr(void* p) { return p; } + void* echo_cstring(const char* s) { return (void*)s; } + void* echo_buffer(void* p) { return p; } + + static int native_calls = 0; + int two_pointers(void* a, void* b) { native_calls++; return a == b; } + int get_native_calls(void) { return native_calls; } + + void* address_of_call_callback(void) { return (void*)&call_callback; } + void* address_of_echo_ptr(void) { return (void*)&echo_ptr; } + void* address_of_echo_cstring(void) { return (void*)&echo_cstring; } + void* address_of_echo_buffer(void) { return (void*)&echo_buffer; } + void* address_of_two_pointers(void) { return (void*)&two_pointers; } + `, + "fixture.js": /* js */ ` + import { cc, CFunction, JSCallback, ptr } from "bun:ffi"; + import path from "path"; + + const signatures = { + call_callback: { args: ["function"], returns: "i32" }, + echo_ptr: { args: ["ptr"], returns: "ptr" }, + echo_cstring: { args: ["cstring"], returns: "ptr" }, + echo_buffer: { args: ["buffer"], returns: "ptr" }, + two_pointers: { args: ["ptr", "ptr"], returns: "i32" }, + }; + const { symbols: compiled } = cc({ + source: path.join(import.meta.dir, "pointers.c"), + symbols: { + ...signatures, + get_native_calls: { args: [], returns: "i32" }, + ...Object.fromEntries(Object.keys(signatures).map(name => ["address_of_" + name, { args: [], returns: "ptr" }])), + }, + }); + // The same C functions behind the engine's (dlopen-style) argument conversion. + const engine = Object.fromEntries( + Object.entries(signatures).map(([name, signature]) => [ + name, + new CFunction({ ...signature, ptr: compiled["address_of_" + name]() }), + ]), + ); + + const callback = new JSCallback(x => x + 1, { args: ["i32"], returns: "i32" }); + // A view over an explicit ArrayBuffer keeps one stable address shared by the view, the + // buffer, and a DataView over it. + const buffer = new ArrayBuffer(8); + const view = new Uint8Array(buffer); + const address = ptr(view); + + const inputs = { + call_callback: { + jscallback: callback, + jscallback_ptr: callback.ptr, + object_with_ptr: { ptr: callback.ptr }, + null: null, + undefined: undefined, + plain_function: () => 1, + plain_object: {}, + string: "callback", + }, + echo_ptr: { + number: address, + view, + array_buffer: buffer, + bigint: BigInt(address), + object_with_ptr: { ptr: address }, + jscallback: callback, + null: null, + undefined: undefined, + plain_object: {}, + string: "hello", + boolean: true, + }, + echo_cstring: { + number: address, + view, + null: null, + plain_object: {}, + string: "hello", + }, + echo_buffer: { + view, + data_view: new DataView(buffer), + array_buffer: buffer, + number: address, + null: null, + plain_object: {}, + }, + }; + + function outcome(fn, input) { + try { + const result = fn(input); + if (result === address) return "address"; + if (result === callback.ptr) return "callback.ptr"; + return result; + } catch (error) { + return error.name + ": " + error.message; + } + } + + function run(symbols) { + const table = {}; + for (const name in inputs) { + table[name] = {}; + for (const label in inputs[name]) table[name][label] = outcome(symbols[name], inputs[name][label]); + } + table.two_pointers = { + both_valid: outcome(x => symbols.two_pointers(x, view), address), + second_invalid: outcome(x => symbols.two_pointers(x, {}), address), + throwing_ptr_getter: outcome( + x => symbols.two_pointers(x, { get ptr() { throw new Error("from the ptr getter"); } }), + address, + ), + native_calls: compiled.get_native_calls(), + }; + return table; + } + + const results = { cc: run(compiled) }; + results.engine = run(engine); + callback.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 cannotConvert = (type: string) => `TypeError: bun:ffi cannot convert argument to '${type}'`; + const noCallback = + "TypeError: bun:ffi: expected a callback (a JSCallback or an FFI function) but got undefined/null"; + const stringIsNotAPointer = "TypeError: To convert a string to a pointer, encode it as a buffer"; + const bufferNeedsAView = "TypeError: bun:ffi 'buffer' argument must be a TypedArray or DataView"; + const expected = { + call_callback: { + jscallback: 44, + jscallback_ptr: 44, + object_with_ptr: 44, + null: noCallback, + undefined: noCallback, + plain_function: cannotConvert("function"), + plain_object: cannotConvert("function"), + string: stringIsNotAPointer, + }, + echo_ptr: { + number: "address", + view: "address", + array_buffer: "address", + bigint: "address", + object_with_ptr: "address", + jscallback: "callback.ptr", + null: null, + undefined: null, + plain_object: cannotConvert("ptr"), + string: stringIsNotAPointer, + boolean: cannotConvert("ptr"), + }, + echo_cstring: { + number: "address", + view: "address", + null: null, + plain_object: cannotConvert("cstring"), + // Only the engine path transcodes JS strings (it owns an arena to free the copy after the + // call); cc() has nowhere to free it, so it refuses the string instead of passing garbage. + string: expect.any(Number), + }, + echo_buffer: { + view: "address", + data_view: "address", + array_buffer: bufferNeedsAView, + number: bufferNeedsAView, + null: bufferNeedsAView, + plain_object: bufferNeedsAView, + }, + two_pointers: { + both_valid: 1, + second_invalid: cannotConvert("ptr"), + throwing_ptr_getter: "Error: from the ptr getter", + // Only both_valid reached C: a failed conversion stops the call before it happens. The + // engine table runs second, so it sees its own call on top of the cc() one. + native_calls: 1, + }, + }; + + // On a crash there is no JSON; report the process output instead so the failure shows it. + const results = stdout.startsWith("{") ? JSON.parse(stdout) : { stdout, stderr }; + expect({ results, exitCode }).toEqual({ + results: { + cc: { + ...expected, + echo_cstring: { + ...expected.echo_cstring, + string: + "TypeError: bun:ffi: a JavaScript string is not valid here; return it from a 'cstring'-returning callback, or pass a pointer/TypedArray", + }, + }, + engine: { + ...expected, + two_pointers: { ...expected.two_pointers, native_calls: 2 }, + }, + }, + exitCode: 0, + }); + }); + + // napi_env wrappers open a handle scope around the native call; a rejected pointer argument + // has to bail out before that happens and leave later calls working. cc()-compiled C only + // exercises napi on POSIX today (see the napi_create_double test above). + it.skipIf(isWindows)("a rejected pointer argument bails out of a napi_env wrapper too", async () => { + using dir = tempDir("bun-ffi-cc-pointer-args-napi", { + "napi_ptr.c": /* c */ ` + typedef struct napi_env_fake* napi_env_t; + static int native_calls = 0; + /* bit 0: the trampoline filled in env, bit 1: p is non-null */ + int with_env(napi_env_t env, void* p) { native_calls++; return (env != 0 ? 1 : 0) | (p != 0 ? 2 : 0); } + int get_native_calls(void) { return native_calls; } + `, + "fixture.js": /* js */ ` + import { cc } from "bun:ffi"; + import path from "path"; + + const { symbols } = cc({ + source: path.join(import.meta.dir, "napi_ptr.c"), + symbols: { + with_env: { args: ["napi_env", "ptr"], returns: "i32" }, + get_native_calls: { args: [], returns: "i32" }, + }, + }); + + const results = {}; + try { + results.rejected = symbols.with_env(undefined, {}); + } catch (error) { + results.rejected = error.name + ": " + error.message; + } + results.calls_after_rejection = symbols.get_native_calls(); + results.view = symbols.with_env(undefined, new Uint8Array(4)); + results.null = symbols.with_env(undefined, null); + results.calls_at_end = symbols.get_native_calls(); + 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, stderr }; + expect({ results, exitCode }).toEqual({ + results: { + rejected: "TypeError: bun:ffi cannot convert argument to 'ptr'", + calls_after_rejection: 0, + view: 3, + null: 1, + calls_at_end: 2, + }, + exitCode: 0, + }); + }); +}); + describe.skipIf(isASAN)("compiler runtime header directory under BUN_TMPDIR", () => { const plantedHeader = "#define bool int\n#define true 100\n#define false 0\n"; const files = { diff --git a/test/js/bun/ffi/ffi.test.fixture.receiver.c b/test/js/bun/ffi/ffi.test.fixture.receiver.c index 03c89180e442..d3337561625b 100644 --- a/test/js/bun/ffi/ffi.test.fixture.receiver.c +++ b/test/js/bun/ffi/ffi.test.fixture.receiver.c @@ -127,6 +127,8 @@ napi_value asNapiValue; EncodedJSValue ValueUndefined = { TagValueUndefined }; EncodedJSValue ValueTrue = { TagValueTrue }; +// What a host function returns after throwing; JSC unwinds to the pending exception and ignores it. +EncodedJSValue ValueEmpty = { 0 }; typedef void* JSContext; @@ -161,7 +163,13 @@ static EncodedJSValue FLOAT_TO_JSVALUE(float val) __attribute__((__always_inline static EncodedJSValue BOOLEAN_TO_JSVALUE(bool val) __attribute__((__always_inline__)); static EncodedJSValue PTR_TO_JSVALUE(void* ptr) __attribute__((__always_inline__)); -static void* JSVALUE_TO_PTR(EncodedJSValue val) __attribute__((__always_inline__)); +// The engine's pointer-argument conversion, shared with dlopen()'d symbols. `abiType` is one of +// the ABI_TYPE_* tags the runtime defines when it compiles this file. Throws a TypeError for values +// that cannot become a pointer and sets `*threw`, in which case the generated wrapper returns +// without calling the native function. +void* JSVALUE_TO_PTR_SLOW(void* jsGlobalObject, int32_t abiType, bool* threw, int64_t val); +static void* JSVALUE_TO_PTR(void* jsGlobalObject, int32_t abiType, bool* threw, EncodedJSValue val) __attribute__((__always_inline__)); +static void* JSVALUE_TO_BUFFER(void* jsGlobalObject, bool* threw, EncodedJSValue val) __attribute__((__always_inline__)); static int32_t JSVALUE_TO_INT32(EncodedJSValue val) __attribute__((__always_inline__)); static float JSVALUE_TO_FLOAT(EncodedJSValue val) __attribute__((__always_inline__)); static double JSVALUE_TO_DOUBLE(EncodedJSValue val) __attribute__((__always_inline__)); @@ -209,21 +217,35 @@ static uint64_t JSVALUE_TO_TYPED_ARRAY_LENGTH(EncodedJSValue val) { // Now, they're stored at the beginning of the 64-bit value // 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) - return 0; +static void* JSVALUE_TO_PTR(void* jsGlobalObject, int32_t abiType, bool* threw, EncodedJSValue val) { + if (JSVALUE_IS_INT32(val)) { + return (void*)(uintptr_t)JSVALUE_TO_INT32(val); + } + + if (JSVALUE_IS_NUMBER(val)) { + val.asInt64 -= DoubleEncodeOffset; + return (void*)(uintptr_t)val.asDouble; + } if (JSCELL_IS_TYPED_ARRAY(val)) { return JSVALUE_TO_TYPED_ARRAY_VECTOR(val); } - if (JSVALUE_IS_INT32(val)) { - return (void*)(uintptr_t)JSVALUE_TO_INT32(val); + // A null callback is a TypeError (decided by the slow path), like it is for dlopen(). + if (val.asInt64 == TagValueNull && abiType != ABI_TYPE_FUNCTION) + return 0; + + // JSCallback, ArrayBuffer, BigInt, objects with a numeric `ptr`, undefined, strings, ... + return JSVALUE_TO_PTR_SLOW(jsGlobalObject, abiType, threw, val.asInt64); +} + +static void* JSVALUE_TO_BUFFER(void* jsGlobalObject, bool* threw, EncodedJSValue val) { + if (JSCELL_IS_TYPED_ARRAY(val)) { + return JSVALUE_TO_TYPED_ARRAY_VECTOR(val); } - // Assume the JSValue is a double - val.asInt64 -= DoubleEncodeOffset; - return (void*)(uintptr_t)val.asDouble; + // Only views are accepted; the slow path throws for everything else. + return JSVALUE_TO_PTR_SLOW(jsGlobalObject, ABI_TYPE_BUFFER, threw, val.asInt64); } static EncodedJSValue PTR_TO_JSVALUE(void* ptr) { From a8ce742303b6e142bceecd2f739522df677cdb16 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 13 Aug 2026 07:44:27 +0000 Subject: [PATCH 2/5] test(ffi): pin the cc() wrapper shape and per-argument conversion order --- test/js/bun/ffi/cc.test.ts | 85 ++++++++++++++++++++++++++++++++------ 1 file changed, 73 insertions(+), 12 deletions(-) diff --git a/test/js/bun/ffi/cc.test.ts b/test/js/bun/ffi/cc.test.ts index bbb4e778a944..1b8957039686 100644 --- a/test/js/bun/ffi/cc.test.ts +++ b/test/js/bun/ffi/cc.test.ts @@ -1,4 +1,4 @@ -import { cc, CString, JSCallback, ptr, type FFIFunction, type Library } from "bun:ffi"; +import { cc, CString, JSCallback, ptr, viewSource, type FFIFunction, type Library } from "bun:ffi"; import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import { chmodSync, @@ -1094,6 +1094,51 @@ describe.concurrent("pointer-typed arguments (function, ptr, cstring, buffer)", // engine's conversion, the one dlopen()/CFunction symbols use, so the fixture runs one input // matrix through a cc() wrapper and through a CFunction over the very same C function and the // two tables must agree. Runs in a subprocess because the unfixed path is a segfault. + // + // The wrapper shape itself: pointer-typed arguments are converted into locals, tagged with + // their type for the engine, and each conversion is followed by a bail-out, since it can run JS + // (a `ptr` getter) and throw. Other arguments are still converted inline in the call, and a + // napi handle scope is only opened once every conversion has succeeded, so bailing out never + // leaves one open. + it("converts pointer-typed arguments before the call and bails out if one threw", () => { + const [source] = viewSource({ + f: { args: ["napi_env", "function", "f64", "cstring", "buffer", "ptr"], returns: "i32" }, + }); + expect(source.slice(source.indexOf("/* --- The Function To Call */"))).toMatchInlineSnapshot(` + "/* --- The Function To Call */ + int32_t f(napi_env arg0, void* arg1, double arg2, void* arg3, void* arg4, void* arg5); + + /* ---- Your Wrapper Function ---- */ + ZIG_REPR_TYPE JSFunctionCall(void* JS_GLOBAL_OBJECT, void* callFrame) { + LOAD_ARGUMENTS_FROM_CALL_FRAME; + napi_env arg0 = (napi_env)&Bun__thisFFIModuleNapiEnv; + argsPtr++; + EncodedJSValue arg1 = { .asInt64 = *argsPtr++ }; + EncodedJSValue arg2 = { .asInt64 = *argsPtr++ }; + EncodedJSValue arg3 = { .asInt64 = *argsPtr++ }; + EncodedJSValue arg4 = { .asInt64 = *argsPtr++ }; + EncodedJSValue arg5; + arg5.asInt64 = *argsPtr; + bool threw = false; + void* ptr1 = JSVALUE_TO_PTR(JS_GLOBAL_OBJECT, ABI_TYPE_FUNCTION, &threw, arg1); + if (threw) return ValueEmpty.asZigRepr; + void* ptr3 = JSVALUE_TO_PTR(JS_GLOBAL_OBJECT, ABI_TYPE_CSTRING, &threw, arg3); + if (threw) return ValueEmpty.asZigRepr; + void* ptr4 = JSVALUE_TO_BUFFER(JS_GLOBAL_OBJECT, &threw, arg4); + if (threw) return ValueEmpty.asZigRepr; + void* ptr5 = JSVALUE_TO_PTR(JS_GLOBAL_OBJECT, ABI_TYPE_PTR, &threw, arg5); + if (threw) return ValueEmpty.asZigRepr; + void* handleScope = NapiHandleScope__open(&Bun__thisFFIModuleNapiEnv, false); + int32_t return_value = f( ((napi_env)&Bun__thisFFIModuleNapiEnv), ptr1, JSVALUE_TO_DOUBLE(arg2), ptr3, ptr4, ptr5); + + NapiHandleScope__close(&Bun__thisFFIModuleNapiEnv, handleScope); + return INT32_TO_JSVALUE((int32_t)return_value).asZigRepr; + } + + " + `); + }); + it("accepts what dlopen accepts and throws a TypeError for the rest", async () => { using dir = tempDir("bun-ffi-cc-pointer-args", { "pointers.c": /* c */ ` @@ -1205,13 +1250,21 @@ describe.concurrent("pointer-typed arguments (function, ptr, cstring, buffer)", table[name] = {}; for (const label in inputs[name]) table[name][label] = outcome(symbols[name], inputs[name][label]); } + + // Every read of .ptr is one conversion of this argument. + let ptrReads = 0; + const counted = { get ptr() { ptrReads++; return address; } }; + const { two_pointers } = symbols; table.two_pointers = { - both_valid: outcome(x => symbols.two_pointers(x, view), address), - second_invalid: outcome(x => symbols.two_pointers(x, {}), address), - throwing_ptr_getter: outcome( - x => symbols.two_pointers(x, { get ptr() { throw new Error("from the ptr getter"); } }), - address, + both_valid: outcome(() => two_pointers(address, view)), + getter_as_first: outcome(() => two_pointers(counted, view)), + getter_reads_so_far: ptrReads, + second_invalid: outcome(() => two_pointers(address, {})), + throwing_getter_as_second: outcome(() => + two_pointers(address, { get ptr() { throw new Error("from the ptr getter"); } }), ), + first_invalid_with_getter_as_second: outcome(() => two_pointers({}, counted)), + getter_reads_at_end: ptrReads, native_calls: compiled.get_native_calls(), }; return table; @@ -1281,11 +1334,17 @@ describe.concurrent("pointer-typed arguments (function, ptr, cstring, buffer)", }, two_pointers: { both_valid: 1, + getter_as_first: 1, + // Each argument is converted exactly once per call. + getter_reads_so_far: 1, second_invalid: cannotConvert("ptr"), - throwing_ptr_getter: "Error: from the ptr getter", - // Only both_valid reached C: a failed conversion stops the call before it happens. The - // engine table runs second, so it sees its own call on top of the cc() one. - native_calls: 1, + throwing_getter_as_second: "Error: from the ptr getter", + // Conversion stops at the first argument that fails: the getter behind it never runs. + first_invalid_with_getter_as_second: cannotConvert("ptr"), + getter_reads_at_end: 1, + // Only the two valid calls reached C. The engine table runs second, so its count includes + // the cc() table's calls. + native_calls: 2, }, }; @@ -1303,7 +1362,7 @@ describe.concurrent("pointer-typed arguments (function, ptr, cstring, buffer)", }, engine: { ...expected, - two_pointers: { ...expected.two_pointers, native_calls: 2 }, + two_pointers: { ...expected.two_pointers, native_calls: 4 }, }, }, exitCode: 0, @@ -1342,6 +1401,7 @@ describe.concurrent("pointer-typed arguments (function, ptr, cstring, buffer)", } results.calls_after_rejection = symbols.get_native_calls(); results.view = symbols.with_env(undefined, new Uint8Array(4)); + results.object_with_ptr = symbols.with_env(undefined, { ptr: 8 }); results.null = symbols.with_env(undefined, null); results.calls_at_end = symbols.get_native_calls(); console.log(JSON.stringify(results)); @@ -1363,8 +1423,9 @@ describe.concurrent("pointer-typed arguments (function, ptr, cstring, buffer)", rejected: "TypeError: bun:ffi cannot convert argument to 'ptr'", calls_after_rejection: 0, view: 3, + object_with_ptr: 3, null: 1, - calls_at_end: 2, + calls_at_end: 3, }, exitCode: 0, }); From a6a512b20a64ca787a8170c2ff78cc9d59e91947 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 13 Aug 2026 08:00:46 +0000 Subject: [PATCH 3/5] ffi: shorten the comments on the cc() pointer conversion helpers --- src/jsc/bindings/JSCFFIBridge.cpp | 9 +++------ src/runtime/ffi/FFI.h | 5 +---- src/runtime/ffi/abi_type.rs | 6 ++---- src/runtime/ffi/ffi_body.rs | 6 ++---- test/js/bun/ffi/ffi.test.fixture.receiver.c | 5 +---- 5 files changed, 9 insertions(+), 22 deletions(-) diff --git a/src/jsc/bindings/JSCFFIBridge.cpp b/src/jsc/bindings/JSCFFIBridge.cpp index 28a4d2cac07a..ace14be6d646 100644 --- a/src/jsc/bindings/JSCFFIBridge.cpp +++ b/src/jsc/bindings/JSCFFIBridge.cpp @@ -123,18 +123,15 @@ extern "C" void Bun__JSCFFICallbackClose(JSC::EncodedJSValue callbackValue) callback->close(); } -// JSVALUE_TO_PTR_SLOW for the wrappers cc() compiles (src/runtime/ffi/FFI.h). The wrapper converts -// numbers, typed arrays and null inline; everything else (JSCallback, ArrayBuffer, BigInt, an object -// with a numeric `ptr`, junk) gets the same conversion dlopen()'d symbols get, TypeErrors included. -// No string arena is passed: nothing would free a transcoded `cstring` after the native call, so a -// JavaScript string throws here instead. +// JSVALUE_TO_PTR_SLOW in the wrappers cc() compiles (src/runtime/ffi/FFI.h). extern "C" void* Bun__FFI__jsValueToPointerSlow(JSC::JSGlobalObject* globalObject, int32_t abiType, bool* threw, JSC::EncodedJSValue encodedValue) { auto& vm = JSC::getVM(globalObject); auto scope = DECLARE_THROW_SCOPE(vm); uint64_t slot = 0; - JSC::FFI::writeSlotFromJSValue(globalObject, globalObject->ffiContext(), static_cast(abiType), JSC::JSValue::decode(encodedValue), slot, nullptr); + // No string arena: nothing would free a transcoded cstring after the native call, so JS strings throw. + JSC::FFI::writeSlotFromJSValue(globalObject, globalObject->ffiContext(), static_cast(abiType), JSC::JSValue::decode(encodedValue), slot, /* arena */ nullptr); if (scope.exception()) [[unlikely]] { *threw = true; return nullptr; diff --git a/src/runtime/ffi/FFI.h b/src/runtime/ffi/FFI.h index 0c33c79a9d96..d2d988b820b5 100644 --- a/src/runtime/ffi/FFI.h +++ b/src/runtime/ffi/FFI.h @@ -161,10 +161,7 @@ static EncodedJSValue FLOAT_TO_JSVALUE(float val) __attribute__((__always_inline static EncodedJSValue BOOLEAN_TO_JSVALUE(bool val) __attribute__((__always_inline__)); static EncodedJSValue PTR_TO_JSVALUE(void* ptr) __attribute__((__always_inline__)); -// The engine's pointer-argument conversion, shared with dlopen()'d symbols. `abiType` is one of -// the ABI_TYPE_* tags the runtime defines when it compiles this file. Throws a TypeError for values -// that cannot become a pointer and sets `*threw`, in which case the generated wrapper returns -// without calling the native function. +// The engine's conversion (the one dlopen()'d symbols use); on a non-pointer it throws and sets *threw. void* JSVALUE_TO_PTR_SLOW(void* jsGlobalObject, int32_t abiType, bool* threw, int64_t val); static void* JSVALUE_TO_PTR(void* jsGlobalObject, int32_t abiType, bool* threw, EncodedJSValue val) __attribute__((__always_inline__)); static void* JSVALUE_TO_BUFFER(void* jsGlobalObject, bool* threw, EncodedJSValue val) __attribute__((__always_inline__)); diff --git a/src/runtime/ffi/abi_type.rs b/src/runtime/ffi/abi_type.rs index 32a8e3e90b88..499a37c22659 100644 --- a/src/runtime/ffi/abi_type.rs +++ b/src/runtime/ffi/abi_type.rs @@ -161,8 +161,7 @@ impl ABIType { /// See [`ABI_TYPE_LABEL`]. pub(crate) const LABEL: &'static __ComptimeStringMap_ABI_TYPE_LABEL = &ABI_TYPE_LABEL; - /// Preprocessor definitions every generated wrapper is compiled with: the tags `FFI.h` and the - /// `to_c` conversions in [`ABI_TABLE`] hand to `JSVALUE_TO_PTR_SLOW`. + /// `#define`d into every generated wrapper; `FFI.h` passes them to `JSVALUE_TO_PTR_SLOW`. pub(crate) const POINTER_TAG_DEFINES: &'static [(&'static str, i64)] = &[ ("ABI_TYPE_PTR", ABIType::Ptr as i64), ("ABI_TYPE_CSTRING", ABIType::CString as i64), @@ -218,8 +217,7 @@ impl ABIType { matches!(self, ABIType::Double | ABIType::Float) } - /// Argument conversions that may throw (see `JSVALUE_TO_PTR_SLOW` in `FFI.h`). The generated - /// wrapper converts these into locals and stops before the native call if one of them threw. + /// Argument conversions that go through `JSVALUE_TO_PTR_SLOW` (`FFI.h`) and so can throw. pub(crate) fn arg_conversion_can_throw(self) -> bool { matches!( self, diff --git a/src/runtime/ffi/ffi_body.rs b/src/runtime/ffi/ffi_body.rs index 76516f12eda8..2632a9d539df 100644 --- a/src/runtime/ffi/ffi_body.rs +++ b/src/runtime/ffi/ffi_body.rs @@ -161,8 +161,7 @@ mod exposed_to_ffi { pub(super) fn INT64_TO_JSVALUE(global: *mut JSGlobalObject, i: i64) -> JSValue; #[link_name = "JSC__JSValue__fromUInt64NoTruncate"] pub(super) fn UINT64_TO_JSVALUE(global: *mut JSGlobalObject, i: u64) -> JSValue; - /// `JSCFFIBridge.cpp`: converts a `ptr`/`cstring`/`function`/`buffer` argument the inline - /// fast paths in `FFI.h` don't handle, using the same conversion as dlopen()'d symbols. + /// Slow path of `JSVALUE_TO_PTR`/`JSVALUE_TO_BUFFER` (`FFI.h`); defined in `JSCFFIBridge.cpp`. #[link_name = "Bun__FFI__jsValueToPointerSlow"] pub(super) fn JSVALUE_TO_PTR_SLOW( global: *mut JSGlobalObject, @@ -2200,8 +2199,7 @@ impl Function { let mut arg_buf = [0u8; 512]; arg_buf[0..3].copy_from_slice(b"arg"); - // Pointer arguments are converted up front because the conversion can run JS (an object's - // `ptr` getter) and throw; the native function must not be called once something threw. + // Pointer conversions can run JS (a `ptr` getter) and throw, so they come before the call. let mut declared_threw = false; for (i, arg) in self.arg_types.iter().enumerate() { if !arg.arg_conversion_can_throw() { diff --git a/test/js/bun/ffi/ffi.test.fixture.receiver.c b/test/js/bun/ffi/ffi.test.fixture.receiver.c index d3337561625b..ae75c320cfac 100644 --- a/test/js/bun/ffi/ffi.test.fixture.receiver.c +++ b/test/js/bun/ffi/ffi.test.fixture.receiver.c @@ -163,10 +163,7 @@ static EncodedJSValue FLOAT_TO_JSVALUE(float val) __attribute__((__always_inline static EncodedJSValue BOOLEAN_TO_JSVALUE(bool val) __attribute__((__always_inline__)); static EncodedJSValue PTR_TO_JSVALUE(void* ptr) __attribute__((__always_inline__)); -// The engine's pointer-argument conversion, shared with dlopen()'d symbols. `abiType` is one of -// the ABI_TYPE_* tags the runtime defines when it compiles this file. Throws a TypeError for values -// that cannot become a pointer and sets `*threw`, in which case the generated wrapper returns -// without calling the native function. +// The engine's conversion (the one dlopen()'d symbols use); on a non-pointer it throws and sets *threw. void* JSVALUE_TO_PTR_SLOW(void* jsGlobalObject, int32_t abiType, bool* threw, int64_t val); static void* JSVALUE_TO_PTR(void* jsGlobalObject, int32_t abiType, bool* threw, EncodedJSValue val) __attribute__((__always_inline__)); static void* JSVALUE_TO_BUFFER(void* jsGlobalObject, bool* threw, EncodedJSValue val) __attribute__((__always_inline__)); From 06b076a36398029c5b7505dec8482a71dfff3630 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 13 Aug 2026 08:37:44 +0000 Subject: [PATCH 4/5] ffi: define the ABI_TYPE_* tags only for the generated wrapper, not the user's C --- src/runtime/ffi/ffi_body.rs | 3 ++- test/js/bun/ffi/cc.test.ts | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/runtime/ffi/ffi_body.rs b/src/runtime/ffi/ffi_body.rs index 2632a9d539df..9b7f554484f1 100644 --- a/src/runtime/ffi/ffi_body.rs +++ b/src/runtime/ffi/ffi_body.rs @@ -2070,6 +2070,8 @@ impl Function { } CompilerRT::define(state); + // Only the wrapper uses these; `CompilerRT::define` is also run for the user's own C. + state.define_symbols(ABIType::POINTER_TAG_DEFINES); // SAFETY: source_code was NUL-terminated above if state @@ -2595,7 +2597,6 @@ impl CompilerRT { jsc::JSType::MAX_TYPED_ARRAY.0 as i64, ), ]); - state.define_symbols(ABIType::POINTER_TAG_DEFINES); } pub(crate) fn inject(state: &mut TCC::State) { diff --git a/test/js/bun/ffi/cc.test.ts b/test/js/bun/ffi/cc.test.ts index 1b8957039686..bfe55acd0093 100644 --- a/test/js/bun/ffi/cc.test.ts +++ b/test/js/bun/ffi/cc.test.ts @@ -1142,6 +1142,9 @@ describe.concurrent("pointer-typed arguments (function, ptr, cstring, buffer)", it("accepts what dlopen accepts and throws a TypeError for the rest", async () => { using dir = tempDir("bun-ffi-cc-pointer-args", { "pointers.c": /* c */ ` + /* The tags the generated wrappers are compiled with must not be defined in the user's C. */ + enum { ABI_TYPE_PTR, ABI_TYPE_CSTRING, ABI_TYPE_FUNCTION, ABI_TYPE_BUFFER }; + typedef int (*callback_t)(int); int call_callback(callback_t callback) { return callback(21) * 2; } void* echo_ptr(void* p) { return p; } From 366b8333de23f6998620db951b689d889a3665f7 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:55:26 +0000 Subject: [PATCH 5/5] ffi: spell out the pointer fast paths in JSVALUE_TO_PTR instead of calling helpers TinyCC ignores always_inline, so every JSVALUE_IS_* / JSCELL_IS_TYPED_ARRAY helper used by JSVALUE_TO_PTR was a real call. Test the tag bits directly in the one function, fold the buffer variant into it, and handle undefined like null for ptr/cstring inline (the engine's isUndefinedOrNull test) so only values the inline code cannot convert leave the wrapper. Accepted inputs now cost the JSVALUE_TO_PTR call alone. The double path converts through int64 like the engine's doubleToInt64 instead of an unsigned conversion. --- src/runtime/ffi/FFI.h | 48 +++++++++------------ src/runtime/ffi/abi_type.rs | 2 +- src/runtime/ffi/ffi_body.rs | 2 +- test/js/bun/ffi/cc.test.ts | 6 ++- test/js/bun/ffi/ffi.test.fixture.receiver.c | 48 +++++++++------------ 5 files changed, 49 insertions(+), 57 deletions(-) diff --git a/src/runtime/ffi/FFI.h b/src/runtime/ffi/FFI.h index d2d988b820b5..bb137b6c96f9 100644 --- a/src/runtime/ffi/FFI.h +++ b/src/runtime/ffi/FFI.h @@ -164,7 +164,6 @@ static EncodedJSValue PTR_TO_JSVALUE(void* ptr) __attribute__((__always_inline__ // The engine's conversion (the one dlopen()'d symbols use); on a non-pointer it throws and sets *threw. void* JSVALUE_TO_PTR_SLOW(void* jsGlobalObject, int32_t abiType, bool* threw, int64_t val); static void* JSVALUE_TO_PTR(void* jsGlobalObject, int32_t abiType, bool* threw, EncodedJSValue val) __attribute__((__always_inline__)); -static void* JSVALUE_TO_BUFFER(void* jsGlobalObject, bool* threw, EncodedJSValue val) __attribute__((__always_inline__)); static int32_t JSVALUE_TO_INT32(EncodedJSValue val) __attribute__((__always_inline__)); static float JSVALUE_TO_FLOAT(EncodedJSValue val) __attribute__((__always_inline__)); static double JSVALUE_TO_DOUBLE(EncodedJSValue val) __attribute__((__always_inline__)); @@ -213,34 +212,29 @@ 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(void* jsGlobalObject, int32_t abiType, bool* threw, EncodedJSValue val) { - if (JSVALUE_IS_INT32(val)) { - return (void*)(uintptr_t)JSVALUE_TO_INT32(val); - } - - if (JSVALUE_IS_NUMBER(val)) { - val.asInt64 -= DoubleEncodeOffset; - return (void*)(uintptr_t)val.asDouble; - } - - if (JSCELL_IS_TYPED_ARRAY(val)) { - return JSVALUE_TO_TYPED_ARRAY_VECTOR(val); - } - - // A null callback is a TypeError (decided by the slow path), like it is for dlopen(). - if (val.asInt64 == TagValueNull && abiType != ABI_TYPE_FUNCTION) - return 0; - - // JSCallback, ArrayBuffer, BigInt, objects with a numeric `ptr`, undefined, strings, ... - return JSVALUE_TO_PTR_SLOW(jsGlobalObject, abiType, threw, val.asInt64); -} - -static void* JSVALUE_TO_BUFFER(void* jsGlobalObject, bool* threw, EncodedJSValue val) { - if (JSCELL_IS_TYPED_ARRAY(val)) { - return JSVALUE_TO_TYPED_ARRAY_VECTOR(val); + // TinyCC does not inline, so the tag tests are spelled out instead of calling the helpers above. + int64_t bits = val.asInt64; + + if (!(bits & NotCellMask)) { + uint8_t type = *(uint8_t*)((char*)val.asPtr + JSCell__offsetOfType); + if (type >= JSTypeArrayBufferViewMin && type <= JSTypeArrayBufferViewMax) + return *(void**)((char*)val.asPtr + JSArrayBufferView__offsetOfVector); + } else if (abiType != ABI_TYPE_BUFFER) { + if ((bits & NumberTag) == NumberTag) + return (void*)(uintptr_t)(int32_t)bits; + + if (bits & NumberTag) { + val.asInt64 = bits - DoubleEncodeOffset; + return (void*)(uintptr_t)(int64_t)val.asDouble; + } + + // null and undefined are NULL, except as a callback, where the slow path throws like dlopen() does. + if ((bits & ~UndefinedTag) == TagValueNull && abiType != ABI_TYPE_FUNCTION) + return 0; } - // Only views are accepted; the slow path throws for everything else. - return JSVALUE_TO_PTR_SLOW(jsGlobalObject, ABI_TYPE_BUFFER, threw, val.asInt64); + // Other cells (JSCallback, ArrayBuffer, BigInt, objects with a `ptr`, strings), non-views for 'buffer', junk. + return JSVALUE_TO_PTR_SLOW(jsGlobalObject, abiType, threw, bits); } static EncodedJSValue PTR_TO_JSVALUE(void* ptr) { diff --git a/src/runtime/ffi/abi_type.rs b/src/runtime/ffi/abi_type.rs index 499a37c22659..db525bcbd14a 100644 --- a/src/runtime/ffi/abi_type.rs +++ b/src/runtime/ffi/abi_type.rs @@ -143,7 +143,7 @@ static ABI_TABLE: [AbiRow; 22] = { /* Function */ r(b"void*", Some("JSVALUE_TO_PTR(JS_GLOBAL_OBJECT, ABI_TYPE_FUNCTION, &threw, "), Some(("PTR_TO_JSVALUE(", ")"))), /* NapiEnv */ r(b"napi_env", None, None), /* NapiValue */ r(b"napi_value", None, Some(("((EncodedJSValue) {.asNapiValue = ", " } )"))), - /* Buffer */ r(b"void*", Some("JSVALUE_TO_BUFFER(JS_GLOBAL_OBJECT, &threw, "), None), + /* Buffer */ r(b"void*", Some("JSVALUE_TO_PTR(JS_GLOBAL_OBJECT, ABI_TYPE_BUFFER, &threw, "), None), /* BufferLen */ r(b"uint64_t", None, None), ] }; diff --git a/src/runtime/ffi/ffi_body.rs b/src/runtime/ffi/ffi_body.rs index 9b7f554484f1..7cea72ffa7b9 100644 --- a/src/runtime/ffi/ffi_body.rs +++ b/src/runtime/ffi/ffi_body.rs @@ -161,7 +161,7 @@ mod exposed_to_ffi { pub(super) fn INT64_TO_JSVALUE(global: *mut JSGlobalObject, i: i64) -> JSValue; #[link_name = "JSC__JSValue__fromUInt64NoTruncate"] pub(super) fn UINT64_TO_JSVALUE(global: *mut JSGlobalObject, i: u64) -> JSValue; - /// Slow path of `JSVALUE_TO_PTR`/`JSVALUE_TO_BUFFER` (`FFI.h`); defined in `JSCFFIBridge.cpp`. + /// Slow path of `JSVALUE_TO_PTR` in `FFI.h`; defined in `JSCFFIBridge.cpp`. #[link_name = "Bun__FFI__jsValueToPointerSlow"] pub(super) fn JSVALUE_TO_PTR_SLOW( global: *mut JSGlobalObject, diff --git a/test/js/bun/ffi/cc.test.ts b/test/js/bun/ffi/cc.test.ts index bfe55acd0093..1af949134f55 100644 --- a/test/js/bun/ffi/cc.test.ts +++ b/test/js/bun/ffi/cc.test.ts @@ -1124,7 +1124,7 @@ describe.concurrent("pointer-typed arguments (function, ptr, cstring, buffer)", if (threw) return ValueEmpty.asZigRepr; void* ptr3 = JSVALUE_TO_PTR(JS_GLOBAL_OBJECT, ABI_TYPE_CSTRING, &threw, arg3); if (threw) return ValueEmpty.asZigRepr; - void* ptr4 = JSVALUE_TO_BUFFER(JS_GLOBAL_OBJECT, &threw, arg4); + void* ptr4 = JSVALUE_TO_PTR(JS_GLOBAL_OBJECT, ABI_TYPE_BUFFER, &threw, arg4); if (threw) return ValueEmpty.asZigRepr; void* ptr5 = JSVALUE_TO_PTR(JS_GLOBAL_OBJECT, ABI_TYPE_PTR, &threw, arg5); if (threw) return ValueEmpty.asZigRepr; @@ -1208,6 +1208,7 @@ describe.concurrent("pointer-typed arguments (function, ptr, cstring, buffer)", }, echo_ptr: { number: address, + small_number: 8, view, array_buffer: buffer, bigint: BigInt(address), @@ -1223,6 +1224,7 @@ describe.concurrent("pointer-typed arguments (function, ptr, cstring, buffer)", number: address, view, null: null, + undefined: undefined, plain_object: {}, string: "hello", }, @@ -1307,6 +1309,7 @@ describe.concurrent("pointer-typed arguments (function, ptr, cstring, buffer)", }, echo_ptr: { number: "address", + small_number: 8, view: "address", array_buffer: "address", bigint: "address", @@ -1322,6 +1325,7 @@ describe.concurrent("pointer-typed arguments (function, ptr, cstring, buffer)", number: "address", view: "address", null: null, + undefined: null, plain_object: cannotConvert("cstring"), // Only the engine path transcodes JS strings (it owns an arena to free the copy after the // call); cc() has nowhere to free it, so it refuses the string instead of passing garbage. diff --git a/test/js/bun/ffi/ffi.test.fixture.receiver.c b/test/js/bun/ffi/ffi.test.fixture.receiver.c index ae75c320cfac..ce17ca7359a9 100644 --- a/test/js/bun/ffi/ffi.test.fixture.receiver.c +++ b/test/js/bun/ffi/ffi.test.fixture.receiver.c @@ -166,7 +166,6 @@ static EncodedJSValue PTR_TO_JSVALUE(void* ptr) __attribute__((__always_inline__ // The engine's conversion (the one dlopen()'d symbols use); on a non-pointer it throws and sets *threw. void* JSVALUE_TO_PTR_SLOW(void* jsGlobalObject, int32_t abiType, bool* threw, int64_t val); static void* JSVALUE_TO_PTR(void* jsGlobalObject, int32_t abiType, bool* threw, EncodedJSValue val) __attribute__((__always_inline__)); -static void* JSVALUE_TO_BUFFER(void* jsGlobalObject, bool* threw, EncodedJSValue val) __attribute__((__always_inline__)); static int32_t JSVALUE_TO_INT32(EncodedJSValue val) __attribute__((__always_inline__)); static float JSVALUE_TO_FLOAT(EncodedJSValue val) __attribute__((__always_inline__)); static double JSVALUE_TO_DOUBLE(EncodedJSValue val) __attribute__((__always_inline__)); @@ -215,34 +214,29 @@ 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(void* jsGlobalObject, int32_t abiType, bool* threw, EncodedJSValue val) { - if (JSVALUE_IS_INT32(val)) { - return (void*)(uintptr_t)JSVALUE_TO_INT32(val); - } - - if (JSVALUE_IS_NUMBER(val)) { - val.asInt64 -= DoubleEncodeOffset; - return (void*)(uintptr_t)val.asDouble; - } - - if (JSCELL_IS_TYPED_ARRAY(val)) { - return JSVALUE_TO_TYPED_ARRAY_VECTOR(val); - } - - // A null callback is a TypeError (decided by the slow path), like it is for dlopen(). - if (val.asInt64 == TagValueNull && abiType != ABI_TYPE_FUNCTION) - return 0; - - // JSCallback, ArrayBuffer, BigInt, objects with a numeric `ptr`, undefined, strings, ... - return JSVALUE_TO_PTR_SLOW(jsGlobalObject, abiType, threw, val.asInt64); -} - -static void* JSVALUE_TO_BUFFER(void* jsGlobalObject, bool* threw, EncodedJSValue val) { - if (JSCELL_IS_TYPED_ARRAY(val)) { - return JSVALUE_TO_TYPED_ARRAY_VECTOR(val); + // TinyCC does not inline, so the tag tests are spelled out instead of calling the helpers above. + int64_t bits = val.asInt64; + + if (!(bits & NotCellMask)) { + uint8_t type = *(uint8_t*)((char*)val.asPtr + JSCell__offsetOfType); + if (type >= JSTypeArrayBufferViewMin && type <= JSTypeArrayBufferViewMax) + return *(void**)((char*)val.asPtr + JSArrayBufferView__offsetOfVector); + } else if (abiType != ABI_TYPE_BUFFER) { + if ((bits & NumberTag) == NumberTag) + return (void*)(uintptr_t)(int32_t)bits; + + if (bits & NumberTag) { + val.asInt64 = bits - DoubleEncodeOffset; + return (void*)(uintptr_t)(int64_t)val.asDouble; + } + + // null and undefined are NULL, except as a callback, where the slow path throws like dlopen() does. + if ((bits & ~UndefinedTag) == TagValueNull && abiType != ABI_TYPE_FUNCTION) + return 0; } - // Only views are accepted; the slow path throws for everything else. - return JSVALUE_TO_PTR_SLOW(jsGlobalObject, ABI_TYPE_BUFFER, threw, val.asInt64); + // Other cells (JSCallback, ArrayBuffer, BigInt, objects with a `ptr`, strings), non-views for 'buffer', junk. + return JSVALUE_TO_PTR_SLOW(jsGlobalObject, abiType, threw, bits); } static EncodedJSValue PTR_TO_JSVALUE(void* ptr) {