From c58069cf53695bc5c0e3ff8e494a67d785da09a4 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 22 Sep 2026 02:56:20 +0000 Subject: [PATCH 1/9] bun:ffi: throw a TypeError when a symbol is called after its library's close() close() unloads the library, and the function objects it made outlive it: a destructured symbol, or a caller the JIT compiled with the address baked in. A call after close() jumped to the unmapped address. The library keeps the functions it made in a hidden slot, and close() closes each of them (JSFFIFunction::close()) before it unloads the library. A call to a closed function throws a TypeError. --- packages/bun-types/ffi.d.ts | 3 +- src/jsc/bindings/JSCFFIBridge.cpp | 38 +++++++++++ src/runtime/ffi/ffi.classes.ts | 2 +- src/runtime/ffi/ffi_body.rs | 11 +++- test/js/bun/ffi/ffi.test.js | 102 ++++++++++++++++++++++++++++++ 5 files changed, 153 insertions(+), 3 deletions(-) diff --git a/packages/bun-types/ffi.d.ts b/packages/bun-types/ffi.d.ts index 93584ffc457d..d708a8e071ed 100644 --- a/packages/bun-types/ffi.d.ts +++ b/packages/bun-types/ffi.d.ts @@ -536,7 +536,8 @@ declare module "bun:ffi" { * * Once called, the library is no longer usable. * - * Calling a function from a library that has been closed is undefined behavior. + * A symbol of a closed {@link dlopen} or {@link linkSymbols} library throws a `TypeError` when called. + * Calling a symbol of a closed {@link cc} library is undefined behavior. */ close(): void; } diff --git a/src/jsc/bindings/JSCFFIBridge.cpp b/src/jsc/bindings/JSCFFIBridge.cpp index cf8c923b7107..fa7fb465ca8d 100644 --- a/src/jsc/bindings/JSCFFIBridge.cpp +++ b/src/jsc/bindings/JSCFFIBridge.cpp @@ -8,10 +8,12 @@ #include #include #include "ScriptExecutionContext.h" +#include #include #include #include +#include "ZigGeneratedClasses.h" #include "ZigGlobalObject.h" #include "headers-handwritten.h" @@ -21,6 +23,13 @@ static_assert(static_cast(JSC::FFI::Type::JSValue) == 19, "FFI::Type ta static_assert(static_cast(JSC::FFI::Type::Buffer) == 20, "FFI::Type tag drift"); static_assert(static_cast(JSC::FFI::Type::BufferLength) == 21, "FFI::Type tag drift"); +// The functions a library made, which close() closes. `symbols` cannot be that list: the user can change it. +static JSC::JSArray* functionsOfLibrary(WebCore::JSFFI* library) +{ + JSC::JSValue functions = library->m_functionsValue.get(); + return functions ? dynamicDowncast(functions) : nullptr; +} + extern "C" JSC::EncodedJSValue Bun__CreateJSCFFIFunction( Zig::GlobalObject* globalObject, const EncodedSlice* symbolName, @@ -52,9 +61,38 @@ extern "C" JSC::EncodedJSValue Bun__CreateJSCFFIFunction( if (!function) RELEASE_AND_RETURN(scope, {}); + if (auto* library = dynamicDowncast(owner)) { + auto* functions = functionsOfLibrary(library); + if (!functions) { + functions = JSC::constructEmptyArray(globalObject, nullptr); + RETURN_IF_EXCEPTION(scope, {}); + library->m_functionsValue.set(vm, library, functions); + } + functions->push(globalObject, function); + RETURN_IF_EXCEPTION(scope, {}); + } + RELEASE_AND_RETURN(scope, JSC::JSValue::encode(function)); } +// After this, a call to a function of the library throws a TypeError. It must run before the code the +// functions point into goes away. +extern "C" void Bun__JSCFFILibraryCloseFunctions(Zig::GlobalObject* globalObject, JSC::EncodedJSValue libraryValue) +{ + auto& vm = JSC::getVM(globalObject); + auto* library = dynamicDowncast(JSC::JSValue::decode(libraryValue)); + if (!library) + return; + auto* functions = functionsOfLibrary(library); + if (!functions) + return; + for (unsigned i = 0, length = functions->length(); i < length; ++i) { + if (auto* function = dynamicDowncast(functions->getIndexQuickly(i))) + function->close(vm); + } + library->m_functionsValue.clear(); +} + static void Bun__jscFFIThreadsafeDispatch(JSC::FFI::ThreadsafeInvocation& invocation) { static_assert(sizeof(WebCore::ScriptExecutionContextIdentifier) <= sizeof(void*)); diff --git a/src/runtime/ffi/ffi.classes.ts b/src/runtime/ffi/ffi.classes.ts index 3fc7bd28aa8c..12ea47c5ebc4 100644 --- a/src/runtime/ffi/ffi.classes.ts +++ b/src/runtime/ffi/ffi.classes.ts @@ -18,6 +18,6 @@ export default [ getter: "getSymbols", }, }, - values: ["symbolsValue"], + values: ["symbolsValue", "functionsValue"], }), ]; diff --git a/src/runtime/ffi/ffi_body.rs b/src/runtime/ffi/ffi_body.rs index b2ff2174976b..8671deb99b0d 100644 --- a/src/runtime/ffi/ffi_body.rs +++ b/src/runtime/ffi/ffi_body.rs @@ -113,6 +113,8 @@ unsafe extern "C" { return_type: u8, threadsafe: bool, ) -> JSValue; + + fn Bun__JSCFFILibraryCloseFunctions(global: *const JSGlobalObject, library: JSValue); } /// `Ok(JSValue::ZERO)` is the C++ side's "could not create" without a @@ -1309,8 +1311,15 @@ impl FFI { } #[bun_jsc::host_fn(method)] - pub(crate) fn close(&self, _global_this: &JSGlobalObject, _: &CallFrame) -> JsResult { + pub(crate) fn close( + &self, + global_this: &JSGlobalObject, + callframe: &CallFrame, + ) -> JsResult { jsc::mark_binding(); + // Before `do_close` unloads the code the functions point into. + // SAFETY: thin FFI wrapper; the C++ side type-checks the cell (dynamicDowncast) before use. + unsafe { Bun__JSCFFILibraryCloseFunctions(global_this, callframe.this()) }; self.do_close(); Ok(JSValue::UNDEFINED) } diff --git a/test/js/bun/ffi/ffi.test.js b/test/js/bun/ffi/ffi.test.js index b82a25035990..7aa36571ac77 100644 --- a/test/js/bun/ffi/ffi.test.js +++ b/test/js/bun/ffi/ffi.test.js @@ -968,6 +968,108 @@ it.skipIf(!FFI_FIXTURE_PATH)("ptr argument: ArrayBuffer cells through an FTL-com expect({ stdout, stderr, exitCode }).toEqual({ stdout: "mismatches 0\n", stderr: "", exitCode: 0 }); }); +// close() unloads the library, so a call after it used to jump to an unmapped address. The function +// objects outlive close() (a destructured symbol, a caller the JIT compiled with the address baked in), +// so each of them has to throw. Runs in a child process: without the fix the first call after close() +// is a segfault. +it.skipIf(!FFI_FIXTURE_PATH)("a symbol called after its library's close() throws a TypeError", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `import { dlopen, linkSymbols } from "bun:ffi"; + const definitions = { + add_int32_t: { args: ["i32", "i32"], returns: "i32" }, + returns_true: { args: [], returns: "bool" }, + }; + const open = () => dlopen(process.env.FFI_FIXTURE_PATH, definitions); + const results = {}; + const attempt = (name, fn) => { + try { + results[name] = "returned " + fn(); + } catch (e) { + results[name] = e.name + ": " + e.message; + } + }; + + { + const lib = open(); + const { add_int32_t, returns_true } = lib.symbols; + function hot(a, b) { return add_int32_t(a, b); } + let sum = 0; + for (let i = 0; i < 200_000; i++) sum += hot(i, 1); + results["before close"] = sum; + + lib.close(); + lib.close(); + attempt("optimized caller", () => hot(1, 2)); + attempt("destructured symbol", () => add_int32_t(1, 2)); + attempt("through lib.symbols", () => lib.symbols.returns_true()); + attempt("Function.prototype.call", () => add_int32_t.call(null, 1, 2)); + attempt("arguments that do not convert", () => add_int32_t("x", Symbol())); + } + + { + // An argument conversion can run JS, so close() can happen after the call has started. + const lib = open(); + const { add_int32_t } = lib.symbols; + let armed = false; + const closer = { valueOf() { if (armed) lib.close(); return 1; } }; + function hot(a, b) { return add_int32_t(a, b); } + for (let i = 0; i < 20_000; i++) hot(i & 1 ? closer : 1, 1); + armed = true; + attempt("close inside valueOf, optimized caller", () => hot(closer, 2)); + } + + { + const lib = open(); + const { add_int32_t } = lib.symbols; + attempt("close inside valueOf", () => add_int32_t({ valueOf() { lib.close(); return 1; } }, 2)); + } + + { + // close() is per handle. + const first = open(); + const second = open(); + first.close(); + attempt("closed handle of a file that is open twice", () => first.symbols.add_int32_t(40, 2)); + results["open handle of a file that is open twice"] = second.symbols.add_int32_t(40, 2); + + const linked = linkSymbols({ sum: { ...definitions.add_int32_t, ptr: second.symbols.add_int32_t.ptr } }); + const { sum } = linked.symbols; + results["linkSymbols before close"] = sum(40, 2); + linked.close(); + attempt("linkSymbols", () => sum(40, 2)); + } + + console.log(JSON.stringify(results, null, 2));`, + ], + env: { ...bunEnv, FFI_FIXTURE_PATH }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const closed = name => `TypeError: bun:ffi: cannot call '${name}' because its library was closed`; + expect({ results: JSON.parse(stdout || "null"), stderr, exitCode }).toEqual({ + results: { + "before close": 20000100000, + "optimized caller": closed("add_int32_t"), + "destructured symbol": closed("add_int32_t"), + "through lib.symbols": closed("returns_true"), + "Function.prototype.call": closed("add_int32_t"), + "arguments that do not convert": closed("add_int32_t"), + "close inside valueOf, optimized caller": closed("add_int32_t"), + "close inside valueOf": closed("add_int32_t"), + "closed handle of a file that is open twice": closed("add_int32_t"), + "open handle of a file that is open twice": 42, + "linkSymbols before close": 42, + "linkSymbols": closed("sum"), + }, + stderr: "", + exitCode: 0, + }); +}); + it("worker teardown drops queued threadsafe JSCallback invocations without crashing", async () => { using dir = tempDir("ffi-jscallback-terminate-queued", { "main.js": ` From 2753885aee529f4ffdcb67bc3012cde9821ccb3b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 22 Sep 2026 07:10:57 +0000 Subject: [PATCH 2/9] bun:ffi: keep the functions close() closes out of reach of JS The list was filled with JSArray::push, which is a [[Set]]: an indexed setter on a prototype was handed the array, and could leave a hole in it or take a function out of it. It is filled with putDirectIndex now, and close() skips an empty slot. The test also covers a symbol deleted from lib.symbols before close(), and a closed function passed as a pointer argument (oven-sh/WebKit#714 now throws for it). --- src/jsc/bindings/JSCFFIBridge.cpp | 6 ++++-- test/js/bun/ffi/ffi.test.js | 31 +++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/jsc/bindings/JSCFFIBridge.cpp b/src/jsc/bindings/JSCFFIBridge.cpp index fa7fb465ca8d..8cf3b26eb7a3 100644 --- a/src/jsc/bindings/JSCFFIBridge.cpp +++ b/src/jsc/bindings/JSCFFIBridge.cpp @@ -68,7 +68,8 @@ extern "C" JSC::EncodedJSValue Bun__CreateJSCFFIFunction( RETURN_IF_EXCEPTION(scope, {}); library->m_functionsValue.set(vm, library, functions); } - functions->push(globalObject, function); + // Not push(): that is a [[Set]], and a setter on a prototype would be handed this array. + functions->putDirectIndex(globalObject, functions->length(), function); RETURN_IF_EXCEPTION(scope, {}); } @@ -87,7 +88,8 @@ extern "C" void Bun__JSCFFILibraryCloseFunctions(Zig::GlobalObject* globalObject if (!functions) return; for (unsigned i = 0, length = functions->length(); i < length; ++i) { - if (auto* function = dynamicDowncast(functions->getIndexQuickly(i))) + JSC::JSValue value = functions->tryGetIndexQuickly(i); + if (auto* function = value ? dynamicDowncast(value) : nullptr) function->close(vm); } library->m_functionsValue.clear(); diff --git a/test/js/bun/ffi/ffi.test.js b/test/js/bun/ffi/ffi.test.js index 7aa36571ac77..21b68e6fd065 100644 --- a/test/js/bun/ffi/ffi.test.js +++ b/test/js/bun/ffi/ffi.test.js @@ -981,6 +981,7 @@ it.skipIf(!FFI_FIXTURE_PATH)("a symbol called after its library's close() throws const definitions = { add_int32_t: { args: ["i32", "i32"], returns: "i32" }, returns_true: { args: [], returns: "bool" }, + identity_ptr: { args: ["ptr"], returns: "ptr" }, }; const open = () => dlopen(process.env.FFI_FIXTURE_PATH, definitions); const results = {}; @@ -1009,6 +1010,15 @@ it.skipIf(!FFI_FIXTURE_PATH)("a symbol called after its library's close() throws attempt("arguments that do not convert", () => add_int32_t("x", Symbol())); } + { + // close() does not find the functions through lib.symbols, which the user can change. + const lib = open(); + const { add_int32_t } = lib.symbols; + delete lib.symbols.add_int32_t; + lib.close(); + attempt("symbol deleted from lib.symbols before close", () => add_int32_t(1, 2)); + } + { // An argument conversion can run JS, so close() can happen after the call has started. const lib = open(); @@ -1035,6 +1045,9 @@ it.skipIf(!FFI_FIXTURE_PATH)("a symbol called after its library's close() throws attempt("closed handle of a file that is open twice", () => first.symbols.add_int32_t(40, 2)); results["open handle of a file that is open twice"] = second.symbols.add_int32_t(40, 2); + // The native callee would call the closed function's address. + attempt("closed function as a pointer argument", () => second.symbols.identity_ptr(first.symbols.add_int32_t)); + const linked = linkSymbols({ sum: { ...definitions.add_int32_t, ptr: second.symbols.add_int32_t.ptr } }); const { sum } = linked.symbols; results["linkSymbols before close"] = sum(40, 2); @@ -1042,6 +1055,19 @@ it.skipIf(!FFI_FIXTURE_PATH)("a symbol called after its library's close() throws attempt("linkSymbols", () => sum(40, 2)); } + { + // Nor can a setter on a prototype reach the functions close() has to find. Last: an indexed + // accessor on a prototype switches every array in the realm to slow puts. + let setterCalls = 0; + Object.defineProperty(Object.prototype, 0, { get() {}, set(v) { setterCalls++; }, configurable: true }); + const lib = open(); + delete Object.prototype[0]; + const { add_int32_t } = lib.symbols; + lib.close(); + attempt("indexed setter on Object.prototype during dlopen", () => add_int32_t(1, 2)); + results["indexed setter calls"] = setterCalls; + } + console.log(JSON.stringify(results, null, 2));`, ], env: { ...bunEnv, FFI_FIXTURE_PATH }, @@ -1058,12 +1084,17 @@ it.skipIf(!FFI_FIXTURE_PATH)("a symbol called after its library's close() throws "through lib.symbols": closed("returns_true"), "Function.prototype.call": closed("add_int32_t"), "arguments that do not convert": closed("add_int32_t"), + "symbol deleted from lib.symbols before close": closed("add_int32_t"), "close inside valueOf, optimized caller": closed("add_int32_t"), "close inside valueOf": closed("add_int32_t"), "closed handle of a file that is open twice": closed("add_int32_t"), "open handle of a file that is open twice": 42, + "closed function as a pointer argument": + "TypeError: bun:ffi: cannot pass 'add_int32_t' as a pointer because its library was closed", "linkSymbols before close": 42, "linkSymbols": closed("sum"), + "indexed setter on Object.prototype during dlopen": closed("add_int32_t"), + "indexed setter calls": 0, }, stderr: "", exitCode: 0, From e1f1ba2e2ab91f938afa66406fe47f46ec77f58d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 22 Sep 2026 07:42:59 +0000 Subject: [PATCH 3/9] JSCFFIBridge: drop a comment that repeats the call site --- src/jsc/bindings/JSCFFIBridge.cpp | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/jsc/bindings/JSCFFIBridge.cpp b/src/jsc/bindings/JSCFFIBridge.cpp index 8cf3b26eb7a3..e38c96f0f3ba 100644 --- a/src/jsc/bindings/JSCFFIBridge.cpp +++ b/src/jsc/bindings/JSCFFIBridge.cpp @@ -76,8 +76,6 @@ extern "C" JSC::EncodedJSValue Bun__CreateJSCFFIFunction( RELEASE_AND_RETURN(scope, JSC::JSValue::encode(function)); } -// After this, a call to a function of the library throws a TypeError. It must run before the code the -// functions point into goes away. extern "C" void Bun__JSCFFILibraryCloseFunctions(Zig::GlobalObject* globalObject, JSC::EncodedJSValue libraryValue) { auto& vm = JSC::getVM(globalObject); From 15bdad1721c492f48245d568105f001ff3f722a7 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 27 Sep 2026 02:56:04 +0000 Subject: [PATCH 4/9] bun:ffi: close() inside a call keeps the library loaded A JSCallback that the library calls can close the library. The call in flight returns into the library, so dlclose() there made that return a jump to an unmapped address. close() asks JSFFIFunction::isRunning() (oven-sh/WebKit#714) for each function, and skips the unload when one of them is running. The functions are closed either way: the call finishes, and the next one throws. --- packages/bun-types/ffi.d.ts | 3 +++ src/jsc/bindings/JSCFFIBridge.cpp | 13 ++++++---- src/runtime/ffi/ffi_body.rs | 9 +++++-- test/js/bun/ffi/ffi-test.c | 3 +++ test/js/bun/ffi/ffi.test.js | 40 ++++++++++++++++++++++++++++--- 5 files changed, 59 insertions(+), 9 deletions(-) diff --git a/packages/bun-types/ffi.d.ts b/packages/bun-types/ffi.d.ts index d708a8e071ed..5b7a9ffcf573 100644 --- a/packages/bun-types/ffi.d.ts +++ b/packages/bun-types/ffi.d.ts @@ -538,6 +538,9 @@ declare module "bun:ffi" { * * A symbol of a closed {@link dlopen} or {@link linkSymbols} library throws a `TypeError` when called. * Calling a symbol of a closed {@link cc} library is undefined behavior. + * + * `close()` keeps a {@link dlopen} library loaded when a symbol of the library is running, for + * example when a {@link JSCallback} that the library calls closes the library. That call then finishes. */ close(): void; } diff --git a/src/jsc/bindings/JSCFFIBridge.cpp b/src/jsc/bindings/JSCFFIBridge.cpp index e38c96f0f3ba..6eebdc509b91 100644 --- a/src/jsc/bindings/JSCFFIBridge.cpp +++ b/src/jsc/bindings/JSCFFIBridge.cpp @@ -76,21 +76,26 @@ extern "C" JSC::EncodedJSValue Bun__CreateJSCFFIFunction( RELEASE_AND_RETURN(scope, JSC::JSValue::encode(function)); } -extern "C" void Bun__JSCFFILibraryCloseFunctions(Zig::GlobalObject* globalObject, JSC::EncodedJSValue libraryValue) +// Returns true if one of the functions is running. Then the caller must keep the library loaded. +extern "C" bool Bun__JSCFFILibraryCloseFunctions(Zig::GlobalObject* globalObject, JSC::EncodedJSValue libraryValue) { auto& vm = JSC::getVM(globalObject); auto* library = dynamicDowncast(JSC::JSValue::decode(libraryValue)); if (!library) - return; + return false; auto* functions = functionsOfLibrary(library); if (!functions) - return; + return false; + bool isRunning = false; for (unsigned i = 0, length = functions->length(); i < length; ++i) { JSC::JSValue value = functions->tryGetIndexQuickly(i); - if (auto* function = value ? dynamicDowncast(value) : nullptr) + if (auto* function = value ? dynamicDowncast(value) : nullptr) { function->close(vm); + isRunning = isRunning || function->isRunning(vm); + } } library->m_functionsValue.clear(); + return isRunning; } static void Bun__jscFFIThreadsafeDispatch(JSC::FFI::ThreadsafeInvocation& invocation) diff --git a/src/runtime/ffi/ffi_body.rs b/src/runtime/ffi/ffi_body.rs index 8671deb99b0d..35db491e8f8d 100644 --- a/src/runtime/ffi/ffi_body.rs +++ b/src/runtime/ffi/ffi_body.rs @@ -114,7 +114,7 @@ unsafe extern "C" { threadsafe: bool, ) -> JSValue; - fn Bun__JSCFFILibraryCloseFunctions(global: *const JSGlobalObject, library: JSValue); + fn Bun__JSCFFILibraryCloseFunctions(global: *const JSGlobalObject, library: JSValue) -> bool; } /// `Ok(JSValue::ZERO)` is the C++ side's "could not create" without a @@ -1319,7 +1319,12 @@ impl FFI { jsc::mark_binding(); // Before `do_close` unloads the code the functions point into. // SAFETY: thin FFI wrapper; the C++ side type-checks the cell (dynamicDowncast) before use. - unsafe { Bun__JSCFFILibraryCloseFunctions(global_this, callframe.this()) }; + let is_running = + unsafe { Bun__JSCFFILibraryCloseFunctions(global_this, callframe.this()) }; + if is_running { + // The running call returns into the library, so `do_close` must find none to unload. + self.dylib.set(None); + } self.do_close(); Ok(JSValue::UNDEFINED) } diff --git a/test/js/bun/ffi/ffi-test.c b/test/js/bun/ffi/ffi-test.c index ae7ba120bb8d..de6613b94b56 100644 --- a/test/js/bun/ffi/ffi-test.c +++ b/test/js/bun/ffi/ffi-test.c @@ -161,3 +161,6 @@ FFI_EXPORT int64_t cb_identity_neg_42_int64_t(int64_t (*cb)()) { return cb(); } FFI_EXPORT uint64_t bl_echo_len(const void *buf, uint64_t len) { (void)buf; return len; } FFI_EXPORT uint32_t bl_last_byte(const uint8_t *buf, uint64_t len) { return len ? buf[len - 1] : 999u; } + +// Not a tail call: the callback returns into this function. +FFI_EXPORT int32_t cb_result_plus_int32_t(int32_t (*cb)(), int32_t a) { return cb() + a; } diff --git a/test/js/bun/ffi/ffi.test.js b/test/js/bun/ffi/ffi.test.js index 21b68e6fd065..c3594b700050 100644 --- a/test/js/bun/ffi/ffi.test.js +++ b/test/js/bun/ffi/ffi.test.js @@ -1,7 +1,8 @@ import { afterAll, describe, expect, it } from "bun:test"; -import { existsSync } from "fs"; +import { copyFileSync, existsSync } from "fs"; import { bunEnv, bunExe, compileFixture, isDebug, isGlibcVersionAtLeast, isWindows, tempDir } from "harness"; import { platform } from "os"; +import { join } from "path"; import { cc, @@ -973,15 +974,22 @@ it.skipIf(!FFI_FIXTURE_PATH)("ptr argument: ArrayBuffer cells through an FTL-com // so each of them has to throw. Runs in a child process: without the fix the first call after close() // is a segfault. it.skipIf(!FFI_FIXTURE_PATH)("a symbol called after its library's close() throws a TypeError", async () => { + using dir = tempDir("ffi-close", {}); + const copies = [1, 2].map(n => { + const copy = join(String(dir), "copy" + n + "." + suffix); + copyFileSync(FFI_FIXTURE_PATH, copy); + return copy; + }); await using proc = Bun.spawn({ cmd: [ bunExe(), "-e", - `import { dlopen, linkSymbols } from "bun:ffi"; + `import { dlopen, JSCallback, linkSymbols } from "bun:ffi"; const definitions = { add_int32_t: { args: ["i32", "i32"], returns: "i32" }, returns_true: { args: [], returns: "bool" }, identity_ptr: { args: ["ptr"], returns: "ptr" }, + cb_result_plus_int32_t: { args: ["callback", "i32"], returns: "i32" }, }; const open = () => dlopen(process.env.FFI_FIXTURE_PATH, definitions); const results = {}; @@ -1037,6 +1045,28 @@ it.skipIf(!FFI_FIXTURE_PATH)("a symbol called after its library's close() throws attempt("close inside valueOf", () => add_int32_t({ valueOf() { lib.close(); return 1; } }, 2)); } + for (const [what, warmUp, copy] of [ + ["cold", 0, process.env.FFI_FIXTURE_COPY_1], + ["warm", ${isDebug ? 5_000 : 20_000}, process.env.FFI_FIXTURE_COPY_2], + ]) { + // close() inside a call: the library calls a callback, and the callback closes the library. + // The call returns into the library, so the library has to stay loaded. Each case has a copy + // of the library to itself: a library that stays loaded would hide a later unload. + const lib = dlopen(copy, definitions); + const { cb_result_plus_int32_t } = lib.symbols; + let armed = false; + const callback = new JSCallback(() => { + if (armed) lib.close(); + return 40; + }, { args: [], returns: "i32" }); + function hot() { return cb_result_plus_int32_t(callback, 2); } + for (let i = 0; i < warmUp; i++) hot(); + armed = true; + results["close inside a call, " + what] = hot(); + attempt("call after a close inside a call, " + what, () => hot()); + callback.close(); + } + { // close() is per handle. const first = open(); @@ -1070,7 +1100,7 @@ it.skipIf(!FFI_FIXTURE_PATH)("a symbol called after its library's close() throws console.log(JSON.stringify(results, null, 2));`, ], - env: { ...bunEnv, FFI_FIXTURE_PATH }, + env: { ...bunEnv, FFI_FIXTURE_PATH, FFI_FIXTURE_COPY_1: copies[0], FFI_FIXTURE_COPY_2: copies[1] }, stdout: "pipe", stderr: "pipe", }); @@ -1087,6 +1117,10 @@ it.skipIf(!FFI_FIXTURE_PATH)("a symbol called after its library's close() throws "symbol deleted from lib.symbols before close": closed("add_int32_t"), "close inside valueOf, optimized caller": closed("add_int32_t"), "close inside valueOf": closed("add_int32_t"), + "close inside a call, cold": 42, + "call after a close inside a call, cold": closed("cb_result_plus_int32_t"), + "close inside a call, warm": 42, + "call after a close inside a call, warm": closed("cb_result_plus_int32_t"), "closed handle of a file that is open twice": closed("add_int32_t"), "open handle of a file that is open twice": 42, "closed function as a pointer argument": From 1db038325ce9bf7a94d0a4af9226f4d65dd280dd Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 27 Sep 2026 02:56:05 +0000 Subject: [PATCH 5/9] Pin the preview build of oven-sh/WebKit#714 at 7c8d32a0 --- scripts/build/deps/webkit.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/build/deps/webkit.ts b/scripts/build/deps/webkit.ts index 47dedb61e337..f597c3c00d07 100644 --- a/scripts/build/deps/webkit.ts +++ b/scripts/build/deps/webkit.ts @@ -3,7 +3,7 @@ * for local mode. Override via `--webkit-version=` to test a branch. * From https://github.com/oven-sh/WebKit releases. */ -export const WEBKIT_VERSION = "f20ce7744553c910bcf16a33faf976af208de091"; +export const WEBKIT_VERSION = "autobuild-preview-pr-714-7c8d32a0"; /** * WebKit (JavaScriptCore) — the JS engine. From 1a2e6f2606b0e84a63efe9cc14edeb9726ca34ea Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 27 Sep 2026 03:10:59 +0000 Subject: [PATCH 6/9] [autofix.ci] apply automated fixes --- src/runtime/ffi/ffi_body.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/runtime/ffi/ffi_body.rs b/src/runtime/ffi/ffi_body.rs index 35db491e8f8d..5526c59b26ba 100644 --- a/src/runtime/ffi/ffi_body.rs +++ b/src/runtime/ffi/ffi_body.rs @@ -1319,8 +1319,7 @@ impl FFI { jsc::mark_binding(); // Before `do_close` unloads the code the functions point into. // SAFETY: thin FFI wrapper; the C++ side type-checks the cell (dynamicDowncast) before use. - let is_running = - unsafe { Bun__JSCFFILibraryCloseFunctions(global_this, callframe.this()) }; + let is_running = unsafe { Bun__JSCFFILibraryCloseFunctions(global_this, callframe.this()) }; if is_running { // The running call returns into the library, so `do_close` must find none to unload. self.dylib.set(None); From d788181407c8889fa410b1c9f38b3137e52bee66 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 27 Sep 2026 04:53:43 +0000 Subject: [PATCH 7/9] Pin the preview build of oven-sh/WebKit#714 at 79abc9a6 --- scripts/build/deps/webkit.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/build/deps/webkit.ts b/scripts/build/deps/webkit.ts index f597c3c00d07..7c851cbc54fe 100644 --- a/scripts/build/deps/webkit.ts +++ b/scripts/build/deps/webkit.ts @@ -3,7 +3,7 @@ * for local mode. Override via `--webkit-version=` to test a branch. * From https://github.com/oven-sh/WebKit releases. */ -export const WEBKIT_VERSION = "autobuild-preview-pr-714-7c8d32a0"; +export const WEBKIT_VERSION = "autobuild-preview-pr-714-79abc9a6"; /** * WebKit (JavaScriptCore) — the JS engine. From ada3fc9bddfca26eb6ad5c0f41a5058a7f315748 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 27 Sep 2026 06:54:37 +0000 Subject: [PATCH 8/9] test(ffi): close() inside a call, in a test of its own, with a callback that makes a tail call A strict callback that ends in a call makes a tail call, and optimized code inlines the callee in place of the callback's frame. The stack walk of JSFFIFunction::isRunning() then did not see the VM entry, close() unloaded the library, and the call in flight returned to an unmapped address. oven-sh/WebKit#714 at d4f2d385 has the fix. --- test/js/bun/ffi/ffi.test.js | 107 ++++++++++++++++++++++++------------ 1 file changed, 72 insertions(+), 35 deletions(-) diff --git a/test/js/bun/ffi/ffi.test.js b/test/js/bun/ffi/ffi.test.js index c3594b700050..b5dc11402388 100644 --- a/test/js/bun/ffi/ffi.test.js +++ b/test/js/bun/ffi/ffi.test.js @@ -974,22 +974,15 @@ it.skipIf(!FFI_FIXTURE_PATH)("ptr argument: ArrayBuffer cells through an FTL-com // so each of them has to throw. Runs in a child process: without the fix the first call after close() // is a segfault. it.skipIf(!FFI_FIXTURE_PATH)("a symbol called after its library's close() throws a TypeError", async () => { - using dir = tempDir("ffi-close", {}); - const copies = [1, 2].map(n => { - const copy = join(String(dir), "copy" + n + "." + suffix); - copyFileSync(FFI_FIXTURE_PATH, copy); - return copy; - }); await using proc = Bun.spawn({ cmd: [ bunExe(), "-e", - `import { dlopen, JSCallback, linkSymbols } from "bun:ffi"; + `import { dlopen, linkSymbols } from "bun:ffi"; const definitions = { add_int32_t: { args: ["i32", "i32"], returns: "i32" }, returns_true: { args: [], returns: "bool" }, identity_ptr: { args: ["ptr"], returns: "ptr" }, - cb_result_plus_int32_t: { args: ["callback", "i32"], returns: "i32" }, }; const open = () => dlopen(process.env.FFI_FIXTURE_PATH, definitions); const results = {}; @@ -1045,28 +1038,6 @@ it.skipIf(!FFI_FIXTURE_PATH)("a symbol called after its library's close() throws attempt("close inside valueOf", () => add_int32_t({ valueOf() { lib.close(); return 1; } }, 2)); } - for (const [what, warmUp, copy] of [ - ["cold", 0, process.env.FFI_FIXTURE_COPY_1], - ["warm", ${isDebug ? 5_000 : 20_000}, process.env.FFI_FIXTURE_COPY_2], - ]) { - // close() inside a call: the library calls a callback, and the callback closes the library. - // The call returns into the library, so the library has to stay loaded. Each case has a copy - // of the library to itself: a library that stays loaded would hide a later unload. - const lib = dlopen(copy, definitions); - const { cb_result_plus_int32_t } = lib.symbols; - let armed = false; - const callback = new JSCallback(() => { - if (armed) lib.close(); - return 40; - }, { args: [], returns: "i32" }); - function hot() { return cb_result_plus_int32_t(callback, 2); } - for (let i = 0; i < warmUp; i++) hot(); - armed = true; - results["close inside a call, " + what] = hot(); - attempt("call after a close inside a call, " + what, () => hot()); - callback.close(); - } - { // close() is per handle. const first = open(); @@ -1100,7 +1071,7 @@ it.skipIf(!FFI_FIXTURE_PATH)("a symbol called after its library's close() throws console.log(JSON.stringify(results, null, 2));`, ], - env: { ...bunEnv, FFI_FIXTURE_PATH, FFI_FIXTURE_COPY_1: copies[0], FFI_FIXTURE_COPY_2: copies[1] }, + env: { ...bunEnv, FFI_FIXTURE_PATH }, stdout: "pipe", stderr: "pipe", }); @@ -1117,10 +1088,6 @@ it.skipIf(!FFI_FIXTURE_PATH)("a symbol called after its library's close() throws "symbol deleted from lib.symbols before close": closed("add_int32_t"), "close inside valueOf, optimized caller": closed("add_int32_t"), "close inside valueOf": closed("add_int32_t"), - "close inside a call, cold": 42, - "call after a close inside a call, cold": closed("cb_result_plus_int32_t"), - "close inside a call, warm": 42, - "call after a close inside a call, warm": closed("cb_result_plus_int32_t"), "closed handle of a file that is open twice": closed("add_int32_t"), "open handle of a file that is open twice": 42, "closed function as a pointer argument": @@ -1135,6 +1102,76 @@ it.skipIf(!FFI_FIXTURE_PATH)("a symbol called after its library's close() throws }); }); +// The library calls a JSCallback, and the callback closes the library. That call returns into the +// library, so the library has to stay loaded. Without that the child dies when the callback returns. +it.skipIf(!FFI_FIXTURE_PATH)("close() inside a call of the library lets the call finish", async () => { + // Each case has a copy of the library to itself: a library that stays loaded would hide a later unload. + using dir = tempDir("ffi-close-inside-a-call", {}); + const copies = ["cold", "warm", "tail-call"].map(name => { + const copy = join(String(dir), name + "." + suffix); + copyFileSync(FFI_FIXTURE_PATH, copy); + return copy; + }); + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `import { dlopen, JSCallback } from "bun:ffi"; + import { noInline } from "bun:jsc"; + const results = {}; + for (const [what, warmUp, copy] of [ + ["cold", 0, process.env.FFI_FIXTURE_COPY_1], + ["warm", ${isDebug ? 5_000 : 20_000}, process.env.FFI_FIXTURE_COPY_2], + ["warm, tail call", ${isDebug ? 5_000 : 20_000}, process.env.FFI_FIXTURE_COPY_3], + ]) { + const lib = dlopen(copy, { cb_result_plus_int32_t: { args: ["callback", "i32"], returns: "i32" } }); + const { cb_result_plus_int32_t } = lib.symbols; + let armed = false; + const closeIfArmed = () => { if (armed) lib.close(); }; + noInline(closeIfArmed); + // A module is strict code, so a function that ends in a call makes a tail call. Optimized + // code inlines finish() in place of the callback's frame, and the stack then has no frame + // that the library called. + const finish = () => { closeIfArmed(); return 40; }; + const callback = new JSCallback( + what.includes("tail call") ? () => finish() : () => { closeIfArmed(); return 40; }, + { args: [], returns: "i32" }, + ); + function hot() { return cb_result_plus_int32_t(callback, 2); } + for (let i = 0; i < warmUp; i++) hot(); + armed = true; + results[what] = { callThatCloses: hot() }; + try { + results[what].nextCall = "returned " + hot(); + } catch (e) { + results[what].nextCall = e.name + ": " + e.message; + } + callback.close(); + } + console.log(JSON.stringify(results, null, 2));`, + ], + env: { + ...bunEnv, + FFI_FIXTURE_PATH, + FFI_FIXTURE_COPY_1: copies[0], + FFI_FIXTURE_COPY_2: copies[1], + FFI_FIXTURE_COPY_3: copies[2], + }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const expected = { + callThatCloses: 42, + nextCall: "TypeError: bun:ffi: cannot call 'cb_result_plus_int32_t' because its library was closed", + }; + expect({ results: JSON.parse(stdout || "null"), stderr, exitCode }).toEqual({ + results: { "cold": expected, "warm": expected, "warm, tail call": expected }, + stderr: "", + exitCode: 0, + }); +}); + it("worker teardown drops queued threadsafe JSCallback invocations without crashing", async () => { using dir = tempDir("ffi-jscallback-terminate-queued", { "main.js": ` From 4d9d5eecbffc216bca1032cf14931f94a680cae4 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 27 Sep 2026 06:54:37 +0000 Subject: [PATCH 9/9] Pin the preview build of oven-sh/WebKit#714 at d4f2d385 --- scripts/build/deps/webkit.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/build/deps/webkit.ts b/scripts/build/deps/webkit.ts index 7c851cbc54fe..d70602b98101 100644 --- a/scripts/build/deps/webkit.ts +++ b/scripts/build/deps/webkit.ts @@ -3,7 +3,7 @@ * for local mode. Override via `--webkit-version=` to test a branch. * From https://github.com/oven-sh/WebKit releases. */ -export const WEBKIT_VERSION = "autobuild-preview-pr-714-79abc9a6"; +export const WEBKIT_VERSION = "autobuild-preview-pr-714-d4f2d385"; /** * WebKit (JavaScriptCore) — the JS engine.