From 376b18c4bfd5d93692beee43cfe11e68aded057f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 10 Jun 2026 20:54:58 +0000 Subject: [PATCH 1/7] bun:ffi: infer exact signatures in types, fix cc symbol conversions Type changes (packages/bun-types/ffi.d.ts): - dlopen, cc, linkSymbols, CFunction, and JSCallback now use const type parameters, so argument arity is checked from the symbol definitions without requiring "as const". Previously add(1, 2, 3, 4) compiled fine for args: ["i32", "i32"]. - CFunction returns a typed call signature instead of CallableFunction. - JSCallback infers the callback's parameter and return types from the definition. cstring arguments are typed as raw Pointer | null because the native trampoline does not wrap them in CString. - ptr/cstring arguments accept ArrayBuffer and DataView, which the runtime converts. Runtime fixes (src/js/bun/ffi.ts): - cc() read symbol definitions from options[key] instead of options.symbols[key], so FFIBuilder was never applied: cstring returns came back as raw pointer numbers, large uint32 arguments were corrupted (take_u32(0xFFFFFFFF) returned 4292870144), and passing a JSCallback object as a function argument segfaulted. This was the cause of the "FIXME: bus error" skips in cc.test.ts. - Pointer arguments now accept CString (unwrapped to its ptr), matching what the published types have always claimed; previously this threw "Unable to convert hello to a pointer". - The ArrayBufferView check now includes DataView, which the compiled stubs already handle (JSType range includes DataView). - napi_env/napi_value arguments pass through unmodified instead of going through the default val|0 coercion, which corrupts JSValues now that cc applies wrappers. Tests: - test/js/bun/ffi/ffi.test.js: dlopen pointer-argument conversion tests (CString, ArrayBuffer, DataView, rejection), using libc strlen. - test/js/bun/ffi/cc.test.ts: un-skipped the ping/strlen cstring tests (fixing makeValidCase, which returned the library variable before beforeAll assigned it), plus new coverage for cstring returns, u32, ArrayBuffer, JSCallback function arguments, and napi_value passthrough. - test/integration/bun-types/fixture/ffi.ts: type-level assertions for inferred arity, CFunction, and JSCallback signatures. --- docs/runtime/ffi.mdx | 28 +++-- packages/bun-types/ffi.d.ts | 134 ++++++++++++++++++---- src/js/bun/ffi.ts | 25 +++- test/integration/bun-types/fixture/ffi.ts | 62 +++++++++- test/js/bun/ffi/cc.test.ts | 123 +++++++++++++++++--- test/js/bun/ffi/ffi.test.js | 60 +++++++++- 6 files changed, 373 insertions(+), 59 deletions(-) diff --git a/docs/runtime/ffi.mdx b/docs/runtime/ffi.mdx index eb96480b996b..3fa2037e35bb 100644 --- a/docs/runtime/ffi.mdx +++ b/docs/runtime/ffi.mdx @@ -278,7 +278,7 @@ const [major, minor, patch] = [lib.symbols.getMajor(), lib.symbols.getMinor(), l Use `JSCallback` to create JavaScript callback functions that can be passed to C/FFI functions. The C/FFI function can call into the JavaScript/TypeScript code. This is useful for asynchronous code or whenever you want to call into JavaScript code from C. ```ts -import { dlopen, JSCallback, ptr, CString } from "bun:ffi"; +import { dlopen, JSCallback, ptr, CString, type Pointer } from "bun:ffi"; const { symbols: { search }, @@ -290,10 +290,13 @@ const { }, }); -const searchIterator = new JSCallback((ptr, length) => /hello/.test(new CString(ptr, length)), { - returns: "bool", - args: ["ptr", "usize"], -}); +const searchIterator = new JSCallback( + (ptr: Pointer, length: bigint) => /hello/.test(new CString(ptr, 0, Number(length))), + { + returns: "bool", + args: ["ptr", "usize"], + }, +); const str = Buffer.from("wwutwutwutwutwutwutwutwutwutwutut\0", "utf8"); if (search(ptr(str), searchIterator)) { @@ -316,11 +319,14 @@ When you're done with a JSCallback, you should call `close()` to free the memory Currently, thread-safe callbacks work best when run from another thread that is running JavaScript code, i.e. a [`Worker`](/runtime/workers). A future version of Bun will enable them to be called from any thread (such as new threads spawned by your native library that Bun is not aware of). ```ts -const searchIterator = new JSCallback((ptr, length) => /hello/.test(new CString(ptr, length)), { - returns: "bool", - args: ["ptr", "usize"], - threadsafe: true, // Optional. Defaults to `false` -}); +const searchIterator = new JSCallback( + (ptr: Pointer, length: bigint) => /hello/.test(new CString(ptr, 0, Number(length))), + { + returns: "bool", + args: ["ptr", "usize"], + threadsafe: true, // Optional. Defaults to `false` + }, +); ``` @@ -338,7 +344,7 @@ const setOnResolve = new CFunction({ }); // This code runs slightly faster: -setOnResolve(onResolve.ptr); +setOnResolve(onResolve.ptr!); // Compared to this: setOnResolve(onResolve); diff --git a/packages/bun-types/ffi.d.ts b/packages/bun-types/ffi.d.ts index e91825ce6dc6..1cc0dc6049c2 100644 --- a/packages/bun-types/ffi.d.ts +++ b/packages/bun-types/ffi.d.ts @@ -361,9 +361,9 @@ declare module "bun:ffi" { [FFIType.double]: number; [FFIType.float]: number; [FFIType.bool]: boolean; - [FFIType.ptr]: NodeJS.TypedArray | Pointer | CString | null; + [FFIType.ptr]: NodeJS.TypedArray | DataView | ArrayBuffer | Pointer | CString | null; [FFIType.void]: undefined; - [FFIType.cstring]: NodeJS.TypedArray | Pointer | CString | null; + [FFIType.cstring]: NodeJS.TypedArray | DataView | ArrayBuffer | Pointer | CString | null; [FFIType.i64_fast]: number | bigint; [FFIType.u64_fast]: number | bigint; [FFIType.function]: Pointer | JSCallback; // cannot be null @@ -394,6 +394,67 @@ declare module "bun:ffi" { [FFIType.napi_value]: unknown; [FFIType.buffer]: NodeJS.TypedArray | DataView; } + /** + * Values a {@link JSCallback} receives when invoked from native code. + * + * Unlike {@link FFITypeToReturnsType}, a `cstring` argument arrives as a raw + * {@link Pointer} (or `null`), not a {@link CString}. Wrap it yourself with + * `new CString(ptr)` if you need the string contents. + */ + interface FFITypeToJSCallbackArgsType { + [FFIType.char]: number; + [FFIType.int8_t]: number; + [FFIType.uint8_t]: number; + [FFIType.int16_t]: number; + [FFIType.uint16_t]: number; + [FFIType.int32_t]: number; + [FFIType.uint32_t]: number; + [FFIType.int64_t]: bigint; + [FFIType.uint64_t]: bigint; + [FFIType.double]: number; + [FFIType.float]: number; + [FFIType.bool]: boolean; + [FFIType.ptr]: Pointer | null; + [FFIType.void]: undefined; + [FFIType.cstring]: Pointer | null; + [FFIType.i64_fast]: number | bigint; + [FFIType.u64_fast]: number | bigint; + [FFIType.function]: Pointer | null; + [FFIType.napi_env]: unknown; + [FFIType.napi_value]: unknown; + [FFIType.buffer]: NodeJS.TypedArray | DataView; + } + /** + * Values a {@link JSCallback} may return to native code. + * + * Conversion happens without the JavaScript-side coercion that calls into + * native functions get, so pointer-typed returns accept a {@link Pointer}, + * a TypedArray (its backing store address is used), or `null`, but not a + * {@link CString} or {@link JSCallback}. + */ + interface FFITypeToJSCallbackReturnsType { + [FFIType.char]: number; + [FFIType.int8_t]: number; + [FFIType.uint8_t]: number; + [FFIType.int16_t]: number; + [FFIType.uint16_t]: number; + [FFIType.int32_t]: number; + [FFIType.uint32_t]: number; + [FFIType.int64_t]: number | bigint; + [FFIType.uint64_t]: number | bigint; + [FFIType.double]: number; + [FFIType.float]: number; + [FFIType.bool]: boolean; + [FFIType.ptr]: NodeJS.TypedArray | DataView | Pointer | null; + [FFIType.void]: void; + [FFIType.cstring]: NodeJS.TypedArray | DataView | Pointer | null; + [FFIType.i64_fast]: number | bigint; + [FFIType.u64_fast]: number | bigint; + [FFIType.function]: Pointer | null; + [FFIType.napi_env]: unknown; + [FFIType.napi_value]: unknown; + [FFIType.buffer]: NodeJS.TypedArray | DataView; + } interface FFITypeStringToType { ["char"]: FFIType.char; ["int8_t"]: FFIType.int8_t; @@ -538,22 +599,47 @@ declare module "bun:ffi" { type ToFFIType = T extends FFIType ? T : T extends string ? FFITypeStringToType[T] : never; const FFIFunctionCallableSymbol: unique symbol; + type ConvertFn = { + ( + ...args: Fn["args"] extends infer A extends readonly FFITypeOrString[] + ? { [L in keyof A]: FFITypeToArgsType[ToFFIType] } + : // eslint-disable-next-line @definitelytyped/no-single-element-tuple-type + [unknown] extends [Fn["args"]] + ? [] + : never + ): [unknown] extends [Fn["returns"]] // eslint-disable-next-line @definitelytyped/no-single-element-tuple-type + ? undefined + : FFITypeToReturnsType[ToFFIType>]; + __ffi_function_callable: typeof FFIFunctionCallableSymbol; + }; type ConvertFns = { - [K in keyof Fns]: { - ( - ...args: Fns[K]["args"] extends infer A extends readonly FFITypeOrString[] - ? { [L in keyof A]: FFITypeToArgsType[ToFFIType] } - : // eslint-disable-next-line @definitelytyped/no-single-element-tuple-type - [unknown] extends [Fns[K]["args"]] - ? [] - : never - ): [unknown] extends [Fns[K]["returns"]] // eslint-disable-next-line @definitelytyped/no-single-element-tuple-type - ? undefined - : FFITypeToReturnsType[ToFFIType>]; - __ffi_function_callable: typeof FFIFunctionCallableSymbol; - }; + [K in keyof Fns]: ConvertFn; }; + /** + * The JavaScript function passed to a {@link JSCallback}. + * + * Argument and return types are derived from the `definition`: + * arguments arrive converted per {@link FFITypeToJSCallbackArgsType} and the + * return value must satisfy {@link FFITypeToJSCallbackReturnsType}. + */ + type JSCallbackFunction = { + // A method signature (rather than a function type) keeps parameter + // checking bivariant, so narrower handwritten parameter types like + // `(ptr: Pointer) => void` remain assignable where the derived type is + // `(ptr: Pointer | null) => void`. + fn( + ...args: Def["args"] extends infer A extends readonly FFITypeOrString[] + ? { [L in keyof A]: FFITypeToJSCallbackArgsType[ToFFIType] } + : // eslint-disable-next-line @definitelytyped/no-single-element-tuple-type + [unknown] extends [Def["args"]] + ? [] + : never + ): [unknown] extends [Def["returns"]] // eslint-disable-next-line @definitelytyped/no-single-element-tuple-type + ? void + : FFITypeToJSCallbackReturnsType[ToFFIType>]; + }["fn"]; + /** * Open a library using `"bun:ffi"` * @@ -582,7 +668,7 @@ declare module "bun:ffi" { * * @category FFI */ - function dlopen>( + function dlopen>( name: string | import("bun").BunFile | URL, symbols: Fns, ): Library; @@ -621,7 +707,7 @@ declare module "bun:ffi" { * } * ``` */ - function cc>(options: { + function cc>(options: { /** * File path to an ISO C11 source file to compile and link */ @@ -723,7 +809,9 @@ declare module "bun:ffi" { * bun uses [tinycc](https://github.com/TinyCC/tinycc), so a big thanks * goes to Fabrice Bellard and TinyCC maintainers for making this possible. */ - function CFunction(fn: FFIFunction & { ptr: Pointer }): CallableFunction & { + function CFunction( + fn: Fn, + ): ConvertFn & { /** * Free the memory allocated by the wrapping function */ @@ -781,7 +869,7 @@ declare module "bun:ffi" { * bun uses [tinycc](https://github.com/TinyCC/tinycc), so a big thanks * goes to Fabrice Bellard and TinyCC maintainers for making this possible. */ - function linkSymbols>(symbols: Fns): Library; + function linkSymbols>(symbols: Fns): Library; /** * Read a pointer as a {@link Buffer} @@ -1079,14 +1167,18 @@ declare module "bun:ffi" { /** * Pass a JavaScript function to FFI (Foreign Function Interface) */ - class JSCallback { + class JSCallback { /** * Enable a JavaScript callback function to be passed to C with bun:ffi * + * The callback's parameter and return types are inferred from + * `definition`, so a mismatch between the declared FFI types and the + * JavaScript function is a type error. + * * @param callback The JavaScript function to be called * @param definition The C function definition */ - constructor(callback: (...args: any[]) => any, definition: FFIFunction); + constructor(callback: JSCallbackFunction, definition: Def); /** * The pointer to the C function diff --git a/src/js/bun/ffi.ts b/src/js/bun/ffi.ts index 390e6f58f8d4..9e0f2085bbec 100644 --- a/src/js/bun/ffi.ts +++ b/src/js/bun/ffi.ts @@ -283,8 +283,10 @@ Object.defineProperty(globalThis, "__GlobalBunFFIPtrFunctionForWrapper", { configurable: true, }); Object.defineProperty(globalThis, "__GlobalBunFFIPtrArrayBufferViewFn", { - value: function isTypedArrayView(val) { - return $isTypedArrayView(val); + value: function isArrayBufferView(val) { + // `$isTypedArrayView` is the fast path, but it excludes DataView, which + // the compiled FFI stubs accept like any other ArrayBufferView. + return $isTypedArrayView(val) || ArrayBuffer.$isView(val); }, enumerable: false, configurable: true, @@ -304,6 +306,10 @@ ffiWrappers[FFIType.cstring] = ffiWrappers[FFIType.pointer] = `{ return __GlobalBunFFIPtrFunctionForWrapper(val); } + if (val instanceof __GlobalBunCString) { + return val.ptr; + } + if (typeof val === "string") { throw new TypeError("To convert a string to a pointer, encode it as a buffer"); } @@ -313,7 +319,7 @@ ffiWrappers[FFIType.cstring] = ffiWrappers[FFIType.pointer] = `{ ffiWrappers[FFIType.buffer] = `{ if (!__GlobalBunFFIPtrArrayBufferViewFn(val)) { - throw new TypeError("Expected a TypedArray"); + throw new TypeError("Expected a TypedArray or DataView"); } return val; @@ -337,6 +343,12 @@ ffiWrappers[FFIType.function] = `{ return ptr; }`; +// Node-API arguments are passed through as raw JSValues: the native side reads +// `napi_value` directly and substitutes the module's env for `napi_env`, so +// neither may go through the default `val|0` coercion. +ffiWrappers[FFIType.napi_env] = "val"; +ffiWrappers[FFIType.napi_value] = "val"; + function FFIBuilder(params, returnType, functionToCall, name) { const hasReturnType = typeof FFIType[returnType] === "number" && FFIType[returnType as string] !== FFIType.void; var paramNames = new Array(params.length); @@ -497,12 +509,13 @@ function cc(options) { const result = ccFn(options); if (Error.isError(result)) throw result; + const symbols = options.symbols; for (let key in result.symbols) { var symbol = result.symbols[key]; - if (options[key]?.args?.length || FFIType[options[key]?.returns as string] === FFIType.cstring) { + if (symbols[key]?.args?.length || FFIType[symbols[key]?.returns as string] === FFIType.cstring) { result.symbols[key] = FFIBuilder( - options[key].args ?? [], - options[key].returns ?? FFIType.void, + symbols[key].args ?? [], + symbols[key].returns ?? FFIType.void, symbol, // in stacktraces: // instead of diff --git a/test/integration/bun-types/fixture/ffi.ts b/test/integration/bun-types/fixture/ffi.ts index 9f18e37c70f2..3d0271ee6eca 100644 --- a/test/integration/bun-types/fixture/ffi.ts +++ b/test/integration/bun-types/fixture/ffi.ts @@ -1,4 +1,4 @@ -import { dlopen, FFIType, JSCallback, read, suffix, type CString, type Pointer } from "bun:ffi"; +import { cc, CFunction, dlopen, FFIType, JSCallback, read, suffix, type CString, type Pointer } from "bun:ffi"; import * as tsd from "./utilities"; // `suffix` is either "dylib", "so", or "dll" depending on the platform @@ -181,3 +181,63 @@ tsd.expectType(read.f32(ptr, 0)); tsd.expectType(read.f64(ptr, 0)); tsd.expectType(read.ptr(ptr, 0)); tsd.expectType(read.intptr(ptr, 0)); + +// Argument arity and types are inferred without `as const` +const inferred = dlopen(path, { + add3: { + args: [FFIType.i32, FFIType.i32, FFIType.i32], + returns: FFIType.i32, + }, +}); +tsd.expectTypeEquals, [number, number, number]>(true); +tsd.expectType(inferred.symbols.add3(1, 2, 3)); +// @ts-expect-error too many arguments +inferred.symbols.add3(1, 2, 3, 4); +// @ts-expect-error not enough arguments +inferred.symbols.add3(1, 2); + +// cc() infers the same way +const compiled = cc({ + source: "./add.c", + symbols: { + mul: { args: [FFIType.i32, FFIType.i32], returns: FFIType.i32 }, + }, +}); +tsd.expectType(compiled.symbols.mul(1, 2)); +// @ts-expect-error too many arguments +compiled.symbols.mul(1, 2, 3); + +// CFunction infers its call signature from the definition +const getVersion = CFunction({ + returns: FFIType.cstring, + args: [FFIType.i32], + ptr, +}); +tsd.expectType(getVersion(1)); +// @ts-expect-error too many arguments +getVersion(1, 2); +getVersion.close(); + +// JSCallback infers the callback signature from the definition +new JSCallback( + (a, b) => { + tsd.expectType(a); + tsd.expectType(b); + return 0; + }, + { args: [FFIType.i32, FFIType.i64], returns: FFIType.i32 }, +); + +// cstring arguments arrive as raw pointers, not CString +new JSCallback( + arg => { + tsd.expectTypeEquals(true); + }, + { args: [FFIType.cstring], returns: FFIType.void }, +); + +// @ts-expect-error the callback must return the declared return type +new JSCallback(() => "not a number", { args: [], returns: FFIType.i32 }); + +// narrower parameter annotations remain assignable +new JSCallback((_p: Pointer) => {}, { args: [FFIType.ptr], returns: FFIType.void }); diff --git a/test/js/bun/ffi/cc.test.ts b/test/js/bun/ffi/cc.test.ts index 99a1b5eac1a1..1a579b98c6e1 100644 --- a/test/js/bun/ffi/cc.test.ts +++ b/test/js/bun/ffi/cc.test.ts @@ -64,10 +64,10 @@ describe.skipIf(isASAN || isFFIUnavailable)("given an add(a, b) function", () => expect(res.symbols.add(1, 2)).toBe(3); }); - // FIXME: produces junk - it.skip("when passed arguments with incorrect types, throws an error", () => { + it("when passed numeric strings, coerces them like the dlopen wrappers do", () => { + // int arguments go through the same `val|0` coercion as dlopen'd symbols // @ts-expect-error - expect(() => res.symbols.add("1", "2")).toThrow(); + expect(res.symbols.add("1", "2")).toBe(3); }); // looks like `b` defaults to `0`, is this U.B. or expected? @@ -134,8 +134,10 @@ describe("given a source file with syntax errors", () => { }); }); -describe.skip("given a ping(cstr) function", () => { - const library = makeValidCase( +// Successful compiles are fine under ASan; the setjmp/longjmp conflict only +// affects TinyCC's error handling, which these tests never reach. +describe.skipIf(isFFIUnavailable)("given a ping(cstr) function", () => { + const holder = makeValidCase( "ping", /* c */ ` char* ping(char* str) { @@ -150,21 +152,25 @@ describe.skip("given a ping(cstr) function", () => { }, ); - it("given a valid CString, returns the same pointer", () => { + it("given a valid CString, returns a CString wrapping the same pointer", () => { const buf = Buffer.from("hello\0"); const arr = new Uint8Array(buf); const cstr = new CString(ptr(arr)); - expect(library.symbols.ping(cstr)).toBe(cstr); + const result = holder.library.symbols.ping(cstr); + expect(result).toBeInstanceOf(CString); + expect(result.ptr).toBe(cstr.ptr); + expect(result.toString()).toBe("hello"); }); }); // -// FIXME: bus error -describe.skip("given a strlen(cstring) function", () => { - const library = makeValidCase( +// Successful compiles are fine under ASan; the setjmp/longjmp conflict only +// affects TinyCC's error handling, which these tests never reach. +describe.skipIf(isFFIUnavailable)("given a strlen(cstring) function", () => { + const holder = makeValidCase( "strlen", /* c */ ` - size_t strlen(char* str) { + unsigned long long strlen(char* str) { char* s = str; while (*s) s++; return s - str; @@ -183,25 +189,105 @@ describe.skip("given a strlen(cstring) function", () => { const arr = new Uint8Array(buf); const cstr = new CString(ptr(arr)); - expect(library.symbols.strlen(cstr)).toBe(5); + expect(holder.library.symbols.strlen(cstr)).toBe(5n); }); it("given a JSString, throws", () => { // @ts-expect-error - expect(() => library.symbols.strlen("hello")).toThrow(TypeError); + expect(() => holder.library.symbols.strlen("hello")).toThrow(TypeError); }); }); // +// These conversions are shared with dlopen(), but `cc` used to skip them +// entirely because it looked up symbol definitions on the options object +// instead of options.symbols. +// Successful compiles are fine under ASan; the setjmp/longjmp conflict only +// affects TinyCC's error handling, which these tests never reach. +describe.skipIf(isFFIUnavailable)("cc applies the same conversions as dlopen", () => { + const holder = makeValidCase( + "conversions", + /* c */ ` + const char* greet() { return "hello"; } + unsigned int identity_u32(unsigned int value) { return value; } + void* identity_ptr(void* value) { return value; } + int invoke(int (*callback)(int), int value) { return callback(value); } + long long napi_echo(void* env, long long value) { return value; } + `, + { + greet: { + args: [], + returns: "cstring", + }, + identity_u32: { + args: ["u32"], + returns: "u32", + }, + identity_ptr: { + args: ["ptr"], + returns: "ptr", + }, + invoke: { + args: ["function", "int"], + returns: "int", + }, + napi_echo: { + args: ["napi_env", "napi_value"], + returns: "napi_value", + }, + }, + ); + + it("wraps cstring return values in a CString", () => { + const result = holder.library.symbols.greet(); + expect(result).toBeInstanceOf(CString); + expect(result.toString()).toBe("hello"); + }); + + it("converts large uint32_t arguments correctly", () => { + expect(holder.library.symbols.identity_u32(0xffffffff)).toBe(0xffffffff); + expect(holder.library.symbols.identity_u32(0)).toBe(0); + }); + + it("converts ArrayBuffer pointer arguments", () => { + const buf = new ArrayBuffer(8); + expect(holder.library.symbols.identity_ptr(buf)).toBe(ptr(buf)); + }); + + it("throws when a pointer argument cannot be converted", () => { + // @ts-expect-error + expect(() => holder.library.symbols.identity_ptr({})).toThrow(TypeError); + }); + + it("accepts a JSCallback object for function arguments", () => { + const callback = new JSCallback((value: number) => value * 3, { + args: ["int"], + returns: "int", + }); + try { + expect(holder.library.symbols.invoke(callback, 14)).toBe(42); + } finally { + callback.close(); + } + }); + + it("passes napi_value arguments through untouched", () => { + const object = { hello: "napi" }; + expect(holder.library.symbols.napi_echo(null, object)).toBe(object); + }); +}); // + // ============================================================================= function makeValidCase>( name: string, source: string, symbols: Fns, -): Library { +): { library: Library } { const filename = `${name}.c`; - var library: Library; + // The library only exists once `beforeAll` has run, so hand tests a holder + // instead of a value captured at describe time. + const holder = {} as { library: Library }; beforeAll(() => { try { @@ -209,7 +295,7 @@ function makeValidCase>( [filename]: source, }); - library = cc({ + holder.library = cc({ source: path.join(dir, filename), symbols, }); @@ -220,11 +306,10 @@ function makeValidCase>( }); afterAll(() => { - library.close(); + holder.library?.close(); }); - // @ts-ignore - return library; + return holder; } // ============================================================================= diff --git a/test/js/bun/ffi/ffi.test.js b/test/js/bun/ffi/ffi.test.js index 1a66bae5d943..88d8692fd243 100644 --- a/test/js/bun/ffi/ffi.test.js +++ b/test/js/bun/ffi/ffi.test.js @@ -1,6 +1,6 @@ import { afterAll, describe, expect, it } from "bun:test"; import { existsSync } from "fs"; -import { bunEnv, bunExe, isArm64, isGlibcVersionAtLeast, isWindows, tempDir } from "harness"; +import { bunEnv, bunExe, isArm64, isGlibcVersionAtLeast, isMusl, isWindows, tempDir } from "harness"; import { platform } from "os"; import { @@ -1085,3 +1085,61 @@ describe.if(!!libPath)("can open more than 63 symbols via", () => { }); } }); + +// Any C runtime with strlen() will do. On musl there is no stable library +// name to dlopen, so these are skipped there. +const strlenLibPath = + platform() === "darwin" + ? "/usr/lib/libSystem.B.dylib" + : platform() === "win32" + ? "msvcrt.dll" + : isMusl + ? null + : "libc.so.6"; + +describe.if(!!strlenLibPath)("pointer argument conversion", () => { + const strings = { + strlen: { + returns: "usize", + args: ["cstring"], + }, + }; + + it("accepts a CString", () => { + const lib = dlopen(strlenLibPath, strings); + const buf = Buffer.from("bunbun\0", "ascii"); + const cstr = new CString(ptr(buf)); + expect(cstr.toString()).toBe("bunbun"); + expect(lib.symbols.strlen(cstr)).toBe(6n); + }); + + it("accepts an ArrayBuffer", () => { + const lib = dlopen(strlenLibPath, strings); + const buf = new ArrayBuffer(7); + new Uint8Array(buf).set(Buffer.from("bunbun\0", "ascii")); + expect(lib.symbols.strlen(buf)).toBe(6n); + }); + + it("accepts a DataView", () => { + const lib = dlopen(strlenLibPath, strings); + const buf = Buffer.from("bunbun\0", "ascii"); + expect(lib.symbols.strlen(new DataView(buf.buffer, buf.byteOffset, buf.byteLength))).toBe(6n); + }); + + it("accepts a DataView for buffer arguments", () => { + const lib = dlopen(strlenLibPath, { + strlen: { + returns: "usize", + args: ["buffer"], + }, + }); + const buf = Buffer.from("bunbun\0", "ascii"); + expect(lib.symbols.strlen(new DataView(buf.buffer, buf.byteOffset, buf.byteLength))).toBe(6n); + }); + + it("rejects values it cannot convert", () => { + const lib = dlopen(strlenLibPath, strings); + expect(() => lib.symbols.strlen("bunbun")).toThrow(TypeError); + expect(() => lib.symbols.strlen({})).toThrow(TypeError); + }); +}); From 43274e813b6df1478584f84491a711138e6b6d94 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 10 Jun 2026 21:34:00 +0000 Subject: [PATCH 2/7] test: skip ffi pointer conversion tests on Windows ARM64 where bun:ffi is disabled --- test/js/bun/ffi/ffi.test.js | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/test/js/bun/ffi/ffi.test.js b/test/js/bun/ffi/ffi.test.js index 88d8692fd243..e942f1af21e5 100644 --- a/test/js/bun/ffi/ffi.test.js +++ b/test/js/bun/ffi/ffi.test.js @@ -1087,15 +1087,18 @@ describe.if(!!libPath)("can open more than 63 symbols via", () => { }); // Any C runtime with strlen() will do. On musl there is no stable library -// name to dlopen, so these are skipped there. +// name to dlopen, and bun:ffi is disabled entirely on Windows ARM64, so these +// are skipped there. const strlenLibPath = - platform() === "darwin" - ? "/usr/lib/libSystem.B.dylib" - : platform() === "win32" - ? "msvcrt.dll" - : isMusl - ? null - : "libc.so.6"; + isWindows && isArm64 + ? null + : platform() === "darwin" + ? "/usr/lib/libSystem.B.dylib" + : platform() === "win32" + ? "msvcrt.dll" + : isMusl + ? null + : "libc.so.6"; describe.if(!!strlenLibPath)("pointer argument conversion", () => { const strings = { From 7e63f7ff09048fe74e47a78f7b27ad61b137c6cf Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 10 Jun 2026 22:08:24 +0000 Subject: [PATCH 3/7] ci: retrigger From f47e8efdebde9bdba1f3b0bda6bc49bfe7504a28 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 28 Jun 2026 11:20:10 +0000 Subject: [PATCH 4/7] bun:ffi types: match FFITypeStringToType to the runtime string map The runtime and the native parser both map "function", "callback", and "fn" to FFIType.function, so string-spelled function arguments accept a JSCallback like the enum spelling does. The string map also gains the other spellings the runtime accepts (c_int, c_uint, isize, char*, void*, i64_fast, u64_fast). Also trims a comment in cc.test.ts to the repo's 3 line limit and drops a non-null assertion in the docs example that had no effect. --- docs/runtime/ffi.mdx | 2 +- packages/bun-types/ffi.d.ts | 14 +++++++++++--- test/integration/bun-types/fixture/ffi.ts | 13 +++++++++++++ test/js/bun/ffi/cc.test.ts | 3 --- 4 files changed, 25 insertions(+), 7 deletions(-) diff --git a/docs/runtime/ffi.mdx b/docs/runtime/ffi.mdx index 3fa2037e35bb..598908782fd1 100644 --- a/docs/runtime/ffi.mdx +++ b/docs/runtime/ffi.mdx @@ -344,7 +344,7 @@ const setOnResolve = new CFunction({ }); // This code runs slightly faster: -setOnResolve(onResolve.ptr!); +setOnResolve(onResolve.ptr); // Compared to this: setOnResolve(onResolve); diff --git a/packages/bun-types/ffi.d.ts b/packages/bun-types/ffi.d.ts index 1cc0dc6049c2..b609810aab8a 100644 --- a/packages/bun-types/ffi.d.ts +++ b/packages/bun-types/ffi.d.ts @@ -481,11 +481,19 @@ declare module "bun:ffi" { ["bool"]: FFIType.bool; ["ptr"]: FFIType.ptr; ["pointer"]: FFIType.pointer; + ["void*"]: FFIType.ptr; + ["char*"]: FFIType.ptr; ["void"]: FFIType.void; ["cstring"]: FFIType.cstring; - ["function"]: FFIType.pointer; // for now - ["usize"]: FFIType.uint64_t; // for now - ["callback"]: FFIType.pointer; // for now + ["i64_fast"]: FFIType.i64_fast; + ["u64_fast"]: FFIType.u64_fast; + ["function"]: FFIType.function; + ["callback"]: FFIType.function; + ["fn"]: FFIType.function; + ["usize"]: FFIType.uint64_t; + ["isize"]: FFIType.int64_t; + ["c_int"]: FFIType.int32_t; + ["c_uint"]: FFIType.uint32_t; ["napi_env"]: FFIType.napi_env; ["napi_value"]: FFIType.napi_value; ["buffer"]: FFIType.buffer; diff --git a/test/integration/bun-types/fixture/ffi.ts b/test/integration/bun-types/fixture/ffi.ts index 3d0271ee6eca..379d91f99cce 100644 --- a/test/integration/bun-types/fixture/ffi.ts +++ b/test/integration/bun-types/fixture/ffi.ts @@ -207,6 +207,19 @@ tsd.expectType(compiled.symbols.mul(1, 2)); // @ts-expect-error too many arguments compiled.symbols.mul(1, 2, 3); +// every string spelling the runtime accepts resolves to the same FFI type as +// its enum counterpart +const spelled = dlopen(path, { + setCallback: { args: ["function"], returns: "void" }, + aliases: { + args: ["c_int", "c_uint", "isize", "char*", "void*", "fn", "i64_fast", "u64_fast"], + returns: "callback", + }, +}); +tsd.expectTypeEquals, [Pointer | JSCallback]>(true); +spelled.symbols.setCallback(new JSCallback(() => {}, {})); +tsd.expectTypeEquals, Pointer | null>(true); + // CFunction infers its call signature from the definition const getVersion = CFunction({ returns: FFIType.cstring, diff --git a/test/js/bun/ffi/cc.test.ts b/test/js/bun/ffi/cc.test.ts index 1a579b98c6e1..5e746bc38da0 100644 --- a/test/js/bun/ffi/cc.test.ts +++ b/test/js/bun/ffi/cc.test.ts @@ -198,9 +198,6 @@ describe.skipIf(isFFIUnavailable)("given a strlen(cstring) function", () => { }); }); // -// These conversions are shared with dlopen(), but `cc` used to skip them -// entirely because it looked up symbol definitions on the options object -// instead of options.symbols. // Successful compiles are fine under ASan; the setjmp/longjmp conflict only // affects TinyCC's error handling, which these tests never reach. describe.skipIf(isFFIUnavailable)("cc applies the same conversions as dlopen", () => { From e6ac93153b3947fcfa6ec816300ed607da73fcb5 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 28 Jun 2026 12:02:41 +0000 Subject: [PATCH 5/7] bun:ffi: map numeric napi_env, napi_value, and buffer FFITypes to wrappers The FFIType lookup object only had reverse entries for 0 through 17, so declaring an argument with FFIType.napi_env, FFIType.napi_value, or FFIType.buffer (the numbers, not the string spellings) made FFIBuilder throw "Unsupported type". For dlopen and linkSymbols this was a pre-existing bug; for cc it would have become reachable once symbol definitions are read from options.symbols, so the napi passthrough test now declares its argument types with the enum values. --- packages/bun-types/ffi.d.ts | 7 +++---- src/js/bun/ffi.ts | 3 +++ test/js/bun/ffi/cc.test.ts | 8 +++++--- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/bun-types/ffi.d.ts b/packages/bun-types/ffi.d.ts index b609810aab8a..c289a1e087d4 100644 --- a/packages/bun-types/ffi.d.ts +++ b/packages/bun-types/ffi.d.ts @@ -632,10 +632,9 @@ declare module "bun:ffi" { * return value must satisfy {@link FFITypeToJSCallbackReturnsType}. */ type JSCallbackFunction = { - // A method signature (rather than a function type) keeps parameter - // checking bivariant, so narrower handwritten parameter types like - // `(ptr: Pointer) => void` remain assignable where the derived type is - // `(ptr: Pointer | null) => void`. + // A method signature (vs a function type) keeps parameter checking + // bivariant, so a narrower handwritten `(ptr: Pointer) => void` stays + // assignable where the derived type is `(ptr: Pointer | null) => void`. fn( ...args: Def["args"] extends infer A extends readonly FFITypeOrString[] ? { [L in keyof A]: FFITypeToJSCallbackArgsType[ToFFIType] } diff --git a/src/js/bun/ffi.ts b/src/js/bun/ffi.ts index 9e0f2085bbec..067f1ca53985 100644 --- a/src/js/bun/ffi.ts +++ b/src/js/bun/ffi.ts @@ -17,6 +17,9 @@ const FFIType = { "15": 15, "16": 16, "17": 17, + "18": 18, + "19": 19, + "20": 20, bool: 11, c_int: 5, c_uint: 6, diff --git a/test/js/bun/ffi/cc.test.ts b/test/js/bun/ffi/cc.test.ts index 5e746bc38da0..6b1414d32451 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, FFIType, JSCallback, ptr, type FFIFunction, type Library } from "bun:ffi"; import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import { promises as fs } from "fs"; import { bunEnv, bunExe, isArm64, isASAN, isWindows, normalizeBunSnapshot, tempDir, tempDirWithFiles } from "harness"; @@ -228,8 +228,10 @@ describe.skipIf(isFFIUnavailable)("cc applies the same conversions as dlopen", ( returns: "int", }, napi_echo: { - args: ["napi_env", "napi_value"], - returns: "napi_value", + // The numeric enum spellings cover the FFIType reverse lookup for + // the types above 17, which the string spellings bypass. + args: [FFIType.napi_env, FFIType.napi_value], + returns: FFIType.napi_value, }, }, ); From 8819d00c242897dfbdb3ae07209b2a2beb11d832 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 28 Jun 2026 12:51:48 +0000 Subject: [PATCH 6/7] bun:ffi: accept the size_t spelling in the JS FFIType map The native ABI parser maps "size_t" to uint64_t but the JS lookup object did not, so FFIBuilder threw "Unsupported type size_t" for dlopen and, once cc applies wrappers, for cc as well. Also exposes the spelling in FFITypeStringToType. --- packages/bun-types/ffi.d.ts | 1 + src/js/bun/ffi.ts | 1 + test/integration/bun-types/fixture/ffi.ts | 2 +- test/js/bun/ffi/cc.test.ts | 10 ++++++++++ 4 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/bun-types/ffi.d.ts b/packages/bun-types/ffi.d.ts index c289a1e087d4..060cb40fd252 100644 --- a/packages/bun-types/ffi.d.ts +++ b/packages/bun-types/ffi.d.ts @@ -491,6 +491,7 @@ declare module "bun:ffi" { ["callback"]: FFIType.function; ["fn"]: FFIType.function; ["usize"]: FFIType.uint64_t; + ["size_t"]: FFIType.uint64_t; ["isize"]: FFIType.int64_t; ["c_int"]: FFIType.int32_t; ["c_uint"]: FFIType.uint32_t; diff --git a/src/js/bun/ffi.ts b/src/js/bun/ffi.ts index 067f1ca53985..60a4f09504ad 100644 --- a/src/js/bun/ffi.ts +++ b/src/js/bun/ffi.ts @@ -48,6 +48,7 @@ const FFIType = { uint64_t: 8, uint8_t: 2, usize: 8, + size_t: 8, "void*": 12, ptr: 12, pointer: 12, diff --git a/test/integration/bun-types/fixture/ffi.ts b/test/integration/bun-types/fixture/ffi.ts index 379d91f99cce..243db84e7d02 100644 --- a/test/integration/bun-types/fixture/ffi.ts +++ b/test/integration/bun-types/fixture/ffi.ts @@ -212,7 +212,7 @@ compiled.symbols.mul(1, 2, 3); const spelled = dlopen(path, { setCallback: { args: ["function"], returns: "void" }, aliases: { - args: ["c_int", "c_uint", "isize", "char*", "void*", "fn", "i64_fast", "u64_fast"], + args: ["c_int", "c_uint", "isize", "size_t", "char*", "void*", "fn", "i64_fast", "u64_fast"], returns: "callback", }, }); diff --git a/test/js/bun/ffi/cc.test.ts b/test/js/bun/ffi/cc.test.ts index 6b1414d32451..b2352ff2eb88 100644 --- a/test/js/bun/ffi/cc.test.ts +++ b/test/js/bun/ffi/cc.test.ts @@ -206,6 +206,7 @@ describe.skipIf(isFFIUnavailable)("cc applies the same conversions as dlopen", ( /* c */ ` const char* greet() { return "hello"; } unsigned int identity_u32(unsigned int value) { return value; } + unsigned long long identity_size_t(unsigned long long value) { return value; } void* identity_ptr(void* value) { return value; } int invoke(int (*callback)(int), int value) { return callback(value); } long long napi_echo(void* env, long long value) { return value; } @@ -219,6 +220,10 @@ describe.skipIf(isFFIUnavailable)("cc applies the same conversions as dlopen", ( args: ["u32"], returns: "u32", }, + identity_size_t: { + args: ["size_t"], + returns: "size_t", + }, identity_ptr: { args: ["ptr"], returns: "ptr", @@ -247,6 +252,11 @@ describe.skipIf(isFFIUnavailable)("cc applies the same conversions as dlopen", ( expect(holder.library.symbols.identity_u32(0)).toBe(0); }); + it("accepts size_t, which only the native type table spelled out", () => { + expect(holder.library.symbols.identity_size_t(42n)).toBe(42n); + expect(holder.library.symbols.identity_size_t(7)).toBe(7n); + }); + it("converts ArrayBuffer pointer arguments", () => { const buf = new ArrayBuffer(8); expect(holder.library.symbols.identity_ptr(buf)).toBe(ptr(buf)); From 58fd5a7b76c77858e58eed5fa5b23aa691e2ccf4 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 28 Jun 2026 20:17:04 +0000 Subject: [PATCH 7/7] bun:ffi: pass the data address of sliced CString pointer arguments A CString constructed with a byteOffset represents the string at ptr + byteOffset, so passing one as a pointer argument now forwards that address instead of the base pointer. The check also no longer relies on instanceof, matching how the function wrapper detects pointer carriers. Also mentions DataView in the callback return type docs and makes the JSCallback docs examples handle a null pointer argument. --- docs/runtime/ffi.mdx | 4 ++-- packages/bun-types/ffi.d.ts | 4 ++-- src/js/bun/ffi.ts | 8 ++++++-- test/js/bun/ffi/cc.test.ts | 9 +++++++++ test/js/bun/ffi/ffi.test.js | 8 ++++++++ 5 files changed, 27 insertions(+), 6 deletions(-) diff --git a/docs/runtime/ffi.mdx b/docs/runtime/ffi.mdx index 598908782fd1..e2042bd68e08 100644 --- a/docs/runtime/ffi.mdx +++ b/docs/runtime/ffi.mdx @@ -291,7 +291,7 @@ const { }); const searchIterator = new JSCallback( - (ptr: Pointer, length: bigint) => /hello/.test(new CString(ptr, 0, Number(length))), + (ptr: Pointer | null, length: bigint) => ptr !== null && /hello/.test(new CString(ptr, 0, Number(length))), { returns: "bool", args: ["ptr", "usize"], @@ -320,7 +320,7 @@ Currently, thread-safe callbacks work best when run from another thread that is ```ts const searchIterator = new JSCallback( - (ptr: Pointer, length: bigint) => /hello/.test(new CString(ptr, 0, Number(length))), + (ptr: Pointer | null, length: bigint) => ptr !== null && /hello/.test(new CString(ptr, 0, Number(length))), { returns: "bool", args: ["ptr", "usize"], diff --git a/packages/bun-types/ffi.d.ts b/packages/bun-types/ffi.d.ts index 060cb40fd252..d7c7b2af2bb4 100644 --- a/packages/bun-types/ffi.d.ts +++ b/packages/bun-types/ffi.d.ts @@ -429,8 +429,8 @@ declare module "bun:ffi" { * * Conversion happens without the JavaScript-side coercion that calls into * native functions get, so pointer-typed returns accept a {@link Pointer}, - * a TypedArray (its backing store address is used), or `null`, but not a - * {@link CString} or {@link JSCallback}. + * a TypedArray or DataView (its backing store address is used), or `null`, + * but not a {@link CString} or {@link JSCallback}. */ interface FFITypeToJSCallbackReturnsType { [FFIType.char]: number; diff --git a/src/js/bun/ffi.ts b/src/js/bun/ffi.ts index 60a4f09504ad..dff7c75b0e58 100644 --- a/src/js/bun/ffi.ts +++ b/src/js/bun/ffi.ts @@ -310,8 +310,12 @@ ffiWrappers[FFIType.cstring] = ffiWrappers[FFIType.pointer] = `{ return __GlobalBunFFIPtrFunctionForWrapper(val); } - if (val instanceof __GlobalBunCString) { - return val.ptr; + // A CString (or anything else carrying a numeric "ptr", like a JSCallback). + // CString data starts at ptr + byteOffset, like its arrayBuffer getter. + var valPtr = val.ptr; + if (typeof valPtr === "number") { + var valByteOffset = val.byteOffset; + return valByteOffset ? valPtr + valByteOffset : valPtr; } if (typeof val === "string") { diff --git a/test/js/bun/ffi/cc.test.ts b/test/js/bun/ffi/cc.test.ts index b2352ff2eb88..cafe356ebea4 100644 --- a/test/js/bun/ffi/cc.test.ts +++ b/test/js/bun/ffi/cc.test.ts @@ -162,6 +162,15 @@ describe.skipIf(isFFIUnavailable)("given a ping(cstr) function", () => { expect(result.ptr).toBe(cstr.ptr); expect(result.toString()).toBe("hello"); }); + + it("given a CString with a byteOffset, passes the address of its data", () => { + const buf = Buffer.from("hello\0"); + const arr = new Uint8Array(buf); + const sliced = new CString(ptr(arr), 2, 3); + expect(sliced.toString()).toBe("llo"); + + expect(holder.library.symbols.ping(sliced).toString()).toBe("llo"); + }); }); // // Successful compiles are fine under ASan; the setjmp/longjmp conflict only diff --git a/test/js/bun/ffi/ffi.test.js b/test/js/bun/ffi/ffi.test.js index e942f1af21e5..6ee589d532a7 100644 --- a/test/js/bun/ffi/ffi.test.js +++ b/test/js/bun/ffi/ffi.test.js @@ -1116,6 +1116,14 @@ describe.if(!!strlenLibPath)("pointer argument conversion", () => { expect(lib.symbols.strlen(cstr)).toBe(6n); }); + it("accepts a CString with a byteOffset", () => { + const lib = dlopen(strlenLibPath, strings); + const buf = Buffer.from("bunbun\0", "ascii"); + const sliced = new CString(ptr(buf), 3, 3); + expect(sliced.toString()).toBe("bun"); + expect(lib.symbols.strlen(sliced)).toBe(3n); + }); + it("accepts an ArrayBuffer", () => { const lib = dlopen(strlenLibPath, strings); const buf = new ArrayBuffer(7);