Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/bun-types/ffi.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
["callback"]: FFIType.pointer; // for now
["napi_env"]: FFIType.napi_env;
["napi_value"]: FFIType.napi_value;
Expand Down
20 changes: 19 additions & 1 deletion src/js/bun/ffi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ const FFIType = {
uint64_t: 8,
uint8_t: 2,
usize: 8,
size_t: 8,
Comment thread
robobun marked this conversation as resolved.
"void*": 12,
ptr: 12,
pointer: 12,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return cb;
}
Comment thread
robobun marked this conversation as resolved.

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);
Expand Down
3 changes: 3 additions & 0 deletions test/integration/bun-types/fixture/ffi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ tsd.expectType<Pointer | null>(lib.symbols.ptr_type(ptr));

tsd.expectType<Pointer | null>(lib.symbols.fn_type(new JSCallback(() => {}, {})));

// "size_t" is accepted anywhere a type name string is (it maps to uint64_t).
tsd.expectType<Pointer | null>(new JSCallback(() => 7n, { args: [], returns: "size_t" }).ptr);

function _arg(
...params: [
number,
Expand Down
100 changes: 100 additions & 0 deletions test/js/bun/ffi/ffi.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading