Skip to content

worker_threads: deliver the emit() payload to MessagePort listeners - #35796

Open
robobun wants to merge 12 commits into
mainfrom
farm/a40a02e0/messageport-emit-payload
Open

worker_threads: deliver the emit() payload to MessagePort listeners#35796
robobun wants to merge 12 commits into
mainfrom
farm/a40a02e0/messageport-emit-payload

Conversation

@robobun

@robobun robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

Repro

const { MessageChannel } = require("node:worker_threads");
const { port1: p } = new MessageChannel();
const payload = { id: 42 };
let onArg, aelData;
p.on("message", x => (onArg = x));
p.addEventListener("message", e => (aelData = e.data));
const ret = p.emit("message", payload);
console.log(onArg === payload, aelData === payload, ret);
// bun:  false false MessagePort {...}
// node: true  true  true

.on("error", cb) and .on("messageerror", cb) similarly receive null/undefined instead of the emitted value, and emit() returns this instead of a boolean.

Cause

injectFakeEmitter's emit() in src/js/node/worker_threads.ts built the event as new MessageEvent("message", payload) / new ErrorEvent("error", err), passing the user's value as the init dictionary instead of wrapping it ({data: payload} / {error: err}). The .on() wrapper then read event.data / event.error, which were their defaults (null). The function also ended with return this.

The messageerror extractor read event.error, but both native MessagePort messageerror dispatch and node's MessagePort[kCreateEvent] produce a MessageEvent whose payload is .data, so .on("messageerror", cb) would miss the value even for real deserialization failures.

Fix

Construct events whose shape matches node's NodeEventTarget/MessagePort[kCreateEvent] and round-trips through the .on() extractors:

  • "message" / "messageerror"new MessageEvent(type, { data: arg })
  • everything else (including "error", which MessagePort never fires natively) → new CustomEvent(type, { detail: arg })

Update the messageerror extractor to read .data, drop the now-unused errorEventHandler/EventClass, and return listenerCount(type) > 0 before dispatch (node's NodeEventTarget.prototype.emit semantics).

Verification

New test in test/js/node/worker_threads/worker_threads.test.ts covers message/error/messageerror/custom events for both .on() (identity) and addEventListener (event class + .data/.detail), plus the boolean return for the listener / no-listener cases. Fails on main with onArg: null, aelData: null, ret: MessagePort, passes with the fix.

The full worker_threads.test.ts (92 tests), test/js/web/workers/message-channel.test.ts, and the node-compat test-worker-message-port.js / test-event-target.js / test-messagechannel.js all pass.


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

fails on main (without fix)
ASAN without fix: 1 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/worker_threads.test.ts
bun test v1.4.0 (6f947afc2)

test/js/node/worker_threads/worker_threads.test.ts:
(pass) support eval in worker [2147.69ms]
(pass) all worker_threads module properties are present [29.58ms]
(pass) markAsUncloneable and markAsUntransferable markers are private, unforgeable, and permanent [29.05ms]
(pass) all worker_threads worker instance properties are present [213.08ms]
(pass) threadId module and worker property is consistent [227.21ms]
(pass) receiveMessageOnPort works across threads [2338.94ms]
(pass) receiveMessageOnPort works as FIFO [18.47ms]
(pass) you can override globalThis.postMessage [1976.62ms]
(pass) support require in eval [3044.09ms]
cwd /workspace/bun
realpath test/js/node/worker_threads/fixture-argv.js
(pass) support require in eval for a file [1852.43ms]
(pass) support require in eval for a file that doesnt exist [1728.69ms]
(pass) support worker eval that throws [1852.74ms]
(pass) execArgv option > inherits the parent's execArgv when falsy or unspecified [8809.62ms]
(
... (truncated)

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

test/js/node/worker_threads/worker_threads.test.ts:
(pass) support eval in worker [35.25ms]
(pass) all worker_threads module properties are present [0.45ms]
(pass) markAsUncloneable and markAsUntransferable markers are private, unforgeable, and permanent [0.55ms]
(pass) all worker_threads worker instance properties are present [3.29ms]
(pass) threadId module and worker property is consistent [4.52ms]
(pass) receiveMessageOnPort works across threads [32.51ms]
(pass) receiveMessageOnPort works as FIFO [0.34ms]
(pass) you can override globalThis.postMessage [45.79ms]
(pass) support require in eval [31.90ms]
cwd /workspace/bun
realpath test/js/node/worker_threads/fixture-argv.js
(pass) support require in eval for a file [34.24ms]
(pass) support require in eval for a file that doesnt exist [45.51ms]
(pass) support worker eval that throws [30.51ms]
(pass) execArgv option > inherits the parent's execArgv when falsy or unspecified [129.77ms]
(pass) execArgv option > provides empty execArgv when passed an empty array [80.19ms]
(pass) execArgv option > can specify an array of strings [61.57ms]
(pass) eval does not leak source code [2003.4
... (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/worker_threads.test.ts
bun test v1.4.0 (6f947afc2)

test/js/node/worker_threads/worker_threads.test.ts:
(pass) support eval in worker [1949.40ms]
(pass) all worker_threads module properties are present [30.60ms]
(pass) markAsUncloneable and markAsUntransferable markers are private, unforgeable, and permanent [27.61ms]
(pass) all worker_threads worker instance properties are present [189.46ms]
(pass) threadId module and worker property is consistent [211.63ms]
(pass) receiveMessageOnPort works across threads [1811.27ms]
(pass) receiveMessageOnPort works as FIFO [13.50ms]
(pass) you can override globalThis.postMessage [1769.77ms]
(pass) support require in eval [2593.65ms]
cwd /workspace/bun
realpath test/js/node/worker_threads/fixture-argv.js
(pass) support require in eval for a file [1961.57ms]
(pass) support require in eval for a file that doesnt exist [2954.41ms]
(pass) support worker eval that throws [1741.76ms]
(pass) execArgv option > inherits the parent's execArgv when falsy or unspecified [7878.89ms]
(
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 868ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/31] gen cpp.rs (cppbind)
[2/31] 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 fi
... (truncated)
diff hotspot
src/js/node/worker_threads.ts                      |  50 +++++-----
 test/js/node/worker_threads/worker_threads.test.ts | 110 +++++++++++++++++++++
 2 files changed, 137 insertions(+), 23 deletions(-)

gate history · 5 passed · 1 rejected · iteration 2

evidence per changed file
file                                                reads  edits  tests
src/js/node/worker_threads.ts                           9     14      0
test/js/node/worker_threads/worker_threads.test.ts      4      3      0

MessagePort's NodeEventTarget emit() shim did
`new MessageEvent("message", payload)` / `new ErrorEvent("error", err)`,
so the payload was read as an init dict and listeners received
null/undefined instead of the value. It also returned `this` where
node returns a boolean.

Wrap the arg in the correct init dict (MessageEvent {data} for
message/messageerror, CustomEvent {detail} for everything else, matching
node's MessagePort[kCreateEvent]), return listenerCount(type) > 0, and
fix the messageerror extractor to read .data (native messageerror is a
MessageEvent, not an ErrorEvent).
@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Updates the worker thread fake emitter so emit uses single payloads, event-specific MessageEvent or CustomEvent dispatch, corrected messageerror routing, detail-based error extraction, and boolean listener-presence results. Adds regression coverage for these behaviors.

Changes

MessagePort emit behavior

Layer / File(s) Summary
Event routing and payload dispatch
src/js/node/worker_threads.ts
message and messageerror use MessageEvent.data, error uses event.error or event.detail, other events use CustomEvent.detail, and emit returns listener presence.
Emit behavior regression coverage
test/js/node/worker_threads/worker_threads.test.ts
Tests payload identity, event classes, data and detail values, custom events, and true/false return values.

Possibly related PRs

  • oven-sh/bun#31216: Updates related worker thread fake-emitter and MessagePort event behavior.

Suggested reviewers: 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: fixing MessagePort.emit() payload delivery in worker_threads.
Description check ✅ Passed The description covers the change and verification well, even though it uses custom headings instead of the template labels.

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

@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 6:27 PM PT - Jul 25th, 2026

@robobun, your commit 6f947af is building: #81881

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

LGTM — focused Node-compat fix with a clear root cause and thorough test coverage. The one inline nit is a pre-existing listenerCount() gap that the new boolean return exposes; fine as a follow-up.

What was reviewed:

  • emit() event construction now round-trips through the .on() extractors for all four event types; verified against Node's NodeEventTarget.prototype.emit semantics.
  • messageerror extractor switch to .data is correct — native dispatch (MessageEvent.cpp:112) builds a MessageEvent, and Node's MessagePort[kCreateEvent] does the same.
  • injectFakeEmitter is only applied to MessagePort, so the "error"CustomEvent change has no other consumers.
Extended reasoning...

Overview

The PR fixes injectFakeEmitter's emit() in src/js/node/worker_threads.ts, which was constructing MessageEvent/ErrorEvent with the user's raw payload as the init dictionary instead of wrapping it in {data: arg} / {detail: arg}. As a result, .on() listeners received null/undefined and emit() returned this instead of a boolean. It also fixes the messageerror extractor to read .data (native messageerror is a MessageEvent, not an ErrorEvent), removes the now-dead errorEventHandler/EventClass, and returns listenerCount(type) > 0 per Node's NodeEventTarget.prototype.emit. A ~85-line test in the existing worker_threads.test.ts covers message/error/messageerror/custom for both .on() (identity) and addEventListener (event class + payload field), plus the boolean return in listener/no-listener cases.

Security risks

None. This is JS-side event-shape plumbing on MessagePort's emitter shim; no untrusted input parsing, no privilege boundaries, no native code.

Level of scrutiny

Low-to-moderate. The change is ~30 net source lines in a single function plus its extractor table, entirely within the Node-compat layer. injectFakeEmitter is applied only to MessagePort (line 278), so scope is contained. I confirmed native messageerror dispatch creates a MessageEvent (src/jsc/bindings/webcore/MessageEvent.cpp:112), so switching that extractor from .error to .data is correct for native events, not just synthetic emit() — this actually fixes a second, independent bug where .on('messageerror', cb) would have missed real deserialization failures. The "error" case moving from ErrorEvent/.error to CustomEvent/.detail is safe because MessagePort never fires error natively; only emit() produces it, and emit() now constructs the matching CustomEvent.

Other factors

The PR description reports the full worker_threads.test.ts suite plus the vendored Node compat tests (test-worker-message-port.js, test-event-target.js, test-messagechannel.js) pass. Dead code is deleted in the same PR per the review guidelines. The one flagged nit — emit() returning false when only addEventListener listeners exist — is a pre-existing listenerCount() limitation that was already user-observable before this PR (p.addEventListener(...); p.listenerCount(...) already returned 0); the new return value adds a second observation point but is still a strict improvement over return this for the common cases. It's correctly scoped as a follow-up.

Comment thread src/js/node/worker_threads.ts
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 Outdated
Comment thread src/js/node/worker_threads.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.

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

  • 🟡 src/js/node/worker_threads.ts:133-145 — The "MessagePort never fires error natively" justification for routing error through customEventHandler doesn't hold for the fake parentPort: its addEventListener is bound to self, and WebWorker__dispatchError dispatches a real ErrorEvent (with .error, no .detail) on self for uncaught worker exceptions. So parentPort.on("error", cb) inside a worker still fires but now delivers undefined where it previously delivered the thrown value. Not blocking — Node never fires error on parentPort in the first place, so the pre-PR behavior was accidental Bun-only leakage through a TODO'd emulation — but worth noting since the code comment's assumption is incomplete.

    Extended reasoning...

    What the bug is

    functionForEventType("error", listener) was changed from wrapped(errorEventHandler, listener) (which extracts event.error) to the default wrapped(customEventHandler, listener) (which extracts event.detail), justified by the comment "Everything else (including "error", which MessagePort never fires natively) rides CustomEvent.detail". That justification is correct for real MessagePort instances but overlooks the fake parentPort, which inherits the injected .on() from MessagePort.prototype while binding its own addEventListener to self.addEventListener. Uncaught worker exceptions dispatch an ErrorEvent on self, and an ErrorEvent has .error populated but no .detail.

    Code path

    In fakeParentPort() (worker_threads.ts:614-680):

    • fake = Object.create(MessagePort.prototype) — inherits the injected NodeEventTarget methods, including on().
    • fake.addEventListener is defined as an own property equal to self.addEventListener.bind(self) (line ~669).
    • No own on is defined on fake, so parentPort.on(...) reaches the injected on() on the intermediate prototype, which calls register(this, event, listener, functionForEventType(event, listener), undefined)this.addEventListener(event, wrapper)self.addEventListener("error", wrapper).

    In WebWorker__dispatchError (src/jsc/bindings/webcore/Worker.cpp:727-737), on an uncaught worker exception:

    ErrorEvent::Init init;
    init.message = messageStr.isolatedCopy();
    init.error = error;
    ...
    globalObject->globalEventScope->dispatchEvent(ErrorEvent::create(eventNames().errorEvent, init, ...));

    globalObject->globalEventScope is the worker's self, so the wrapper registered above fires with an ErrorEvent whose .error is the thrown value and whose .detail is undefined (ErrorEvent has no detail field).

    Step-by-step proof

    Inside a worker:

    const { parentPort } = require("worker_threads");
    parentPort.on("error", e => console.log("got", e));
    setImmediate(() => { throw new Error("boom"); });
    1. parentPort.on("error", cb) → injected on() (fake has no own on) → functionForEventType("error", cb). Before this PR that returned wrapped(errorEventHandler, cb) (reads event.error); after it returns wrapped(customEventHandler, cb) (reads event.detail).
    2. register(...) calls this.addEventListener("error", wrapper)self.addEventListener("error", wrapper) (own-property override on fake).
    3. throw new Error("boom") reaches the uncaught-exception path → WebWorker__dispatchError dispatches an ErrorEvent on self with init.error = Error("boom").
    4. The wrapper runs: cb(customEventHandler(event)) = cb(event.detail) = cb(undefined). Before this PR it was cb(event.error) = cb(Error("boom")).

    So the callback still fires but now receives undefined instead of the thrown value — a user-observable regression introduced by this PR.

    Why existing code doesn't prevent it

    The new comment's premise ("MessagePort never fires error natively") is what the change relies on, and it holds for every path except the fake parentPort's own-property addEventListener binding to self. Nothing in fakeParentPort() shadows .on(), so it inherits the extractor table this PR changed. The PR's new tests only exercise real MessagePort instances (new MessageChannel().port1), where error events indeed only arrive via emit() as CustomEvent.

    Impact / severity

    Low. The pre-PR behavior was accidental and Bun-specific: in Node, parentPort is a real MessagePort that never fires error, so parentPort.on("error", cb) never fires at all — the fact that it fired in Bun with the thrown value was leakage of self's events through the fake port's binding, and fakeParentPort() is explicitly marked "TODO: parent port emulation is not complete". The listener still fires, only its argument regresses; parentPort.addListener("error", cb) (own property on the fake, unwrapped) still delivers the raw ErrorEvent unchanged. Not a Node-compat break and not worth blocking on.

    How to fix

    Either restore an errorEventHandler branch in functionForEventType that reads event.error ?? event.detail (so both native ErrorEvents and emit()-constructed CustomEvents round-trip), or shadow .on/.once on the fake parentPort so its error wrapper reads event.error. The former is a two-line change and keeps the extractors the single source of truth; the latter keeps the extractor comment accurate for real MessagePorts. Given the fake-parentPort emulation is already TODO'd for replacement, a note in the comment ("except the fake parentPort routes self's ErrorEvent") plus the ?? event.detail fallback is probably the pragmatic choice.

fakeParentPort binds addEventListener to self, so parentPort.on('error')
inside a worker receives the native ErrorEvent from an uncaught
exception. Read .error ?? .detail so both that path and emit()'s
CustomEvent deliver the value.
@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

Good catch on the fake parentPort path. fakeParentPort binds addEventListener to self but inherits the injected .on(), so parentPort.on('error', cb) inside a worker receives the native ErrorEvent from an uncaught exception. f241783 restores an error extractor that reads event.error ?? event.detail, so both that path and emit('error', ...)'s CustomEvent deliver the value. emit('error', ...) still produces a CustomEvent (matching node's NodeEventTarget default) for addEventListener consumers.

@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:134-140 — Collateral change: parentPort.on('error', cb) inside a worker now receives undefined instead of the thrown value when the worker global dispatches a native ErrorEvent for an uncaught exception. fakeParentPort binds addEventListener to self but inherits this on(), so its 'error' listener now unwraps via event.detail (the customEventHandler default) rather than the removed event.error extractor. This was already a Bun-only, non-Node quirk on an admittedly incomplete parentPort emulation, so not worth blocking on — just flagging in case it was unintentional.

    Extended reasoning...

    What changed

    functionForEventType() previously routed 'error' through errorEventHandler (which returned event.error). This PR removes that arm, so 'error' now falls through to the default case → customEventHandler (which returns event.detail). That's correct for the emit() round-trip this PR targets — emit('error', x) now builds a CustomEvent with { detail: x }, and MessagePort itself never fires a native 'error' event.

    But injectFakeEmitter is also inherited by fakeParentPort, which is a different beast.

    The code path

    fakeParentPort() (further down in the same file) creates fake = Object.create(MessagePort.prototype) and then defines an own addEventListener property as self.addEventListener.bind(self) — but does not define an own on. So parentPort.on('error', cb) resolves to the injected on() on the prototype, which calls:

    register(this, 'error', cb, functionForEventType('error', cb), undefined)
       this.addEventListener('error', wrapper, undefined)   // own property → self.addEventListener
       self.addEventListener('error', wrapped(customEventHandler, cb))

    Meanwhile, WebWorker__dispatchError (src/jsc/bindings/webcore/Worker.cpp:737) unconditionally dispatches a native ErrorEvent on globalObject->globalEventScope — i.e. self — for uncaught worker exceptions, before the Web/Node kind switch. That ErrorEvent has .error set to the thrown value and no .detail property.

    Step-by-step proof

    Inside a node:worker_threads worker:

    1. User calls parentPort.on('error', cb).
    2. on is inherited → register(fake, 'error', cb, wrapped(customEventHandler, cb))fake.addEventListener(...) → own property → self.addEventListener('error', wrapper).
    3. Worker code throws uncaught → WebWorker__dispatchError fires ErrorEvent { error: <thrown>, message: ... } on self's event scope.
    4. wrapper(event) runs → cb(customEventHandler(event))cb(event.detail)cb(undefined).

    Before this PR, step 2 produced wrapped(errorEventHandler, cb), so step 4 was cb(event.error)cb(<thrown value>).

    Why existing code doesn't prevent it

    The PR's reasoning — "MessagePort never fires 'error' natively, so 'error' can go through the CustomEvent default" — holds for real MessagePort instances but not for fakeParentPort, whose addEventListener is redirected to the worker global scope. Nothing in functionForEventType() distinguishes the two receivers.

    Impact

    Narrow and Bun-specific. Node's parentPort never emits 'error' for uncaught exceptions (those go to process.on('uncaughtException') in the worker and worker.on('error') in the parent), so this listener pattern is not portable to begin with. The pre-PR behavior was already an undocumented Bun quirk on an emulation the file itself marks // TODO: parent port emulation is not complete, no test covers it, and grep finds no parentPort.on('error' anywhere in the repo. That said, going from "delivers the error" to "delivers undefined" is silently worse for anyone who was relying on the old Bun behavior.

    How to fix (if desired)

    Either keep an 'error' arm in functionForEventType() that reads event.error ?? event.detail (covers both native ErrorEvent and the new CustomEvent from emit()), or — better long-term — stop fakeParentPort from proxying arbitrary events to self at all, which is the real divergence from Node. Given the obscurity, deferring to the parentPort rewrite is reasonable.

@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 current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/js/node/worker_threads.ts`:
- Around line 200-212: Update emit in src/js/node/worker_threads.ts at lines
200-212 to include addEventListener() registrations when computing its boolean
return, while preserving the existing .on()/.once() listener count and dispatch
behavior. Add an addEventListener()-only test in
test/js/node/worker_threads/worker_threads.test.ts at lines 1379-1385 that
asserts emit() returns true.
🪄 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: 776440ce-3f8c-44b3-a118-5a454d5db51f

📥 Commits

Reviewing files that changed from the base of the PR and between 04bb5c4 and f241783.

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

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.

LGTM — the follow-up commits since my earlier note look good.

What was reviewed:

  • emit() now wraps the arg as {data:}/{detail:} so it round-trips through the .on() extractors; verified the messageerror extractor switch to .data matches native MessagePort dispatch.
  • f241783 restores the error extractor as event.error ?? event.detail — checked that this covers both fakeParentPort's native ErrorEvent path and emit('error', ...)'s CustomEvent, including falsy payloads.
  • The unresolved CodeRabbit note on addEventListener-only emit() return is the same pre-existing listenerCount() gap I flagged earlier and the author acknowledged for follow-up; it doesn't block this fix.
Extended reasoning...

Overview

This PR fixes injectFakeEmitter's emit() in src/js/node/worker_threads.ts (the NodeEventTarget shim applied to MessagePort.prototype). Previously it passed the user's payload directly as the event init dictionary (new MessageEvent('message', payload)), so .on() listeners — which read event.data/event.error — received the constructor defaults (null/undefined). It also returned this instead of a boolean. The fix constructs MessageEvent with {data: arg} for message/messageerror, CustomEvent with {detail: arg} for everything else, remaps the messageerror extractor to .data (matching native dispatch and Node's MessagePort[kCreateEvent]), keeps an error extractor reading event.error ?? event.detail for the fakeParentPort path, drops the dead EventClass helper, and returns listenerCount(type) > 0. A new test in worker_threads.test.ts covers all four event kinds for both .on() and addEventListener, plus the boolean return.

Security risks

None. This is a JS-side Node-compat shim adjusting how event payloads are wrapped/unwrapped for MessagePort listeners. No untrusted input parsing, no privilege boundaries, no native code.

Level of scrutiny

Moderate — it touches a builtin module hot path (src/js/), but the change is small (~30 lines net), mechanically clear, and the extractor/constructor pairing is now internally consistent (each emit() branch produces exactly the shape its functionForEventType extractor reads). I traced the fakeParentPort interaction: it overrides addEventListener to target self but inherits the injected .on(), so parentPort.on('error', cb) receives native ErrorEvents — the restored event.error ?? event.detail extractor handles both that and the CustomEvent produced by emit('error', ...). The ?? correctly falls through for CustomEvent (whose .error is undefined) even for falsy payloads like 0/false/null.

Other factors

  • I previously flagged that emit() returns false for addEventListener-only listeners because listenerCount() only reads the JS-side registry. The author acknowledged this as a pre-existing listenerCount() gap and left it for follow-up; the return-value change is still a strict improvement over return this. CodeRabbit's later unresolved note is a duplicate of that same finding and doesn't need separate action.
  • The comment-cop bot's paragraph-length warnings were addressed across a1dbd7f/f999fcaa; the final comments are single-line.
  • The signature change emit(event, ...args)emit(event, arg) matches Node's NodeEventTarget.prototype.emit (single-arg) and the old code only ever used args[0] anyway.
  • Test coverage is thorough: identity assertions (.toBe) for the .on() payload, event-class + property checks for addEventListener, and the no-listener false case.

…Listeners

.on() listeners are wrappers registered via addEventListener, so
events.getEventListeners(port, type) already sees both styles on a real
MessagePort. Fall back to the .on() registry for fakeParentPort (no
EventTarget internal slot). Fixes emit() returning false when the only
listener was registered via addEventListener.
Comment thread src/js/node/worker_threads.ts Outdated
robobun and others added 2 commits July 25, 2026 22:26
events.getEventListeners first checks emitter?.listeners via a prototype
walk, so a user-set MessagePort.prototype.listeners would silently hijack
the count. Call jsFunctionNodeEventsGetEventListeners directly instead,
matching the tamper-resistance of the pre-existing SafeMap path.
Comment thread src/js/node/worker_threads.ts
Comment thread src/js/node/worker_threads.ts
…ning

event.error ?? event.detail reads a property absent from the event's own
prototype in both directions (CustomEvent has no .error, ErrorEvent has no
.detail), so either read walks to Object.prototype. Branching on the
constructor keeps both reads on real prototype accessors.
Comment thread src/js/node/worker_threads.ts
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.

LGTM — the emit() payload/return fix and the follow-up hardening rounds all look correct.

What was reviewed:

  • emit() now wraps the arg in a null-proto init dict and returns listenerCount(type) > 0 before dispatch, matching Node's NodeEventTarget; messageerror extractor now reads .data (native messageerror is a MessageEvent).
  • listenerCount() calls the same jsFunctionNodeEventsGetEventListeners binding events.ts uses; verified the C++ throws ERR_INVALID_ARG_TYPE on a non-JSEventTarget, so the fakeParentPort catch-fallback engages.
  • Prior nits (__proto__: null init dicts, captured _ErrorEvent, .listeners tamper bypass) all applied; the deferred eventNames()/Symbol.hasInstance items are pre-existing/out-of-scope and don't regress anything.
Extended reasoning...

Overview

Fixes injectFakeEmitter's emit() on MessagePort in src/js/node/worker_threads.ts: it was passing the user's payload as the WebIDL init dict (new MessageEvent('message', payload)) so .on() callbacks received null, and it returned this instead of a boolean. The fix wraps the arg in { __proto__: null, data: arg } / { __proto__: null, detail: arg }, moves messageerror to the .data extractor (native messageerror is a MessageEvent), reworks errorEventHandler to branch on a captured _ErrorEvent so fakeParentPort's native ErrorEvent and emit('error')'s CustomEvent both round-trip, and makes listenerCount() read the native EventTarget map via the jsFunctionNodeEventsGetEventListeners binding so emit()'s boolean return counts addEventListener-only listeners. EventClass() is deleted (dead). ~25 source lines, ~110 test lines.

Security risks

None. This is a Node-compat behavioural fix on MessagePort's emitter façade; no auth/crypto/permissions surface. The tamper-resistance angles (prototype pollution on init dicts, .listeners duck-typing, live ErrorEvent global) were raised in earlier rounds and addressed with __proto__: null literals, a direct native binding call, and a module-eval-time captured const. The remaining Symbol.hasInstance vector on _ErrorEvent is the same tamper class as the file's other instanceof uses and is not a privilege boundary.

Level of scrutiny

Medium — built-in JS module in the hostile-environment / hot-path category, but the change is small, mechanical (init-dict shape + return type), and mirrors Node's NodeEventTarget.prototype.emit. I verified the native binding path: JSEventTargetNode.cpp throws on non-JSEventTarget, so the fakeParentPort (an Object.create(MessagePort.prototype) without internal slot) hits the catch and falls back to the SafeMap registry. The $newCppFunction reference is byte-identical to the one already in events.ts.

Other factors

This PR has already been through four review rounds from me plus one from CodeRabbit; every thread is resolved. The new test covers all four event types × both listener styles × the boolean return × the addEventListener-only case × a .listeners tamper case, and the PR description shows fails-on-main / passes-with-fix evidence. The two acknowledged deferrals — eventNames()/removeAllListeners() still reading only the .on() registry (needs a new native binding), and Symbol.hasInstance on the captured ErrorEvent — are strictly-no-worse-than-main and reasonably scoped out.

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

LGTM — the two nits from my last pass are addressed (null-proto init dicts, ErrorEvent captured at module eval). The remaining Symbol.hasInstance angle and the eventNames()/removeAllListeners() inconsistency were reasonably scoped out; neither regresses anything.

What was reviewed:

  • emit() payload delivery + boolean return round-trips through the .on() extractors for message/messageerror/error/custom
  • listenerCount() now reads the native EventTarget map via the same $newCppFunction binding events.ts uses; try/catch fallback covers fakeParentPort
  • messageerror extractor reads .data (native messageerror is a MessageEvent) — verified against the JSMessagePort.cpp event-name wiring
  • Tamper-resistance: init dicts are null-proto; _ErrorEvent captured inside injectFakeEmitter, which runs at module eval
Extended reasoning...

Overview

Fixes MessagePort.prototype.emit() in node:worker_threads: the injected fake-emitter previously passed the user's payload as the WebIDL init dictionary (new MessageEvent("message", payload)), so .on() listeners received the default null/undefined and emit() returned this. The fix wraps the payload correctly ({ __proto__: null, data: arg } / { __proto__: null, detail: arg }), returns listenerCount(type) > 0, moves messageerror to the .data extractor (native messageerror is a MessageEvent), and upgrades listenerCount() to read the native EventTarget listener map so addEventListener-only listeners count. Two files touched: src/js/node/worker_threads.ts (~50 lines net) and a 110-line test block in worker_threads.test.ts.

Security risks

None. This is Node-compat event plumbing; no auth/crypto/permissions/IO surface. The tamper-resistance rounds (null-proto init dicts, captured ErrorEvent, direct native binding for listenerCount) are hardening, not new exposure.

Level of scrutiny

Moderate. It's a built-in src/js/ module (hot-path, tamper-sensitive per REVIEW.md), but the change is localized to injectFakeEmitter and is a straightforward correctness fix with strong test coverage — seven sub-cases exercising both listener styles, all four event kinds, the boolean return with/without listeners, and a .listeners tamper test.

Other factors

This PR has been through four review rounds; all inline threads are resolved. Since my last pass the author applied both requested changes in 2feb39f. The two consciously deferred items — Symbol.hasInstance on the captured ErrorEvent and the eventNames()/removeAllListeners() store mismatch — are pre-existing-class nits I already marked non-blocking; the author's scoping rationale (same tamper class as the file's other bare instanceof URL/Map/Set checks; a proper eventNames() fix needs a native enumeration binding that doesn't exist yet) is sound. The $newCppFunction("JSEventTargetNode.cpp", "jsFunctionNodeEventsGetEventListeners", 1) call is byte-identical to the one already shipping in src/js/node/events.ts. The bug-hunting system found nothing this run.

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

LGTM — the fixes from the last round (__proto__: null init dicts, module-local _ErrorEvent capture) landed in 2feb39f, and the two commits since are empty CI retriggers. The remaining Symbol.hasInstance and eventNames() points were reasonably deferred as out of scope.

What was reviewed:

  • emit() now wraps the payload in the correct init-dict shape and returns listenerCount > 0; extractors round-trip for message/messageerror/error/custom.
  • listenerCount() calls jsFunctionNodeEventsGetEventListeners directly (verified present in JSEventTargetNode.cpp), with a registry fallback for fakeParentPort; the .listeners tamper test guards it.
  • Checked that fakeParentPort.on('error') still receives event.error from native ErrorEvents via the instanceof _ErrorEvent branch.
Extended reasoning...

Overview

Fixes MessagePort.emit() in the injected NodeEventTarget shim (src/js/node/worker_threads.ts): the payload was previously passed as the event's init dictionary rather than wrapped in {data: arg} / {detail: arg}, so .on() listeners received null/undefined, and emit() returned this instead of a boolean. Also fixes the messageerror extractor (native dispatches a MessageEvent, not an ErrorEvent) and upgrades listenerCount() to read the native EventTarget listener map so addEventListener-only listeners are counted, matching Node's NodeEventTarget. ~30 lines of production change plus a 110-line test with seven sub-cases.

Security risks

None material. This is a Node-compat shim over an in-process EventTarget; no auth, crypto, or I/O boundaries. The tamper-resistance concerns raised in earlier rounds (routing listenerCount through a user-overridable .listeners duck-type; init-dict prototype-chain reads; live ErrorEvent global lookup) have all been addressed — listenerCount now calls the C++ binding directly, both init dicts are { __proto__: null, … }, and ErrorEvent is captured into a module-local const. The residual Symbol.hasInstance vector is the same class as the file's pre-existing instanceof URL/Map/Set checks and requires deliberate defineProperty on a builtin; not a regression.

Level of scrutiny

Medium. Built-in JS in src/js/ is hot-path and tamper-sensitive per REVIEW.md, and this file has an explicit tamper-resistance invariant — hence the three prior review rounds. But the change itself is a small, well-understood Node-compat correction with clear before/after semantics, thorough test coverage (identity checks for .on(), event-class + payload-slot checks for addEventListener, boolean return with/without listeners, an addEventListener-only case, and a .listeners tamper case), and passing full-file / node-compat suites per the PR evidence.

Other factors

Since my last review the only substantive commit is 2feb39f, which applied both remaining nits; e34c22d and 6f947af are empty CI retriggers. All inline threads are resolved. The two acknowledged deferrals — eventNames()/removeAllListeners() not seeing addEventListener-only listeners (pre-existing, needs a new native binding) and the Symbol.hasInstance tamper vector (same class as other instanceof uses in the file) — are reasonable scope cuts that don't regress anything. No outstanding reviewer asks.

@robobun

robobun commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author

CI on build #81881 has two unrelated failures:

  • :freebsd: aarch64 build-bun: step failed outside runner (pre-existing build infra, also on main)
  • test/cli/install/bun-security-scanner-matrix-with-node-modules.test.ts on alpine 3.23 aarch64: flaky installer TTY-prompt test (marked flaky by the CI scraper)

Neither touches src/js/node/worker_threads.ts. The gate's fail-before/pass-after check passes on both ASAN and release (robobun/evidence ✓). The worker_threads.test.ts suite (92 tests), message-channel.test.ts, and the node-compat test-worker-message-port.js / test-event-target.js / test-messagechannel.js all pass on every lane that ran them. Ready for review.

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