From 03ba56b2ed6f1fc55359a787a916b9ec510372e0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 17 Sep 2026 19:08:09 -0700 Subject: [PATCH 01/10] fix(worker_threads): close ports when worker startup fails --- .../bindings/webcore/WorkerMessagingProxy.cpp | 19 ++++++++++++++ .../bindings/webcore/WorkerMessagingProxy.h | 1 + .../worker_threads/worker_threads.test.ts | 25 +++++++++++++++++++ 3 files changed, 45 insertions(+) diff --git a/src/jsc/bindings/webcore/WorkerMessagingProxy.cpp b/src/jsc/bindings/webcore/WorkerMessagingProxy.cpp index fc34c06b41a8..2be0d3408dcc 100644 --- a/src/jsc/bindings/webcore/WorkerMessagingProxy.cpp +++ b/src/jsc/bindings/webcore/WorkerMessagingProxy.cpp @@ -540,6 +540,21 @@ void WorkerMessagingProxy::releaseWorkerThread() deref(); } +void WorkerMessagingProxy::dropUndeliveredWorkerMessages() +{ + // Entry resolution can fail before workerData ports are entangled. Closing + // those endpoints also closes ports queued on the public parentPort. + auto droppedDataPorts = std::exchange(m_options.dataMessagePorts, {}); + Deque droppedMessages; + { + Locker locker { m_toWorker.lock }; + droppedMessages = std::exchange(m_toWorker.queue, {}); + m_toWorker.drainScheduled = false; + } + // Destroy transferred ports outside the inbox lock. Their destructors close + // orphaned endpoints and notify each entangled peer. +} + void WorkerMessagingProxy::workerGlobalScopeDestroyedInternal(int32_t exitCode, bool stoppedByParent) { ASSERT(m_scriptExecutionContext && m_scriptExecutionContext->isContextThread()); @@ -558,6 +573,7 @@ void WorkerMessagingProxy::workerGlobalScopeDestroyedInternal(int32_t exitCode, m_state.store(State::Closing); m_pendingTasks.clear(); } + dropUndeliveredWorkerMessages(); rejectAllCrossVMRequests(); // Everything the worker posted before it exited is delivered before 'close' (Node: before @@ -592,6 +608,9 @@ void WorkerMessagingProxy::parentContextWillDestroy() m_pendingCrossVMRequests.clear(); } releaseWorkerThread(); + // The worker can move workerData ports during startup. Join it before + // reclaiming ports that never reached the worker global scope. + dropUndeliveredWorkerMessages(); m_scriptExecutionContext = nullptr; } diff --git a/src/jsc/bindings/webcore/WorkerMessagingProxy.h b/src/jsc/bindings/webcore/WorkerMessagingProxy.h index 273a0f129f95..b990ab03413e 100644 --- a/src/jsc/bindings/webcore/WorkerMessagingProxy.h +++ b/src/jsc/bindings/webcore/WorkerMessagingProxy.h @@ -124,6 +124,7 @@ class WorkerMessagingProxy final : public ThreadSafeRefCounted { expect(order).toEqual(["online", "error:MODULE_NOT_FOUND"]); expect(code).toBe(1); }); + + test("a transferred MessagePort closes when the worker entry does not resolve", async () => { + using dir = tempDir("worker-missing-entry-transferred-port", {}); + const worker = new Worker(join(String(dir), "missing.js")); + const { port1, port2 } = new MessageChannel(); + const events: string[] = []; + worker.on("online", () => events.push("online")); + worker.on("error", error => events.push(`error:${error.code}`)); + const portClosed = once(port1, "close").then(() => events.push("port-close")); + const exit = new Promise(resolve => + worker.on("exit", code => { + events.push(`exit:${code}`); + resolve(code); + }), + ); + worker.postMessage({ port: port2 }, [port2]); + + const code = await exit; + await portClosed; + + expect({ code, events }).toEqual({ + code: 1, + events: ["online", "error:MODULE_NOT_FOUND", "exit:1", "port-close"], + }); + }); }); // ─── worker teardown vs. work still in flight ──────────────────────────────── From a75c99e6ddefda46e6cdbf32ad331e2ed8e742f0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 17 Sep 2026 19:29:15 -0700 Subject: [PATCH 02/10] test(worker_threads): cover startup workerData ports --- .../worker_threads/worker_threads.test.ts | 52 +++++++++++-------- 1 file changed, 30 insertions(+), 22 deletions(-) diff --git a/test/js/node/worker_threads/worker_threads.test.ts b/test/js/node/worker_threads/worker_threads.test.ts index f8d09ad26905..d3ca8ded9346 100644 --- a/test/js/node/worker_threads/worker_threads.test.ts +++ b/test/js/node/worker_threads/worker_threads.test.ts @@ -2222,30 +2222,38 @@ describe("'online' precedes the worker's first message", () => { expect(code).toBe(1); }); - test("a transferred MessagePort closes when the worker entry does not resolve", async () => { - using dir = tempDir("worker-missing-entry-transferred-port", {}); - const worker = new Worker(join(String(dir), "missing.js")); - const { port1, port2 } = new MessageChannel(); - const events: string[] = []; - worker.on("online", () => events.push("online")); - worker.on("error", error => events.push(`error:${error.code}`)); - const portClosed = once(port1, "close").then(() => events.push("port-close")); - const exit = new Promise(resolve => - worker.on("exit", code => { - events.push(`exit:${code}`); - resolve(code); - }), - ); - worker.postMessage({ port: port2 }, [port2]); + test.each(["workerData", "postMessage"] as const)( + "a %s transferred MessagePort closes when the worker entry does not resolve", + async transferRoute => { + using dir = tempDir("worker-missing-entry-transferred-port", {}); + const { port1, port2 } = new MessageChannel(); + const worker = new Worker( + join(String(dir), "missing.js"), + transferRoute === "workerData" ? { workerData: { port: port2 }, transferList: [port2] } : undefined, + ); + const events: string[] = []; + worker.on("online", () => events.push("online")); + worker.on("error", error => events.push(`error:${error.code}`)); + const portClosed = once(port1, "close").then(() => events.push("port-close")); + const exit = new Promise(resolve => + worker.on("exit", code => { + events.push(`exit:${code}`); + resolve(code); + }), + ); + if (transferRoute === "postMessage") { + worker.postMessage({ port: port2 }, [port2]); + } - const code = await exit; - await portClosed; + const code = await exit; + await portClosed; - expect({ code, events }).toEqual({ - code: 1, - events: ["online", "error:MODULE_NOT_FOUND", "exit:1", "port-close"], - }); - }); + expect({ code, events }).toEqual({ + code: 1, + events: ["online", "error:MODULE_NOT_FOUND", "exit:1", "port-close"], + }); + }, + ); }); // ─── worker teardown vs. work still in flight ──────────────────────────────── From 2e666e0bf0390bb121a9125cac57b4e96c7f2e94 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 18 Sep 2026 20:08:31 +0000 Subject: [PATCH 03/10] test(worker): cover the Web Worker inbox and terminate() before startup A port in a message that is still queued for a Web Worker goes through the proxy's own inbox, not through the parentPort pipe. A worker that terminate() stops before it starts leaves its ports in the same state as a worker whose entry does not resolve. Co-authored-by: Peter Steinberger --- .../worker_threads/worker_threads.test.ts | 25 ++++++++++++++ test/js/web/workers/worker.test.ts | 33 +++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/test/js/node/worker_threads/worker_threads.test.ts b/test/js/node/worker_threads/worker_threads.test.ts index d3ca8ded9346..66e794590013 100644 --- a/test/js/node/worker_threads/worker_threads.test.ts +++ b/test/js/node/worker_threads/worker_threads.test.ts @@ -2256,6 +2256,31 @@ describe("'online' precedes the worker's first message", () => { ); }); +// terminate() in the same tick as the constructor stops the thread before it takes its +// workerData ports or reads parentPort. A thread that wins that race closes them as it exits, +// so only the close is asserted, not its order against 'exit'. +describe("a transferred MessagePort closes when terminate() stops the worker before it starts", () => { + // Stays referenced, as a Worker in a pool does: a collected Worker drops its ports too. + let worker: Worker; + + test.each(["workerData", "postMessage"] as const)("transferred through %s", async transferRoute => { + const { port1, port2 } = new MessageChannel(); + worker = new Worker( + "setInterval(() => {}, 1000)", + transferRoute === "workerData" + ? { eval: true, workerData: { port: port2 }, transferList: [port2] } + : { eval: true }, + ); + const portClosed = once(port1, "close").then(() => "port-close"); + if (transferRoute === "postMessage") { + worker.postMessage({ port: port2 }, [port2]); + } + + await worker.terminate(); + expect(await portClosed).toBe("port-close"); + }); +}); + // ─── worker teardown vs. work still in flight ──────────────────────────────── // Each of these terminates a worker (or exits the process) while some off-thread // or cross-thread work of that worker is still pending. They exercise the diff --git a/test/js/web/workers/worker.test.ts b/test/js/web/workers/worker.test.ts index 102b6c6f7dbd..7545a133b174 100644 --- a/test/js/web/workers/worker.test.ts +++ b/test/js/web/workers/worker.test.ts @@ -403,6 +403,39 @@ describe("web worker", () => { }); }); + // A message the worker never took from its inbox is dropped when the worker is gone. A port in + // that message is closed with it, so the port's peer hears 'close'. + describe("a MessagePort posted to a worker that never reads it closes", () => { + // Stays referenced, as a Worker in a pool does: a collected Worker drops its inbox too. + let worker: Worker; + + test("the entry point does not resolve", async () => { + using dir = tempDir("web-worker-missing-entry-port", {}); + const { port1, port2 } = new MessageChannel(); + worker = new Worker(path.join(String(dir), "missing.js")); + const events: string[] = []; + worker.addEventListener("error", () => events.push("error")); + worker.addEventListener("close", e => events.push(`close:${e.code}`)); + const portClosed = once(port1, "close").then(() => events.push("port-close")); + worker.postMessage({ port: port2 }, [port2]); + + await portClosed; + expect(events).toEqual(["error", "close:1", "port-close"]); + }); + + // A thread that wins the race against terminate() receives the port and closes it as it + // exits, so only the close is asserted. + test("terminate() stops the worker before it starts", async () => { + const { port1, port2 } = new MessageChannel(); + worker = new Worker("data:text/javascript,setInterval(() => {}, 1000)"); + const portClosed = once(port1, "close").then(() => "port-close"); + worker.postMessage({ port: port2 }, [port2]); + worker.terminate(); + + expect(await portClosed).toBe("port-close"); + }); + }); + // As in browsers (and Node's Web Worker), the worker's implicit port opens once the entry's // synchronous part has run: a message dispatched while no 'message' handler exists is dropped. // node:worker_threads' parentPort is what queues until a listener is attached. #40141 From ffd7d16d2cdebf242836721b8817b21a0521bc34 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 18 Sep 2026 20:45:33 +0000 Subject: [PATCH 04/10] worker: keep two comments accurate about who drops an undelivered message The worker's inbox is now dropped when the worker is gone, not with the proxy. The worker thread takes its workerData ports when it first loads node:worker_threads, which is not always at startup. Co-authored-by: Peter Steinberger --- src/jsc/bindings/webcore/WorkerMessagingProxy.cpp | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/jsc/bindings/webcore/WorkerMessagingProxy.cpp b/src/jsc/bindings/webcore/WorkerMessagingProxy.cpp index 2be0d3408dcc..9e971e6bdb76 100644 --- a/src/jsc/bindings/webcore/WorkerMessagingProxy.cpp +++ b/src/jsc/bindings/webcore/WorkerMessagingProxy.cpp @@ -318,8 +318,8 @@ static bool drainInbox(WorkerMessagingProxy::MessageInbox& inbox, Zig::GlobalObj remaining -= batch.size(); while (!batch.isEmpty()) { - // The receiving VM is being stopped: nothing more is delivered (the - // rest is dropped with the proxy). + // The receiving VM is being stopped: nothing more is delivered. The rest of the + // worker's inbox is dropped once the worker is gone, the parent's with the proxy. if (context.isJSExecutionForbidden()) return false; auto message = batch.takeFirst(); @@ -608,8 +608,8 @@ void WorkerMessagingProxy::parentContextWillDestroy() m_pendingCrossVMRequests.clear(); } releaseWorkerThread(); - // The worker can move workerData ports during startup. Join it before - // reclaiming ports that never reached the worker global scope. + // After the join: until then the worker thread can still be taking its workerData ports, + // which it does when it first loads node:worker_threads (createNodeWorkerThreadsBinding). dropUndeliveredWorkerMessages(); m_scriptExecutionContext = nullptr; } From c32f42d3089ee3ea33826f4bc645246de42234a9 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 18 Sep 2026 21:43:24 +0000 Subject: [PATCH 05/10] test(worker): pin the Web Worker terminate() test with an entry that never returns A worker whose entry is still running never reads its inbox, whether terminate() lands before the thread starts or while the entry runs. So the test cannot pass without the drop. The node:worker_threads variant cannot be pinned: that thread takes its ports before any user code runs. Co-authored-by: Peter Steinberger --- .../js/node/worker_threads/worker_threads.test.ts | 6 ++++-- test/js/web/workers/worker.test.ts | 15 +++++++++------ 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/test/js/node/worker_threads/worker_threads.test.ts b/test/js/node/worker_threads/worker_threads.test.ts index 66e794590013..85b2c571e64d 100644 --- a/test/js/node/worker_threads/worker_threads.test.ts +++ b/test/js/node/worker_threads/worker_threads.test.ts @@ -2257,8 +2257,10 @@ describe("'online' precedes the worker's first message", () => { }); // terminate() in the same tick as the constructor stops the thread before it takes its -// workerData ports or reads parentPort. A thread that wins that race closes them as it exits, -// so only the close is asserted, not its order against 'exit'. +// workerData ports or reads parentPort. The thread takes them before any user code runs, so +// nothing can hold it back, and a thread that wins that race closes them as it exits. So only +// the close is asserted, not its order against 'exit'. The tests above, where the entry does +// not resolve, are the ones that cannot pass without the drop. describe("a transferred MessagePort closes when terminate() stops the worker before it starts", () => { // Stays referenced, as a Worker in a pool does: a collected Worker drops its ports too. let worker: Worker; diff --git a/test/js/web/workers/worker.test.ts b/test/js/web/workers/worker.test.ts index 7545a133b174..049db4723ddf 100644 --- a/test/js/web/workers/worker.test.ts +++ b/test/js/web/workers/worker.test.ts @@ -423,16 +423,19 @@ describe("web worker", () => { expect(events).toEqual(["error", "close:1", "port-close"]); }); - // A thread that wins the race against terminate() receives the port and closes it as it - // exits, so only the close is asserted. - test("terminate() stops the worker before it starts", async () => { + // The entry never returns, so the worker never reads its inbox, whether terminate() lands + // before the thread starts or while the entry runs. + test("terminate() stops a worker whose entry is still running", async () => { const { port1, port2 } = new MessageChannel(); - worker = new Worker("data:text/javascript,setInterval(() => {}, 1000)"); - const portClosed = once(port1, "close").then(() => "port-close"); + worker = new Worker("data:text/javascript,for(;;){}"); + const events: string[] = []; + worker.addEventListener("close", () => events.push("close")); + const portClosed = once(port1, "close").then(() => events.push("port-close")); worker.postMessage({ port: port2 }, [port2]); worker.terminate(); - expect(await portClosed).toBe("port-close"); + await portClosed; + expect(events).toEqual(["close", "port-close"]); }); }); From d5e720d20e150202da2c499476ad8c33ea8c1912 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 18 Sep 2026 21:48:45 +0000 Subject: [PATCH 06/10] worker: cut the new proxy comments to one line each Co-authored-by: Peter Steinberger --- src/jsc/bindings/webcore/WorkerMessagingProxy.cpp | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/jsc/bindings/webcore/WorkerMessagingProxy.cpp b/src/jsc/bindings/webcore/WorkerMessagingProxy.cpp index 9e971e6bdb76..dc32d6aaa91a 100644 --- a/src/jsc/bindings/webcore/WorkerMessagingProxy.cpp +++ b/src/jsc/bindings/webcore/WorkerMessagingProxy.cpp @@ -318,8 +318,7 @@ static bool drainInbox(WorkerMessagingProxy::MessageInbox& inbox, Zig::GlobalObj remaining -= batch.size(); while (!batch.isEmpty()) { - // The receiving VM is being stopped: nothing more is delivered. The rest of the - // worker's inbox is dropped once the worker is gone, the parent's with the proxy. + // The receiving VM is being stopped: nothing more is delivered, and what is left is dropped unread. if (context.isJSExecutionForbidden()) return false; auto message = batch.takeFirst(); @@ -542,8 +541,7 @@ void WorkerMessagingProxy::releaseWorkerThread() void WorkerMessagingProxy::dropUndeliveredWorkerMessages() { - // Entry resolution can fail before workerData ports are entangled. Closing - // those endpoints also closes ports queued on the public parentPort. + // Ports a worker that never started did not take. Closing its parentPort end also closes the ports queued on it. auto droppedDataPorts = std::exchange(m_options.dataMessagePorts, {}); Deque droppedMessages; { @@ -551,8 +549,7 @@ void WorkerMessagingProxy::dropUndeliveredWorkerMessages() droppedMessages = std::exchange(m_toWorker.queue, {}); m_toWorker.drainScheduled = false; } - // Destroy transferred ports outside the inbox lock. Their destructors close - // orphaned endpoints and notify each entangled peer. + // Destroyed here, outside the lock: ~TransferredMessagePort closes its pipe side and notifies the peer. } void WorkerMessagingProxy::workerGlobalScopeDestroyedInternal(int32_t exitCode, bool stoppedByParent) @@ -608,8 +605,7 @@ void WorkerMessagingProxy::parentContextWillDestroy() m_pendingCrossVMRequests.clear(); } releaseWorkerThread(); - // After the join: until then the worker thread can still be taking its workerData ports, - // which it does when it first loads node:worker_threads (createNodeWorkerThreadsBinding). + // After the join: a live worker thread can still be taking its ports (createNodeWorkerThreadsBinding). dropUndeliveredWorkerMessages(); m_scriptExecutionContext = nullptr; } From 242c6edf672bf2d601b15d978928458077468816 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 18 Sep 2026 23:46:00 +0000 Subject: [PATCH 07/10] test(worker_threads): move the transferred port tests to a node:test file The file runs with `node --test` and with `bun test`. It fails on Bun 1.4.2 and passes on Node.js 22 and 24. Node reports 'exit' and the port's 'close' in either order, so the shared test does not assert that order. Co-authored-by: Peter Steinberger --- .../worker-transferred-port-close.test.ts | 50 ++++++++++++++++ .../worker_threads/worker_threads.test.ts | 60 ------------------- 2 files changed, 50 insertions(+), 60 deletions(-) create mode 100644 test/js/node/worker_threads/worker-transferred-port-close.test.ts diff --git a/test/js/node/worker_threads/worker-transferred-port-close.test.ts b/test/js/node/worker_threads/worker-transferred-port-close.test.ts new file mode 100644 index 000000000000..66296b8fb46e --- /dev/null +++ b/test/js/node/worker_threads/worker-transferred-port-close.test.ts @@ -0,0 +1,50 @@ +// Runs in Node.js and in Bun: `node --test ` and `bun test `. +import assert from "node:assert"; +import { once } from "node:events"; +import { join } from "node:path"; +import { describe, test } from "node:test"; +import { MessageChannel, Worker } from "node:worker_threads"; + +describe("a MessagePort transferred to a worker that never runs is closed", () => { + // Stays referenced, as a Worker in a pool does: a collected Worker drops its ports too. + let worker: Worker; + + for (const route of ["workerData", "postMessage"]) { + // Node reports 'exit' and the port's 'close' in either order, so that order is not asserted. + test(`the entry does not resolve, port transferred through ${route}`, async () => { + const { port1, port2 } = new MessageChannel(); + worker = new Worker( + join(import.meta.dirname, "worker-transferred-port-close-missing-entry.cjs"), + route === "workerData" ? { workerData: { port: port2 }, transferList: [port2] } : undefined, + ); + const events: string[] = []; + worker.on("online", () => events.push("online")); + worker.on("error", (error: NodeJS.ErrnoException) => events.push(`error:${error.code}`)); + const portClosed = once(port1, "close").then(() => events.push("port-close")); + const exited = new Promise(resolve => worker.on("exit", code => resolve(events.push(`exit:${code}`)))); + if (route === "postMessage") worker.postMessage({ port: port2 }, [port2]); + + await Promise.all([exited, portClosed]); + assert.deepStrictEqual( + { first: events.slice(0, 2), rest: events.slice(2).sort() }, + { first: ["online", "error:MODULE_NOT_FOUND"], rest: ["exit:1", "port-close"] }, + ); + }); + + // terminate() in the same tick as the constructor stops the thread before it takes its ports. + // The thread takes them before any user code runs, so nothing can hold it back, and a thread + // that wins that race closes them as it exits. The exit code depends on that timing too. + test(`terminate() stops the worker before it starts, port transferred through ${route}`, async () => { + const { port1, port2 } = new MessageChannel(); + worker = new Worker( + "setInterval(() => {}, 1000)", + route === "workerData" ? { eval: true, workerData: { port: port2 }, transferList: [port2] } : { eval: true }, + ); + const portClosed = once(port1, "close").then(() => "port-close"); + if (route === "postMessage") worker.postMessage({ port: port2 }, [port2]); + + await worker.terminate(); + assert.strictEqual(await portClosed, "port-close"); + }); + } +}); diff --git a/test/js/node/worker_threads/worker_threads.test.ts b/test/js/node/worker_threads/worker_threads.test.ts index 85b2c571e64d..809f6376c75c 100644 --- a/test/js/node/worker_threads/worker_threads.test.ts +++ b/test/js/node/worker_threads/worker_threads.test.ts @@ -2221,66 +2221,6 @@ describe("'online' precedes the worker's first message", () => { expect(order).toEqual(["online", "error:MODULE_NOT_FOUND"]); expect(code).toBe(1); }); - - test.each(["workerData", "postMessage"] as const)( - "a %s transferred MessagePort closes when the worker entry does not resolve", - async transferRoute => { - using dir = tempDir("worker-missing-entry-transferred-port", {}); - const { port1, port2 } = new MessageChannel(); - const worker = new Worker( - join(String(dir), "missing.js"), - transferRoute === "workerData" ? { workerData: { port: port2 }, transferList: [port2] } : undefined, - ); - const events: string[] = []; - worker.on("online", () => events.push("online")); - worker.on("error", error => events.push(`error:${error.code}`)); - const portClosed = once(port1, "close").then(() => events.push("port-close")); - const exit = new Promise(resolve => - worker.on("exit", code => { - events.push(`exit:${code}`); - resolve(code); - }), - ); - if (transferRoute === "postMessage") { - worker.postMessage({ port: port2 }, [port2]); - } - - const code = await exit; - await portClosed; - - expect({ code, events }).toEqual({ - code: 1, - events: ["online", "error:MODULE_NOT_FOUND", "exit:1", "port-close"], - }); - }, - ); -}); - -// terminate() in the same tick as the constructor stops the thread before it takes its -// workerData ports or reads parentPort. The thread takes them before any user code runs, so -// nothing can hold it back, and a thread that wins that race closes them as it exits. So only -// the close is asserted, not its order against 'exit'. The tests above, where the entry does -// not resolve, are the ones that cannot pass without the drop. -describe("a transferred MessagePort closes when terminate() stops the worker before it starts", () => { - // Stays referenced, as a Worker in a pool does: a collected Worker drops its ports too. - let worker: Worker; - - test.each(["workerData", "postMessage"] as const)("transferred through %s", async transferRoute => { - const { port1, port2 } = new MessageChannel(); - worker = new Worker( - "setInterval(() => {}, 1000)", - transferRoute === "workerData" - ? { eval: true, workerData: { port: port2 }, transferList: [port2] } - : { eval: true }, - ); - const portClosed = once(port1, "close").then(() => "port-close"); - if (transferRoute === "postMessage") { - worker.postMessage({ port: port2 }, [port2]); - } - - await worker.terminate(); - expect(await portClosed).toBe("port-close"); - }); }); // ─── worker teardown vs. work still in flight ──────────────────────────────── From 969aa0e3abbe6f611d7e25e9dee4e5b9990b4dd6 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 18 Sep 2026 23:55:12 +0000 Subject: [PATCH 08/10] test(worker_threads): say why the shared test imports only Node modules Co-authored-by: Peter Steinberger --- .../node/worker_threads/worker-transferred-port-close.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/js/node/worker_threads/worker-transferred-port-close.test.ts b/test/js/node/worker_threads/worker-transferred-port-close.test.ts index 66296b8fb46e..db82e4a7d82b 100644 --- a/test/js/node/worker_threads/worker-transferred-port-close.test.ts +++ b/test/js/node/worker_threads/worker-transferred-port-close.test.ts @@ -1,4 +1,4 @@ -// Runs in Node.js and in Bun: `node --test ` and `bun test `. +// Also runs in Node.js (`node --test `), so it uses node:test and imports only Node modules. import assert from "node:assert"; import { once } from "node:events"; import { join } from "node:path"; From fc9f99e3bbbdf3a7b21e809f4a81684128d89161 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 19 Sep 2026 00:28:44 +0000 Subject: [PATCH 09/10] test(worker): move the Web Worker inbox tests next to the other inbox tests message-port-pipe.test.ts already has a "Worker postMessage inbox" block, and these two tests are about a port that is still in that inbox when the worker is gone. Co-authored-by: Peter Steinberger --- test/js/web/workers/message-port-pipe.test.ts | 40 ++++++++++++++++++- test/js/web/workers/worker.test.ts | 36 ----------------- 2 files changed, 39 insertions(+), 37 deletions(-) diff --git a/test/js/web/workers/message-port-pipe.test.ts b/test/js/web/workers/message-port-pipe.test.ts index 9ff401768d06..a0cbf7fa89f6 100644 --- a/test/js/web/workers/message-port-pipe.test.ts +++ b/test/js/web/workers/message-port-pipe.test.ts @@ -1,5 +1,7 @@ import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, isASAN, isDebug } from "harness"; +import { bunEnv, bunExe, isASAN, isDebug, tempDir } from "harness"; +import { once } from "node:events"; +import { join } from "node:path"; import { receiveMessageOnPort } from "node:worker_threads"; // Exercises the MessagePortPipe layer that backs MessagePort/MessageChannel: @@ -478,4 +480,40 @@ describe("Worker postMessage inbox", () => { expect(stdout.trim()).toBe("OK"); expect(exitCode).toBe(0); }); + + // A message the worker never took from its inbox is dropped when the worker is gone. A port in + // that message is closed with it, so the port's peer hears 'close'. + describe("a MessagePort in a message the worker never reads is closed", () => { + // Stays referenced, as a Worker in a pool does: a collected Worker drops its inbox too. + let worker: Worker; + + test("the entry point does not resolve", async () => { + using dir = tempDir("web-worker-missing-entry-port", {}); + const { port1, port2 } = new MessageChannel(); + worker = new Worker(join(String(dir), "missing.js")); + const events: string[] = []; + worker.addEventListener("error", () => events.push("error")); + worker.addEventListener("close", e => events.push(`close:${e.code}`)); + const portClosed = once(port1, "close").then(() => events.push("port-close")); + worker.postMessage({ port: port2 }, [port2]); + + await portClosed; + expect(events).toEqual(["error", "close:1", "port-close"]); + }); + + // The entry never returns, so the worker never reads its inbox, whether terminate() lands + // before the thread starts or while the entry runs. + test("terminate() stops a worker whose entry is still running", async () => { + const { port1, port2 } = new MessageChannel(); + worker = new Worker("data:text/javascript,for(;;){}"); + const events: string[] = []; + worker.addEventListener("close", () => events.push("close")); + const portClosed = once(port1, "close").then(() => events.push("port-close")); + worker.postMessage({ port: port2 }, [port2]); + worker.terminate(); + + await portClosed; + expect(events).toEqual(["close", "port-close"]); + }); + }); }); diff --git a/test/js/web/workers/worker.test.ts b/test/js/web/workers/worker.test.ts index 049db4723ddf..102b6c6f7dbd 100644 --- a/test/js/web/workers/worker.test.ts +++ b/test/js/web/workers/worker.test.ts @@ -403,42 +403,6 @@ describe("web worker", () => { }); }); - // A message the worker never took from its inbox is dropped when the worker is gone. A port in - // that message is closed with it, so the port's peer hears 'close'. - describe("a MessagePort posted to a worker that never reads it closes", () => { - // Stays referenced, as a Worker in a pool does: a collected Worker drops its inbox too. - let worker: Worker; - - test("the entry point does not resolve", async () => { - using dir = tempDir("web-worker-missing-entry-port", {}); - const { port1, port2 } = new MessageChannel(); - worker = new Worker(path.join(String(dir), "missing.js")); - const events: string[] = []; - worker.addEventListener("error", () => events.push("error")); - worker.addEventListener("close", e => events.push(`close:${e.code}`)); - const portClosed = once(port1, "close").then(() => events.push("port-close")); - worker.postMessage({ port: port2 }, [port2]); - - await portClosed; - expect(events).toEqual(["error", "close:1", "port-close"]); - }); - - // The entry never returns, so the worker never reads its inbox, whether terminate() lands - // before the thread starts or while the entry runs. - test("terminate() stops a worker whose entry is still running", async () => { - const { port1, port2 } = new MessageChannel(); - worker = new Worker("data:text/javascript,for(;;){}"); - const events: string[] = []; - worker.addEventListener("close", () => events.push("close")); - const portClosed = once(port1, "close").then(() => events.push("port-close")); - worker.postMessage({ port: port2 }, [port2]); - worker.terminate(); - - await portClosed; - expect(events).toEqual(["close", "port-close"]); - }); - }); - // As in browsers (and Node's Web Worker), the worker's implicit port opens once the entry's // synchronous part has run: a message dispatched while no 'message' handler exists is dropped. // node:worker_threads' parentPort is what queues until a listener is attached. #40141 From 8003adbdd6afed806eb8476a538859fb508b7081 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 19 Sep 2026 00:50:24 +0000 Subject: [PATCH 10/10] ci: retrigger