Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 15 additions & 4 deletions src/js/node/worker_threads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,11 @@ type NodeWorkerOptions = import("node:worker_threads").WorkerOptions;
// after their Worker exits
let urlRevokeRegistry: FinalizationRegistry<string> | undefined = undefined;

// The native `messageerror` MessageEvent carries no error object; node hands listeners an Error.
function deserializeError() {
return new TypeError("Unable to deserialize data.");
}

function injectFakeEmitter(Class) {
// Per-instance registry mapping each event to (user listener -> wrapper), so
// listenerCount/eventNames/removeAllListeners work over EventTarget's opaque
Expand All @@ -124,6 +129,10 @@ function injectFakeEmitter(Class) {
return event.error;
}

function messageErrorEventHandler(event: ErrorEvent | MessageEvent) {
return event instanceof MessageEvent ? deserializeError() : event.error;
}
Comment on lines +132 to +134

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 event instanceof MessageEvent reads a bare global at listener-invocation time and routes through user-overridable Symbol.hasInstance. REVIEW.md's built-in JS section says to avoid this, but the file already uses bare MessageEvent/ErrorEvent and instanceof elsewhere so this matches local convention — a simpler tamper-safe branch like event.error === undefined (ErrorEvent's .error defaults to null, MessageEvent has none) would sidestep it.

Extended reasoning...

What the issue is

The new messageErrorEventHandler at src/js/node/worker_threads.ts:132-134 distinguishes a native messageerror MessageEvent (dispatched by C++ when deserialization fails) from an ErrorEvent (dispatched via the fake-emitter's emit() path) by testing event instanceof MessageEvent. Two things about this are not tamper-resistant per REVIEW.md's built-in JS modules section ("globals captured at module load", "never route internal logic through user-overridable machinery (Array.isArray, never instanceof Array)"):

  1. MessageEvent is read as a bare global at listener-invocation time, not captured at module load like MessageChannel/BroadcastChannel/Worker are on lines 57-66.
  2. instanceof goes through user-overridable Symbol.hasInstance.

Concrete walk-through

  1. User code runs Object.defineProperty(MessageEvent, Symbol.hasInstance, { value: () => false }) (or reassigns globalThis.MessageEvent).
  2. A message posted over a MessagePort fails to deserialize; native code dispatches a MessageEvent with type "messageerror" and data === null.
  3. The wrapper installed by port.on("messageerror", listener) calls messageErrorEventHandler(event).
  4. event instanceof MessageEvent evaluates to false, so the handler returns event.error — which is undefined on a MessageEvent.
  5. The user's node-style listener receives undefined instead of the synthesized TypeError("Unable to deserialize data.").

Why existing code doesn't prevent it

Nothing in this file captures MessageEvent at load time; the reference at line 133 is a live global lookup on every event. instanceof has no intrinsic form here.

Why this is a nit, not blocking

  • The file already references MessageEvent/ErrorEvent as bare globals in EventClass() and emit(), and already uses instanceof URL, instanceof Map, instanceof Set, instanceof ArrayBuffer, instanceof _MessagePort throughout — REVIEW.md also says "Match the exact file's local conventions", and this line does.
  • The impact is entirely self-inflicted: a user who tampers with MessageEvent breaks the argument shape of their own messageerror listener. There is no security boundary crossed and no correctness issue for anyone who hasn't monkey-patched a global.

How to fix

Any of these avoids the tamperable check without changing behavior:

  • Branch on the property that actually differs: return event.error === undefined ? deserializeError() : event.error; — ErrorEvent#error defaults to null (never undefined), and MessageEvent has no .error property.
  • Or capture the constructor at module load alongside the other globals: const { MessageChannel, BroadcastChannel, Worker: WebWorker, MessageEvent } = globalThis; and keep the instanceof.


function customEventHandler(event) {
return event.detail;
}
Expand All @@ -136,11 +145,14 @@ function injectFakeEmitter(Class) {

function functionForEventType(event, listener) {
switch (event) {
case "error":
case "messageerror": {
case "error": {
return wrapped(errorEventHandler, listener);
}

case "messageerror": {
return wrapped(messageErrorEventHandler, listener);
}

case "message": {
return wrapped(messageEventHandler, listener);
}
Expand Down Expand Up @@ -1394,8 +1406,7 @@ class Worker extends EventEmitter {
}

#onMessageError(event: MessageEvent) {
// TODO: is this right?
this.emit("messageerror", (event as any).error ?? event.data ?? event);
this.emit("messageerror", (event as any).error ?? deserializeError());
}
Comment on lines 1408 to 1410

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 One sibling on this same parentPort→Worker channel is missed: #onClose's drain loop (~line 1369, while ((entry = _receiveMessageOnPort(this.#publicPort)) !== undefined) this.emit('message', entry.message)) goes through tryTakeMessage → deserialize(NonThrowing) directly, not the fixed MessageEvent::create path. On the same undeserializable payload, either jsReceiveMessageOnPort's RETURN_IF_EXCEPTION propagates and aborts #onClose before emit('exit')/#publicPort.close(), or (with this PR's jsNull() change) the loop emits 'message' with null instead of 'messageerror'. Reachability needs the exit-before-drain race, and the throwing behavior is pre-existing, so nit — but a try { entry = _receiveMessageOnPort(...) } catch { this.emit('messageerror', deserializeError()); continue; } (plus entry.message === null → messageerror) around the loop body would complete the class.

Extended reasoning...

What the bug is

This PR fixes "a message that fails to deserialize" for the four faces that funnel through MessageEvent::create(JSGlobalObject&, Ref<SerializedScriptValue>&&, …) — BroadcastChannel, MessagePort::dispatchOneMessage, the worker inbox drain, and (via the #onMessageError change here) the node:worker_threads Worker's live #publicPort listener. REVIEW.md: "Fix the whole class in the same PR — grep for every sibling site sharing the pattern." One sibling on the same parentPort→Worker channel is missed: the #onClose drain loop about 40 lines above, at src/js/node/worker_threads.ts:1367–1372:

{
  let entry;
  while ((entry = _receiveMessageOnPort(this.#publicPort)) !== undefined) {
    this.emit("message", entry.message);
  }
  this.#publicPort.close();
}
this.#onExitPromise = e.code;
this.emit("exit", e.code);

Code path

_receiveMessageOnPort → jsReceiveMessageOnPort (Worker.cpp:206–232) → MessagePort::tryTakeMessage (MessagePort.cpp) → message->message->deserialize(*lexicalGlobalObject, lexicalGlobalObject, ports, SerializationErrorMode::NonThrowing). This does not go through MessageEvent::create, so the PR's new "clear a non-termination exception and mark as messageerror" logic does not apply. As the PR description itself establishes, deserialize(NonThrowing) can leave a non-termination exception ("Unable to deserialize data.") pending on the VM for exactly the DataView payload the new tests use. jsReceiveMessageOnPort then does RETURN_IF_EXCEPTION(scope, {}) at Worker.cpp:227, so the exception propagates to JS.

On the no-exception failure branch, this PR's own change at SerializedScriptValue.cpp:5083 (return result.first ? result.first : jsNull()) means tryTakeMessage returns jsNull(), so jsReceiveMessageOnPort builds { message: null } and the loop emits 'message' with null.

Why existing code doesn't prevent it

The #onMessageError handler this PR fixes at line 1408 is registered via this.#publicPort.addEventListener("messageerror", …) and only fires when messages arrive through the port's live event dispatch (MessagePort::dispatchOneMessage → MessageEvent::create). The #onClose drain loop is a different consumer of the same pipe: it runs when the WebWorker's 'close' event lands with messages still queued in #publicPort (the loop's own comment: "node delivers everything the worker posted before it exited ahead of 'exit'"). It pulls messages synchronously via receiveMessageOnPort, which has no messageerror path at all.

Step-by-step proof

  1. Worker does parentPort.postMessage(UNDESERIALIZABLE); parentPort.postMessage('after'); and exits immediately (no setInterval — unlike the PR's new test at worker_threads.test.ts:1114, which deliberately keeps the worker alive so both messages arrive via the fixed live-event path).
  2. On the parent, both the MessagePortPipe drain task and the WebWorker 'close' task are posted via ScriptExecutionContext::postTaskTo from the worker thread. Normally FIFO ordering routes the messages through dispatchOneMessage first — but the loop exists precisely for the race where it doesn't (e.g. drainBatchLimit exhausted → postTaskAfterYield reschedule lands after the close task, or a 'close' listener registered before a 'message' listener so the port never started).
  3. #onClose runs, calls _receiveMessageOnPort(this.#publicPort), which deserializes UNDESERIALIZABLE.
  4. Case (a) — deserialize left an exception pending: RETURN_IF_EXCEPTION at Worker.cpp:227 propagates it. The while loop aborts. this.#publicPort.close(), this.#onExitPromise = e.code, and this.emit('exit', e.code) never run. Code awaiting worker.on('exit') or the promise returned from an in-flight terminate() never fires; 'after' is never delivered; the public port stays open.
  5. Case (b) — deserialize returned empty without throwing: with this PR's jsNull() change, tryTakeMessage returns null, so the loop does this.emit('message', null) instead of 'messageerror'. 'after' is still delivered and 'exit' fires — so this branch is a strict improvement over the pre-PR putDirect-of-empty-JSValue path.

Impact and severity

nit. (a) Reachability requires both an adversarial undeserializable payload and the exit-before-drain race — an edge case, though one the drain loop exists precisely to handle. (b) The throwing behavior of receiveMessageOnPort on undeserializable payloads is pre-existing; this PR does not regress it. (c) On the non-throwing failure branch the PR is a strict improvement. Not worth blocking merge, but worth completing the class since it is exactly the bug being fixed on the same channel.

Fix

Wrap the loop body in try/catch and route both failure modes to 'messageerror':

let entry;
for (;;) {
  try {
    entry = _receiveMessageOnPort(this.#publicPort);
  } catch {
    this.emit("messageerror", deserializeError());
    continue;
  }
  if (entry === undefined) break;
  this.emit("message", entry.message);
}
this.#publicPort.close();

(Optionally also treat entry.message === null as messageerror to cover the non-throwing branch, mirroring what MessageEvent::create now does.)


#onOpen() {
Expand Down
11 changes: 10 additions & 1 deletion src/jsc/bindings/webcore/MessageEvent.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,17 @@ auto MessageEvent::create(JSC::JSGlobalObject& globalObject, Ref<SerializedScrip
bool didFail = false;

auto deserialized = data->deserialize(globalObject, &globalObject, ports, SerializationErrorMode::NonThrowing, &didFail);
if (topExceptionScope.exception()) [[unlikely]]
if (topExceptionScope.exception()) [[unlikely]] {
// A termination exception is left pending for the caller; anything else is a
// deserialization failure and becomes a `messageerror` event.
if (!vm.hasPendingTerminationException()) {
topExceptionScope.clearException();
didFail = true;
}
deserialized = jsUndefined();
}
if (didFail)
deserialized = jsNull();

JSC::Strong<JSC::Unknown> strongData(vm, deserialized);

Expand Down
4 changes: 4 additions & 0 deletions src/jsc/bindings/webcore/MessagePort.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,10 @@ void MessagePort::dispatchOneMessage(ScriptExecutionContext& context, MessageWit
}

auto event = MessageEvent::create(*context.jsGlobalObject(), message.message.releaseNonNull(), {}, {}, {}, WTF::move(ports));
if (scope.exception()) [[unlikely]] {
RELEASE_ASSERT(vm->hasPendingTerminationException());
return;
}
dispatchEvent(event.event);
}

Expand Down
2 changes: 1 addition & 1 deletion src/jsc/bindings/webcore/SerializedScriptValue.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -5080,7 +5080,7 @@ JSValue SerializedScriptValue::deserialize(JSGlobalObject& lexicalGlobalObject,
// Rethrow is a bit simpler here since we don't deal with return codes.
RETURN_IF_EXCEPTION(scope, {});

return result.first;
return result.first ? result.first : jsNull();
}

} // namespace WebCore
2 changes: 2 additions & 0 deletions src/jsc/bindings/webcore/WorkerMessagingProxy.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,8 @@ static bool drainInbox(WorkerMessagingProxy::MessageInbox& inbox, Zig::GlobalObj
auto message = batch.takeFirst();
auto ports = MessagePort::entanglePorts(context, WTF::move(message.transferredPorts));
auto event = MessageEvent::create(globalObject, message.message.releaseNonNull(), nullptr, WTF::move(ports));
if (globalObject.vm().hasPendingTerminationException()) [[unlikely]]
return false;
dispatch(event.event);
if (globalObject.drainMicrotasks())
return false; // termination pending
Expand Down
44 changes: 44 additions & 0 deletions test/js/node/worker_threads/worker_threads.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1085,6 +1085,50 @@ test("onmessageerror alone does not ref the port", () => {
port1.close();
});

describe.concurrent("a message that fails to deserialize emits 'messageerror'", () => {
// Serializes fine but fails to deserialize: the DataView's offset is only in bounds
// after a getter resized the buffer, and the buffer was serialized before that.
const undeserializable = `(() => { const ab = new ArrayBuffer(8, { maxByteLength: 65536 }); return { ab, get grow() { ab.resize(65536); return 1; }, get view() { return new DataView(ab, 4096, 16); } }; })()`;

async function run(script: string) {
await using proc = Bun.spawn({ cmd: [bunExe(), "-e", script], env: bunEnv, stdout: "pipe", stderr: "inherit" });
const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]);
return { stdout, exitCode };
}

test("on a MessagePort, and later messages still arrive", async () => {
const { stdout, exitCode } = await run(
`const { MessageChannel } = require("node:worker_threads");
const { port1, port2 } = new MessageChannel();
const seen = [];
const record = s => { seen.push(s); if (seen.length === 2) { console.log(seen.join(",")); port1.close(); port2.close(); } };
port2.on("messageerror", e => record("messageerror:" + e.constructor.name + ":" + e.message));
port2.on("message", m => record("message:" + m));
port1.postMessage(${undeserializable});
port1.postMessage("after");`,
);
expect(stdout).toBe("messageerror:TypeError:Unable to deserialize data.,message:after\n");
expect(exitCode).toBe(0);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

test("on the Worker when parentPort posts it, and later messages still arrive", async () => {
const { stdout, exitCode } = await run(
`const { Worker } = require("node:worker_threads");
const w = new Worker(
'const { parentPort } = require("node:worker_threads"); parentPort.postMessage(${undeserializable}); parentPort.postMessage("after"); setInterval(() => {}, 1000);',
{ eval: true },
);
const seen = [];
const record = s => { seen.push(s); if (seen.length === 2) { console.log(seen.join(",")); w.terminate(); } };
w.on("error", e => { console.log("error", e); process.exit(1); });
w.on("messageerror", e => record("messageerror:" + e.constructor.name + ":" + e.message));
w.on("message", m => record("message:" + m));`,
);
expect(stdout).toBe("messageerror:TypeError:Unable to deserialize data.,message:after\n");
expect(exitCode).toBe(0);
});
});

// Collecting the unreferenced peer must not look like a peer close: node never
// closes a channel because a port was garbage-collected, so ref() still works.
test("hasRef() survives collection of the unreferenced peer", () => {
Expand Down
28 changes: 28 additions & 0 deletions test/js/web/broadcastchannel/broadcast-channel.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,33 @@
import { bunEnv, bunExe } from "harness";
import util from "util";

// A payload that serializes fine but fails to deserialize: the DataView's offset is only
// in bounds after a getter resized the buffer, and the buffer was serialized before that.
const undeserializable = `(() => { const ab = new ArrayBuffer(8, { maxByteLength: 65536 });
return { ab, get grow() { ab.resize(65536); return 1; }, get view() { return new DataView(ab, 4096, 16); } }; })()`;

test("a message that fails to deserialize fires messageerror and later messages still arrive", async () => {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`const a = new BroadcastChannel("undeserializable"), b = new BroadcastChannel("undeserializable");
const seen = [];
const record = s => { seen.push(s); if (seen.length === 2) { console.log(seen.join(",")); a.close(); b.close(); } };
b.onmessageerror = e => record("messageerror:" + e.data);
b.onmessage = e => record("message:" + e.data);
a.postMessage(${undeserializable});
a.postMessage("after");`,
],
env: bunEnv,
stdout: "pipe",
stderr: "inherit",
});
const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]);
expect(stdout).toBe("messageerror:null,message:after\n");
expect(exitCode).toBe(0);
});

test("postMessage results in correct event", done => {
let c1 = new BroadcastChannel("eventType");
let c2 = new BroadcastChannel("eventType");
Expand Down
27 changes: 27 additions & 0 deletions test/js/web/workers/message-channel.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { bunEnv, bunExe } from "harness";

test("simple usage", done => {
const channel = new MessageChannel();
const port1 = channel.port1;
Expand All @@ -11,6 +13,31 @@ test("simple usage", done => {
port1.postMessage("hello");
});

test("a message that fails to deserialize fires messageerror and later messages still arrive", async () => {
// Serializes fine but fails to deserialize: the DataView's offset is only in bounds
// after a getter resized the buffer, and the buffer was serialized before that.
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`const { port1, port2 } = new MessageChannel();
const seen = [];
const record = s => { seen.push(s); if (seen.length === 2) { console.log(seen.join(",")); port1.close(); port2.close(); } };
port2.onmessageerror = e => record("messageerror:" + e.data);
port2.onmessage = e => record("message:" + e.data);
const ab = new ArrayBuffer(8, { maxByteLength: 65536 });
port1.postMessage({ ab, get grow() { ab.resize(65536); return 1; }, get view() { return new DataView(ab, 4096, 16); } });
port1.postMessage("after");`,
],
env: bunEnv,
stdout: "pipe",
stderr: "inherit",
});
const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]);
expect(stdout).toBe("messageerror:null,message:after\n");
expect(exitCode).toBe(0);
});

test("transfer message port", done => {
const channel = new MessageChannel();
const anotherChannel = new MessageChannel();
Expand Down
28 changes: 28 additions & 0 deletions test/js/web/workers/worker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -572,6 +572,34 @@ describe("web worker", () => {
expect(exitCode).toBe(0);
});
});

test("a message that fails to deserialize fires messageerror in the worker and later messages still arrive", async () => {
// Serializes fine but fails to deserialize: the DataView's offset is only in bounds
// after a getter resized the buffer, and the buffer was serialized before that.
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`const src = \`self.addEventListener("messageerror", e => postMessage("messageerror:" + e.data));
self.onmessage = e => postMessage("message:" + e.data);\`;
const w = new Worker(URL.createObjectURL(new Blob([src])));
w.onerror = e => { console.log("error", e.message); process.exit(1); };
const seen = [];
w.onmessage = e => { seen.push(e.data); if (seen.length === 2) { console.log(seen.join(",")); w.terminate(); } };
w.addEventListener("open", () => {
const ab = new ArrayBuffer(8, { maxByteLength: 65536 });
w.postMessage({ ab, get grow() { ab.resize(65536); return 1; }, get view() { return new DataView(ab, 4096, 16); } });
w.postMessage("after");
});`,
],
env: bunEnv,
stdout: "pipe",
stderr: "inherit",
});
const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]);
expect(stdout).toBe("messageerror:null,message:after\n");
expect(exitCode).toBe(0);
});
});

// TODO: move to node:worker_threads tests directory
Expand Down