Skip to content

MessagePort: release an explicit ref() when the last 'message' listener is removed - #38009

Open
robobun wants to merge 5 commits into
mainfrom
farm/ae639bbb/messageport-last-listener-releases-ref
Open

MessagePort: release an explicit ref() when the last 'message' listener is removed#38009
robobun wants to merge 5 commits into
mainfrom
farm/ae639bbb/messageport-last-listener-releases-ref

Conversation

@robobun

@robobun robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • port.ref(); port.on("message", f); port.off("message", f) leaves port.hasRef() true and the process alive. Node prints false and exits. The same happens for a worker doing this on parentPort (the thread never exits), and for removeEventListener(), removeAllListeners() and a once() listener firing.
  • Cause: the port has two independent loop refs (src/jsc/bindings/webcore/MessagePort.h): m_hasRef, taken by jsRef() (.ref() or a callable onmessage =), and the listener ref, held while m_isRefd && m_messageEventCount > 0. onDidChangeListenerImpl() (MessagePort.cpp) only reconciled the second one, so an explicit ref outlived the listeners. Node has one handle ref flag, and setupPortReferencing (lib/internal/worker/io.js) calls port.unref() unconditionally when the last 'message' listener is removed.
  • Same mechanism, other direction: the onmessage setter (JSMessagePort.cpp) called jsUnref() for every non-callable assignment as its own approximation of that release. port.on("message", f); port.onmessage = null therefore unref'd a port that is still listening, and port.ref(); port.onmessage = null dropped the explicit ref. Node changes nothing in either case.
  • Found while fixing the above, pre-existing on main: MessagePort::contextDestroyed() -> close() released m_hasRef (a self-reference) and kept using the port. When the context dies without a stop phase (a collected ShadowRealm global, a retired test-isolation global) and the port's wrapper is already gone, that self-reference is the last one, and close() reads freed memory (heap-use-after-free in MessagePort::close() from MessagePort::contextDestroyed() <- ~ScriptExecutionContext <- Zig::GlobalObject::~GlobalObject, reproduced on main under ASAN with Malloc=1).

Fix

  • onDidChangeListenerImpl() releases m_hasRef when the 'message' listener count goes from non-zero to zero (Remove or Clear). Every removal path funnels through this hook: off()/removeListener(), removeEventListener(), removeAllListeners() (one removal per listener), once() listeners being removed before they fire, AbortSignal removal, and onmessage = <non-object> via setAttributeEventListener. parentPort is the same class, so workers are covered.
  • m_isRefd is left alone on that transition: with no listeners it has no effect, and keeping it means on(); off(); on() refs the port again, as in node.
  • The onmessage setter keeps jsRef() for a callable value and no longer calls jsUnref() otherwise; the removal itself (if there was one, and it was the last listener) now releases, which is node's rule. A non-callable object is installed as the handler by the attribute setter (and returned by the getter), so it counts as a listener and keeps the port ref'd until it is cleared; node agrees for onmessage = {} on an empty port and differs only on replacing a function with an object (it unrefs there) and on clearing an object (it stays ref'd there), because it only counts functions. Pinned by the test either way.
  • The three copies of the m_hasRef release in close(), disentangle() and jsUnref() become releaseJsRef(), which the hook also uses. It unrefs through the port's ScriptExecutionContext as close() already did (the same per-thread VM jsRef() ref'd), so jsUnref() no longer needs a JSGlobalObject and loses the parameter. Every caller keeps using the port after the release, so the self-reference must never be the last one; releaseJsRef() now asserts that (ASSERT(!hasOneRef())), and the callers hold one: the JS wrapper for the bindings, protectedThis in peerClosed(), disentanglePorts()'s RefPtr, forEachActiveDOMObject()'s RefPtr for stop(), and for the hook EventTarget::removeEventListener (wrapper or the Ref taken by innerInvokeEventListeners / the abort algorithm), the close task (queueTaskKeepingObjectAlive) and disentangle().
  • contextDestroyed() was the one caller without such a reference; it now holds one across close() and ActiveDOMObject::contextDestroyed() (the wrapper-less port is then destroyed when that reference goes away, after its context pointer has been cleared, which is the order ~ActiveDOMObject expects).
  • src/js/node/worker_threads.ts makePortReadable (a worker's process.stdin, and captured worker.stdout/stderr) dropped its port listener as soon as the EOF message arrived. With the release above that also dropped the ref() it takes when reading starts, so a worker whose stdin consumer was paused exited before the buffered data and 'end' were delivered (node and main deliver them). The EOF branch now only pushes null; the stream's 'close' handler already removes the listener and unrefs once the data has been consumed, which is node's kWaitingStreams behavior. The other internal ref() users are unaffected: the Worker public port and the messaging hub port never remove their listener, and the stdio writable unrefs before it drops its ack listener.
  • Why this is correct: it is node's rule (setupPortReferencing in v26.3.0: removeListener(size) { if (size === 0) { stopMessagePort(port); port.unref(); } }, called with the count left after an actual removal), and it leaves no way for an explicit ref to outlive the listeners except an explicit ref() taken afterwards. The stdio change keeps the one internal user that relied on the old timing at its previous (and node's) semantics, and the contextDestroyed() reference makes the lifetime rule the new hook relies on hold for every caller.
  • Verification:
    • test/js/node/worker_threads/worker_threads.test.ts: a hasRef() matrix over the removal paths and the cases that must not release, the removeAllListeners() cases, the onmessage cases (null, string, object, nothing set, other listeners remaining), a spawned process and a worker that must exit on their own, and the paused-stdin delivery test. The first five fail on main (release and debug+ASAN builds); the stdin test passes on main, fails with the native change alone, and passes with the makePortReadable change.
    • test/js/web/workers/message-port-context-destroy-leak.test.ts: the ShadowRealm teardown under ASAN with Malloc=1 (it collects until the realm's global is gone, checked through heapStats(), so the teardown path is known to have run). Against main's MessagePort files it fails on its stderr assertion with the ASAN report above; with the contextDestroyed() change it passes, 10/10 runs.
    • Unchanged and passing on this branch (debug+ASAN): the rest of worker_threads.test.ts (128 tests), message-channel, message-port-closed-leak, message-port-pipe (except its two sanitizer-gated burst tests, whose fixture takes 11 to 16 seconds on this machine with or without the change against a 5 second budget), worker-postmessage-transfer, the broadcastchannel suite, and 40 upstream tests: test-messagechannel, test-worker-message-port*, test-worker-message-channel*, test-worker-workerdata-messageport, test-worker-ref*, test-worker-parent-port-ref, test-worker-terminate-ref-public-port, test-worker-unref-from-message-during-exit, test-worker-onmessage*, test-worker-stdio*, test-worker-no-stdin-stdout-interaction, test-worker-terminate-unrefed, test-worker-event, test-worker-message-event, test-worker-cleanexit*, test-worker-exit-code. The new assertion did not fire anywhere in these.
  • Remaining differences from node, both pre-existing and not changed here: removeAllListeners() in node bypasses its listener hooks and leaves a listener-less port ref'd (the process hangs); bun already released the listener ref there and now releases the explicit one the same way. unref(); on("message", f) re-refs the port in node and not in bun (the add side of the same hook; MessagePort: pin the entangled peer while a side holds a loop ref #36434 addresses it).

Background

  • A MessagePort "refs" its thread's event loop while something may still need it. Node keeps one boolean on the uv handle: ref()/unref() set it, and setupPortReferencing sets it when the first 'message' listener is added and clears it when the last one is removed. hasRef() reads it.
  • Bun models this with two contributions on the native MessagePort: m_hasRef (an event-loop ref plus a self-reference, so the native object outlives its JS wrapper while explicitly ref'd) and a listener-driven event-loop ref. hasRef() reports either being held.
  • The native MessagePort is refcounted; its JS wrapper holds one reference, m_hasRef holds one, and code paths that run on a port take a temporary one (Ref protectedThis) when they may drop one of those. Dropping the last reference destroys the object immediately, so a method that drops a reference and continues needs the caller to hold another.
  • onDidChangeListener is a hook EventTarget invokes after a listener was actually added (Add), actually removed (Remove), or the whole list was cleared (Clear); duplicates and removals of absent listeners do not invoke it. MessagePort uses it to count 'message' listeners.
  • A ScriptExecutionContext is the native per-global object that ports, channels and workers register on. Normal teardown (process exit, worker exit) runs a stop phase over those objects before the context goes away; a global collected on a live VM (ShadowRealm, a test-isolation global) skips that and only notifies them from the context's destructor (contextDestroyed()).
  • node's on()/off()/removeAllListeners() on a port are a shim in src/js/node/worker_threads.ts over the native addEventListener/removeEventListener, which is why one native hook covers the node-style and web-style APIs. The worker stdio streams in the same file are Readable/Writable objects fed by internal MessagePorts; the readable refs its port while it has a consumer so unconsumed data keeps the thread alive.
hasRef() / exit probe: node v26.3.0, bun 1.4.0 (main), this branch

Each row is a fresh MessageChannel with the peer kept reachable; f/g are no-op functions. "exits" means the process exited on its own; "alive" means it was still running 400ms later.

scenario on port1 node main this PR
ref; on; off false, exits true, alive false, exits
on; ref; off false, exits true, alive false, exits
ref; addEventListener; removeEventListener false, exits true, alive false, exits
ref; once; message delivered false, exits true, alive false, exits
ref; onmessage=null (nothing set) true, alive false, exits true, alive
on f; onmessage=null true, alive false, exits true, alive
on f; onmessage=g; onmessage=null true, alive false, exits true, alive
ref; on f; onmessage=null true, alive false, exits true, alive
onmessage={} true false true
onmessage=f; onmessage={} false false true
onmessage={}; onmessage=null true false false
ref; on; removeAllListeners('message') true, alive true, alive false, exits
ref; on; removeAllListeners() true, alive true, alive false, exits
unref; on true, alive false, exits false, exits
ref; onmessage=f; onmessage=null false, exits false, exits false, exits
onmessage=f; onmessage=null false, exits false, exits false, exits
onmessage=f; onmessage='str' false false false
onmessage=f; unref; onmessage=g true, alive true, alive true, alive
ref; on f; on g; off f true, alive true, alive true, alive
ref; off (never added) true, alive true, alive true, alive
ref; removeEventListener (never added) true, alive true, alive true, alive
ref; on close; off close true, alive true, alive true, alive
ref; on messageerror; off messageerror true, alive true, alive true, alive
on; off; on true, alive true, alive true, alive
ref; on; off; on true, alive true, alive true, alive
ref; on; off; ref true, alive true, alive true, alive
ref / on / on; unref / on; unref; ref / unref; ref as node as node as node

Worker stdin probe (parent writes two chunks and ends; the worker pauses in the first 'data' handler and resumes from an unref'd timer once EOF has been received): node and main report both chunks and 'end' and exit 0; the native change alone made the worker exit 0 after the first chunk; with the makePortReadable change it matches node and main again.

Earlier revision of this description

The first revision of this PR contained only the hook release, the setter change and the releaseJsRef() consolidation. Review of that revision turned up the stdio readable's dependence on the old release timing, the unprotected contextDestroyed() path, and the unpinned non-callable-object onmessage behavior; the current revision adds the fixes and tests for all three. Its internal-caller analysis claimed the stdio readable was "done with the port" when it dropped its listener at EOF, which was wrong for a consumer that still has buffered data; that is the makePortReadable bullet above.

…er is removed

Node's setupPortReferencing unref()s a port whenever its last 'message'
listener goes away, regardless of an earlier port.ref(). Bun kept the
explicit ref (m_hasRef) in a separate slot that only unref()/close()/a
peer close released, so `port.ref(); port.on("message", f);
port.off("message", f)` left hasRef() true and the process (or worker
thread) alive.

Release it from onDidChangeListenerImpl when the 'message' listener count
drops to zero. That covers off()/removeListener(), removeEventListener(),
removeAllListeners(), once() listeners firing, AbortSignal removal and
onmessage = null, for MessageChannel ports and parentPort alike.

The onmessage setter no longer calls jsUnref() for a non-callable value:
the removal itself now decides, so clearing a handler that was never set,
or while other 'message' listeners remain, no longer unrefs a port that
is still listening (node leaves it alone in both cases).

The three copies of the m_hasRef release (close(), disentangle(),
jsUnref()) become releaseJsRef(); jsUnref() loses its now unused
JSGlobalObject parameter.
@coderabbitai

coderabbitai Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 9 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: b6942132-0bc9-466c-88f7-b7e394cc9f47

📥 Commits

Reviewing files that changed from the base of the PR and between f1b3a92 and 10b6fde.

📒 Files selected for processing (6)
  • src/js/node/worker_threads.ts
  • src/jsc/bindings/webcore/JSMessagePort.cpp
  • src/jsc/bindings/webcore/MessagePort.cpp
  • src/jsc/bindings/webcore/MessagePort.h
  • test/js/node/worker_threads/worker_threads.test.ts
  • test/js/web/workers/message-port-context-destroy-leak.test.ts

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

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 4:37 AM PT - Aug 13th, 2026

@robobun, your commit 10b6fde is building: #94696

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Status: fixed, review feedback folded in, waiting on CI for 10b6fde.

  • Reproduced on bun 1.4.0 and on a debug build of main: port.ref(); port.on("message", f); port.off("message", f) leaves hasRef() true and the process alive (node v26.3.0: false, exits).
  • Since the first revision (82e4d92), self-review turned up three things, all fixed and tested in b791a81: the worker stdio readable relied on the old release timing (a paused stdin consumer lost buffered data at EOF), contextDestroyed() -> close() could free the port mid-close (pre-existing on main, reproduced under ASAN with a ShadowRealm; close() now runs under a reference and releaseJsRef() asserts the invariant), and non-callable onmessage values were unpinned. Details are in the description, including a probe table against node and main.
  • Tests: test/js/node/worker_threads/worker_threads.test.ts (6 new tests; 5 fail on main, the stdin one fails with the native change alone) and test/js/web/workers/message-port-context-destroy-leak.test.ts (fails on main with the ASAN report, passes here). The rest of those files, the web MessagePort suites and 40 upstream worker/port tests pass unchanged on a debug+ASAN build.
  • CI on 82e4d92: the only non-flaky-tagged failure was the bake dev-server deinit segfault on Windows, a known intermittent (Intermittent ASAN heap-use-after-free in bake dev-server deinit (test/bake/deinitialization.test.ts) #34850) in code this PR does not touch; reported to main-break triage. Everything else was flagged flaky by the annotations and none of it involves MessagePort.
  • The comment-length bot threads were addressed by shortening the comments (99cf238, f2498e9) and are resolved.
  • Not overlapping with the open MessagePort PRs: MessagePort: pin the entangled peer while a side holds a loop ref #36434 covers the add side of the same hook (unref(); on()), MessagePort: fire 'close' on a port that is transferred away #37991 the 'close' event on transfer. Both touch neighbouring lines in MessagePort.cpp; whichever lands second needs a trivial rebase.

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

I reviewed this and didn't find any bugs. Because it changes when deref() runs on the native MessagePort (now reachable from the onDidChangeListener hook, whose call sites in EventTarget touch this afterwards) and reroutes the event-loop unref through ScriptExecutionContext, a human look would still be worthwhile.

What was checked:

  • ScriptExecutionContext::unrefEventLoop() is Bun__eventLoop__refKeepAlive(m_bunVM, -1), so releaseJsRef() releases the same counter the old jsUnref(global) did.
  • Every path that reaches releaseJsRef() via the listener hook holds an independent ref (innerInvokeEventListeners' protectedThis, the abort-signal Ref{*weakThis}, disentanglePorts' RefPtr, the JS wrapper for direct removeEventListener), so the deref() of the self-ref is never the last one before eventListenersDidChange() runs.
  • disentangle() now hits releaseJsRef() twice (once via removeAllEventListeners()Clear, once directly); the second is a guarded no-op.
Extended reasoning...

Overview

The PR makes MessagePort::onDidChangeListenerImpl release the explicit m_hasRef self-ref + event-loop ref when the last 'message' listener is removed, matching Node's setupPortReferencing behavior. It also stops the onmessage setter from calling jsUnref() on non-callable assignment (the removal hook now handles it correctly), and consolidates three inline copies of the m_hasRef release into a new releaseJsRef() helper. jsUnref() loses its JSGlobalObject* parameter since the unref now goes through scriptExecutionContext()->unrefEventLoop(). Five new tests cover the hasRef() matrix, onmessage = null cases, and end-to-end process/worker exit.

Security risks

None. This is event-loop-keepalive bookkeeping for MessagePort; no untrusted input parsing, auth, or data exposure is involved.

Level of scrutiny

High. This is native C++ that changes when deref() is called on a ThreadSafeRefCounted object, reachable from a hook (onDidChangeListener) whose EventTarget call sites (removeEventListener, the Clear loop in removeAllEventListeners) touch this after the hook returns. The safety argument — that m_hasRef == true implies a self-ref was taken by jsRef(), so releasing it can never be the last ref while any caller holds one — is sound, and I verified each hook caller holds an independent ref (innerInvokeEventListeners takes Ref<EventTarget> protectedThis, the abort algorithm wraps in Ref{*weakThis}, disentanglePorts holds RefPtr<MessagePort>, the close task uses queueTaskKeepingObjectAlive, and JS-initiated removals hold via the wrapper). But ref-counting changes in this file have historically been subtle (per the extensive existing comments about GC-thread reads and m_hasRef release ordering), and a mistake here is a UAF or a process hang.

The event-loop-unref rerouting is behavior-preserving: I confirmed ScriptExecutionContext::unrefEventLoop() is exactly Bun__eventLoop__refKeepAlive(m_bunVM, -1), matching what the old jsUnref(global) did via clientData(vm)->bunVM — same per-thread VM.

Other factors

The PR intentionally diverges from Node on removeAllListeners() (Node bypasses its hooks and leaves the port ref'd; Bun releases both refs), documented and pinned by a test. The unref(); on() divergence is pre-existing (#36434). The test coverage is thorough — matrix over all removal paths plus negative cases, spawned-process and worker exit tests — and the PR description includes a full Node-vs-main-vs-branch comparison table. No prior human review comments to address. Given the memory-safety-adjacent nature and the intentional Node divergences, a maintainer familiar with the MessagePort ref model should confirm the design choice.

…t close() during context destruction

makePortReadable dropped its 'message' listener as soon as the EOF message
arrived. Now that removing the last listener also releases the port's
read-time ref(), a worker whose stdin consumer was paused exited before the
buffered data and 'end' were delivered. The 'close' handler already removes
the listener once the stream has drained, so the EOF path only pushes null.

contextDestroyed() is the one close() caller without an outside reference:
a context torn down without a stop phase (a collected ShadowRealm global, a
retired test-isolation global) closes a port that may be alive only through
the self-ref close() releases, and close() kept using the freed port. Hold a
reference across the teardown, and assert in releaseJsRef() that the
self-ref is never the last one.

Tests: a paused-stdin delivery test, the ShadowRealm teardown under ASAN
with Malloc=1, and the non-callable onmessage values in the hasRef matrix.
Comment thread src/js/node/worker_threads.ts Outdated
Comment thread src/jsc/bindings/webcore/JSMessagePort.cpp Outdated
Comment thread src/jsc/bindings/webcore/MessagePort.cpp Outdated
Comment thread src/jsc/bindings/webcore/MessagePort.cpp Outdated
Comment thread src/jsc/bindings/webcore/MessagePort.cpp Outdated
Comment thread src/jsc/bindings/webcore/MessagePort.cpp Outdated
Comment thread src/jsc/bindings/webcore/MessagePort.cpp Outdated
Comment thread src/jsc/bindings/webcore/MessagePort.h 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.

I reviewed this PR and didn't find any bugs. Because it changes native MessagePort ref-counting — adding a deref() reachable from the listener-change hook and reworking when m_hasRef is released across close/disentangle/context-destroy — a human look at the lifetime reasoning would still be worthwhile.

What was reviewed:

  • The new releaseJsRef() path from onDidChangeListenerImpl and whether every hook caller holds its own ref across the deref() — the callers enumerated in the description (EventTarget removal, once-fire, abort algorithm, close task, disentangle) each do.
  • The onmessage = null setter change: removing jsUnref() there is safe because setEventHandlerAttributesetAttributeEventListener funnels through the same hook when a handler was actually removed.
  • The stdio readable in worker_threads.ts deferring port.off("message", ...) from EOF to 'close' — checked that 'close' fires on both natural end and destroy, so the listener is not leaked.
  • The contextDestroyed() protectedThis addition — needed now that close() can drop the last (self-)ref mid-teardown; the new ASAN test covers it.
Extended reasoning...

Overview

This PR aligns Bun's MessagePort ref semantics with Node's setupPortReferencing: when the last 'message' listener is removed, an explicit .ref() (or onmessage=) keepalive is released too. The mechanism is a new releaseJsRef() helper (consolidating three existing copies in close(), disentangle(), and jsUnref()) that is now also invoked from onDidChangeListenerImpl on the >0→0 transition. jsUnref() drops its JSGlobalObject* parameter and unrefs via scriptExecutionContext() (same per-thread VM). The onmessage setter no longer calls jsUnref() for non-callable values (the removal hook handles it). contextDestroyed() gains a Ref protectedThis. On the JS side, the stdio readable keeps its port listener until stream 'close' rather than dropping it at EOF, so buffered data still keeps the thread alive. Six new tests cover the hasRef() matrix, process/worker exit, and the ASAN context-destroy case.

Security risks

None. This is event-loop ref bookkeeping and native object lifetime; no untrusted-input parsing, auth, or boundary crossings.

Level of scrutiny

High. This is native C++ ref-counting on a ThreadSafeRefCounted object where a deref() is now reachable from a new call site (the EventTarget listener hook). Per the repo's review guidance, ref/deref balance and "can this be the last reference" reasoning across every terminal path is exactly what maintainers scrutinize most closely. The PR's argument that every hook caller holds its own reference is sound and is backed by an ASSERT(!hasOneRef()), but that assert is debug-only and the callers span EventTarget internals (once-listener removal inside innerInvokeEventListeners, AbortSignal removal, removeAllEventListeners from disentangle() and the close task). A maintainer should confirm they agree with that enumeration.

Other factors

  • CI on the earlier commit (82e4d92) reported a Windows-x64 segfault in test/bake/deinitialization.test.ts; the latest commit (b791a81) added the protectedThis in contextDestroyed(). Whether that failure was related and is now resolved should be confirmed on the current build.
  • The automated comment-cop bot has flagged several of the new/edited comments as too long; those are outstanding on the thread.
  • Test coverage is thorough (removal-path matrix, negative cases, spawned process + worker exit, ASAN UAF repro), and the description documents verification against the upstream Node test suite. The change is well-motivated and well-argued — it's the native lifetime aspect, not the correctness of the Node semantics, that warrants a human pass.

Comment thread src/jsc/bindings/webcore/JSMessagePort.cpp Outdated
Comment thread src/jsc/bindings/webcore/MessagePort.cpp Outdated
Comment thread src/jsc/bindings/webcore/MessagePort.cpp 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.

I reviewed this PR and the bug-hunting pass found no issues. Because it changes native MessagePort refcounting — adding a new deref() site reachable from the listener-change hook and relying on a caller-holds-a-ref invariant across several paths (bindings, peerClosed, disentangle, the close task, contextDestroyed) — a maintainer should confirm the lifetime reasoning before this lands.

What was checked:

  • releaseJsRef()'s context->unrefEventLoop() is the same Bun__eventLoop__refKeepAlive(..., -1) the old jsUnref(JSGlobalObject*) used, so the event-loop bookkeeping is unchanged.
  • disentangle() now releases via the Clear hook inside removeAllEventListeners() one line earlier than before; disentanglePorts()'s RefPtr still covers it, and the follow-up releaseJsRef() handles the no-listener ref()-only case.
  • The makePortReadable change only defers listener removal to the existing 'close' handler (which already off()s and unref()s), so endFromOwner() and destroy paths still clean up.
Extended reasoning...

Overview

The PR fixes a Node-compat bug where port.ref(); port.on('message', f); port.off('message', f) leaves the port ref'd (Node unrefs). It touches MessagePort.{cpp,h} (the listener-change hook now calls a new releaseJsRef() when the last 'message' listener is removed; three copies of the m_hasRef release are consolidated into that helper; contextDestroyed() gains a Ref protectedThis), JSMessagePort.cpp (the onmessage setter no longer calls jsUnref() for non-callable values; jsUnref() loses its JSGlobalObject* parameter), worker_threads.ts (makePortReadable keeps its listener until the stream closes rather than at EOF), plus six new tests and one new ASAN-gated UAF regression test.

Security risks

None in the traditional sense. The risk here is memory safety: releaseJsRef() calls deref() on this, and the new hook call site means a listener removal can now drop the port's self-reference. The PR asserts !hasOneRef() before the deref() and enumerates the reference each caller holds, and it fixes a pre-existing UAF in contextDestroyed() (reproduced under ASAN with a ShadowRealm). But this is exactly the class of change REVIEW.md calls out as most-blocked: a new early release on a refcounted object, whose safety depends on every reachable caller holding an independent reference.

Level of scrutiny

High. This is native lifecycle code on a ThreadSafeRefCounted object with a self-reference, reachable from user JS through off()/removeEventListener()/removeAllListeners()/once() firing/AbortSignal/onmessage= — i.e., user code can synchronously trigger the new deref(). The ASSERT compiles out in release, so a missed caller would be a UAF in production rather than a crash. The PR description's caller enumeration is thorough and I spot-checked disentangle() and the deferred-close task, but a maintainer familiar with EventTarget's ref discipline (particularly innerInvokeEventListeners and the abort-algorithm path) should confirm.

Other factors

  • Test coverage is strong: a full hasRef() matrix, process- and worker-exit end-to-end tests, a paused-stdin regression for the makePortReadable change, and an ASAN-only ShadowRealm teardown test for the UAF. The PR verified the new tests fail on main and that 40 upstream Node tests still pass.
  • The change intentionally diverges from Node on two edge cases (removeAllListeners() and onmessage = {} transitions), documented in the description's table; these are pinned by tests but are the kind of compat decision a maintainer should sign off on.
  • The comment-cop bot threads are all resolved (comments were shortened in 99cf238/f2498e9).
  • No prior human review; CI on the latest commit is still building.

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