Skip to content

MessagePort: implement NodeEventTarget on the native listener list - #35811

Open
robobun wants to merge 13 commits into
mainfrom
farm/44e114fa/messageport-node-event-target
Open

MessagePort: implement NodeEventTarget on the native listener list#35811
robobun wants to merge 13 commits into
mainfrom
farm/44e114fa/messageport-node-event-target

Conversation

@robobun

@robobun robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

Repro

const { getEventListeners } = require("node:events");
// no worker_threads import
const { port1, port2 } = new MessageChannel();
typeof port1.on;            // undefined (node: 'function')

require("node:worker_threads");
const fn = () => {};
port1.on("message", fn);
port1.addEventListener("message", fn);
port1.listenerCount("message");          // 1 (node: 2, but the same fn dedupes to 1 the other way)
getEventListeners(port1, "message")[0] === fn;  // false (node: true)
port1.removeEventListener("message", fn);       // leaves the .on() wrapper live (node: removes it)

port1.emit("message", 1);                // throws (init-dict slot); node: returns true, delivers 1
port1.emit("nope");                      // returns this (node: returns false)

Cause

injectFakeEmitter in src/js/node/worker_threads.ts grafted the emitter surface onto MessagePort.prototype at module top level and kept its own (event → userFn → wrapper) registry alongside WebCore's listener list. .on(t, f) registered a wrapper via addEventListener, so the two APIs couldn't see each other: listenerCount/eventNames were blind to addEventListener listeners, removeEventListener(f) couldn't remove a .on(f), removeAllListeners left addEventListener listeners live, getEventListeners returned the wrapper, and emit('message', x) built new MessageEvent('message', x) with x in the init-dict slot (so a primitive threw and the listener got null). The surface was also absent until something imported node:worker_threads.

Node's model (lib/internal/event_target.js): MessagePort is a NodeEventTarget; there is one listener list. .on/.addListener is addEventListener(type, f, {[kIsNodeStyleListener]: true}) storing f itself; once is the same with once: true; listenerCount/eventNames/removeAllListeners read/clear that list; emit(type, arg) computes had = listenerCount(type) > 0, dispatches so node-style listeners receive arg by identity and EventTarget-style listeners receive a MessageEvent{data:arg} / CustomEvent{detail:arg}, and returns had.

Fix

  • RegisteredEventListener / AddEventListenerOptions gain an isNodeStyleListener bit, keyed off a private kIsNodeStyleListener symbol.
  • EventTarget::innerInvokeEventListeners invokes flagged listeners via JSEventListener::handleEventNodeStyle, which passes event.data / .detail / .error instead of the Event wrapper.
  • JSMessagePort's prototype chain now has an intermediate prototype (between MessagePort.prototype and EventTarget.prototype) carrying native on/once/off/addListener/removeListener/emit/listenerCount/eventNames/removeAllListeners/set|getMaxListeners, all reading the one native listener list. The intermediate prototype keeps Object.getOwnPropertyNames(MessagePort.prototype) matching node.
  • emit() builds MessageEvent{data:arg} (for message/messageerror) or CustomEvent{detail:arg} (otherwise) and dispatches; node-style listeners recover arg by identity at invoke.
  • CustomEventInterfaceType is wired into EventFactory so a natively-created CustomEvent is wrapped as JSCustomEvent (its .detail was previously unreachable from such events).
  • The ~180-line injectFakeEmitter shim is deleted. The worker-side parentPort stand-in forwards on/once/off to self.addEventListener with the private flag.

BroadcastChannel is a plain EventTarget in node too; no shim there (unchanged).

Verification

test/js/node/worker_threads/message-port-node-event-target.test.ts runs a 15-row coherence matrix (same fn via .on+ael invoked once; listenerCount/eventNames count both; cross-removeEventListener; removeAllListeners clears both; emit payload identity + boolean return + primitive payload + error identity; once dedupe; getEventListeners identity; hybrid dispatch; surface present with no worker_threads import). Before: 14/15 fail. After: 15/15 pass.

Existing coverage stays green: test/js/node/worker_threads/worker_threads.test.ts (91 tests), test/js/web/workers/message-channel.test.ts, test-worker-message-port*.js, test-eventtarget*.js, test-events-customevent.js.

This supersedes #33856 (which only moved the shim to load earlier but kept the separate registry), #35799 (shared registry only), and #35796 (emit() payload only).


[review] gate passed · iteration 2 · 19 files touched

fails on main (without fix)
ASAN without fix: 12 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/worker_threads/message-port-node-event-target.test.ts
bun test v1.4.0 (a36752016)

test/js/node/worker_threads/message-port-node-event-target.test.ts:
29 |       env: bunEnv,
30 |       stdout: "pipe",
31 |       stderr: "pipe",
32 |     });
33 |     const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
34 |     expect(stderr).toBe("");
                        ^
error: expect(received).toBe(expected)

- ""
+ "1 | 
+ 2 |         if (require.cache["node:worker_threads"]) throw new Error("worker_threads preloaded");
+ 3 |         const { port1 } = new MessageChannel();
+ 4 |         const names = ["on","off","once","emit","addListener","removeListener","listenerCount","eventNames","removeAllListeners","setMaxListeners","getMaxListeners"];
+ 5 |         for (const n of names) {
+ 6 |           if (typeof port1[n] !== "function") throw new Error("missing " + n + ": " + typeof port1[n]);
+                                                             ^
+ error: missing on: und
... (truncated)

release without fix: all passed
bun test v1.4.0-canary.1 (d2147a7de)

test/js/node/worker_threads/message-port-node-event-target.test.ts:
(pass) MessagePort NodeEventTarget > surface exists without importing node:worker_threads [25.87ms]
(pass) MessagePort NodeEventTarget > same fn via .on + addEventListener is one listener, invoked once [2.32ms]
(pass) MessagePort NodeEventTarget > listenerCount counts both .on and addEventListener listeners [0.05ms]
(pass) MessagePort NodeEventTarget > eventNames reports types registered via addEventListener [0.07ms]
(pass) MessagePort NodeEventTarget > removeEventListener removes a listener added via .on [0.04ms]
(pass) MessagePort NodeEventTarget > removeAllListeners clears addEventListener listeners too [0.89ms]
(pass) MessagePort NodeEventTarget > emit passes the argument by identity to .on listeners [0.07ms]
(pass) MessagePort NodeEventTarget > emit returns a boolean (true when listeners, false otherwise) [0.05ms]
(pass) MessagePort NodeEventTarget > emit with a primitive payload does not throw [0.04ms]
(pass) MessagePort NodeEventTarget > emit('error', err) passes the error by identity [0.06ms]
(pass) MessagePort NodeEventTarget > .once dedupes against .on
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/worker_threads/message-port-node-event-target.test.ts
bun test v1.4.0 (a36752016)

test/js/node/worker_threads/message-port-node-event-target.test.ts:
(pass) MessagePort NodeEventTarget > surface exists without importing node:worker_threads [1139.48ms]
(pass) MessagePort NodeEventTarget > same fn via .on + addEventListener is one listener, invoked once [37.53ms]
(pass) MessagePort NodeEventTarget > listenerCount counts both .on and addEventListener listeners [3.84ms]
(pass) MessagePort NodeEventTarget > eventNames reports types registered via addEventListener [3.99ms]
(pass) MessagePort NodeEventTarget > removeEventListener removes a listener added via .on [4.07ms]
(pass) MessagePort NodeEventTarget > removeAllListeners clears addEventListener listeners too [19.05ms]
(pass) MessagePort NodeEventTarget > emit passes the argument by identity to .on listeners [15.75ms]
(pass) MessagePort NodeEventTarget > emit returns a boolean (true when listeners, false otherwise) [3.68ms]
(pass) MessagePort NodeEventTarget > emit with a pr
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 1170ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/123] gen cpp.rs (cppbind)
[2/123] gen ZigGeneratedClasses.{cpp,h,rs}
Found 2 classes from /workspace/bun/src/jsc/resolve_message.classes.ts
  - ResolveMessage (13 fields)
  - BuildMessage (10 fields)
Found 1 classes from /workspace/bun/src/runtime/api/Archive.classes.ts
  - Archive (4 fields, 1 class fields)
Found 2 classes from /workspace/bun/src/runtime/api/BunObject.classes.ts
  - ResourceUsage (8 fields)
  - Subprocess (20 fields)
Found 1 classes from /workspace/bun/src/runtime/api/cron.classes.ts
  - CronJob (5 fields)
Found 3 classes from /workspace/bun/src/runtime/api/filesystem_router.classes.ts
  - FileSystemRouter (5 fields)
  - FrameworkFileSystemRouter (2 fields)
  - MatchedRoute (8 fields)
Found 1 classes from /workspace/bun/src/runtime/api/Glob.classes.ts
  - Glob (5 fields)
Found 1 classes from /workspace/bun/src/runtime/api/h2.classes.ts
  - H2FrameParser (31 fields)
Found 8 classes from /workspace/bun/src/runtime/api/html_rewriter.classes.ts
  - HTMLRewriter (3 fields)
  - TextChunk (7
... (truncated)
diff hotspot
src/js/builtins.d.ts                               |   6 +
 src/js/builtins/BunBuiltinNames.h                  |   1 +
 src/js/node/worker_threads.ts                      | 278 +++++++--------------
 src/jsc/bindings/webcore/AddEventListenerOptions.h |   5 +
 src/jsc/bindings/webcore/EventFactory.cpp          |   4 +-
 src/jsc/bindings/webcore/EventHeaders.h            |   4 +-
 src/jsc/bindings/webcore/EventListener.h           |   3 +
 src/jsc/bindings/webcore/EventListenerMap.cpp      |  17 ++
 src/jsc/bindings/webcore/EventListenerMap.h        |   1 +
 src/jsc/bindings/webcore/EventTarget.cpp           |  19 +-
 src/jsc/bindings/webcore/EventTarget.h             |   1 +
 .../bindings/webcore/JSAddEventListenerOptions.cpp |   6 +
 src/jsc/bindings/webcore/JSEventListener.cpp       |  40 ++-
 src/jsc/bindings/webcore/JSEventListener.h         |   3 +
 src/jsc/bindings/webcore/JSMessagePort.cpp         | 217 +++++++++++++++-
 src/jsc/bindings/webcore/MessagePort.cpp           |  10 +-
 src/jsc/bindings/webcore/MessagePort.h             |   4 +
 src/jsc/bindings/webcore/RegisteredEventListener.h |   7 +-
 .../message-port-node-event-target.test.ts         | 252 +++++++++++++++++++
 19 files changed, 675 insertions(+), 203 deletions(-)

gate history · 6 passed · 1 rejected · iteration 2

evidence per changed file
file                                                    reads  edits  tests
src/js/builtins.d.ts                                        2      1      0
src/js/builtins/BunBuiltinNames.h                           1      1      0
src/js/node/worker_threads.ts                              11     16      0
src/jsc/bindings/webcore/AddEventListenerOptions.h          2      1      0
src/jsc/bindings/webcore/EventFactory.cpp                   1      1      0
src/jsc/bindings/webcore/EventHeaders.h                     1      1      0
src/jsc/bindings/webcore/EventListener.h                    3      3      0
src/jsc/bindings/webcore/EventListenerMap.cpp               1      1      0
src/jsc/bindings/webcore/EventListenerMap.h                 1      1      0
src/jsc/bindings/webcore/EventTarget.cpp                    1      7      0
src/jsc/bindings/webcore/EventTarget.h                      1      1      0
src/jsc/bindings/webcore/JSAddEventListenerOptions.cpp      1      1      0
src/jsc/bindings/webcore/JSEventListener.cpp                5      7      0
src/jsc/bindings/webcore/JSEventListener.h                  3      5      0
src/jsc/bindings/webcore/JSMessagePort.cpp                  7     14      0
src/jsc/bindings/webcore/MessagePort.cpp                    2      1      0
(+ 3 more files)

MessagePort's EventEmitter surface (.on/.once/.off/.emit/listenerCount/
eventNames/removeAllListeners/set|getMaxListeners) was a JS shim grafted
onto the prototype when node:worker_threads first loaded. It kept its
own (event -> userFn -> wrapper) registry alongside WebCore's EventTarget
listener list, so:

- the surface was absent until worker_threads was imported somewhere
- .on(t, f) registered a wrapper via addEventListener, so the same
  function added via both APIs ran twice, listenerCount/eventNames
  didn't see addEventListener listeners, removeEventListener(f) couldn't
  remove a .on(f), removeAllListeners left addEventListener listeners
  live, and getEventListeners returned the wrapper not f
- emit('message', payload) built MessageEvent(event, payload) with the
  payload in the init-dict slot, so listeners got null, a primitive
  payload threw, and emit returned this instead of a boolean

Node's model (lib/internal/event_target.js): MessagePort IS a
NodeEventTarget. .on/.addListener is addEventListener with
kIsNodeStyleListener storing f itself in the one listener list; once is
the same + once:true; listenerCount/eventNames/removeAllListeners read
or clear that map; emit(type, arg) = had = listenerCount > 0, hybrid
dispatch (node-style listeners get arg by identity, EventTarget-style
get a lazily-built MessageEvent/CustomEvent), returns had.

This change:

- adds an isNodeStyleListener bit to RegisteredEventListener /
  AddEventListenerOptions, keyed off a private kIsNodeStyleListener
  symbol so only internal code can set it
- in innerInvokeEventListeners, flagged listeners are invoked via
  JSEventListener::handleEventNodeStyle, which passes event.data /
  .detail / .error instead of the Event wrapper
- puts the NodeEventTarget methods natively on an intermediate prototype
  between MessagePort.prototype and EventTarget.prototype (so
  Object.getOwnPropertyNames(MessagePort.prototype) matches node) and
  implements them over the one native listener list, including
  removeAllEventListenersForType
- emit() builds a MessageEvent{data:arg} or CustomEvent{detail:arg} and
  dispatches it; node-style listeners recover arg by identity
- wires CustomEventInterfaceType into EventFactory so a natively-created
  CustomEvent is wrapped as JSCustomEvent (its .detail was unreachable)
- deletes the ~180-line injectFakeEmitter shim; parentPort's stand-in
  forwards on/once/off to self.addEventListener with the private flag

BroadcastChannel is a plain EventTarget in node too; it keeps no shim
(unchanged).
@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

Adds Node-compatible MessagePort event methods, listener tracking, raw-payload invocation, event-specific wrappers, bulk removal, max-listener controls, worker parentPort support, and compatibility tests.

MessagePort Node event support

Layer / File(s) Summary
Listener metadata and removal contracts
src/js/builtins*, src/jsc/bindings/webcore/{AddEventListenerOptions,RegisteredEventListener,EventListener,EventListenerMap,EventTarget,JSAddEventListenerOptions}.*
Adds Node-style listener metadata, private option parsing, dispatch hooks, and event-type listener removal.
Node-style payload dispatch
src/jsc/bindings/webcore/{JSEventListener,EventFactory,EventHeaders}.*
Passes MessageEvent.data, CustomEvent.detail, or ErrorEvent.error to Node-style listeners while retaining standard event wrappers elsewhere.
MessagePort Node event API
src/jsc/bindings/webcore/{JSMessagePort,MessagePort}.*
Adds on, once, off, emit, listener enumeration, bulk removal, and max-listener APIs with corresponding native state updates.
Worker parentPort wiring
src/js/node/worker_threads.ts
Replaces the MessagePort emitter shim and adds SafeMap-backed listener tracking for the fake parent port.
Compatibility coverage
test/js/node/worker_threads/message-port-node-event-target.test.ts
Tests shared listener state, payload behavior, event wrappers, removal, buffering, and worker-thread APIs.

Possibly related PRs

  • oven-sh/bun#31216: Overlaps with the MessagePort emitter and listener compatibility plumbing.
  • oven-sh/bun#34710: Overlaps with CustomEvent and MessageEvent payload wrapper handling.
  • oven-sh/bun#35796: Overlaps with MessageEvent and CustomEvent emission payload handling.

Suggested reviewers: alii, cirospaciari

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main change: MessagePort adopting NodeEventTarget behavior via the native listener list.
Description check ✅ Passed The description is detailed and includes repro, root cause, fix, and verification, though it doesn't use the exact template headings.

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

@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 12:15 AM PT - Jul 26th, 2026

@robobun, your commit a367520 has 2 failures in Build #82001 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 35811

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

bun-35811 --bun

Comment thread src/js/builtins.d.ts
Comment thread src/js/node/worker_threads.ts Outdated
Comment thread src/js/node/worker_threads.ts Outdated
Comment thread src/jsc/bindings/webcore/AddEventListenerOptions.h
Comment thread src/jsc/bindings/webcore/EventListener.h Outdated
Comment thread src/jsc/bindings/webcore/JSEventListener.cpp Outdated
Comment thread src/jsc/bindings/webcore/JSEventListener.cpp Outdated
Comment thread src/jsc/bindings/webcore/JSEventListener.cpp Outdated
Comment thread src/jsc/bindings/webcore/JSEventListener.h Outdated
Comment thread src/jsc/bindings/webcore/JSMessagePort.cpp Outdated
Comment thread src/jsc/bindings/webcore/JSMessagePort.cpp Outdated
Comment thread src/jsc/bindings/webcore/JSMessagePort.cpp Outdated
@github-actions

Copy link
Copy Markdown
Contributor

Found 2 issues this PR may fix:

  1. node:worker_threads: Messages delivered twice when both parentPort.on('message') and self.onmessage are registered #25860 - Messages delivered twice when both parentPort.on('message') and self.onmessage are registered; the PR replaces the JS shim that forwarded .on() through addEventListener, which caused double-delivery
  2. Argument 1 ('event') to EventTarget.dispatchEvent must be an instance of Event #11005 - dispatchEvent requires an Event instance; the PR properly wires up CustomEvent in EventFactory and fixes emit() to construct proper event objects instead of passing raw values

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

Fixes #25860
Fixes #11005

🤖 Generated with Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. MessagePort: define node-style emitter methods on the prototype itself #33856 - Explicitly superseded by this PR; both add node-style emitter methods to MessagePort.prototype
  2. worker_threads: make MessagePort's on()/addEventListener() share one listener registry #35799 - Unifies on()/addEventListener() listener registries for MessagePort, which is a key component of this PR fix
  3. worker_threads: deliver the emit() payload to MessagePort listeners #35796 - Fixes emit() payload delivery and boolean return value, both addressed in this PR comprehensive fix

🤖 Generated with Claude Code

Comment thread src/jsc/bindings/webcore/EventListener.h
Comment thread src/jsc/bindings/webcore/JSEventListener.cpp
Comment thread src/jsc/bindings/webcore/JSEventListener.cpp
Comment thread src/jsc/bindings/webcore/JSMessagePort.cpp
Comment thread src/jsc/bindings/webcore/JSMessagePort.cpp
@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

The comment-cop threads are resolved: trimmed the multi-line comments in c53eb03. The remaining two-line blocks it still flags are doc comments that match the existing neighbor pattern (e.g. resistStopPropagation in AddEventListenerOptions.h, $kResistStopPropagation in builtins.d.ts), not workaround justifications.

Also: the duplicate-PR bot is right that #35799 and #35796 cover subsets of this. Both are from parallel sessions on the same brief; this one is the full single-list design (native isNodeStyleListener bit + unwrap at invoke + intermediate prototype), so it supersedes them along with #33856.

@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.

Additional findings (outside current diff — PR may have been updated during review):

  • 🔴 src/js/node/worker_threads.ts:653-661fakeParentPort() builds parentPort as Object.create(MessagePort.prototype) (not a real JSMessagePort), and only shadows on/once/off/addListener/removeListener — so listenerCount/eventNames/removeAllListeners/getMaxListeners/setMaxListeners fall through to the new native prototype methods, which castThisValue<JSMessagePort> and throw TypeError on this receiver. Before this PR the JS-shim versions of those methods worked on the fake object (e.g. parentPort.on('message', fn); parentPort.listenerCount('message') === 1), so this is a regression inside every worker thread. Add own-property forwarders for these methods on fake (reading self's listener list) alongside the existing on/off/once shadows.

    Extended reasoning...

    What the bug is

    Inside a worker thread, parentPort from node:worker_threads is not a real MessagePort — it is a stand-in built by fakeParentPort() at src/js/node/worker_threads.ts:565 as const fake = Object.create(MessagePort.prototype). That produces a plain JSFinalObject whose prototype chain is MessagePort.prototype → <intermediate NodeEventTarget prototype> → EventTarget.prototype, but which has no JSMessagePort wrapper cell backing it.

    This PR moves the NodeEventTarget surface (on, once, off, addListener, removeListener, emit, listenerCount, eventNames, removeAllListeners, setMaxListeners, getMaxListeners) from the deleted JS injectFakeEmitter shim onto a native intermediate prototype (JSMessagePortNodeEventTargetTableValues in JSMessagePort.cpp). Each of those native host functions is dispatched via IDLOperation<JSMessagePort>::call<...>, which calls castThisValue<JSMessagePort>(callFrame.thisValue()) and, when the receiver is not a JSMessagePort, executes throwThisTypeError(lexicalGlobalObject, throwScope, "MessagePort", operationName) (JSDOMOperation.h:45,56).

    fakeParentPort() was updated in this PR to add own-property forwarders for on / addListener / once / off / removeListener (worker_threads.ts:653-661), forwarding to self.addEventListener / self.removeEventListener with the $kIsNodeStyleListener flag. But it does not shadow listenerCount, eventNames, removeAllListeners, getMaxListeners, setMaxListeners, or emit. Those fall through to the native intermediate-prototype methods and now throw a TypeError on the fake receiver.

    Why this is a regression

    Before this PR, injectFakeEmitter installed plain JS functions on the intermediate prototype. Those functions read a per-this symbol-keyed SafeMap registry (this[kListenerRegistry]) and worked on any receiver — including fake:

    • The old on() called register(this, ...) which wrote to registryFor(fake, true) and called fake.addEventListener(...) (bound to self), so listeners were registered on the global scope and counted in fake's registry.
    • The old listenerCount(type) returned registryFor(this, false)?.get(type)?.size ?? 0 → read fake's registry, returned the correct count.
    • The old eventNames() iterated fake's registry keys.
    • The old removeAllListeners() iterated the registry and called this.removeEventListener(t, w) — which on fake is the own-property self.removeEventListener.bind(self), so it actually removed the listeners from the global scope.
    • The old getMaxListeners() / setMaxListeners() read/wrote this[kMaxListeners] — worked on any object.

    All of those now throw. (emit was already broken pre-PR because the old JS emit called this.dispatchEvent(...), which is inherited from EventTarget.prototype and also required a real EventTarget receiver — so emit alone is not a regression here.)

    Step-by-step proof

    Inside a worker:

    1. const { parentPort } = require('node:worker_threads')parentPort = fakeParentPort()fake = Object.create(MessagePort.prototype), a JSFinalObject.
    2. parentPort.on('message', fn) → hits the own-property on shadow at worker_threads.ts:641 → self.addEventListener('message', fn, {$kIsNodeStyleListener: true}). Works.
    3. parentPort.listenerCount('message') → no own property; prototype chain lookup reaches the native listenerCount on the intermediate prototype → jsMessagePortPrototypeFunction_listenerCountIDLOperation<JSMessagePort>::callcastThisValue<JSMessagePort>(fake) returns nullptr (fake is a JSFinalObject, not a JSMessagePort) → throwThisTypeError(..., "MessagePort", "listenerCount").
    4. Before this PR, step 3 would have reached the JS listenerCount from injectFakeEmitter, which returned registryFor(fake, false)?.get('message')?.size1.

    Same for parentPort.eventNames(), parentPort.removeAllListeners(), parentPort.getMaxListeners(), parentPort.setMaxListeners(n).

    Impact

    parentPort is the primary communication object in every node:worker_threads worker. listenerCount, eventNames, removeAllListeners, and get/setMaxListeners are documented Node.js NodeEventTarget methods that user code and libraries call on it. Turning them from working calls into hard TypeErrors is a user-visible regression that will break worker code at runtime.

    Fix

    Add own-property forwarders on fake for the remaining NodeEventTarget methods, reading/writing self's listener list (which is where on/off now register). For example:

    function listenerCount(type) { return getEventListeners(self, type).length; }
    function eventNames() { /* enumerate self's listener types */ }
    function removeAllListeners(type) { /* iterate getEventListeners(self, t) and self.removeEventListener each */ return this; }
    function getMaxListeners() { return 10; }
    function setMaxListeners() { return this; }

    and include them in the Object.defineProperty loop alongside on/once/off. Alternatively, make fakeParentPort() return an object backed by a real EventTarget/MessagePort so the native prototype methods accept it — but that's a larger change than this PR's scope.

Comment thread src/jsc/bindings/webcore/JSMessagePort.cpp
Comment thread src/jsc/bindings/webcore/JSMessagePort.cpp
… parentPort shadows

- setMaxListeners: toUInt32 can enter user JS; declare a ThrowScope and
  RETURN_IF_EXCEPTION before mutating state.
- onDidChangeListenerImpl: reset m_hasMessageEventListener /
  m_hasCloseEventListener on Clear so removeAllListeners() pauses
  delivery (re-buffers) instead of leaving the flags stale and draining
  to zero listeners.
- fakeParentPort: shadow listenerCount / eventNames / removeAllListeners
  / emit / set|getMaxListeners so they don't fall through to the native
  prototype (which requires a real MessagePort receiver). Tracks
  parentPort-registered listeners in a local map so removeAllListeners()
  only removes what parentPort added (self carries internal listeners).

Two new tests cover the re-buffer path and the parentPort surface.
Comment thread src/js/node/worker_threads.ts
Comment thread src/jsc/bindings/webcore/MessagePort.cpp
@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed all three review findings in 54bac10:

  1. fakeParentPort() regression (top-level comment): shadowed listenerCount/eventNames/removeAllListeners/emit/set|getMaxListeners on the fake so they don't fall through to the native prototype. They track a local map of parentPort-added listeners (forwarding to self for actual registration), so removeAllListeners() only touches what parentPort added and leaves internal listeners on the global scope intact. New test covers listenerCount/eventNames/removeAllListeners/getMaxListeners on parentPort inside a worker.
  2. setMaxListeners missing ThrowScope: added DECLARE_THROW_SCOPE + RETURN_IF_EXCEPTION after toUInt32.
  3. removeAllListeners() leaves flags stale: onDidChangeListenerImpl now resets m_hasMessageEventListener / m_hasCloseEventListener on Clear, so the pipe pauses and re-buffers instead of draining to zero listeners. New test covers onremoveAllListenerspostMessage → re-on delivers the message.

The remaining comment-cop flags are on two-line doc comments that match existing neighbor patterns; the comment-cop CI check itself passes.

@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.

Additional findings (outside current diff — PR may have been updated during review):

  • 🔴 src/js/node/worker_threads.ts:631-655fakeParentPort() shims on/once/off/addListener/removeListener locally (with the comment "MessagePort.prototype.on/etc. require a real MessagePort receiver") but leaves listenerCount/eventNames/removeAllListeners/getMaxListeners/setMaxListeners/emit inherited from the new native intermediate prototype — those go through IDLOperation<JSMessagePort>::call, which jsDynamicCast<JSMessagePort*>s the receiver and throws on the plain Object.create(MessagePort.prototype) fake. The deleted JS shim's versions read symbol-keyed state off this and worked on any receiver (returned 0/[]/10/this), so worker code like parentPort.listenerCount('message') or parentPort.removeAllListeners() that previously returned a value now throws TypeError: Can only call MessagePort.listenerCount on instances of MessagePort. Add local overrides on fake for these six methods the same way the other five were shimmed.

    Extended reasoning...

    What the bug is

    fakeParentPort() (worker_threads.ts:562) builds the worker-side parentPort as const fake = Object.create(MessagePort.prototype) — a plain JSFinalObject whose prototype chain is MessagePort.prototype → <intermediate NodeEventTarget prototype> → EventTarget.prototype, but which is not a JSMessagePort wrapper. The PR moved the NodeEventTarget surface from JS (the deleted injectFakeEmitter) to native host functions on that intermediate prototype, and each of those host functions is dispatched via IDLOperation<JSMessagePort>::call, which does castThisValue<JSMessagePort>(..., callFrame.thisValue()) and, when the cast fails, throwThisTypeError(..., "MessagePort", operationName) (JSDOMOperation.h:43-56).

    fakeParentPort() was updated in this PR to shim five of the eleven NodeEventTarget methods locally — on, addListener, once, off, removeListener — with the explicit comment "MessagePort.prototype.on/etc. require a real MessagePort receiver; forward to self." But the other six — listenerCount, eventNames, removeAllListeners, getMaxListeners, setMaxListeners, emit — were not shimmed and now resolve up the prototype chain to the native host functions that reject the fake receiver.

    Why the old code didn't have this problem

    Before this PR, injectFakeEmitter placed pure-JS implementations of these methods on the intermediate prototype. Those implementations read symbol-keyed state off this and returned benign defaults on any receiver:

    • listenerCount(type)registryFor(this, false)?.get(type)?.size ?? 00
    • eventNames()registryFor(this, false) is undefined[]
    • removeAllListeners()registryFor(this, false) is undefinedreturn this
    • getMaxListeners()this[kMaxListeners] ?? 1010
    • setMaxListeners(n)this[kMaxListeners] = n; return thisworked

    (emit is a partial exception: the old shim called this.dispatchEvent(...), which was already a native EventTarget method that would have rejected the fake receiver too — so emit on the fake parentPort was likely already broken. The other five definitively worked.)

    Step-by-step proof

    Inside a worker:

    const { parentPort } = require('node:worker_threads');
    parentPort.listenerCount('message');
    1. parentPort is the object returned by fakeParentPort(). It is Object.create(MessagePort.prototype); its [[Class]] is JSFinalObject, not JSMessagePort.
    2. .listenerCount is not an own property of fake (only on/addListener/once/off/removeListener/addEventListener/removeEventListener/postMessage/close/start/ref/unref/hasRef/setEncoding/onmessage/onmessageerror are). Lookup walks to MessagePort.prototype (no listenerCount there — it's on the intermediate prototype per JSMessagePortNodeEventTargetTableValues), then to the intermediate prototype, where it finds the native jsMessagePortPrototypeFunction_listenerCount.
    3. That host function calls IDLOperation<JSMessagePort>::call<...listenerCountBody>(*lexicalGlobalObject, *callFrame, "listenerCount").
    4. IDLOperation<JSMessagePort>::cast calls castThisValue<JSMessagePort>(lexicalGlobalObject, callFrame.thisValue()), which jsDynamicCast<JSMessagePort*>s the fake object → nullptr.
    5. Line 55-56: return throwThisTypeError(lexicalGlobalObject, throwScope, "MessagePort", "listenerCount")TypeError: Can only call MessagePort.listenerCount on instances of MessagePort.

    Before this PR, step 2 would have found the JS listenerCount on the intermediate prototype, which evaluated registryFor(fake, false)?.get('message')?.size ?? 00.

    Impact

    This is a user-facing regression on parentPort in every worker thread. parentPort.removeAllListeners() in particular is a common cleanup idiom (e.g. before re-registering handlers, or in worker-pool teardown), and parentPort.listenerCount('message') is used to check whether anyone is listening. Both now throw instead of returning. Any worker code that calls one of these five methods — code that ran fine on the previous release — will now crash with a TypeError.

    Fix

    Extend the local-override loop in fakeParentPort() to also cover the remaining six methods, e.g.:

    function listenerCount() { return 0; }   // or forward to self if a global-scope count is meaningful
    function eventNames() { return []; }
    function removeAllListeners() { return this; }
    let maxListeners = 10;
    function getMaxListeners() { return maxListeners; }
    function setMaxListeners(n: number) { maxListeners = n; return this; }
    function emit() { return false; }
    for (const [name, fn] of [
      ["on", on], ["addListener", on], ["once", once], ["off", off], ["removeListener", off],
      ["listenerCount", listenerCount], ["eventNames", eventNames],
      ["removeAllListeners", removeAllListeners], ["getMaxListeners", getMaxListeners],
      ["setMaxListeners", setMaxListeners], ["emit", emit],
    ] as const) {
      Object.defineProperty(fake, name, { value: fn, enumerable: false, configurable: true, writable: true });
    }

    (Longer term the fake parentPort should probably become a real EventTarget/JSMessagePort so none of this shimming is needed — the file already carries a // TODO: parent port emulation is not complete — but restoring the previously-working surface is the minimal fix for this PR.)

@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

The second fakeParentPort() finding above was on c53eb03 (pre-review-fix). 54bac10 already shadows all eleven methods (on/addListener/once/off/removeListener/listenerCount/eventNames/removeAllListeners/emit/getMaxListeners/setMaxListeners) and the new test exercises listenerCount/eventNames/removeAllListeners/getMaxListeners on parentPort inside a worker.

robobun and others added 2 commits July 25, 2026 22:36
Only the no-import row and the parentPort row need subprocess isolation;
the rest now run directly against the debug build, which avoids 15
concurrent bun-debug spawns exceeding the default 5s timeout under
ASAN.
Comment thread src/jsc/bindings/webcore/JSMessagePort.cpp Outdated
Comment thread src/js/node/worker_threads.ts
…e error for once

- oxlint: Array.from(byType.keys()) instead of spread.
- fakeParentPort.once(): register a wrapper that deletes the listener
  from byType when it fires so listenerCount/eventNames/emit are
  accurate afterwards; off() looks up the wrapper.
- messagePortNodeAddListener: name the function in the listener-arg
  TypeError ('once' vs 'on').
Comment thread src/js/node/worker_threads.ts Outdated
Comment thread src/js/node/worker_threads.ts
track() now returns false when the listener is already tracked and the
caller skips self.addEventListener, so a second once()/on() for the same
function is a no-op (matching node) and off() can't orphan an earlier
registration.
Comment thread src/js/node/worker_threads.ts
Comment thread test/js/node/worker_threads/message-port-node-event-target.test.ts Outdated
Avoids the subprocess + worker double-spawn that sat at the 5s default
timeout boundary under debug+ASAN.
Comment thread src/js/node/worker_threads.ts
Comment thread src/js/node/worker_threads.ts Outdated
Comment thread test/js/node/worker_threads/message-port-node-event-target.test.ts Outdated
…tale comment

- once() wrapper returns listener(arg) so a promise return reaches
  handleEvent's addCatch path.
- removeAllListeners iterates byType.keys() directly; SafeMap's iterator
  tolerates deleting the just-yielded key so no snapshot is needed.
- Drop the test-file comment that referenced the deleted JS shim.
Comment thread src/js/node/worker_threads.ts
So .on(fn) -> removeEventListener(fn) -> .on(fn) re-registers instead of
being swallowed by track()'s first-wins check.

@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: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/jsc/bindings/webcore/EventHeaders.h`:
- Around line 81-82: Add CustomEvent.h and JSCustomEvent.h to the generator
input that produces EventHeaders.h, rather than modifying the generated header
directly, then regenerate EventHeaders.h and verify both includes are preserved.

In `@src/jsc/bindings/webcore/JSMessagePort.cpp`:
- Around line 593-601: Update jsMessagePortPrototypeFunction_setMaxListenersBody
to validate the original argument as a finite, non-negative integer before
converting or storing it. Throw for negative, NaN, Infinity, fractional, or
otherwise invalid values, and only call setNodeMaxListeners after validation
succeeds.

In `@test/js/node/worker_threads/message-port-node-event-target.test.ts`:
- Around line 87-101: The negative assertion in “removeAllListeners clears
addEventListener listeners too” relies on a single setImmediate tick and can
pass before delayed delivery occurs. Replace it with a short bounded polling
window, following the established negative-condition polling pattern in this
test file, and assert that n remains zero throughout the window.
- Around line 18-24: Replace the require.cache check in the embedded
worker_threads preload test with a sentinel that detects the module’s bootstrap
side effect, and throw when that sentinel indicates node:worker_threads was
preloaded. Keep the existing MessageChannel API validation unchanged.
🪄 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: d0a25aca-f740-4c83-bfb7-7848192fc934

📥 Commits

Reviewing files that changed from the base of the PR and between 916492f and 46834ad.

📒 Files selected for processing (19)
  • src/js/builtins.d.ts
  • src/js/builtins/BunBuiltinNames.h
  • src/js/node/worker_threads.ts
  • src/jsc/bindings/webcore/AddEventListenerOptions.h
  • src/jsc/bindings/webcore/EventFactory.cpp
  • src/jsc/bindings/webcore/EventHeaders.h
  • src/jsc/bindings/webcore/EventListener.h
  • src/jsc/bindings/webcore/EventListenerMap.cpp
  • src/jsc/bindings/webcore/EventListenerMap.h
  • src/jsc/bindings/webcore/EventTarget.cpp
  • src/jsc/bindings/webcore/EventTarget.h
  • src/jsc/bindings/webcore/JSAddEventListenerOptions.cpp
  • src/jsc/bindings/webcore/JSEventListener.cpp
  • src/jsc/bindings/webcore/JSEventListener.h
  • src/jsc/bindings/webcore/JSMessagePort.cpp
  • src/jsc/bindings/webcore/MessagePort.cpp
  • src/jsc/bindings/webcore/MessagePort.h
  • src/jsc/bindings/webcore/RegisteredEventListener.h
  • test/js/node/worker_threads/message-port-node-event-target.test.ts

Comment thread src/jsc/bindings/webcore/EventHeaders.h
Comment thread src/jsc/bindings/webcore/JSMessagePort.cpp
Comment thread test/js/node/worker_threads/message-port-node-event-target.test.ts
Comment thread test/js/node/worker_threads/message-port-node-event-target.test.ts
Comment thread src/js/node/worker_threads.ts
Comment thread src/jsc/bindings/webcore/JSMessagePort.cpp
…ventListener forwards options and wrapper

- removeAllListeners treats an undefined/null argument the same as no
  argument (node checks type !== undefined && type !== null).
- fakeParentPort.removeEventListener looks up the once-wrapper via
  byType and forwards the options/useCapture argument, mirroring off().

@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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (5)
src/js/node/worker_threads.ts (3)

681-690: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Treat undefined and null as “remove all.”

With one argument, removeAllListeners(undefined) and removeAllListeners(null) call clear() for those values instead of clearing the registry. The native implementation explicitly treats both values as the no-argument form, so fake parentPort retains listeners and reports stale state.

Use an explicit arguments.length === 0 || type === undefined || type === null check before type-specific removal.

As per coding guidelines, deliberately enumerate empty, unset, and null input variants rather than relying only on argument count.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/js/node/worker_threads.ts` around lines 681 - 690, The removeAllListeners
function must treat no argument, undefined, and null as requests to remove all
listeners. Update its branching condition to explicitly check arguments.length
=== 0, type === undefined, or type === null before invoking type-specific clear
logic, while preserving targeted removal for valid event types.

Source: Coding guidelines


673-679: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Include direct EventTarget listeners in shared bookkeeping.

fake.addEventListener() registers on self but never updates byType, while listenerCount(), eventNames(), and emit() consult only byType. A listener added through parentPort.addEventListener() can therefore run during emit() while listenerCount() returns 0, eventNames() omits the event, and emit() returns false. The native MessagePort implementation reads the shared listener list for these operations.

Track fake-owned direct registrations separately from internal self listeners, or route all listener APIs through one registry.

Also applies to: 693-700

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/js/node/worker_threads.ts` around lines 673 - 679, The fake EventTarget
listener registrations are not reflected in the shared bookkeeping used by
listenerCount, eventNames, and emit. Update the add/remove listener handling
associated with fake.addEventListener and the byType registry so direct
parentPort registrations are tracked alongside the relevant event type without
counting internal self listeners, and ensure listenerCount, eventNames, and emit
observe the same registry and return accurate results.

658-664: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Preserve handleEvent support in once()
This wrapper calls listener(arg) directly, so { handleEvent() {} } listeners throw when the event fires even though addEventListener accepts them. Dispatch callable listeners and EventListener objects the same way here.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/js/node/worker_threads.ts` around lines 658 - 664, The once function’s
wrapper must support EventListener objects with handleEvent in addition to
callable listeners. Update the listener invocation inside once to dispatch
through the existing listener-handling mechanism, preserving cleanup and
callable-listener behavior.
src/jsc/bindings/webcore/JSMessagePort.cpp (2)

445-462: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Skip nullish listeners before track() in src/js/node/worker_threads.ts#L644-L665. addEventListener(null) is a no-op, but the shim still records the entry; once() also closes over a null listener and can throw when it fires.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/jsc/bindings/webcore/JSMessagePort.cpp` around lines 445 - 462, Skip
nullish listeners before registering or tracking them. In
messagePortNodeAddListener, preserve the existing no-op behavior for a null
listener and ensure the worker_threads.ts shim does not call track() or create a
once() wrapper for nullish listeners; apply the corresponding change at
src/jsc/bindings/webcore/JSMessagePort.cpp lines 445-462 and
src/js/node/worker_threads.ts lines 644-655.

Source: Coding guidelines


486-499: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Preserve the registered capture on fake.removeEventListener().
JSMessagePort.off() / removeListener() only take (type, listener), so the capture concern doesn’t apply there. In src/js/node/worker_threads.ts#L638-L641, byType is deleted unconditionally, so a capture-mismatched removeEventListener() can desync listenerCount() / eventNames() from the actual listener list. Store capture with the registration, or skip the delete when the requested capture doesn’t match.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/jsc/bindings/webcore/JSMessagePort.cpp` around lines 486 - 499, Update
the listener bookkeeping in worker_threads.ts at lines 638-641 and 667-670 so
byType entries are removed only when the requested capture matches the
registered listener, or store the registration’s capture value for comparison;
preserve the actual listener removal behavior. The JSMessagePort.cpp function
jsMessagePortPrototypeFunction_offBody at lines 486-499 requires no direct
change because off/removeListener only accept type and listener.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/js/node/worker_threads.ts`:
- Around line 681-690: The removeAllListeners function must treat no argument,
undefined, and null as requests to remove all listeners. Update its branching
condition to explicitly check arguments.length === 0, type === undefined, or
type === null before invoking type-specific clear logic, while preserving
targeted removal for valid event types.
- Around line 673-679: The fake EventTarget listener registrations are not
reflected in the shared bookkeeping used by listenerCount, eventNames, and emit.
Update the add/remove listener handling associated with fake.addEventListener
and the byType registry so direct parentPort registrations are tracked alongside
the relevant event type without counting internal self listeners, and ensure
listenerCount, eventNames, and emit observe the same registry and return
accurate results.
- Around line 658-664: The once function’s wrapper must support EventListener
objects with handleEvent in addition to callable listeners. Update the listener
invocation inside once to dispatch through the existing listener-handling
mechanism, preserving cleanup and callable-listener behavior.

In `@src/jsc/bindings/webcore/JSMessagePort.cpp`:
- Around line 445-462: Skip nullish listeners before registering or tracking
them. In messagePortNodeAddListener, preserve the existing no-op behavior for a
null listener and ensure the worker_threads.ts shim does not call track() or
create a once() wrapper for nullish listeners; apply the corresponding change at
src/jsc/bindings/webcore/JSMessagePort.cpp lines 445-462 and
src/js/node/worker_threads.ts lines 644-655.
- Around line 486-499: Update the listener bookkeeping in worker_threads.ts at
lines 638-641 and 667-670 so byType entries are removed only when the requested
capture matches the registered listener, or store the registration’s capture
value for comparison; preserve the actual listener removal behavior. The
JSMessagePort.cpp function jsMessagePortPrototypeFunction_offBody at lines
486-499 requires no direct change because off/removeListener only accept type
and listener.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 22bdefe0-d0ec-4609-b430-fddf3265f846

📥 Commits

Reviewing files that changed from the base of the PR and between 46834ad and d2147a7.

📒 Files selected for processing (2)
  • src/js/node/worker_threads.ts
  • src/jsc/bindings/webcore/JSMessagePort.cpp

@robobun

robobun commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author

Re the five outside-diff coderabbit findings on fakeParentPort():

  • removeAllListeners(undefined|null) on the fake: the old shim gated on arguments.length === 0 too; not a regression. Can fold in with the native-side nullish handling as a follow-up.
  • fake.addEventListener not updating byType: by design. The comment on byType says it tracks listeners added via the parentPort emitter surface so removeAllListeners() cannot touch internal listeners on self. addEventListener is the DOM API and its listeners are not counted by node's NodeEventTarget.listenerCount on the real parentPort either (node stores them in the same map, but node's parentPort is a real MessagePort, which the stand-in is not).
  • once() wrapper and {handleEvent} objects: node's NodeEventTarget.on/once/addListener throws ERR_INVALID_ARG_TYPE for a non-function listener (validateListener). handleEvent objects are only accepted via addEventListener, which the fake forwards directly.
  • nullish listener tracked: node throws for a null listener on .on/.once; the fake doesn't validate (matching the old shim), so a null listener is a user error either way.
  • capture-mismatch desync: capture is only settable via addEventListener (which byType doesn't track), so there is nothing in byType to desync against.

The stand-in is explicitly // TODO: parent port emulation is not complete; the surface that matters to this PR (the MessagePort listener list unification) is native and unaffected by these. Happy to roll the fake-side removeAllListeners(undefined) into a follow-up alongside the setMaxListeners validation.

Comment thread src/js/node/worker_threads.ts

@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.

No new findings on d2147a7. This is a substantial rework of the MessagePort event surface (native NodeEventTarget prototype, EventTarget dispatch hook, ~200 lines of new C++ bindings), so it's worth a human pass.

What was reviewed across iterations: exception-scope coverage in every new JSMessagePort body; EventListenerMap::removeAll marking listeners removed before erase (matches clear()); onDidChangeListenerImpl reconciling m_hasMessageEventListener/m_hasCloseEventListener on Clear so re-buffering and GC liveness stay correct; SetForScope keeping m_invokeAsNodeStyle transient; the intermediate-prototype construction in createPrototype. The remaining acknowledged nits (fakeParentPort removeAllListeners(undefined), setMaxListeners range validation) are pre-existing gaps deferred to a follow-up.

Extended reasoning...

Overview

This PR replaces the ~180-line injectFakeEmitter JS shim with a native NodeEventTarget implementation on MessagePort. It touches 19 files: an isNodeStyleListener bit threaded through AddEventListenerOptionsRegisteredEventListenerEventTarget::innerInvokeEventListeners; a handleEventNodeStyle override on JSEventListener that passes .data/.detail/.error instead of the Event wrapper; ~200 lines of new host functions in JSMessagePort.cpp (on/once/off/emit/listenerCount/eventNames/removeAllListeners/get|setMaxListeners) reified onto an intermediate prototype inserted between MessagePort.prototype and EventTarget.prototype; EventListenerMap::removeAll + EventTarget::removeAllEventListenersForType; wiring CustomEventInterfaceType into EventFactory; and a rewritten fakeParentPort() stand-in in worker_threads.ts. 15-row test matrix added.

Security risks

None identified. The kIsNodeStyleListener flag is keyed off a JSC private name (unforgeable from userland), matching the existing kResistStopPropagation pattern. No auth/crypto/permission code is touched.

Level of scrutiny

High. innerInvokeEventListeners is on the hot path for every event dispatch in the runtime, and inserting a prototype into a WebCore class chain is a structural change. The new C++ host functions do argument coercion (which enters user JS) and dispatch events (which run user callbacks that can synchronously close()/removeAllListeners() the port). The onDidChangeListener(Clear) path now feeds into m_hasMessageEventListener/m_hasCloseEventListener, which gate GC liveness (hasPendingActivity) and drain scheduling.

Other factors

Over the course of ~7 earlier passes I flagged and the author fixed: missing ThrowScope in setMaxListeners; stale m_has*EventListener after removeAllListeners (now handled in onDidChangeListenerImpl for Clear); several fakeParentPort desync/tamper-resistance issues; removeAllListeners(undefined|null) on the native path; removeEventListener options/wrapper forwarding on the fake. Two acknowledged-not-regressions remain for a follow-up (fake-side removeAllListeners(undefined) on the TODO-marked stub, setMaxListeners input validation). The change is well-tested and the design mirrors Node's lib/internal/event_target.js, but the surface area — prototype-chain surgery, event-dispatch hook, GC-liveness bookkeeping — is large enough that a maintainer should sign off.

@robobun

robobun commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author

CI status: the diff is green on every lane that ran my tests (gate passed on ASAN and release; message-port-node-event-target.test.ts, worker_threads.test.ts, message-channel.test.ts, and the test-worker-message-port* / test-eventtarget* node-parallel suite all pass).

Build 81904 failed only on a darwin-aarch64 build-bun timeout (no test failures). The retrigger, build 82001, hit a fleet-wide build outage: nearly every build-bun/build-cpp lane (darwin, linux x64/aarch64/musl/android, freebsd, windows-aarch64) expired or timed out before producing a binary, so their downstream test lanes never ran. On the two lanes that did run tests:

  • test/js/web/fetch/fetch-backpressure.test.ts on Windows 2019 x64: panic: index out of bounds: the len is 1 but the index is 1 in fetch/http_server. Marked flaky; reported to main-break triage.
  • test/cli/install/migration/complex-workspace.test.ts on debian 13 x64-asan: bun install failed because git was SIGKILLed (git failed with signal 9) cloning the install-test git dependencies. Infra (OOM/resource exhaustion on the same overloaded fleet); reported to main-break triage.

Neither touches MessagePort/EventTarget/worker_threads. Ready for review; needs a fresh CI run once the build fleet recovers.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants