worker_threads: bind this to the port in MessagePort on()/once() listeners - #38068
worker_threads: bind this to the port in MessagePort on()/once() listeners#38068robobun wants to merge 3 commits into
Conversation
…d to the port The on()/once() wrappers that node:worker_threads installs on MessagePort unwrapped the Event and then called the user's listener bare, so the listener ran with this === undefined. EventTarget already invokes the wrapper with this set to the port; forward it, as node does for node-style listeners.
|
Warning Review limit reached
Next review available in: 56 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (1)
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (2)
WalkthroughEvent listener wrappers now preserve their registration target as the callback ChangesWorker listener context
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
|
Updated 11:59 AM PT - Aug 13th, 2026
❌ @robobun, your commit 0e322f0 has 2 failures in
🧪 To try this PR locally: bunx bun-pr 38068That installs a local version of the PR into your bun-38068 --bun |
|
Status: ready for review (head 0e322f0). Reproduced with the script in the PR body ( Change since the first push: the wrapper now binds the object |
There was a problem hiding this comment.
LGTM — two-line this-forwarding fix that makes the on()/once() wrappers transparent, matching Node's NodeEventTarget behavior.
What was reviewed:
- Confirmed both wrappers are regular
functionexpressions (not arrows), sothisis the port EventTarget already passes ascurrentTarget;$callis the tamper-proof intrinsic used elsewhere in this file. - Return value is still forwarded, so async-listener rejection handling in
JSEventListeneris unchanged. - New tests cover
on/oncefor nativeMessageEvent, nativeclose,emit(), andparentPortin a real worker; ordering is deterministic (synchronousemit→ awaited message → awaited close), and the close-listener argument is deliberately not asserted to stay independent of #38060.
Extended reasoning...
Overview
Two-line change in src/js/node/worker_threads.ts's injectFakeEmitter: wrapped() now calls listener.$call(this, run(event)) instead of listener(run(event)), and onceWrapper now calls wrapper.$call(this, ev) instead of wrapper(ev). This forwards the this that EventTarget already invokes the wrapper with (the port, per DOM currentTarget semantics implemented in JSEventListener::handleEvent) through to the user's node-style listener. Two new tests in test/js/node/worker_threads/worker_threads.test.ts cover on/once for message, close, and emit()-dispatched custom events, plus parentPort inside a real worker.
Security risks
None. This is a Node.js compatibility fix that changes only the this binding of user-registered listener callbacks. No new inputs are parsed, no privilege boundaries are crossed, and $call is the tamper-proof primordial already used throughout src/js (e.g., nativeMessagePortClose.$call(this) in the same file).
Level of scrutiny
Low-to-moderate. The production change is two lines, mechanically transparent (forward this through a wrapper), and matches both the DOM spec (currentTarget) and Node's documented NodeEventTarget dispatch (FunctionPrototypeCall(callback, this, arg)). Both changed call sites use regular function expressions, so this is dynamically bound as intended. The return value is still returned, preserving async-listener rejection handling.
Other factors
- Tests await real observable conditions (
Promise.withResolversresolved from inside the listeners) rather than sleeping, and assert an exact call array whose ordering is deterministic:emit()dispatches synchronously, thenpostMessageis awaited, thencloseis awaited. - The close-listener assertions deliberately omit the argument so this stays independent of #38060, as the PR description notes.
- No CODEOWNERS entry covers these paths.
- The PR description documents that the whole test file (124 tests), the upstream
test-worker-message-port*suite, andmessage-channel.test.tsall pass with the debug build, and that both new tests fail before the fix — satisfying the repo's "prove the test fails for the right reason" requirement.
… and cover close(cb), the stand-in parentPort and async rejections
There was a problem hiding this comment.
LGTM — small, well-scoped fix that binds this to the registration target in the injectFakeEmitter wrappers, matching Node's NodeEventTarget semantics.
What was reviewed:
- The
targetcapture approach vs. forwarding the wrapper's ownthis— capturing is correct becausefakeParentPort()forwardsaddEventListenerto the global scope, and the third new test covers exactly that case. onceWrapperstill callswrapper(ev)bare, which is fine sincewrappernow closes overtargetand ignores its ownthis.- The closure now references the port it's registered on — that's a cycle within the same JS heap (port → registry → wrapper → port), so it's GC-collectable and not a leak.
- The four new tests await real events (no sleeps), wire error paths to reject, drain subprocess pipes concurrently, and assert exact values.
The comment-cop bot flag on the 3-line comment above wrapped() looks like a false positive — it's a concise "why not the alternative" note, not a workaround justification.
Extended reasoning...
Overview
This PR fixes this binding for listeners registered via MessagePort.on() / .once() in node:worker_threads. The injectFakeEmitter shim wraps user listeners before passing them to addEventListener, and the wrapper was calling the user's function bare (listener(run(event))), so this was undefined. The fix threads the registration target through functionForEventType → wrapped and calls listener.$call(target, run(event)). once() captures const target = this before building the wrapper. Four new tests in test/js/node/worker_threads/worker_threads.test.ts cover on/once for native message/close events, emit(), close(cb), a real worker's parentPort, the stand-in parentPort used by non-node globalThis.Worker, and preservation of async-rejection forwarding.
Security risks
None. This is a this-binding change in a Node compat shim; no auth, crypto, filesystem, or untrusted-input parsing is touched. The tamper-proof $call intrinsic is used, consistent with the rest of src/js/.
Level of scrutiny
Low-to-medium. The runtime change is a handful of lines in one function (wrapped) plus threading a parameter through two call sites. The design choice — capturing the registration target rather than forwarding the wrapper's own this — is the more robust option and is required for the fakeParentPort() case, where addEventListener is bound to the global scope so EventTarget would otherwise pass self as this. The PR body traces this against Node's kHybridDispatch and Bun's JSEventListener::handleEvent, and the third test asserts thisIsParentPort: true, thisIsGlobal: false for exactly that path. I checked that onceWrapper calling wrapper(ev) bare is still correct (the inner closure already has target captured), and that the internal port.on("message", ...) uses in makePortReadable/makePortWritable don't rely on this and are unaffected.
Other factors
The tests follow the repo's conventions: conditions are awaited via Promise.withResolvers() and once(), error events are wired to reject, the subprocess test drains stdout/stderr/exited concurrently and asserts a combined {stdout, stderr, exitCode} object, and resources are cleaned up in finally. The wrapper now captures a reference to the port, forming a port → listener-registry → wrapper → port cycle, but that's an ordinary intra-heap cycle the GC handles. The only outstanding thread comment is from the automated comment-cop bot flagging the 3-line comment above wrapped(); that comment is short and explains a non-obvious "why this and not the alternative" (which REVIEW.md explicitly asks for), so I'm treating it as a heuristic false positive rather than a blocker.
There was a problem hiding this comment.
LGTM — small, focused Node compat fix.
What was reviewed:
wrapped()now captures the registration target and invokes via$call, matching Node'sNodeEventTargetthis-binding; the return-value forwarding (async rejection → uncaughtException) is preserved.- Checked the
fakeParentPort()path: binding at registration (rather than forwarding EventTarget'sthis) is the right choice there since itsaddEventListenerforwards to the global scope; the third test pins this. - Confirmed the closure over
targetintroduces no new retention — the wrapper is already stored in the port's own listener registry. - The comment-cop feedback was addressed in 0e322f0 (comment is now one line).
Extended reasoning...
Overview
This PR fixes this-binding for node-style on()/once() listeners on MessagePort in node:worker_threads. Previously the wrapper called the user's listener bare (listener(run(event))), so this was undefined; now it captures the object on()/once() was called on and invokes via listener.$call(target, run(event)), matching Node's NodeEventTarget dispatch. The production change is ~6 lines in src/js/node/worker_threads.ts (threading a target parameter through wrapped() → functionForEventType() → on()/once()), plus four new tests in test/js/node/worker_threads/worker_threads.test.ts.
Security risks
None. This is a pure Node.js compatibility fix in a JS shim — no auth, crypto, filesystem, or untrusted-input parsing. The $call intrinsic is the standard tamper-proof call used throughout src/js (500+ occurrences).
Level of scrutiny
Low-to-medium. The change is mechanical parameter threading with a well-defined reference behavior (Node's kHybridDispatch in lib/internal/event_target.js). The one non-obvious design choice — binding the registration target rather than forwarding the wrapper's dispatch-time this — is explained in the PR body and covered by a dedicated test (the stand-in parentPort case, where the two would differ). I verified the wrapper still returns the listener's result, so the async-rejection → uncaughtException path is preserved (test 4 pins this). The new closure over target doesn't change lifetime: the wrapper was already stored in the port's own registry (symbol-keyed on the port) and in EventTarget's listener list, so port ↔ wrapper was already a cycle.
Other factors
- Tests follow repo conventions: await observable conditions (no sleeps), drain subprocess pipes concurrently via
Promise.all, usebunExe()/bunEnv,try/finallycleanup, and assert exact values withtoEqual.oncefromnode:eventsis already imported at the top of the file. - The github-actions comment-cop flagged a long code comment on an earlier revision; commit 0e322f0 shortened it to a single line, so that feedback is addressed.
- No CODEOWNERS entry for
src/js/node/worker_threads.ts. - The bug-hunting system found no issues.
Problem
node:worker_threadsMessagePortwithport.on(...)orport.once(...)runs withthis === undefined(globalThisin sloppy-mode code). Node calls it withthisbound to the port, soport.on("message", function () { this.postMessage(...) })works in node and in bun either throws (strict code) or hits the global object (sloppy code).'close'listeners, to the callback ofclose(cb)(which isonce('close', cb)), to listeners fired throughport.emit(), and toparentPort(aMessagePortin anode:worker_threadsworker).src/js/node/worker_threads.ts(injectFakeEmitter) registers a wrapper throughaddEventListener.wrapped()unwraps the Event and then calls the user's listener bare,listener(run(event)), so the listener gets nothisat all.Fix
on()andonce()pass the object they were called on intowrapped(), and the wrapper callslistener.$call(target, run(event)).functionForEventType()only threads the extra argument through.NodeEventTargetdispatch (lib/internal/event_target.js,kHybridDispatch) invokes a node-style listener asFunctionPrototypeCall(callback, this, arg)wherethisis the target whoseon()registered it. Node'sEventTargethas no propagation, so that is always the object.on()was called on, which is what this binds.thisbun'sEventTargethands the wrapper because the two differ in one case: the stand-inparentPortthatfakeParentPort()builds for a plainglobalThis.Workerthat loadsnode:worker_threads. ItsaddEventListenerforwards to the worker's global scope, soEventTargetwould invoke the listener with the global scope asthis; binding givesthis === parentPortthere as well. For a real port both are the same object.'message', native'close',close(cb), andemit()all end up in the same wrapper. The wrapper still returns the listener's result, so a rejecting async listener is still turned into anuncaughtExceptionbyJSEventListener(same as node).$callis the tamper-proof call used throughoutsrc/js.test/js/node/worker_threads/worker_threads.test.ts, four new tests):on()/once() listeners are called with \this` bound to the port:on+oncefor a nativeMessageEvent, a native closeEvent,close(cb), andemit(). Fails before the fix (everytrueisfalse`).parentPort.on() listeners are called with \this` bound to parentPort`: real worker; fails before the fix.the stand-in parentPort of a non-node Worker also binds \this` to parentPort: thefakeParentPort()path; fails before the fix and also fails if the wrapper forwardsEventTarget'sthisinstead of binding (this` is the global scope there).a rejecting async on()/once() listener is reported as an uncaughtException: pins the return-value forwarding the rewritten lines are responsible for. Passes before and after, and node prints the same output.test/js/node/test/parallel/test-worker-message-port*.js,test-worker-message-channel.js,test-worker-message-event.js,test-worker-messaging.js,test-worker-onmessage.js,test-worker-parent-port-ref.js,test-worker-workerdata-messageport.jsall exit 0;test/js/web/workers/message-channel.test.tspasses.'close'listener receives as its argument in this same function and is independent of this change (the new tests do not assert the close listener's argument). MessagePort: implement NodeEventTarget on the native listener list #35811 rewrites the shim natively and would bindthisas a side effect; this PR is the small fix for the shim as it exists today.Background
MessagePortin bun is WebCore'sEventTarget-based port.node:worker_threadsadds node's emitter-style surface (on,once,off,emit,listenerCount, ...) to its prototype ininjectFakeEmitter..on(type, fn)does not storefnitself; it registers a small wrapper viaaddEventListenerthat converts the Event into the value a node listener expects (MessageEvent.datafor'message', and so on) and then callsfn.MessagePortis aNodeEventTarget: anEventTargetwith node-style methods on top. A node-style listener receives the unwrapped value instead of an Event and is called withthisbound to the target.fakeParentPort()(same file): in a plain webWorkerthere is no parentMessagePort, soparentPortis an object created fromMessagePort.prototypewhoseaddEventListener/postMessageforward to the worker's global scope. It inherits the shim'son()/once().Repro
node v26.3.0: every line prints
true.bun before this change:
addEventListenerprintstrue, everyon/once/emitline printsfalse.bun with this change: every line prints
true.First version of this PR
The first push forwarded the
thisthatEventTargetinvokes the wrapper with (listener.$call(this, ...)). That is the port for a realMessagePort, but for the stand-inparentPortof a plainglobalThis.Workerit is the worker's global scope, since that object registers its listeners there. Binding the registration target covers both, at the same size; the stand-in test was added to pin it.