diff --git a/packages/bun-types/ffi.d.ts b/packages/bun-types/ffi.d.ts index 8c365189a310..770cd023229c 100644 --- a/packages/bun-types/ffi.d.ts +++ b/packages/bun-types/ffi.d.ts @@ -420,6 +420,7 @@ declare module "bun:ffi" { ["cstring"]: FFIType.cstring; ["function"]: FFIType.pointer; // for now ["usize"]: FFIType.uint64_t; // for now + ["size_t"]: FFIType.uint64_t; // for now ["callback"]: FFIType.pointer; // for now ["napi_env"]: FFIType.napi_env; ["napi_value"]: FFIType.napi_value; diff --git a/src/js/bun/ffi.ts b/src/js/bun/ffi.ts index 3c885ccf1368..a69e63db0e9e 100644 --- a/src/js/bun/ffi.ts +++ b/src/js/bun/ffi.ts @@ -45,6 +45,7 @@ const FFIType = { uint64_t: 8, uint8_t: 2, usize: 8, + size_t: 8, "void*": 12, ptr: 12, pointer: 12, @@ -81,7 +82,7 @@ delete ffi.closeCallback; class JSCallback { constructor(cb, options) { - const { ctx, ptr } = nativeCallback(options, cb); + const { ctx, ptr } = nativeCallback(options, FFICallbackReturnWrapper(cb, options)); this.#ctx = ctx; this.ptr = ptr; this.#threadsafe = !!options?.threadsafe; @@ -334,6 +335,23 @@ ffiWrappers[FFIType.function] = `{ return ptr; }`; +// The generated trampoline decodes the callback's return value with the raw +// JSValue macros in FFI.h, which assume the value already has the declared C +// type's representation. ffiWrappers establishes that, like FFIBuilder does for args. +function FFICallbackReturnWrapper(cb, options) { + if (typeof cb !== "function") return cb; + const returnTypeId = FFIType[options?.returns]; + // void: nothing to coerce. napi_value: the raw JSValue is the return value. + if (typeof returnTypeId !== "number" || returnTypeId === FFIType.void || returnTypeId === FFIType.napi_value) { + return cb; + } + + var paramNames = ""; + const argCount = options?.args?.length; + for (let i = 0; i < argCount; i++) paramNames += (i ? ",p" : "p") + i; + return new Function("cb", `return (${paramNames}) => (val=>${ffiWrappers[returnTypeId]})(cb(${paramNames}));`)(cb); +} + function FFIBuilder(params, returnType, functionToCall, name) { const hasReturnType = typeof FFIType[returnType] === "number" && FFIType[returnType as string] !== FFIType.void; var paramNames = new Array(params.length); diff --git a/test/integration/bun-types/fixture/ffi.ts b/test/integration/bun-types/fixture/ffi.ts index 9f18e37c70f2..cb08a381e4b0 100644 --- a/test/integration/bun-types/fixture/ffi.ts +++ b/test/integration/bun-types/fixture/ffi.ts @@ -71,6 +71,9 @@ tsd.expectType(lib.symbols.ptr_type(ptr)); tsd.expectType(lib.symbols.fn_type(new JSCallback(() => {}, {}))); +// "size_t" is accepted anywhere a type name string is (it maps to uint64_t). +tsd.expectType(new JSCallback(() => 7n, { args: [], returns: "size_t" }).ptr); + function _arg( ...params: [ number, diff --git a/test/js/bun/ffi/ffi.test.js b/test/js/bun/ffi/ffi.test.js index 1a66bae5d943..45262a222afa 100644 --- a/test/js/bun/ffi/ffi.test.js +++ b/test/js/bun/ffi/ffi.test.js @@ -716,6 +716,106 @@ it.skipIf(isFFIUnavailable)("JSCallback exceptions propagate out of the native c }); }); +// FFI.h's JSVALUE_TO_* macros only reinterpret bits, so the callback's return +// value must be coerced to the declared C type first or native code receives +// the raw JSValue encoding. Subprocess for the same CFunction-handle reason as above. +it.skipIf(isFFIUnavailable)("JSCallback return values are coerced to the declared return type", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `import { CFunction, JSCallback, ptr } from "bun:ffi"; + + // Calls the JSCallback's native trampoline directly; the CFunction has + // the same signature, so it reports the exact value native code received. + function nativeSees(returns, value) { + const cb = new JSCallback(() => value, { args: [], returns }); + const call = new CFunction({ ptr: cb.ptr, returns, args: [] }); + try { + return call(); + } finally { + call.close(); + cb.close(); + } + } + const show = value => [typeof value, String(value)]; + function probe(returns, value) { + try { + return show(nativeSees(returns, value)); + } catch (err) { + return ["throws", err.name]; + } + } + + const view = new Uint8Array(8); + console.log( + JSON.stringify({ + i32_int: probe("i32", 7), + i32_string: probe("i32", "123"), + i32_object: probe("i32", {}), + i32_undefined: probe("i32", undefined), + i32_null: probe("i32", null), + i32_true: probe("i32", true), + i32_double: probe("i32", 3.9), + i32_overflow: probe("i32", 2147483648), + u32_max: probe("u32", 4294967295), + u8_string: probe("u8", "200"), + i16_object: probe("i16", {}), + bool_one: probe("bool", 1), + bool_object: probe("bool", {}), + bool_empty_string: probe("bool", ""), + f64_string: probe("f64", "1.5"), + f64_object: probe("f64", {}), + f32_string: probe("f32", "1.5"), + ptr_view: nativeSees("ptr", view) === ptr(view), + ptr_undefined: probe("ptr", undefined), + ptr_object: probe("ptr", {}), + ptr_string: probe("ptr", "nope"), + i64_string: probe("i64", "7"), + u64_undefined: probe("u64", undefined), + size_t_string: probe("size_t", "7"), + }), + );`, + ], + env: bunEnv, + stdout: "pipe", + 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 is captured so failures show it, but is not asserted empty: debug + // builds emit benign warnings. + expect({ results, stderr, exitCode }).toMatchObject({ + results: { + i32_int: ["number", "7"], + i32_string: ["number", "123"], + i32_object: ["number", "0"], + i32_undefined: ["number", "0"], + i32_null: ["number", "0"], + i32_true: ["number", "1"], + i32_double: ["number", "3"], + i32_overflow: ["number", "-2147483648"], + u32_max: ["number", "4294967295"], + u8_string: ["number", "200"], + i16_object: ["number", "0"], + bool_one: ["boolean", "true"], + bool_object: ["boolean", "true"], + bool_empty_string: ["boolean", "false"], + f64_string: ["number", "1.5"], + f64_object: ["number", "NaN"], + f32_string: ["number", "1.5"], + ptr_view: true, + ptr_undefined: ["object", "null"], + ptr_object: ["throws", "TypeError"], + ptr_string: ["throws", "TypeError"], + i64_string: ["bigint", "7"], + u64_undefined: ["bigint", "0"], + size_t_string: ["bigint", "7"], + }, + exitCode: 0, + }); +}); + // worker.terminate() delivered inside a threadsafe JSCallback used to trip // "ASSERTION FAILED: !isTerminationException(exception) || hasTerminationRequest()" // in JSC::VM::setException on the worker thread and re-enter the terminated VM.