From 8c5312ccdf89e5f374f2df1938614e98b926899d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 1 Aug 2026 05:18:03 +0000 Subject: [PATCH 1/5] worker_threads: remove the global onmessage accessor inside a node worker Node.js does not expose a global `onmessage` event-handler accessor inside a worker_threads worker, but Bun installs one for Web Workers and left it in place for node workers as well. Emscripten's pthread worker shim registers both `parentPort.on('message', d => onmessage({data: d}))` and `self.onmessage = handler`; since parentPort is backed by the same global event target, the handler ran once for each path and the pthread 'load' command was processed twice, aborting with "Module.arguments has been replaced with plain arguments_". Delete `globalThis.onmessage` in node workers so `self.onmessage = fn` is a plain property assignment, and make `parentPort.onmessage` track its own listener via addEventListener/removeEventListener rather than aliasing `self.onmessage`. Fixes #7816 Fixes #29211 --- src/js/node/worker_threads.ts | 26 +++++++-- .../worker_threads/worker_threads.test.ts | 56 +++++++++++++++++++ 2 files changed, 78 insertions(+), 4 deletions(-) diff --git a/src/js/node/worker_threads.ts b/src/js/node/worker_threads.ts index 921df1a621d2..73314336f244 100644 --- a/src/js/node/worker_threads.ts +++ b/src/js/node/worker_threads.ts @@ -755,21 +755,31 @@ function receiveMessageOnPort(port: MessagePort) { // TODO: parent port emulation is not complete function fakeParentPort() { const fake = Object.create(MessagePort.prototype); + // parentPort.onmessage must not forward to self.onmessage: in a node worker + // the global accessor is removed (see below), and in either kind aliasing + // them means code that sets both parentPort.on("message", ...) and + // self.onmessage receives every message twice. + let onmessageHandler: any = null; Object.defineProperty(fake, "onmessage", { get() { - return self.onmessage; + return onmessageHandler; }, set(value) { - self.onmessage = value; + if (onmessageHandler !== null) self.removeEventListener("message", onmessageHandler); + onmessageHandler = $isCallable(value) || (value !== null && typeof value === "object") ? value : null; + if (onmessageHandler !== null) self.addEventListener("message", onmessageHandler); }, }); + let onmessageerrorHandler: any = null; Object.defineProperty(fake, "onmessageerror", { get() { - return self.onmessageerror; + return onmessageerrorHandler; }, set(value) { - self.onmessageerror = value; + if (onmessageerrorHandler !== null) self.removeEventListener("messageerror", onmessageerrorHandler); + onmessageerrorHandler = $isCallable(value) || (value !== null && typeof value === "object") ? value : null; + if (onmessageerrorHandler !== null) self.addEventListener("messageerror", onmessageerrorHandler); }, }); @@ -832,6 +842,14 @@ let parentPort: MessagePort | null = isMainThread ? null : fakeParentPort(); // Gate on _isNodeWorker so a raw `new globalThis.Worker` that transitively loads // this module does NOT have process.abort/chdir/setuid replaced. if (!isMainThread && _isNodeWorker) { + // Node.js does not have a global `onmessage` event-handler accessor inside a + // worker_threads worker. Bun installs one for Web Workers, but leaving it in + // place here means that code which registers both parentPort.on("message", ...) + // and self.onmessage (as Emscripten's pthread worker shim does) receives each + // message twice, since parentPort is backed by the same global event target. + try { + delete (globalThis as any).onmessage; + } catch {} applyWorkerProcessOverrides(); } function applyWorkerProcessOverrides() { diff --git a/test/js/node/worker_threads/worker_threads.test.ts b/test/js/node/worker_threads/worker_threads.test.ts index 68d6c6f3f103..252f62d62989 100644 --- a/test/js/node/worker_threads/worker_threads.test.ts +++ b/test/js/node/worker_threads/worker_threads.test.ts @@ -1756,3 +1756,59 @@ test("the SHARE_ENV founding thread's process.env stays live after the swap", as expect(stdout.trim()).toBe("yes,unset"); expect(exitCode).toBe(0); }); + +// https://github.com/oven-sh/bun/issues/7816 +// Emscripten's pthread worker shim registers parentPort.on("message", ...) and +// then assigns self.onmessage. In Node the global onmessage is not an event +// handler accessor, so only the parentPort listener fires; Bun was delivering +// the message to both, so the pthread 'load' handler ran twice and aborted. +test("globalThis.onmessage is not an event handler accessor inside a node:worker_threads worker", async () => { + const worker = new Worker( + /* js */ ` + const { parentPort } = require("worker_threads"); + let count = 0; + const hadOnMessage = "onmessage" in globalThis; + Object.assign(global, { self: global }); + parentPort.on("message", data => { + globalThis.onmessage({ data }); + }); + self.onmessage = e => { + count++; + if (e.data === "ping") { + setImmediate(() => parentPort.postMessage({ count, hadOnMessage })); + } + }; + parentPort.postMessage("ready"); + `, + { eval: true }, + ); + try { + await once(worker, "message"); // "ready" + worker.postMessage("ping"); + const [result] = await once(worker, "message"); + expect(result).toEqual({ count: 1, hadOnMessage: false }); + } finally { + await worker.terminate(); + } +}); + +test("parentPort.onmessage works inside a node:worker_threads worker", async () => { + const worker = new Worker( + /* js */ ` + const { parentPort } = require("worker_threads"); + parentPort.onmessage = e => { + parentPort.postMessage({ echoed: e.data, handlerIsSet: typeof parentPort.onmessage === "function" }); + }; + parentPort.postMessage("ready"); + `, + { eval: true }, + ); + try { + await once(worker, "message"); // "ready" + worker.postMessage("hello"); + const [result] = await once(worker, "message"); + expect(result).toEqual({ echoed: "hello", handlerIsSet: true }); + } finally { + await worker.terminate(); + } +}); From 7694a074d664f4f8e8283072458493caf86ef005 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 1 Aug 2026 05:38:41 +0000 Subject: [PATCH 2/5] test: reference onmessage/onerror via globalThis in napi common harness globalThis.onmessage is no longer defined inside a node:worker_threads worker, so referencing it as a bare identifier threw a ReferenceError when the napi test harness was loaded inside a worker. --- test/napi/node-napi-tests/test/common/index.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/napi/node-napi-tests/test/common/index.js b/test/napi/node-napi-tests/test/common/index.js index 5267266ac390..e3d01cd1e9d6 100644 --- a/test/napi/node-napi-tests/test/common/index.js +++ b/test/napi/node-napi-tests/test/common/index.js @@ -356,8 +356,8 @@ if (typeof Bun === "object") { ResolveMessage, ErrorEvent, Worker, - onmessage, - onerror, + globalThis.onmessage, + globalThis.onerror, ); } From 9deeba9148d89a61119ee0bf1f02f71fb9a8b98b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 1 Aug 2026 05:49:16 +0000 Subject: [PATCH 3/5] worker_threads: trim comments --- src/js/node/worker_threads.ts | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/src/js/node/worker_threads.ts b/src/js/node/worker_threads.ts index 73314336f244..0abba82f055e 100644 --- a/src/js/node/worker_threads.ts +++ b/src/js/node/worker_threads.ts @@ -755,10 +755,6 @@ function receiveMessageOnPort(port: MessagePort) { // TODO: parent port emulation is not complete function fakeParentPort() { const fake = Object.create(MessagePort.prototype); - // parentPort.onmessage must not forward to self.onmessage: in a node worker - // the global accessor is removed (see below), and in either kind aliasing - // them means code that sets both parentPort.on("message", ...) and - // self.onmessage receives every message twice. let onmessageHandler: any = null; Object.defineProperty(fake, "onmessage", { get() { @@ -842,11 +838,7 @@ let parentPort: MessagePort | null = isMainThread ? null : fakeParentPort(); // Gate on _isNodeWorker so a raw `new globalThis.Worker` that transitively loads // this module does NOT have process.abort/chdir/setuid replaced. if (!isMainThread && _isNodeWorker) { - // Node.js does not have a global `onmessage` event-handler accessor inside a - // worker_threads worker. Bun installs one for Web Workers, but leaving it in - // place here means that code which registers both parentPort.on("message", ...) - // and self.onmessage (as Emscripten's pthread worker shim does) receives each - // message twice, since parentPort is backed by the same global event target. + // Node has no global onmessage; parentPort shares this event target, so keeping it double-delivers. try { delete (globalThis as any).onmessage; } catch {} From b5269972f127544cbbf5116daf442e41ba0bcd5b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 1 Aug 2026 06:18:27 +0000 Subject: [PATCH 4/5] worker_threads: register a wrapper for parentPort.onmessage, not the user function Keeps the handler-attribute slot independent of addEventListener registrations of the same function, matching Node's defineEventHandler semantics. --- src/js/node/worker_threads.ts | 43 ++++++++++++++++------------------- 1 file changed, 20 insertions(+), 23 deletions(-) diff --git a/src/js/node/worker_threads.ts b/src/js/node/worker_threads.ts index 0abba82f055e..012f05c7f13b 100644 --- a/src/js/node/worker_threads.ts +++ b/src/js/node/worker_threads.ts @@ -755,29 +755,26 @@ function receiveMessageOnPort(port: MessagePort) { // TODO: parent port emulation is not complete function fakeParentPort() { const fake = Object.create(MessagePort.prototype); - let onmessageHandler: any = null; - Object.defineProperty(fake, "onmessage", { - get() { - return onmessageHandler; - }, - set(value) { - if (onmessageHandler !== null) self.removeEventListener("message", onmessageHandler); - onmessageHandler = $isCallable(value) || (value !== null && typeof value === "object") ? value : null; - if (onmessageHandler !== null) self.addEventListener("message", onmessageHandler); - }, - }); - - let onmessageerrorHandler: any = null; - Object.defineProperty(fake, "onmessageerror", { - get() { - return onmessageerrorHandler; - }, - set(value) { - if (onmessageerrorHandler !== null) self.removeEventListener("messageerror", onmessageerrorHandler); - onmessageerrorHandler = $isCallable(value) || (value !== null && typeof value === "object") ? value : null; - if (onmessageerrorHandler !== null) self.addEventListener("messageerror", onmessageerrorHandler); - }, - }); + function defineHandlerAttribute(name: string, event: string) { + let handler: any = null; + const wrapper = (e: Event) => { + if ($isCallable(handler)) handler.$call(fake, e); + else if ($isCallable(handler?.handleEvent)) handler.handleEvent.$call(handler, e); + }; + Object.defineProperty(fake, name, { + get() { + return handler; + }, + set(value) { + const next = $isCallable(value) || (value !== null && typeof value === "object") ? value : null; + if (handler === null && next !== null) self.addEventListener(event, wrapper); + else if (handler !== null && next === null) self.removeEventListener(event, wrapper); + handler = next; + }, + }); + } + defineHandlerAttribute("onmessage", "message"); + defineHandlerAttribute("onmessageerror", "messageerror"); const postMessage = $newCppFunction("ZigGlobalObject.cpp", "jsFunctionPostMessage", 1); Object.defineProperty(fake, "postMessage", { From 3de4278eb5a0d8994441cd3810d1c30e3da96a77 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 1 Aug 2026 06:30:15 +0000 Subject: [PATCH 5/5] ci: retrigger