diff --git a/src/jsc/bindings/JSFFIFunction.cpp b/src/jsc/bindings/JSFFIFunction.cpp index beb3d6122e3..daccfc90831 100644 --- a/src/jsc/bindings/JSFFIFunction.cpp +++ b/src/jsc/bindings/JSFFIFunction.cpp @@ -116,10 +116,10 @@ extern "C" JSC::EncodedJSValue Bun__CreateFFIFunctionValue(Zig::GlobalObject* gl if (addPtrField) { auto* function = Zig::JSFFIFunction::createForFFI(globalObject->vm(), globalObject, argCount, symbolName != nullptr ? Zig::toStringCopy(*symbolName) : String(), reinterpret_cast(functionPointer)); auto& vm = JSC::getVM(globalObject); - // We should only expose the "ptr" field when it's a JSCallback for bun:ffi. - // Not for internal usages of this function type. - // We should also consider a separate JSFunction type for our usage to not have this branch in the first place... - function->putDirect(vm, JSC::Identifier::fromString(vm, String("ptr"_s)), JSC::jsNumber(std::bit_cast(functionPointer)), JSC::PropertyAttribute::ReadOnly | 0); + // .ptr is the native symbol address (dlsym result / user-provided ptr), encoded the same + // way as every other bun:ffi pointer (PTR_TO_JSVALUE / JSValue::from_ptr_address) so it + // round-trips through CFunction/linkSymbols. Not the JS-call trampoline (functionPointer). + function->putDirect(vm, JSC::Identifier::fromString(vm, String("ptr"_s)), JSC::jsNumber(static_cast(reinterpret_cast(symbolFromDynamicLibrary))), JSC::PropertyAttribute::ReadOnly | 0); function->symbolFromDynamicLibrary = symbolFromDynamicLibrary; return JSC::JSValue::encode(function); } diff --git a/test/js/bun/ffi/ffi.test.js b/test/js/bun/ffi/ffi.test.js index 2546e1d1fcf..bb529764e22 100644 --- a/test/js/bun/ffi/ffi.test.js +++ b/test/js/bun/ffi/ffi.test.js @@ -718,6 +718,54 @@ it(".ptr is not leaked", () => { } }); +// Runs in a subprocess: `bun test`'s exit path does not finalize the CFunction's native handle, +// which the ASan lane's leak checker then reports against this file. +it.skipIf(isFFIUnavailable)( + "lib.symbols..ptr is the native symbol address and round-trips through CFunction", + async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `import { CFunction, JSCallback, linkSymbols } from "bun:ffi"; + const cb = new JSCallback((a, b) => a + b, { args: ["int", "int"], returns: "int" }); + const lib = linkSymbols({ add2: { ptr: cb.ptr, args: ["int", "int"], returns: "int" } }); + const symPtr = lib.symbols.add2.ptr; + console.log(JSON.stringify({ + cbPtr: cb.ptr, + symPtr, + nativePtr: lib.symbols.add2.native.ptr, + isInteger: Number.isInteger(symPtr), + })); + const rewrapped = new CFunction({ ptr: symPtr, args: ["int", "int"], returns: "int" }); + console.log("call", rewrapped(2, 3)); + rewrapped.close(); + lib.close(); + cb.close();`, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const lines = stdout.split("\n"); + const parsed = lines[0].startsWith("{") ? JSON.parse(lines[0]) : lines[0]; + expect({ parsed, call: lines[1], stderr, exitCode }).toEqual({ + parsed: { + cbPtr: parsed.cbPtr, + symPtr: parsed.cbPtr, + nativePtr: parsed.cbPtr, + isInteger: true, + }, + call: "call 5", + stderr: "", + exitCode: 0, + }); + expect(Number.isInteger(parsed.cbPtr)).toBe(true); + expect(parsed.cbPtr).toBeGreaterThan(0); + }, +); + // Runs in a subprocess: `bun test`'s exit path does not finalize the CFunction's native handle, // which the ASan lane's leak checker then reports against this file. it.skipIf(isFFIUnavailable)("JSCallback exceptions propagate out of the native call", async () => {