Skip to content

MessagePort: define node-style emitter methods on the prototype itself - #33856

Closed
robobun wants to merge 2 commits into
mainfrom
claude/farm/75f53c78/messageport-emitter-without-worker-threads
Closed

robobun wants to merge 2 commits into
mainfrom
claude/farm/75f53c78/messageport-emitter-without-worker-threads

Conversation

@robobun

@robobun robobun commented Jul 9, 2026 •

Copy link
Copy Markdown
Collaborator

Repro

// No import of node:worker_threads anywhere in the process.
const { port1, port2 } = new MessageChannel();
port1.on("message", m => console.log(m));  // textbook node code
port2.postMessage(42);
$ bun repro.mjs
TypeError: port1.on is not a function

Importing node:worker_threads anywhere (even transitively, even after the port is constructed) retroactively makes the same port work:

$ IMPORT_WT=1 bun repro.mjs
before-import on=undefined once=undefined off=undefined emit=undefined
after-import  on=function once=function off=function emit=function
port.on delivered: 42

Node has these methods on the class from the start.

Cause

src/js/node/worker_threads.ts calls injectFakeEmitter(globalThis.MessagePort) at module top level, so the NodeEventTarget emitter surface (on/once/off/emit/addListener/removeListener/listenerCount/eventNames/removeAllListeners/setMaxListeners/getMaxListeners) only appears on MessagePort's prototype chain once node:worker_threads is first evaluated. A port from the global new MessageChannel() has none of them until something else in the dependency graph loads that module. In node, MessagePort's prototype chain runs through NodeEventTarget, so the methods are always present.

Fix

Move injectFakeEmitter into internal/worker/messageport_emitter and put self-replacing bootstrap stubs for each emitter method name on JSMessagePortPrototype (via src/js/builtins/MessagePort.ts). The first call to any stub requires the internal module, which inserts the real methods on the intermediate prototype (same as before) and deletes the stubs so subsequent lookups hit the inherited implementations directly. worker_threads.ts now just requires the same module; the module cache makes that idempotent.

The emitter implementation itself is unchanged from main; only where and when it is installed changes.

Verification

New tests in test/js/web/workers/message-channel.test.ts spawn subprocesses that assert node:worker_threads is not in require.cache, then check the prototype shape and exercise on/once/off/addListener/removeListener/listenerCount end to end, plus events.once(port, 'message') (which now resolves with the payload, matching Node).

# fail-before (main src)
TypeError: port1.once is not a function
got: {"isTrusted": true}   # events.once takes the EventTarget path

# pass-after
2 pass, 0 fail

Existing coverage stays green: test/js/web/workers/, test/js/node/worker_threads/worker_threads.test.ts (all MessagePort/NodeEventTarget/listener-registry tests), and the test-worker-message-port* / test-messagechannel* node-compat tests.


[review] gate passed · iteration 3 · 5 files touched

fails on main (without fix)
ASAN without fix: 2 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/web/workers/message-channel.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (beb068b55)

test/js/web/workers/message-channel.test.ts:
(pass) simple usage [12.84ms]
(pass) transfer message port [10.39ms]
(pass) transfer array buffer [5.16ms]
Warning: The target port was posted to itself, and the communication channel was lost
      at <anonymous> (/workspace/bun/test/js/web/workers/message-channel.test.ts:60:19)
      at <anonymous> (/workspace/bun/test/js/web/workers/message-channel.test.ts:61:10)

(pass) non-transferable [38.38ms]
(pass) transfer message ports and post messages [14.72ms]
(pass) message channel created on main thread [143.37ms]
(pass) message channel created on other thread [145.85ms]
Warning: The target port was posted to itself, and the communication channel was lost
      at <an
... (truncated)

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

test/js/web/workers/message-channel.test.ts:
(pass) simple usage [4.83ms]
(pass) transfer message port [0.22ms]
(pass) transfer array buffer [0.13ms]
Warning: The target port was posted to itself, and the communication channel was lost
      at <anonymous> (/workspace/bun/test/js/web/workers/message-channel.test.ts:60:19)
      at <anonymous> (/workspace/bun/test/js/web/workers/message-channel.test.ts:61:10)

(pass) non-transferable [2.94ms]
(pass) transfer message ports and post messages [0.21ms]
(pass) message channel created on main thread [4.05ms]
(pass) message channel created on other thread [4.44ms]
Warning: The target port was posted to itself, and the communication channel was lost
      at <anonymous> (/workspace/bun/test/js/web/workers/message-channel.test.ts:152:25)
      at <anonymous> (/workspace/bun/test/js/web/workers/message-channel.test.ts:153:12)

(pass) many message channels [0.89ms]
(pass) gc [0.81ms]
(pass) cloneable and transferable equals [6.83ms]
(pass) cloneable and non-transferable equals (BunFile) [0.84ms]
(pass) cloneable and non-transferable equals (net.BlockList) [5.41ms]
(pass) a pending close eve
... (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/web/workers/message-channel.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (beb068b55)

test/js/web/workers/message-channel.test.ts:
(pass) simple usage [16.92ms]
(pass) transfer message port [10.65ms]
(pass) transfer array buffer [4.80ms]
Warning: The target port was posted to itself, and the communication channel was lost
      at <anonymous> (/workspace/bun/test/js/web/workers/message-channel.test.ts:60:19)
      at <anonymous> (/workspace/bun/test/js/web/workers/message-channel.test.ts:61:10)

(pass) non-transferable [38.20ms]
(pass) transfer message ports and post messages [14.96ms]
(pass) message channel created on main thread [147.55ms]
(pass) message channel created on other thread [153.95ms]
Warning: The target port was posted to itself, and the communication channel was lost
      at <an
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
[configured] bun-profile → bun (stripped) in 735ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/22] gen cpp.rs (cppbind)
[2/22] gen JS modules (bundle-modules)
Preprocess modules (6584ms)
Bundle modules (43ms)
Postprocesss modules (141ms)
Bundle Functions (787ms)
Generate Code (105ms)

[7.68s] Bundled "src/js" for production
  1914 kb
  163 internal modules
  12 native modules
  101 internal functions across 20 files
[2/9] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: component rust-std is up to date

info: checking for self-update (current version: 1.29.0)
  nightly-2026-05-06-x86_64-unknown-linux-gnu unchanged - rustc
... (truncated)
diff hotspot
src/js/builtins/MessagePort.ts                |  61 ++++++++
 src/js/internal/worker/messageport_emitter.ts | 207 ++++++++++++++++++++++++++
 src/js/node/worker_threads.ts                 | 203 +------------------------
 src/jsc/bindings/webcore/JSMessagePort.cpp    |  16 ++
 test/js/web/workers/message-channel.test.ts   | 118 +++++++++++++++
 5 files changed, 410 insertions(+), 195 deletions(-)

gate history · 6 passed · 0 rejected · iteration 3

evidence per changed file
file                                           reads  edits  tests
src/js/builtins/MessagePort.ts                     1      3      0
src/js/internal/worker/messageport_emitter.ts      0      1      0
src/js/node/worker_threads.ts                      8      8      0
src/jsc/bindings/webcore/JSMessagePort.cpp         4      6      0
test/js/web/workers/message-channel.test.ts        4     11      0

@coderabbitai

coderabbitai Bot commented Jul 9, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

MessagePort EventEmitter-style behavior now loads from a shared EventTarget-based emitter module, with bootstrap builtins and C++ prototype registration. worker_threads uses the global MessagePort directly, and regression tests cover initialization and events.once abort handling.

MessagePort emitter integration

Layer / File(s) Summary
Emitter bootstrap functions
src/js/builtins/MessagePort.ts
Adds lazy stubs for EventEmitter-style methods that load and forward to inherited implementations.
EventTarget emitter implementation
src/js/internal/worker/messageport_emitter.ts
Implements listener tracking, event conversion, emission, listener queries, removal, and max-listener APIs.
Prototype property wiring
src/jsc/bindings/webcore/JSMessagePort.cpp
Registers emitter methods and listener aliases on the MessagePort prototype.
worker_threads integration
src/js/node/worker_threads.ts
Removes the local fake-emitter installation, eagerly loads the shared module, updates transfer-list filtering, and removes fake parent-port listener aliases.
Regression coverage
test/js/web/workers/message-channel.test.ts
Tests emitter methods without worker_threads side effects and validates events.once abort behavior.

Possibly related PRs

  • oven-sh/bun#31216: Also changes how MessagePort EventEmitter methods and listener behavior are installed.
🚥 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: making MessagePort expose node-style emitter methods on its prototype.
Description check ✅ Passed The description covers what the PR does and how it was verified, with repro, fix, and test evidence despite different headings.

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

@github-actions github-actions Bot added the claude label Jul 9, 2026
@robobun

robobun commented Jul 9, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 4:44 PM PT - Jul 12th, 2026

❌ @robobun, your commit beb068b has 3 failures in Build #72345 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 33856

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

bun-33856 --bun

@github-actions

github-actions Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. worker_threads: give MessagePort node's EventEmitter surface #29024 - Also replaces injectFakeEmitter in worker_threads.ts with permanent node-style EventEmitter methods (on, off, once, emit, etc.) on the MessagePort prototype

🤖 Generated with Claude Code

@robobun

robobun commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator Author

Not a duplicate of #29024. That PR expands the emitter surface (addListener/removeListener/removeAllListeners/listenerCount/eventNames/...) but still installs everything via injectFakeEmitter inside node:worker_threads, so port.on still throws TypeError until that module is loaded. This PR moves the existing six methods onto JSMessagePortPrototype so they exist regardless of module load order. The two are orthogonal; whichever lands second will need a small rebase.

Comment thread src/jsc/bindings/webcore/JSMessagePort.cpp Outdated
Comment thread src/js/node/worker_threads.ts Outdated

@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/js/builtins/MessagePort.ts`:
- Around line 6-32: The listener wrapper tracking in MessagePort.on and
MessagePort.once is overwritten when the same function is registered more than
once, which breaks MessagePort.off removal. Update the wrapper storage so it is
keyed by both event and listener (for example, a per-listener map/list of
wrapped handlers) instead of assigning a single $nodeEventTargetWrapped value on
the listener. Then adjust off() to look up the matching wrapped handler for the
specific event and remove that exact wrapper, preserving previous registrations.
- Around line 43-51: The MessagePort emit helper currently returns the port
instance, which masks whether the dispatch was handled. Update the emit function
in MessagePort so it returns the boolean result of dispatchEvent for both the
error/messageerror and other event branches, instead of returning this, and keep
the behavior aligned with the emit method name used in MessagePort.

In `@src/jsc/bindings/webcore/JSMessagePort.cpp`:
- Around line 140-147: MessagePort.prototype is exposing unsupported
EventEmitter-style methods via JSMessagePort’s property table, specifically
prependListener and prependOnceListener, and they are wired to the on/once
generators instead of any true prepend behavior. Remove these entries from the
JSMessagePort builtins list, or only retain them if the API divergence is
explicitly intended and documented, so the exported MessagePort surface matches
Node’s supported methods.

In `@test/js/web/workers/message-channel.test.ts`:
- Around line 330-437: Mark the two new subprocess-spawning tests in
message-channel.test.ts as concurrent, since they are independent and
self-contained and currently run sequentially. Update the test declarations for
the MessagePort node-style emitter methods check and the events.once(port, ...)
check to use test.concurrent (or wrap them in a concurrent describe) so they can
run in parallel without sharing state.
🪄 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: 7d6d8d9c-c5bf-4c9a-a298-5f3e4c5b3301

📥 Commits

Reviewing files that changed from the base of the PR and between fc865b3 and bf0162d.

📒 Files selected for processing (5)
  • src/js/builtins/BunBuiltinNames.h
  • src/js/builtins/MessagePort.ts
  • src/js/node/worker_threads.ts
  • src/jsc/bindings/webcore/JSMessagePort.cpp
  • test/js/web/workers/message-channel.test.ts

Comment thread src/js/builtins/MessagePort.ts Outdated
Comment thread src/js/builtins/MessagePort.ts Outdated
Comment thread src/jsc/bindings/webcore/JSMessagePort.cpp Outdated
Comment thread test/js/web/workers/message-channel.test.ts Outdated
@robobun

robobun commented Jul 9, 2026 •

Copy link
Copy Markdown
Collaborator Author

The diff is green: the new message-channel.test.ts tests and all existing worker_threads/MessagePort/node-compat tests pass on every lane, and the evidence gate passed (fails without the fix, passes with it).

The remaining CI red is unrelated to this diff:

  • test/js/node/test/parallel/test-worker-message-port-transfer-terminate.js SIGABRT (!scope.exception() || !hasSlot) on debian 13 x64-asan: a pre-existing termination-exception-handling race that is also hitting unrelated branches on the same lane (builds 72342, 72338, 72334 on farm/e11a2452/bun-build-rss-purge and farm/9b887ac5/root-pending-internal-promise). Not reproducible locally over 35 runs with BUN_JSC_validateExceptionChecks=1. Reported separately.
  • Flaky-on-retry: bake/dev-and-prod.test.ts, zlib/leak.test.ts, test-repl-close.js, test-fs-promises-file-handle-readFile.js on various Windows/ASAN lanes.

Ready for review.

…ut loading node:worker_threads

The emitter methods (on/once/off/emit/addListener/removeListener/listenerCount/
eventNames/removeAllListeners/setMaxListeners/getMaxListeners) were installed on
MessagePort's prototype chain by injectFakeEmitter() as a top-level side effect
of evaluating node:worker_threads. A port from the global new MessageChannel()
therefore had none of them until some dependency happened to load that module,
so `port.on('message', cb)` threw TypeError depending on import order. Node's
MessagePort inherits these from NodeEventTarget unconditionally.

Move injectFakeEmitter into internal/worker/messageport_emitter and put
self-replacing bootstrap stubs for each method name on JSMessagePortPrototype.
The first call to any stub requires the internal module, which inserts the real
methods on an intermediate prototype and deletes the stubs so subsequent lookups
hit the inherited implementations directly. worker_threads.ts now just requires
the same module (idempotent via the module cache).
@robobun
robobun force-pushed the claude/farm/75f53c78/messageport-emitter-without-worker-threads branch from 02a2b90 to a7accad Compare July 12, 2026 22:46
Comment thread src/js/internal/worker/messageport_emitter.ts
Comment thread src/js/node/worker_threads.ts
…m the emitter prototype

fakeParentPort defined own addListener/removeListener as raw
self.addEventListener/removeEventListener, which shadowed the intermediate
prototype's wrapper-aware aliases. Drop the own properties so the inherited
on/off apply; they route through the own self-bound addEventListener/
removeEventListener, so listeners still land on the worker global scope.
Comment thread src/js/internal/worker/messageport_emitter.ts
@robobun

robobun commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

Closing as part of a cleanup of stale pull requests. This PR has had no new commits since 2026-07-12, it conflicts with main, and its last CI run failed. This is not a judgment on the fix itself. If the problem still reproduces on a current build, reopen this PR after a rebase or open a new one against main.

@robobun robobun closed this Sep 13, 2026
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.

1 participant