diff --git a/docs/runtime/ffi.mdx b/docs/runtime/ffi.mdx index eb96480b996b..e2042bd68e08 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 | null, length: bigint) => ptr !== null && /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 | null, length: bigint) => ptr !== null && /hello/.test(new CString(ptr, 0, Number(length))), + { + returns: "bool", + args: ["ptr", "usize"], + threadsafe: true, // Optional. Defaults to `false` + }, +); ``` diff --git a/packages/bun-types/ffi.d.ts b/packages/bun-types/ffi.d.ts index e91825ce6dc6..d7c7b2af2bb4 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 or DataView (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; @@ -420,11 +481,20 @@ 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; + ["size_t"]: 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; @@ -538,22 +608,46 @@ 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 (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] } + : // 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 +676,7 @@ declare module "bun:ffi" { * * @category FFI */ - function dlopen>( + function dlopen>( name: string | import("bun").BunFile | URL, symbols: Fns, ): Library; @@ -621,7 +715,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 +817,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 +877,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 +1175,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..dff7c75b0e58 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, @@ -45,6 +48,7 @@ const FFIType = { uint64_t: 8, uint8_t: 2, usize: 8, + size_t: 8, "void*": 12, ptr: 12, pointer: 12, @@ -283,8 +287,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 +310,14 @@ ffiWrappers[FFIType.cstring] = ffiWrappers[FFIType.pointer] = `{ return __GlobalBunFFIPtrFunctionForWrapper(val); } + // 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") { throw new TypeError("To convert a string to a pointer, encode it as a buffer"); } @@ -313,7 +327,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 +351,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 +517,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..243db84e7d02 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,76 @@ 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); + +// 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", "size_t", "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, + 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..cafe356ebea4 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"; @@ -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,34 @@ 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"); + }); + + 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"); }); }); // -// 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 +198,114 @@ 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); }); }); // +// 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; } + 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; } + `, + { + greet: { + args: [], + returns: "cstring", + }, + identity_u32: { + args: ["u32"], + returns: "u32", + }, + identity_size_t: { + args: ["size_t"], + returns: "size_t", + }, + identity_ptr: { + args: ["ptr"], + returns: "ptr", + }, + invoke: { + args: ["function", "int"], + returns: "int", + }, + napi_echo: { + // 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, + }, + }, + ); + + 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("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)); + }); + + 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 +313,7 @@ function makeValidCase>( [filename]: source, }); - library = cc({ + holder.library = cc({ source: path.join(dir, filename), symbols, }); @@ -220,11 +324,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..6ee589d532a7 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,72 @@ 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, and bun:ffi is disabled entirely on Windows ARM64, so these +// are skipped there. +const strlenLibPath = + 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 = { + 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 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); + 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); + }); +});