From 9fdf4d2f7351633993ec38ecf62bd370aaa1d3da Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 26 Jun 2026 22:25:13 +0000 Subject: [PATCH 1/7] ffi: don't re-throw JSC's TerminationException from a JSCallback FFI_Callback_* called the JS function through the NakedPtr profiledCall overload, then cleared and re-threw the returned exception. For the TerminationException raised by worker.terminate() that re-throw happens after the outermost VMEntryScope has already retired the termination request, which violates VM::setException's precondition and re-enters JS on the terminated worker VM: ASSERTION FAILED: !isTerminationException(exception) || hasTerminationRequest() JavaScriptCore/runtime/VM.cpp(1072) : void JSC::VM::setException(Exception *) Leave the exception pending on the VM instead, like any other host function, and skip callback tasks posted to a global whose script execution has already stopped. --- src/jsc/bindings/JSFFIFunction.cpp | 160 +++++------------------------ test/js/bun/ffi/ffi.test.js | 99 +++++++++++++++++- 2 files changed, 125 insertions(+), 134 deletions(-) diff --git a/src/jsc/bindings/JSFFIFunction.cpp b/src/jsc/bindings/JSFFIFunction.cpp index cbe3a0f2d373..dd334fcbf8c1 100644 --- a/src/jsc/bindings/JSFFIFunction.cpp +++ b/src/jsc/bindings/JSFFIFunction.cpp @@ -184,24 +184,26 @@ JSFFIFunction* JSFFIFunction::createForFFI(VM& vm, Zig::GlobalObject* globalObje } // namespace JSC +// Shared tail for the FFI_Callback_* entry points: call back into JS and leave any exception +// pending on the VM, like any other host function. Clearing + re-throwing it (the old NakedPtr +// dance) violated VM::setException's precondition for worker.terminate()'s TerminationException +// once the outermost VMEntryScope had already retired the termination request. +static JSC::EncodedJSValue invokeFFICallback(Zig::GlobalObject* globalObject, JSC::JSFunction* function, JSC::MarkedArgumentBuffer& arguments) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + auto result = JSC::profiledCall(globalObject, JSC::ProfilingReason::API, function, JSC::getCallData(function), JSC::jsUndefined(), arguments); + RETURN_IF_EXCEPTION(scope, JSC::JSValue::encode(JSC::jsNull())); + return JSC::JSValue::encode(result); +} + extern "C" JSC::EncodedJSValue FFI_Callback_call(FFICallbackFunctionWrapper& wrapper, size_t argCount, JSC::EncodedJSValue* args) { - auto* function = wrapper.m_function.get(); - auto* globalObject = wrapper.globalObject.get(); - auto& vm = JSC::getVM(globalObject); JSC::MarkedArgumentBuffer arguments; for (size_t i = 0; i < argCount; ++i) arguments.appendWithCrashOnOverflow(JSC::JSValue::decode(args[i])); - WTF::NakedPtr exception; - auto result = JSC::profiledCall(globalObject, JSC::ProfilingReason::API, function, JSC::getCallData(function), JSC::jsUndefined(), arguments, exception); - if (exception) [[unlikely]] { - auto scope = DECLARE_THROW_SCOPE(vm); - scope.throwException(globalObject, exception); - return JSC::JSValue::encode(JSC::jsNull()); - } - - return JSC::JSValue::encode(result); + return invokeFFICallback(wrapper.globalObject.get(), wrapper.m_function.get(), arguments); } extern "C" void @@ -214,160 +216,74 @@ FFI_Callback_threadsafe_call(FFICallbackFunctionWrapper& wrapper, size_t argCoun WebCore::ScriptExecutionContext::postTaskTo(wrapper.m_contextId, [argsVec = WTF::move(argsVec), protectedWrapper = Ref { wrapper }](WebCore::ScriptExecutionContext& ctx) mutable { auto* globalObject = uncheckedDowncast(ctx.jsGlobalObject()); - auto& vm = JSC::getVM(globalObject); + // The worker may have been terminated (or the VM begun shutting down) between + // enqueue and dispatch; never re-enter JS on a stopped global. + if (Zig::GlobalObject::scriptExecutionStatus(globalObject, globalObject) != JSC::ScriptExecutionStatus::Running) [[unlikely]] + return; JSC::MarkedArgumentBuffer arguments; - auto* function = protectedWrapper->m_function.get(); for (size_t i = 0; i < argsVec.size(); ++i) arguments.appendWithCrashOnOverflow(JSC::JSValue::decode(argsVec[i])); - WTF::NakedPtr exception; - JSC::profiledCall(globalObject, JSC::ProfilingReason::API, function, JSC::getCallData(function), JSC::jsUndefined(), arguments, exception); - if (exception) [[unlikely]] { - auto scope = DECLARE_THROW_SCOPE(vm); - scope.throwException(globalObject, exception); - return; - } + invokeFFICallback(globalObject, protectedWrapper->m_function.get(), arguments); }); } extern "C" JSC::EncodedJSValue FFI_Callback_call_0(FFICallbackFunctionWrapper& wrapper, size_t argCount, JSC::EncodedJSValue* args) { - auto* function = wrapper.m_function.get(); - auto* globalObject = wrapper.globalObject.get(); - auto& vm = JSC::getVM(globalObject); - JSC::MarkedArgumentBuffer arguments; - - WTF::NakedPtr exception; - auto result = JSC::profiledCall(globalObject, JSC::ProfilingReason::API, function, JSC::getCallData(function), JSC::jsUndefined(), arguments, exception); - if (exception) [[unlikely]] { - auto scope = DECLARE_THROW_SCOPE(vm); - scope.throwException(globalObject, exception); - return JSC::JSValue::encode(JSC::jsNull()); - } - - return JSC::JSValue::encode(result); + return invokeFFICallback(wrapper.globalObject.get(), wrapper.m_function.get(), arguments); } extern "C" JSC::EncodedJSValue FFI_Callback_call_1(FFICallbackFunctionWrapper& wrapper, size_t argCount, JSC::EncodedJSValue* args) { - auto* function = wrapper.m_function.get(); - auto* globalObject = wrapper.globalObject.get(); - auto& vm = JSC::getVM(globalObject); - JSC::MarkedArgumentBuffer arguments; arguments.append(JSC::JSValue::decode(args[0])); - - WTF::NakedPtr exception; - auto result = JSC::profiledCall(globalObject, JSC::ProfilingReason::API, function, JSC::getCallData(function), JSC::jsUndefined(), arguments, exception); - if (exception) [[unlikely]] { - auto scope = DECLARE_THROW_SCOPE(vm); - scope.throwException(globalObject, exception); - return JSC::JSValue::encode(JSC::jsNull()); - } - - return JSC::JSValue::encode(result); + return invokeFFICallback(wrapper.globalObject.get(), wrapper.m_function.get(), arguments); } extern "C" JSC::EncodedJSValue FFI_Callback_call_2(FFICallbackFunctionWrapper& wrapper, size_t argCount, JSC::EncodedJSValue* args) { - auto* function = wrapper.m_function.get(); - auto* globalObject = wrapper.globalObject.get(); - auto& vm = JSC::getVM(globalObject); - JSC::MarkedArgumentBuffer arguments; arguments.append(JSC::JSValue::decode(args[0])); arguments.append(JSC::JSValue::decode(args[1])); - - WTF::NakedPtr exception; - auto result = JSC::profiledCall(globalObject, JSC::ProfilingReason::API, function, JSC::getCallData(function), JSC::jsUndefined(), arguments, exception); - if (exception) [[unlikely]] { - auto scope = DECLARE_THROW_SCOPE(vm); - scope.throwException(globalObject, exception); - return JSC::JSValue::encode(JSC::jsNull()); - } - - return JSC::JSValue::encode(result); + return invokeFFICallback(wrapper.globalObject.get(), wrapper.m_function.get(), arguments); } extern "C" JSC::EncodedJSValue FFI_Callback_call_3(FFICallbackFunctionWrapper& wrapper, size_t argCount, JSC::EncodedJSValue* args) { - auto* function = wrapper.m_function.get(); - auto* globalObject = wrapper.globalObject.get(); - auto& vm = JSC::getVM(globalObject); - JSC::MarkedArgumentBuffer arguments; arguments.append(JSC::JSValue::decode(args[0])); arguments.append(JSC::JSValue::decode(args[1])); arguments.append(JSC::JSValue::decode(args[2])); - - WTF::NakedPtr exception; - auto result = JSC::profiledCall(globalObject, JSC::ProfilingReason::API, function, JSC::getCallData(function), JSC::jsUndefined(), arguments, exception); - if (exception) [[unlikely]] { - auto scope = DECLARE_THROW_SCOPE(vm); - scope.throwException(globalObject, exception); - return JSC::JSValue::encode(JSC::jsNull()); - } - - return JSC::JSValue::encode(result); + return invokeFFICallback(wrapper.globalObject.get(), wrapper.m_function.get(), arguments); } extern "C" JSC::EncodedJSValue FFI_Callback_call_4(FFICallbackFunctionWrapper& wrapper, size_t argCount, JSC::EncodedJSValue* args) { - auto* function = wrapper.m_function.get(); - auto* globalObject = wrapper.globalObject.get(); - auto& vm = JSC::getVM(globalObject); - JSC::MarkedArgumentBuffer arguments; arguments.append(JSC::JSValue::decode(args[0])); arguments.append(JSC::JSValue::decode(args[1])); arguments.append(JSC::JSValue::decode(args[2])); arguments.append(JSC::JSValue::decode(args[3])); - - WTF::NakedPtr exception; - auto result = JSC::profiledCall(globalObject, JSC::ProfilingReason::API, function, JSC::getCallData(function), JSC::jsUndefined(), arguments, exception); - if (exception) [[unlikely]] { - auto scope = DECLARE_THROW_SCOPE(vm); - scope.throwException(globalObject, exception); - return JSC::JSValue::encode(JSC::jsNull()); - } - - return JSC::JSValue::encode(result); + return invokeFFICallback(wrapper.globalObject.get(), wrapper.m_function.get(), arguments); } extern "C" JSC::EncodedJSValue FFI_Callback_call_5(FFICallbackFunctionWrapper& wrapper, size_t argCount, JSC::EncodedJSValue* args) { - auto* function = wrapper.m_function.get(); - auto* globalObject = wrapper.globalObject.get(); - auto& vm = JSC::getVM(globalObject); - JSC::MarkedArgumentBuffer arguments; arguments.append(JSC::JSValue::decode(args[0])); arguments.append(JSC::JSValue::decode(args[1])); arguments.append(JSC::JSValue::decode(args[2])); arguments.append(JSC::JSValue::decode(args[3])); arguments.append(JSC::JSValue::decode(args[4])); - - WTF::NakedPtr exception; - auto result = JSC::profiledCall(globalObject, JSC::ProfilingReason::API, function, JSC::getCallData(function), JSC::jsUndefined(), arguments, exception); - if (exception) [[unlikely]] { - auto scope = DECLARE_THROW_SCOPE(vm); - scope.throwException(globalObject, exception); - return JSC::JSValue::encode(JSC::jsNull()); - } - - return JSC::JSValue::encode(result); + return invokeFFICallback(wrapper.globalObject.get(), wrapper.m_function.get(), arguments); } extern "C" JSC::EncodedJSValue FFI_Callback_call_6(FFICallbackFunctionWrapper& wrapper, size_t argCount, JSC::EncodedJSValue* args) { - auto* function = wrapper.m_function.get(); - auto* globalObject = wrapper.globalObject.get(); - auto& vm = JSC::getVM(globalObject); - JSC::MarkedArgumentBuffer arguments; arguments.append(JSC::JSValue::decode(args[0])); arguments.append(JSC::JSValue::decode(args[1])); @@ -375,25 +291,12 @@ FFI_Callback_call_6(FFICallbackFunctionWrapper& wrapper, size_t argCount, JSC::E arguments.append(JSC::JSValue::decode(args[3])); arguments.append(JSC::JSValue::decode(args[4])); arguments.append(JSC::JSValue::decode(args[5])); - - WTF::NakedPtr exception; - auto result = JSC::profiledCall(globalObject, JSC::ProfilingReason::API, function, JSC::getCallData(function), JSC::jsUndefined(), arguments, exception); - if (exception) [[unlikely]] { - auto scope = DECLARE_THROW_SCOPE(vm); - scope.throwException(globalObject, exception); - return JSC::JSValue::encode(JSC::jsNull()); - } - - return JSC::JSValue::encode(result); + return invokeFFICallback(wrapper.globalObject.get(), wrapper.m_function.get(), arguments); } extern "C" JSC::EncodedJSValue FFI_Callback_call_7(FFICallbackFunctionWrapper& wrapper, size_t argCount, JSC::EncodedJSValue* args) { - auto* function = wrapper.m_function.get(); - auto* globalObject = wrapper.globalObject.get(); - auto& vm = JSC::getVM(globalObject); - JSC::MarkedArgumentBuffer arguments; arguments.append(JSC::JSValue::decode(args[0])); arguments.append(JSC::JSValue::decode(args[1])); @@ -402,14 +305,5 @@ FFI_Callback_call_7(FFICallbackFunctionWrapper& wrapper, size_t argCount, JSC::E arguments.append(JSC::JSValue::decode(args[4])); arguments.append(JSC::JSValue::decode(args[5])); arguments.append(JSC::JSValue::decode(args[6])); - - WTF::NakedPtr exception; - auto result = JSC::profiledCall(globalObject, JSC::ProfilingReason::API, function, JSC::getCallData(function), JSC::jsUndefined(), arguments, exception); - if (exception) [[unlikely]] { - auto scope = DECLARE_THROW_SCOPE(vm); - scope.throwException(globalObject, exception); - return JSC::JSValue::encode(JSC::jsNull()); - } - - return JSC::JSValue::encode(result); + return invokeFFICallback(wrapper.globalObject.get(), wrapper.m_function.get(), arguments); } diff --git a/test/js/bun/ffi/ffi.test.js b/test/js/bun/ffi/ffi.test.js index 48b6c5c67910..c74017be5f64 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 { isGlibcVersionAtLeast } from "harness"; +import { bunEnv, bunExe, isGlibcVersionAtLeast, tempDir } from "harness"; import { platform } from "os"; import { @@ -677,6 +677,103 @@ it(".ptr is not leaked", () => { } }); +it("JSCallback exceptions propagate out of the native call", () => { + const callback = new JSCallback( + () => { + throw new Error("boom"); + }, + { returns: "int32_t", args: [] }, + ); + const call = new CFunction({ ptr: callback.ptr, returns: "int32_t", args: [] }); + try { + expect(call).toThrow("boom"); + } finally { + callback.close(); + } +}); + +// worker.terminate() while the worker is inside a threadsafe JSCallback made FFI_Callback_* +// clear and re-throw JSC's TerminationException, which trips +// "ASSERTION FAILED: !isTerminationException(exception) || hasTerminationRequest()" in +// JSC::VM::setException and re-enters JS on the terminated worker VM. +it("JSCallback tolerates worker.terminate() arriving inside the callback", async () => { + using dir = tempDir("ffi-jscallback-terminate", { + "main.js": ` + import { join } from "node:path"; + import { Worker } from "node:worker_threads"; + + const sab = new SharedArrayBuffer(4); + const flag = new Int32Array(sab); + + const worker = new Worker(join(import.meta.dir, "worker.js"), { workerData: sab }); + let terminating = false; + worker.on("error", err => { + console.error("worker error:", err); + process.exit(1); + }); + worker.on("exit", code => { + if (!terminating) { + console.error("worker exited early:", code); + process.exit(1); + } + }); + + // Wait until the worker thread is inside the native -> JS callback frame. + while (Atomics.load(flag, 0) === 0) { + await Bun.sleep(5); + } + + terminating = true; + await worker.terminate(); + console.log("done"); + `, + "worker.js": ` + import { CFunction, JSCallback } from "bun:ffi"; + import { workerData } from "node:worker_threads"; + + const flag = new Int32Array(workerData); + + const callback = new JSCallback( + () => { + // Tell the parent we are inside the native -> JS callback frame, then + // spin until worker.terminate() delivers the TerminationException. + Atomics.store(flag, 0, 1); + while (true) {} + }, + { returns: "void", args: [], threadsafe: true }, + ); + + // CFunction turns the callback's native function pointer back into a callable, so + // each fire() re-enters JS through the native FFI trampoline. A threadsafe callback + // enqueues a task instead of calling synchronously, so both run at the top of the + // worker's event loop once this module finishes evaluating. + const fire = new CFunction({ ptr: callback.ptr, returns: "void", args: [] }); + fire(); + fire(); + + // Keep the worker alive until the queued callback tasks run. + setInterval(() => {}, 1000); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "main.js"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + // stderr is not asserted (debug builds write benign noise there), but including it in the + // received object surfaces the crash output whenever one of the other fields mismatches. + expect({ stdout, stderr, exitCode, signalCode: proc.signalCode }).toEqual({ + stdout: "done\n", + stderr: expect.any(String), + exitCode: 0, + signalCode: null, + }); +}); + const libPath = platform() === "darwin" ? "/usr/lib/libSystem.B.dylib" From 1c818ac2e31054b6217474310ace4089c7360b22 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 26 Jun 2026 22:41:07 +0000 Subject: [PATCH 2/7] Trim the invokeFFICallback comment to the invariant --- src/jsc/bindings/JSFFIFunction.cpp | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/jsc/bindings/JSFFIFunction.cpp b/src/jsc/bindings/JSFFIFunction.cpp index dd334fcbf8c1..ae77b0b5fe6d 100644 --- a/src/jsc/bindings/JSFFIFunction.cpp +++ b/src/jsc/bindings/JSFFIFunction.cpp @@ -185,9 +185,8 @@ JSFFIFunction* JSFFIFunction::createForFFI(VM& vm, Zig::GlobalObject* globalObje } // namespace JSC // Shared tail for the FFI_Callback_* entry points: call back into JS and leave any exception -// pending on the VM, like any other host function. Clearing + re-throwing it (the old NakedPtr -// dance) violated VM::setException's precondition for worker.terminate()'s TerminationException -// once the outermost VMEntryScope had already retired the termination request. +// pending on the VM, like any other host function. Never clear and re-throw here: re-installing +// the TerminationException once the termination request is retired trips VM::setException. static JSC::EncodedJSValue invokeFFICallback(Zig::GlobalObject* globalObject, JSC::JSFunction* function, JSC::MarkedArgumentBuffer& arguments) { auto& vm = JSC::getVM(globalObject); From 4e159f814b93151a1d7c1045d5d3205bccba52fb Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 26 Jun 2026 22:51:16 +0000 Subject: [PATCH 3/7] Use Atomics.waitAsync instead of polling in the terminate test --- test/js/bun/ffi/ffi.test.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/test/js/bun/ffi/ffi.test.js b/test/js/bun/ffi/ffi.test.js index c74017be5f64..fd015e331337 100644 --- a/test/js/bun/ffi/ffi.test.js +++ b/test/js/bun/ffi/ffi.test.js @@ -719,9 +719,7 @@ it("JSCallback tolerates worker.terminate() arriving inside the callback", async }); // Wait until the worker thread is inside the native -> JS callback frame. - while (Atomics.load(flag, 0) === 0) { - await Bun.sleep(5); - } + await Atomics.waitAsync(flag, 0, 0).value; terminating = true; await worker.terminate(); @@ -738,6 +736,7 @@ it("JSCallback tolerates worker.terminate() arriving inside the callback", async // Tell the parent we are inside the native -> JS callback frame, then // spin until worker.terminate() delivers the TerminationException. Atomics.store(flag, 0, 1); + Atomics.notify(flag, 0); while (true) {} }, { returns: "void", args: [], threadsafe: true }, From d0b152bd3ee07571e97a8fb0e49d60bac17fc069 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 26 Jun 2026 23:14:53 +0000 Subject: [PATCH 4/7] Gate the JSCallback tests on FFI availability and keep them LSan clean - skip both new tests on Windows ARM64, where TinyCC (and with it JSCallback/CFunction) is compiled out and the constructors throw - run the throwing-callback test in a spawned script: bun test's exit path does not finalize the CFunction's native handle, which the ASan lane's leak checker reports against the test process - enqueue a single callback task: a worker terminated with a task still queued re-buffers it in EventLoop::deinit, and that buffer is unreachable to LSan once the worker arena is freed --- test/js/bun/ffi/ffi.test.js | 68 ++++++++++++++++++++++++------------- 1 file changed, 45 insertions(+), 23 deletions(-) diff --git a/test/js/bun/ffi/ffi.test.js b/test/js/bun/ffi/ffi.test.js index fd015e331337..9ac0aa7b8b8d 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, isGlibcVersionAtLeast, tempDir } from "harness"; +import { bunEnv, bunExe, isArm64, isGlibcVersionAtLeast, isWindows, tempDir } from "harness"; import { platform } from "os"; import { @@ -677,26 +677,49 @@ it(".ptr is not leaked", () => { } }); -it("JSCallback exceptions propagate out of the native call", () => { - const callback = new JSCallback( - () => { - throw new Error("boom"); - }, - { returns: "int32_t", args: [] }, - ); - const call = new CFunction({ ptr: callback.ptr, returns: "int32_t", args: [] }); - try { - expect(call).toThrow("boom"); - } finally { - callback.close(); - } +// TinyCC, which implements JSCallback and CFunction, is unavailable on Windows ARM64. +const isFFIUnavailable = isWindows && isArm64; + +// 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 () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `import { CFunction, JSCallback } from "bun:ffi"; + const callback = new JSCallback( + () => { + throw new Error("boom"); + }, + { returns: "int32_t", args: [] }, + ); + const call = new CFunction({ ptr: callback.ptr, returns: "int32_t", args: [] }); + try { + call(); + console.log("did not throw"); + } catch (e) { + console.log("caught", e.message); + } + call.close(); + callback.close();`, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr, exitCode }).toEqual({ + stdout: "caught boom\n", + stderr: expect.any(String), + exitCode: 0, + }); }); -// worker.terminate() while the worker is inside a threadsafe JSCallback made FFI_Callback_* -// clear and re-throw JSC's TerminationException, which trips -// "ASSERTION FAILED: !isTerminationException(exception) || hasTerminationRequest()" in -// JSC::VM::setException and re-enters JS on the terminated worker VM. -it("JSCallback tolerates worker.terminate() arriving inside the callback", async () => { +// worker.terminate() delivered inside a threadsafe JSCallback used to trip +// "ASSERTION FAILED: !isTerminationException(exception) || hasTerminationRequest()" +// in JSC::VM::setException on the worker thread and re-enter the terminated VM. +it.skipIf(isFFIUnavailable)("JSCallback tolerates worker.terminate() arriving inside the callback", async () => { using dir = tempDir("ffi-jscallback-terminate", { "main.js": ` import { join } from "node:path"; @@ -743,14 +766,13 @@ it("JSCallback tolerates worker.terminate() arriving inside the callback", async ); // CFunction turns the callback's native function pointer back into a callable, so - // each fire() re-enters JS through the native FFI trampoline. A threadsafe callback - // enqueues a task instead of calling synchronously, so both run at the top of the + // fire() re-enters JS through the native FFI trampoline. A threadsafe callback + // enqueues a task instead of calling synchronously, so it runs at the top of the // worker's event loop once this module finishes evaluating. const fire = new CFunction({ ptr: callback.ptr, returns: "void", args: [] }); fire(); - fire(); - // Keep the worker alive until the queued callback tasks run. + // Keep the worker alive until the queued callback task runs. setInterval(() => {}, 1000); `, }); From 1e8263d4c6f78e6aaba9fc1fec1ea5a757032334 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 26 Jun 2026 23:29:45 +0000 Subject: [PATCH 5/7] Assert empty stderr in the JSCallback subprocess tests --- test/js/bun/ffi/ffi.test.js | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/test/js/bun/ffi/ffi.test.js b/test/js/bun/ffi/ffi.test.js index 9ac0aa7b8b8d..0c8510566188 100644 --- a/test/js/bun/ffi/ffi.test.js +++ b/test/js/bun/ffi/ffi.test.js @@ -711,7 +711,7 @@ it.skipIf(isFFIUnavailable)("JSCallback exceptions propagate out of the native c const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect({ stdout, stderr, exitCode }).toEqual({ stdout: "caught boom\n", - stderr: expect.any(String), + stderr: "", exitCode: 0, }); }); @@ -785,11 +785,9 @@ it.skipIf(isFFIUnavailable)("JSCallback tolerates worker.terminate() arriving in stderr: "pipe", }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - // stderr is not asserted (debug builds write benign noise there), but including it in the - // received object surfaces the crash output whenever one of the other fields mismatches. expect({ stdout, stderr, exitCode, signalCode: proc.signalCode }).toEqual({ stdout: "done\n", - stderr: expect.any(String), + stderr: "", exitCode: 0, signalCode: null, }); From b7cf7549e8ffa0bf6c1d3da836f7d59490a4742c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 26 Jun 2026 23:35:33 +0000 Subject: [PATCH 6/7] Trim the fixture comment to three lines --- test/js/bun/ffi/ffi.test.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/test/js/bun/ffi/ffi.test.js b/test/js/bun/ffi/ffi.test.js index 0c8510566188..1a66bae5d943 100644 --- a/test/js/bun/ffi/ffi.test.js +++ b/test/js/bun/ffi/ffi.test.js @@ -765,10 +765,9 @@ it.skipIf(isFFIUnavailable)("JSCallback tolerates worker.terminate() arriving in { returns: "void", args: [], threadsafe: true }, ); - // CFunction turns the callback's native function pointer back into a callable, so - // fire() re-enters JS through the native FFI trampoline. A threadsafe callback - // enqueues a task instead of calling synchronously, so it runs at the top of the - // worker's event loop once this module finishes evaluating. + // CFunction makes the callback's native function pointer callable from JS. A threadsafe + // JSCallback enqueues a task instead of running synchronously, so the callback runs at + // the top of the worker's event loop once this module finishes evaluating. const fire = new CFunction({ ptr: callback.ptr, returns: "void", args: [] }); fire(); From 9c5224a79e37f7cfed8c95e85e4fd4ba53d3dd67 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 27 Jun 2026 00:20:51 +0000 Subject: [PATCH 7/7] Drop the scriptExecutionStatus guard from the threadsafe callback task Zig::GlobalObject::scriptExecutionStatus always reports Running from C++: its extern, Bun__VM__scriptExecutionStatus, resolves to the phase_c_exports stub, so the branch could never fire. Entry traps already turn a post-termination JS entry into the TerminationException this change handles. --- src/jsc/bindings/JSFFIFunction.cpp | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/jsc/bindings/JSFFIFunction.cpp b/src/jsc/bindings/JSFFIFunction.cpp index ae77b0b5fe6d..beb3d6122e35 100644 --- a/src/jsc/bindings/JSFFIFunction.cpp +++ b/src/jsc/bindings/JSFFIFunction.cpp @@ -215,10 +215,6 @@ FFI_Callback_threadsafe_call(FFICallbackFunctionWrapper& wrapper, size_t argCoun WebCore::ScriptExecutionContext::postTaskTo(wrapper.m_contextId, [argsVec = WTF::move(argsVec), protectedWrapper = Ref { wrapper }](WebCore::ScriptExecutionContext& ctx) mutable { auto* globalObject = uncheckedDowncast(ctx.jsGlobalObject()); - // The worker may have been terminated (or the VM begun shutting down) between - // enqueue and dispatch; never re-enter JS on a stopped global. - if (Zig::GlobalObject::scriptExecutionStatus(globalObject, globalObject) != JSC::ScriptExecutionStatus::Running) [[unlikely]] - return; JSC::MarkedArgumentBuffer arguments; for (size_t i = 0; i < argsVec.size(); ++i) arguments.appendWithCrashOnOverflow(JSC::JSValue::decode(argsVec[i]));