Skip to content

Stop dispatching parent messages to self.onmessage in node:worker_threads workers - #29215

Closed
robobun wants to merge 14 commits into
mainfrom
farm/ac5aee3e/worker-onmessage-double-fire
Closed

robobun wants to merge 14 commits into
mainfrom
farm/ac5aee3e/worker-onmessage-double-fire

Conversation

@robobun

@robobun robobun commented Apr 12, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #29211
Fixes #29635
Fixes #25860
Fixes #19453
Fixes #25454
Fixes #7816

Repro

import { init } from 'z3-solver';
const { Context } = await init();
const { Solver, Int, And } = new Context('main');
const x = Int.const('x');
const solver = new Solver();
solver.add(And(x.ge(0), x.le(9)));
console.log(await solver.check()); // expected: sat

Node prints sat. Bun aborts with Aborted(Assertion failed) inside z3's Emscripten runtime at removeRunDependency('wasm-instantiate') and then segfaults when the worker error surfaces.

Cause

Bun's native worker runtime dispatches messages posted by the parent onto the worker's global scope, so self.onmessage and any self.addEventListener('message', …) listeners fire automatically. That matches web-worker semantics but not Node's node:worker_threads, where parent messages are only observable through parentPort — self.onmessage in a Node worker is a dead assignment.

Emscripten-generated pthread code (and z3-solver) relies on the Node behavior:

parentPort.on('message', (m) => onmessage({ data: m }));
self.onmessage = handleMessage;

Under Bun, every parent message reached handleMessage twice — once via the automatic self.onmessage dispatch and once via the explicit forwarding inside the parentPort listener — so z3's removeRunDependency('wasm-instantiate') assertion failed on the second call and the worker tore down with an unhandled error, which then surfaced as a segfault in the main thread's error-handling path.

Fix

fakeParentPort in src/js/node/worker_threads.ts now owns its own EventTarget. addEventListener / removeEventListener / on / off / once / onmessage / onmessageerror all route through it. While at least one user listener is registered, a capture-phase forwarder is installed on self that calls stopImmediatePropagation() (so handlers on the global scope never see parent messages) and re-dispatches the event on the parentPort target.

The forwarders are attached lazily and removed when the last user listener goes away, so a worker that never touches parentPort (or only sets listeners and then removes them) doesn't leak the event-loop ref that installing a message listener on self otherwise keeps — via onDidChangeListenerImpl in BunWorkerGlobalScope.cpp. That way workers like new Worker('require("worker_threads").setEnvironmentData(...)', { eval: true }) still exit cleanly when their module finishes executing.

Verification

test/regression/issue/29211.test.ts covers three scenarios:

  1. The exact emscripten pattern from z3: a parentPort.on('message', …) forwarder that manually invokes self.onmessage. With the bug, handleMessage fires twice per parent message and global-scope listeners also fire; with the fix, it fires exactly once and global listeners stay silent (matching Node).
  2. Every parentPort listener variant (on / addEventListener / onmessage) sees exactly one dispatch per parent message.
  3. parentPort.off actually removes a listener.

The first test fails on main and passes with the fix. The pre-existing test/js/node/worker_threads/worker_threads.test.ts suite still passes the same 21 tests it did on main (no new regressions; the same 6 tests time out in this environment both before and after the change).

@robobun

robobun commented Apr 12, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 3:30 AM PT - Apr 23rd, 2026

❌ @autofix-ci[bot], your commit 17af498 has 2 failures in Build #47475 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 29215

That installs a local version of the PR into your bun-29215 executable, so you can run:

bun-29215 --bun

@coderabbitai

coderabbitai Bot commented Apr 12, 2026 •

Copy link
Copy Markdown
Contributor

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Reworks worker_threads parentPort emulation to forward native worker "message"/"messageerror" events into a dedicated EventTarget, prevent native global delivery, implement wrapped listener lifecycle (once/AbortSignal), update onmessage/onmessageerror accessors/dispatch semantics, and add an end-to-end regression test validating delivery and listener behavior.

Changes

Cohort / File(s) Summary
Parent Port Messaging Refactor
src/js/node/worker_threads.ts
Reimplements fakeParentPort() to create a parentPortTarget EventTarget and install global forwarders on self that intercept native "message"/"messageerror", call stopImmediatePropagation(), clone and re-dispatch MessageEvents (including ports) to parentPortTarget. Replaces prior emulated addEventListener/removeEventListener with wrappers tracked in a WeakMap, supports { once: true }, tracks listenerCount to install/uninstall forwarders, handles AbortSignal removal by wiring abort listeners, updates onmessage/onmessageerror to use wrapped handlers and rethrow exceptions via queueMicrotask, exposes dispatchEvent, and adjusts addListener/removeListener to use the new emulated logic.
Worker Threads Regression Tests
test/regression/issue/29211.test.ts
Adds an ESM regression test that writes worker.mjs/main.mjs fixtures, spawns workers, and asserts end-to-end semantics: parent messages are delivered exactly once to parentPort handlers and do not trigger global self/globalThis message handlers; verifies .on/.off, .addEventListener (including EventListenerObject), .onmessage, transferred MessagePort round-trip, AbortSignal listener removal, stderr normalization for ASAN warning, and clean process exit.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main change: preventing parent messages from being dispatched to self.onmessage in node:worker_threads, which is the core fix addressing all linked issues.
Linked Issues check ✅ Passed The PR directly addresses all five linked issues (#29211, #25860, #19453, #25454, #7816) which all stem from the same root cause: double message delivery in worker_threads. The fix prevents parent messages from reaching global scope handlers while preserving parentPort delivery, eliminating duplicate calls that caused assertions, crashes, and stack overflows in Emscripten-compiled modules like z3-solver.
Out of Scope Changes check ✅ Passed All changes are scoped to fixing the double-dispatch issue: the implementation modifies fakeParentPort in worker_threads.ts with lazy forwarders and listener tracking, and the test file comprehensively validates the fix across multiple listener patterns and edge cases. No unrelated changes are present.
Description check ✅ Passed PR description provides comprehensive context including issue references, root cause analysis, implemented fix, and verification approach.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions

Copy link
Copy Markdown
Contributor

Found 4 issues this PR may fix:

  1. node:worker_threads: Messages delivered twice when both parentPort.on('message') and self.onmessage are registered #25860 - Directly reports the double-dispatch bug: messages delivered twice when both parentPort.on('message') and self.onmessage are registered
  2. Z3 doesn't work in Bun #19453 - Z3 doesn't work in Bun due to Emscripten pthread handlers firing twice, causing assertion failures
  3. z3 optimizer crashes #25454 - Z3 optimizer crashes from the same Emscripten double-dispatch root cause
  4. RangeError: Maximum call stack size exceeded when using z3-solver #7816 - Maximum call stack size exceeded with z3-solver caused by double-dispatch feedback loop

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #25860
Fixes #19453
Fixes #25454
Fixes #7816

🤖 Generated with Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. fix(worker_threads): use MessagePort for parentPort instead of dispatching to self #26293 - Also fixes parent messages being dispatched to both self.onmessage and parentPort in node:worker_threads workers, modifying the same fakeParentPort() function (uses a real MessagePort pair approach vs this PR EventTarget approach)

🤖 Generated with Claude Code

Comment thread src/js/node/worker_threads.ts Outdated
Comment thread src/js/node/worker_threads.ts Outdated
Comment thread src/js/node/worker_threads.ts

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/js/node/worker_threads.ts`:
- Around line 245-299: Replace uses of regular Map prototype methods on the
listener-tracking maps with the intrinsic-prefixed variants to avoid prototype
tampering: update accesses to trackedByListener and bucket inside
parentPortAddEventListener (and the TrackEntry logic) to use
trackedByListener.$get/$has/$set/$delete and bucket.$has/$get/$set/$delete and
read bucket.$size instead of the plain .get/.has/.set/.delete/.size; keep the
same semantics (creating a new Map for a missing bucket, storing entry, and
deleting when empty) but call the intrinsic methods (e.g., replace
trackedByListener.get(listener) with trackedByListener.$get(listener),
bucket.size with bucket.$size) so tamper-resistant intrinsics are used
consistently.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 482a37ca-30dd-4f2d-aebc-c1d8697cadd7

📥 Commits

Reviewing files that changed from the base of the PR and between 2a883b1 and 6cc8135.

📒 Files selected for processing (2)
  • src/js/node/worker_threads.ts
  • test/regression/issue/29211.test.ts

Comment thread src/js/node/worker_threads.ts Outdated
Comment thread src/js/node/worker_threads.ts Outdated
Comment thread test/regression/issue/29211.test.ts
Comment thread src/js/node/worker_threads.ts Outdated
robobun added a commit that referenced this pull request Apr 12, 2026
Addresses two follow-up review comments on #29215:

- `parentPortAddEventListener` called `acquireListener()` unconditionally
  for every event type — including `close`, `error`, etc. — so
  `parentPort.once('close', cleanup)` installed the capture-phase message
  forwarder on `self` even when the user was not listening for messages.
  The forwarder then intercepted parent messages, called
  `stopImmediatePropagation()`, and re-dispatched onto `parentPortTarget`
  where no `message` listener existed — so subsequent parent messages
  were silently dropped. And since native `close` events never reach
  `parentPortTarget`, `releaseListener()` never ran, pinning the worker
  event loop forever.

  Both `parentPortAddEventListener` and `parentPortRemoveEventListener`
  now gate `acquireListener`/`releaseListener` on
  `type === 'message' || type === 'messageerror'`, so listeners for
  unrelated event types pass straight through to `parentPortTarget`
  without touching the global-scope forwarder.

- Use tamper-resistant intrinsics on the inner listener-tracking Map
  (`bucket.$get` / `$set` / `$has` / `$delete` / `$size`) and
  `$Array.from` for the ports clone — following src/js/CLAUDE.md. The
  outer `WeakMap` stays on plain prototype methods because `.$get` is
  not defined as an intrinsic for WeakMap.

Regression test added: `parentPort listener for non-message events does
not block parent messages` — registers `.on('close')` before
`.on('message')` and asserts all three posted messages reach the
handler. Fails on the previous commit; passes after this change.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@test/regression/issue/29211.test.ts`:
- Around line 99-117: Swap the assertion order so stdout is checked before
exitCode/stderr: capture stdout, stderr, exitCode as you do, but move the
JSON.parse(stdout.trim()) / expect(...) block to run prior to asserting
exitCode/stderr (the expect({ stderr: cleanStderr(stderr), exitCode
}).toEqual(...) line). This ensures the parsed worker output
(handleMessageCalls/selfAddListenerCalls/globalAddListenerCalls) is reported on
failures before verifying exitCode, improving debug info for failures in the
Promise.all-collected values (stdout, stderr, exited).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 4e6f1e69-2d55-4e21-88d0-5277fab450ff

📥 Commits

Reviewing files that changed from the base of the PR and between 6cc8135 and 3f220d2.

📒 Files selected for processing (2)
  • src/js/node/worker_threads.ts
  • test/regression/issue/29211.test.ts

Comment thread test/regression/issue/29211.test.ts

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@test/regression/issue/29211.test.ts`:
- Line 116: The test currently asserts both cleaned stderr and exitCode with
expect({ stderr: cleanStderr(stderr), exitCode }).toEqual({ stderr: "",
exitCode: 0 }); which is brittle in CI/ASAN; change the assertion to only check
the subprocess exit code (e.g., assert exitCode is 0 via
expect(exitCode).toBe(0) or equivalent) and remove the strict empty stderr
comparison (leave stderr untouched unless the test explicitly validates a
message). Apply the same change for other occurrences that use
cleanStderr(stderr) in these tests (the spots referenced around lines 179, 237,
296, 342, 388, 443).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 8aa3d902-eeda-4589-81a6-ffc5b995351d

📥 Commits

Reviewing files that changed from the base of the PR and between 3f220d2 and 08de32c.

📒 Files selected for processing (1)
  • test/regression/issue/29211.test.ts

Comment thread test/regression/issue/29211.test.ts Outdated

@claude claude Bot left a comment

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.

The core logic is sound and all previously-flagged 🔴 issues have been addressed, but this is a substantial behavioral change to the worker_threads parentPort dispatch mechanism — worth a human pass before merge.

Extended reasoning...

Overview

This PR replaces the global-scope forwarding in fakeParentPort() with a dedicated parentPortTarget EventTarget, lazy capture-phase forwarders on self, and full listener lifecycle tracking. It touches ~250 lines of production JS runtime code in src/js/node/worker_threads.ts and adds a 446-line regression test suite.

Previous Bug Round

Three prior 🔴 bugs I flagged have all been addressed in the follow-up commits (6cc8135, 3f220d2): the AbortSignal leak that kept listenerCount stuck, missing ports in the cloned MessageEvent, and the EventListenerObject crash. The acquireListener gating for non-message event types was also fixed. The remaining bug reports are nits only (assertion ordering and missing test.concurrent).

Security Risks

No security concerns. The change is scoped to worker message dispatch within the JS builtin layer — no auth, crypto, or permission-sensitive paths involved.

Level of Scrutiny

Medium-high. While the fixes are correct in isolation, the implementation is complex: lazy forwarder install/uninstall, WeakMap-based listener tracking, signal interception, once-wrapper cleanup, and EventListenerObject dispatch. Any subtle mistake in reference counting could silently drop messages or leak event-loop refs. Human verification of the overall flow is warranted.

Other Factors

The test suite covers the core scenarios well (z3/emscripten pattern, all listener variants, off, transferred ports, EventListenerObject, AbortSignal, non-message events). The two remaining nits (assertion order and test.concurrent) are flagged as inline comments and do not affect correctness.

Comment thread test/regression/issue/29211.test.ts
Comment thread test/regression/issue/29211.test.ts
Comment thread src/js/node/worker_threads.ts Outdated
Comment thread src/js/node/worker_threads.ts Outdated
Comment thread test/regression/issue/29211.test.ts Outdated
Comment thread src/js/node/worker_threads.ts
Comment thread src/js/node/worker_threads.ts
Comment thread src/js/node/worker_threads.ts
Comment thread src/js/node/worker_threads.ts Outdated
Comment thread test/regression/issue/29211.test.ts Outdated
Comment thread src/js/node/worker_threads.ts
Comment thread src/js/node/worker_threads.ts
Comment on lines +544 to +549
// Two pre-removal messages: 'handler' fires twice (x2 registrations) = 4
w.postMessage({ n: 1 });
w.postMessage({ n: 2 });
w.postMessage({ cmd: 'remove-one' });
// After removal ack, main posts two more — handler should fire once
// each (only one registration left). Final total: 4 + 2 = 6.

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.

🟡 The inline comment in the main.mjs fixture says "Two pre-removal messages: 'handler' fires twice (x2 registrations) = 4" and "Final total: 4 + 2 = 6", but the assertion below is toEqual({ fired: 8 }). The comment forgets that the {cmd:'remove-one'} message itself fires both handler registrations (bringing the pre-removal count to 6) before the third listener removes one — so the real total is 6 + 2 = 8. Either delete this inline comment (the block above the assertion at lines 565-568 already explains it correctly) or fix it to match.

Extended reasoning...

What the bug is

In the parentPort.on twice + off once test, the inline comment embedded in the main.mjs fixture string (test/regression/issue/29211.test.ts:544-549) reads:

// Two pre-removal messages: 'handler' fires twice (x2 registrations) = 4
w.postMessage({ n: 1 });
w.postMessage({ n: 2 });
w.postMessage({ cmd: 'remove-one' });
// After removal ack, main posts two more — handler should fire once
// each (only one registration left). Final total: 4 + 2 = 6.

But the actual assertion at line 574 is expect(JSON.parse(stdout.trim())).toEqual({ fired: 8 }). A reader following the inline comment will expect 6 and be confused when the test asserts 8.

The specific code path

The worker registers handler twice via parentPort.on('message', handler), then registers a third anonymous listener that processes remove-one / report commands. Listeners dispatch in registration order, so for every incoming message both handler registrations fire before the anonymous command listener.

Why the comment is wrong

The inline comment counts only {n:1} and {n:2} as "pre-removal messages" (2 messages × 2 registrations = 4). It forgets that {cmd:'remove-one'} is also a pre-removal message from handler's perspective: both handler registrations fire on it (bringing fired to 6) before the third listener — registered last — runs removeListener. So the pre-removal subtotal is 6, not 4. After the removal ack, {n:1} → 7 and {cmd:'report'} → 8.

Step-by-step proof

  1. w.postMessage({n:1}) → handler fires for reg Fix ?? operator  #1 and reg Fix calling #private() functions in classes #2 → fired = 2
  2. w.postMessage({n:2}) → handler fires for reg Fix ?? operator  #1 and reg Fix calling #private() functions in classes #2 → fired = 4
  3. w.postMessage({cmd:'remove-one'}) → handler fires for reg Fix ?? operator  #1 and reg Fix calling #private() functions in classes #2 → fired = 6; then the anonymous listener (registered third) runs removeListener('message', handler) and posts {removed:true}
  4. Main receives {removed:true}, posts {n:1} → the single remaining handler registration fires → fired = 7
  5. Main posts {cmd:'report'} → the single remaining handler registration fires → fired = 8; then the anonymous listener posts {fired: 8}

The outer comment block above the assertion (lines 565-568) traces exactly this and is correct; only the inline fixture comment is stale.

Impact

Zero functional impact — the test passes and asserts the right value. But the file now contains two contradictory explanations of the same count (one wrong at 544-549, one right at 565-568), which will confuse anyone debugging this test in the future.

Fix

Either delete the inline fixture comment (the outer block already explains the count) or correct it, e.g.:

// Three pre-removal messages reach both registrations: {n:1}→2, {n:2}→4,
// {cmd:'remove-one'}→6 (handler fires before the remove-one listener runs).
...
// After removal ack, main posts two more — each fires the single remaining
// registration. Final total: 6 + 2 = 8.

Comment on lines +233 to +237
import { parentPort } from 'node:worker_threads';
parentPort.on('message', (_msg, ports) => {
// Node passes ports as a second argument; the DOM-style MessageEvent
// also exposes them via event.ports. We test both surfaces below via
// addEventListener.

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.

🟡 The comment "Node passes ports as a second argument" is factually incorrect — Node's parentPort 'message' event passes a single argument (the cloned value) and no further arguments; transferred ports are only reachable via event.ports on the DOM-style addEventListener path (which is what this test actually exercises below). Since the .on() listener body is empty and the ports parameter is unused, the simplest fix is to delete this no-op parentPort.on(...) block entirely.

Extended reasoning...

What the issue is

The worker.mjs fixture in the "transferred MessagePorts" test registers:

parentPort.on('message', (_msg, ports) => {
  // Node passes ports as a second argument; the DOM-style MessageEvent
  // also exposes them via event.ports. We test both surfaces below via
  // addEventListener.
});

The comment asserts that Node passes transferred ports as a second positional argument to parentPort.on('message', …) listeners. This is factually incorrect. Per the Node.js docs for the MessagePort 'message' event: "Listeners on this event receive a clone of the value parameter as passed to postMessage() and no further arguments." In Node, transferred MessagePort instances are only reachable either (a) by being included somewhere in the cloned value itself, or (b) via event.ports when using the DOM-style addEventListener('message', e => e.ports) path. There is no second positional argument.

Why nothing else contradicts this

Bun's own implementation in this PR confirms the single-argument convention: invokeEmitterListener (src/js/node/worker_threads.ts) calls listener.$call(fake, payload) with exactly one argument — event.data. So the ports parameter in this fixture's .on() callback is always undefined, the comment misdescribes both Node and Bun, and the listener body is empty anyway.

Impact

Zero functional impact — the listener body is a no-op and the test's actual assertion is driven by the addEventListener block below it, which correctly reads event.ports[0]. The only harm is documentation: this file's whole purpose is to encode Node-compat semantics for parentPort, and a factually-wrong comment about Node's calling convention will mislead future readers (same class of issue as the already-flagged "4+2=6 vs fired:8" comment discrepancy elsewhere in this file at inline-comment 3129793245).

Step-by-step proof

  1. Main thread: w.postMessage({ cmd: 'use-port' }, [port2]).
  2. Bun's capture forwarder fires, clones a MessageEvent with { data, ports: [port2] }, and dispatches it on parentPortTarget.
  3. The emitter-style wrapper for the .on() listener calls invokeEmitterListener('message', listener, event) → listener.$call(fake, event.data) — i.e. listener({ cmd: 'use-port' }). The second parameter ports is undefined.
  4. In Node.js itself, parentPort is a NodeEventTarget; its 'message' event is emitted with a single argument (the deserialized value). Same result: ports is undefined.
  5. Therefore the comment "Node passes ports as a second argument" is wrong in both runtimes, and the (_msg, ports) => {} signature documents a calling convention that doesn't exist.

Fix

Delete the no-op parentPort.on('message', (_msg, ports) => { … }) block — it serves no purpose in the test (the addEventListener block immediately below already exercises event.ports). Alternatively, correct the comment to say that Node's emitter-style 'message' listeners receive only the cloned value, and that transferred ports must be read from event.ports via addEventListener.

Comment on lines +289 to +294
// calling convention on a MessagePort-like object.
function invokeEmitterListener(
type: string,
listener: EventListener | EventListenerObject,
event: Event,
): void {

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.

🟣 Pre-existing: invokeEmitterListener reads (event as ErrorEvent).error for type === 'messageerror', but makeForwarder always re-dispatches a new MessageEvent(type, {data, ports}) — which has no .error property — so parentPort.on('messageerror', fn) receives undefined instead of the deserialization-error payload. This matches pre-PR behavior (the old injectFakeEmitter.errorEventHandler also read .error from the native messageerror MessageEvent), so it's not a regression; a one-line fix is to read (event as MessageEvent).data for the messageerror branch.

Extended reasoning...

What the bug is

invokeEmitterListener (src/js/node/worker_threads.ts:289-294) extracts the payload to pass to emitter-style listeners as follows:

if (type === "error" || type === "messageerror") {
  payload = (event as ErrorEvent).error;
} else {
  payload = (event as MessageEvent).data;
}

However, the only producer of messageerror events on parentPortTarget is makeForwarder, which always constructs the re-dispatched event as:

const clone = new MessageEvent(type, {
  data: messageEvent.data,
  ports: nativePorts && nativePorts.length > 0 ? $Array.from(nativePorts) : undefined,
});

A MessageEvent has .data but no .error property. So when invokeEmitterListener runs for messageerror, it reads clone.error, which is undefined, and the user listener receives undefined instead of the deserialization-error payload.

Why this is pre-existing, not a regression

Before this PR, parentPort.on('messageerror', fn) routed through injectFakeEmitter's functionForEventType, which used errorEventHandler(event) { return event.error; }. The native messageerror event dispatched on self is also a MessageEvent per the HTML spec (and per Bun's MessageEvent.cpp), carrying its payload in .data — not .error. So event.error was undefined pre-PR too. The user-visible behavior of parentPort.on('messageerror', fn) is identical before and after this PR: fn receives undefined.

The PR rewrote the dispatch path from scratch (the producer makeForwarder and consumer invokeEmitterListener are now in the same closure), making the inconsistency self-contained and trivially fixable, but it carries forward the exact same broken pattern rather than introducing it.

Why existing code doesn't prevent it

There is no code path that constructs an ErrorEvent for messageerror on parentPortTarget. makeForwarder is the only thing that calls parentPortTarget.dispatchEvent for messageerror, and it unconditionally builds a MessageEvent. The (event as ErrorEvent) cast is purely a TypeScript type assertion with no runtime effect.

Impact

messageerror only fires on structured-clone deserialization failure — a rare edge case. When it does fire, code like parentPort.on('messageerror', err => console.error(err)) logs undefined instead of the actual error/payload, making the failure harder to diagnose. No crash or hang. The file is also annotated // TODO: parent port emulation is not complete.

How to fix

Change the messageerror branch in invokeEmitterListener to read .data instead of .error:

if (type === "error") {
  payload = (event as ErrorEvent).error;
} else {
  // 'message' and 'messageerror' are both MessageEvent — payload is in .data
  payload = (event as MessageEvent).data;
}

(Node.js itself passes the deserialization error as the single argument to 'messageerror' listeners, sourced from the MessageEvent's data slot.)

Step-by-step proof

  1. Parent posts a value that fails structured-clone deserialization in the worker.
  2. Bun's native runtime dispatches a MessageEvent of type 'messageerror' on self with event.data = <the error>.
  3. messageErrorForwarder (installed via installMessageErrorForwarder) fires, calls stopImmediatePropagation(), then constructs new MessageEvent('messageerror', { data: event.data, ports: undefined }) and dispatches it on parentPortTarget.
  4. The wrapped listener on parentPortTarget (registered for the user's parentPort.on('messageerror', fn)) calls invokeEmitterListener('messageerror', fn, clone).
  5. type === 'messageerror' matches the first branch → payload = (clone as ErrorEvent).error → clone is a MessageEvent, has no .error → payload = undefined.
  6. fn.$call(fake, undefined) — user listener receives undefined.

Pre-PR, step 3 didn't exist (the listener was on self directly), but step 5's equivalent (errorEventHandler) read .error from the same native MessageEvent and also got undefined.

robobun and others added 7 commits May 4, 2026 10:37
…eads workers

Bun's native worker runtime dispatches messages posted by the parent onto
the worker's global scope, so `self.onmessage` and any
`self.addEventListener('message', ...)` listeners fired automatically.
That matches web-worker semantics but does not match Node's
`node:worker_threads`, where parent messages are only observable through
`parentPort` — setting `self.onmessage` in a Node worker is a dead
assignment.

Emscripten-generated pthread code (e.g. z3-solver) relies on the Node
behavior:

    parentPort.on('message', (m) => onmessage({ data: m }));
    self.onmessage = handleMessage;

Under Bun, every parent message reached `handleMessage` twice — once
via the automatic `self.onmessage` dispatch and once via the explicit
forwarding inside the parentPort listener — so z3's
`removeRunDependency('wasm-instantiate')` assertion failed on the second
call and the worker tore down with an unhandled error.

`fakeParentPort` in `src/js/node/worker_threads.ts` now owns its own
EventTarget. `addEventListener` / `removeEventListener` / `on` / `off`
/ `once` and the `onmessage` / `onmessageerror` accessors route through
it. While at least one user listener is registered, a capture-phase
forwarder is installed on `self` that stops immediate propagation (so
listeners on the global scope never see parent messages) and
re-dispatches on the parentPort's target. The forwarders are attached
lazily and removed when the last user listener goes away, so a worker
that never touches parentPort keeps the event loop refcount it had
before and still exits cleanly when its module finishes executing.

Fixes #29211
…tSignal leak

Addresses three issues in the parentPort EventTarget shim introduced to
stop parent messages from firing self.onmessage:

- The capture forwarder cloned MessageEvents with only `data`, silently
  dropping `event.ports`. Any caller doing
  `worker.postMessage(data, [port])` and reading `event.ports` from a
  parentPort listener lost the transferred port. The clone now carries
  `ports` through.

- `parentPortAddEventListener`'s wrapped callback unconditionally called
  `listener.$call(fake, event)`, which throws on an EventListenerObject
  (`{handleEvent(e) { … }}`). The wrapper now dispatches via a small
  `invokeListener` helper that handles both the function and
  handleEvent-object forms, matching DOM EventTarget semantics.

- `addEventListener` with `{signal: ac.signal}` passed the signal to the
  inner `parentPortTarget.addEventListener`. On abort, the native
  EventTarget removed the wrapped listener directly at the C++ level,
  bypassing our JS `parentPortRemoveEventListener` and leaving
  `listenerCount` at 1 — so the capture forwarder stayed installed on
  `self`, pinning the worker event loop forever. We now strip the signal
  from the forwarded options and drive abort ourselves, routing the
  removal through `parentPortRemoveEventListener` so `releaseListener()`
  runs.

Three new regression tests in test/regression/issue/29211.test.ts cover
each case; all three fail on the previous commit and pass after this
change.
Addresses two follow-up review comments on #29215:

- `parentPortAddEventListener` called `acquireListener()` unconditionally
  for every event type — including `close`, `error`, etc. — so
  `parentPort.once('close', cleanup)` installed the capture-phase message
  forwarder on `self` even when the user was not listening for messages.
  The forwarder then intercepted parent messages, called
  `stopImmediatePropagation()`, and re-dispatched onto `parentPortTarget`
  where no `message` listener existed — so subsequent parent messages
  were silently dropped. And since native `close` events never reach
  `parentPortTarget`, `releaseListener()` never ran, pinning the worker
  event loop forever.

  Both `parentPortAddEventListener` and `parentPortRemoveEventListener`
  now gate `acquireListener`/`releaseListener` on
  `type === 'message' || type === 'messageerror'`, so listeners for
  unrelated event types pass straight through to `parentPortTarget`
  without touching the global-scope forwarder.

- Use tamper-resistant intrinsics on the inner listener-tracking Map
  (`bucket.$get` / `$set` / `$has` / `$delete` / `$size`) and
  `$Array.from` for the ports clone — following src/js/CLAUDE.md. The
  outer `WeakMap` stays on plain prototype methods because `.$get` is
  not defined as an intrinsic for WeakMap.

Regression test added: `parentPort listener for non-message events does
not block parent messages` — registers `.on('close')` before
`.on('message')` and asserts all three posted messages reach the
handler. Fails on the previous commit; passes after this change.
…ion tests

Move the `JSON.parse(stdout)` assertion ahead of the
`expect({ stderr, exitCode })` check in all seven regression tests, so a
failure surfaces the actual worker output (handleMessageCalls counts,
fired counters, etc.) before the less-specific exit-code comparison.
Matches the CLAUDE.md convention for spawn-based tests.
Each test spawns an independent Bun subprocess with a unique tempDir
and shares no state with the others, so they're safe to run in parallel.
Matches the test/CLAUDE.md preference and cuts this suite's wall-clock
from ~21s to ~7s.
…rr asserts

Three fixes rolled into one commit after reviewer feedback and a CI
failure on the debian-13-x64-asan lane:

- **Drop the cleanStderr/empty-stderr assertions (CI fix).** The regression
  tests compared a hand-filtered stderr against the empty string to assert
  the subprocess was clean. That filter only stripped the known
  `ASAN interferes with JSC signal handlers` warning; any additional
  ASAN noise (leak summaries, SUMMARY lines, stack frames) leaked through
  and failed the assertion. Per test/CLAUDE.md convention for regression
  tests, the crash/failure signal is `expect(exitCode).toBe(0)`. All
  seven original assertions switched over; `cleanStderr` and the stderr
  destructuring side are gone.

- **Fix parentPort.removeListener bypassing wrappedListener.**
  `injectFakeEmitter`'s `Class.prototype.on` wraps `fn` into a callback
  and stores `fn[wrappedListener] = callback`; the tracking WeakMap in
  `fakeParentPort` is keyed on the callback. The previous code defined
  `fake.removeListener = parentPortRemoveEventListener`, which bypassed
  the wrappedListener lookup — so `parentPort.removeListener('message', fn)`
  looked up `fn` in the map, missed, and silently kept the listener
  (and its capture-forwarder ref) alive. `fake.addListener` /
  `fake.removeListener` now alias `MessagePort.prototype.on` / `.off`
  from `injectFakeEmitter`, which resolve `listener[wrappedListener]`
  before calling `this.addEventListener` / `this.removeEventListener`
  (both of which correctly hit `parentPortAddEventListener` /
  `parentPortRemoveEventListener` via the own-property overrides on
  `fake`).

- **Split message / messageerror listener counts.** `installForwarders`
  previously installed BOTH the `message` and `messageerror` capture
  forwarders on `self` as a single unit gated by one shared
  `listenerCount`. That meant `parentPort.onmessageerror = handler` (or
  `.on('messageerror', …)`) alone pulled in the `message` forwarder,
  which in turn pinned `m_messageEventCount` via
  `onDidChangeListenerImpl` in BunWorkerGlobalScope.cpp and kept the
  worker event loop alive forever — a regression from the pre-fix code
  where registering only an `onmessageerror` handler exited cleanly.
  The forwarders are now installed/uninstalled independently based on
  separate `messageListenerCount` and `messageErrorListenerCount`
  counters. `acquireListener` / `releaseListener` take a type arg; all
  callers thread that through.

Two new regression tests cover the fixes:
- `parentPort.removeListener unsubscribes through the wrapped-listener slot`
- `parentPort.onmessageerror alone does not keep the event loop alive`
Both fail on the previous commit and pass after this change. Each test
also gets a 30-second timeout to absorb the extra variance of running
the suite concurrently under ASAN.
autofix-ci Bot and others added 7 commits May 4, 2026 10:37
Cross-API removal — `parentPort.on('message', fn)` followed by
`parentPort.removeEventListener('message', fn)` — was silently
no-opping and leaking the capture forwarder's event-loop ref.

`injectFakeEmitter.on` wraps `fn` into a callback and stashes it on
`fn[wrappedListener]`; the tracking WeakMap in `fakeParentPort` keys
that entry on the callback, not the original `fn`.
`parentPortRemoveEventListener` was looking up `listener` directly,
missing the entry, and returning early without decrementing
`messageListenerCount`. The forwarder stayed installed on `self` and
the worker event loop never exited.

`wrappedListener` is promoted from an `injectFakeEmitter`-local symbol
to module scope so `parentPortRemoveEventListener` can follow the same
indirection that `Class.prototype.off` already does:
`(listener as any)[wrappedListener] ?? listener`.

This matches Node's `NodeEventTarget`, which unifies all six
registration/removal APIs in a single registry — cross-API removal is
both documented and idiomatic there, and Emscripten-generated code in
particular often mixes the two styles.

Regression test added: `parentPort.removeEventListener unwraps
listeners registered via .on() (#29211)` — fails on the previous
commit, passes after this change.
test/CLAUDE.md is explicit: "Do not set a timeout on tests. Bun already
has timeouts." The suite was setting 30000ms on every `test.concurrent`
call. Rely on Bun's global timeout instead.
`parentPort.on(fn)` + `.on(fn)` + single `off(fn)` used to leak an
orphaned wrapper forever because the emitter shim stashed each new
wrapper at `fn[wrappedListener]`, clobbering the first. The stale
slot also broke `add -> off -> addEventListener -> removeListener`
cross-API cycles (the final `removeListener` chased a dead slot and
missed the direct registration). And an `addEventListener` registered
with an `AbortSignal`, removed, re-registered without one, then
aborted, evicted the new registration because the abort closure
only captured `(type, listener, capture)`.

Override `on`/`once`/`off`/`addListener`/`removeListener`/prepend
variants directly on `fake`, bypassing `injectFakeEmitter`. All adds
go through `addListenerGeneric`, which:

- keys tracking by the ORIGINAL user listener (WeakMap<listener,
  Map<slot, Entry[]>>), so `off(fn)` finds the right entries,
- shares one registration list per `(listener, type, capture)` between
  `on` and `addEventListener`, matching Node where `removeListener` and
  `removeEventListener` are aliases,
- allows duplicate emitter registrations (Node semantics) and removes
  LIFO,
- records `style` per entry so the right calling convention (raw data
  vs MessageEvent) fires, and
- captures the specific TrackEntry in the AbortSignal abort closure and
  checks `entry.removed` before evicting.

Three regression tests added for the exact patterns above.
@Jarred-Sumner
Jarred-Sumner force-pushed the farm/ac5aee3e/worker-onmessage-double-fire branch from 17af498 to e06e383 Compare May 4, 2026 10:37
list.splice(idx, 1);
if (list.length === 0) {
bucket.$delete(slot);
if (bucket.$size === 0) trackedByListener.delete(listener);

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.

🟡 bucket.$size is not a valid Map intrinsic — builtins.d.ts only declares $get/$set/$has/$delete/$clear for Map, so after codegen this becomes bucket.@size which is undefined at runtime, and undefined === 0 is always false. The eager trackedByListener.delete(listener) cleanup is therefore dead code; use the public bucket.size accessor instead. (No functional impact — trackedByListener is a WeakMap and all callers handle empty-but-present buckets correctly.)

Extended reasoning...

What the bug is

evictEntry() ends with:

if (list.length === 0) {
  bucket.$delete(slot);
  if (bucket.$size === 0) trackedByListener.delete(listener);
}

bucket is a plain Map<string, TrackEntry[]>. The $-prefixed intrinsic methods available on Map in Bun's builtin context are exactly those declared in src/js/builtins.d.ts lines 628-634: $clear, $delete, $has, $set, $get. There is no $size. The ClassWithIntrinsics<T> helper at builtins.d.ts:564 only generates $-keys for Function-typed properties, and Map.prototype.size is an accessor getter, not a method, so it's excluded by construction.

The global declare function $size() at builtins.d.ts:486 is a stream-related private name (it sits among sink/start/startAlgorithm/state), and BunBuiltinNames.h:185 registers @size as a parseable private identifier — but registering a private name does not install a property under that name on Map.prototype. JSC's MapPrototype::finishCreation only installs private-name aliases for the method functions, not for the size accessor.

Why this means the cleanup never runs

After codegen (src/codegen/replacements.ts:183 + bundle-modules.ts:255), bucket.$size becomes bucket.@size — a private-name property lookup. Since Map.prototype has no property under that private symbol, the lookup yields undefined. undefined === 0 is always false, so trackedByListener.delete(listener) is unreachable. A grep across src/js/ confirms this is the only .$size usage on a Map instance in the entire codebase — it's not an established pattern.

Step-by-step proof

  1. parentPort.addEventListener('message', fn) → getOrCreateBucket(fn) creates bucket = new Map(), bucket.$set('message:0', [entry]).
  2. parentPort.removeEventListener('message', fn) → evictEntry(fn, 'message:0', entry):
    • list.splice(0, 1) → list.length === 0
    • bucket.$delete('message:0') → bucket is now empty (real size is 0)
    • bucket.$size → bucket.@size → undefined
    • undefined === 0 → false
    • trackedByListener.delete(fn) is not called
  3. trackedByListener still holds fn → (empty Map).

Why the impact is negligible

This is dead cleanup code only — no observable behavior changes:

  • No leak: trackedByListener is a WeakMap, so the empty bucket is reclaimed when fn becomes unreachable.
  • removeListenerGeneric: getBucket(fn) returns the empty Map, bucket.$get(slot) returns undefined, the if (!list || list.length === 0) return guard fires. Correct.
  • addListenerGeneric: getOrCreateBucket(fn) returns the existing empty Map and reuses it, then creates a fresh list. Correct.
  • evictEntry (called again on a stale entry): bucket.$get(slot) returns undefined → returns false. Correct.

None of the 12 regression tests in this PR would detect this. It was introduced by mechanically applying the CodeRabbit suggestion in commit 3f220d2, which recommended $size without verifying the intrinsic exists.

Fix

Use the public accessor — there is no $-intrinsic for Map's size getter:

if (bucket.size === 0) trackedByListener.delete(listener);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Good catch — $size isn't declared in builtins.d.ts so this line is dead cleanup code. As you note, the functional impact is zero (trackedByListener is a WeakMap, entries are reclaimed when the listener becomes unreachable). The repo here is currently locked by a concurrent session running git reset so I can't push a fix right now — but the one-line change is trivial: bucket.$size → bucket.size. Either the concurrent session will pick this up when it lands its own changes, or I can sweep it in a follow-up.

@Noir01

Noir01 commented May 5, 2026

Copy link
Copy Markdown

So glad to see this PR.

@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Closing: the double delivery this PR targeted was fixed on main by #37075 (merged as 9d519e8). In a node:worker_threads worker, parentPort is now a real MessagePort entangled with the parent Worker's public port, and worker.postMessage() posts to that port, so parent messages no longer reach the worker's global scope (self.onmessage or self.addEventListener("message")) at all. The parentPort event target and forwarders added here are no longer needed.

Every issue this PR listed is closed as fixed by #37075: #7816, #19453, #25454, #25860, #29211, #29635.

Checked with a worker that registers both parentPort.on("message") and self.onmessage (the emscripten pthread pattern from z3-solver) and counts what one worker.postMessage() produces:

main also carries a test for this case, "a parent message reaches parentPort only, not self.onmessage, in a node worker" in test/js/node/worker_threads/worker_threads.test.ts. #36639, the other open PR for the same bug, is closed for the same reason.

script used for the counts above
import { Worker } from "node:worker_threads";

const src = `
  const { parentPort } = require("node:worker_threads");
  if (typeof globalThis.self === "undefined") globalThis.self = globalThis;
  const counts = { parentPort: 0, selfOnmessage: 0, globalAddEventListener: 0 };
  parentPort.on("message", (m) => { counts.parentPort++; self.onmessage({ data: m }); });
  self.onmessage = () => { counts.selfOnmessage++; };
  if (typeof self.addEventListener === "function") self.addEventListener("message", () => { counts.globalAddEventListener++; });
  parentPort.on("message", () => setImmediate(() => parentPort.postMessage(counts)));
`;

const w = new Worker(src, { eval: true });
const result = await new Promise((resolve, reject) => {
  w.once("message", resolve);
  w.once("error", reject);
  w.postMessage("ping");
});
await w.terminate();
console.log(JSON.stringify(result));
bun 1.3.14:        {"parentPort":1,"selfOnmessage":2,"globalAddEventListener":1}
main @ da3851e57:  {"parentPort":1,"selfOnmessage":1,"globalAddEventListener":0}
node v26.3.0:      {"parentPort":1,"selfOnmessage":1,"globalAddEventListener":0}

@robobun robobun closed this Aug 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants