Skip to content

MessagePort: ref the port when its first 'message' listener is added - #38144

Open
robobun wants to merge 1 commit into
mainfrom
farm/dc1c9629/messageport-first-listener-refs
Open

MessagePort: ref the port when its first 'message' listener is added#38144
robobun wants to merge 1 commit into
mainfrom
farm/dc1c9629/messageport-first-listener-refs

Conversation

@robobun

@robobun robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • port.unref(); port.on("message", f) leaves port.hasRef() false and the process exits with the listener installed. Node prints true and stays alive until the port is closed or unref'd again. Same for addEventListener("message"), once("message"), and for a worker doing this on parentPort (the worker exits before any message can reach it). Node's own test/parallel/test-messageport-hasref.js asserts this (unref(); ref(); unref(); on("message"), then hasRef() === true).
  • Cause: MessagePort::onDidChangeListenerImpl() (src/jsc/bindings/webcore/MessagePort.cpp) only counted listeners, and the listener loop ref is held while m_isRefd && m_messageEventCount > 0. jsUnref() clears m_isRefd and only an explicit .ref() set it again, so after an unref() the first listener took nothing. Node has one ref flag, and setupPortReferencing (lib/internal/worker/io.js) calls port.ref() when the first 'message' listener is added.
  • The onmessage setter (src/jsc/bindings/webcore/JSMessagePort.cpp) covered its own path by calling jsRef() on every callable assignment, which also made port.on("message", g); port.unref(); port.onmessage = f re-ref the port; node leaves it unref'd, that handler being the second listener.
  • Found while fixing the above: a listening port that nobody references is collectable as soon as its peer closes, and a GC can collect it before the posted peer-close notification (which holds only a weak pointer to it, MessagePortPipe.cpp notifyPeerClosed) runs and releases its listener loop ref. The ref then outlived the port and the process could never exit. On main this already happens for on() / addEventListener() ports (repro in the new tests: 20 unreferenced listening ports, peers closed, Bun.gc(), process hangs); onmessage ports were shielded only by the self-reference the setter took, which this change removes.

Fix

  • The first 'message' listener (count 0 to 1 in onDidChangeListenerImpl()) calls setRefd(), the half of jsRef() that sets m_isRefd, so it passes the same gate as .ref(): a closed or transferred port, or one whose peer closed by request, still takes nothing (node closes such a port outright; covered by the existing "ref()/onmessage after the peer closes" test and a new one). m_isRefd now defaults to false, which is also node's state for a fresh port; the first listener is what sets it.
  • The onmessage setter no longer calls jsRef(). Installing a handler registers a 'message' listener, so the hook refs the port when it is the first one and leaves it alone otherwise. The one transition the hook cannot see is a callable handler replacing an installed one (setAttributeEventListener swaps the function in place); node's setter re-registers it (removeListener, then newListener), so replacing the port's only listener refs again even after an unref(), and didSetMessageHandler() does exactly that case. The decision stays keyed on callability, as on main: a non-callable value (null, a string, or an object, which setAttributeEventListener stores but JSEventListener never invokes) unrefs, matching node, whose setter only counts functions.
  • ~MessagePort() releases the listener loop ref if the port still holds one. It is the only release path that does not need the port to still be alive when the peer's close is delivered; m_hasRef cannot be set there since it holds a reference to the port itself. This fixes the latent on()/addEventListener() hang on main and is what lets the setter drop its self-reference safely.
  • Why this is correct: it is node's model. setupPortReferencing refs on the first listener regardless of what ref()/unref() did before, and the setter is an ordinary listener registration to it. Nothing is taken that is not released: the listener ref is still released by unref(), by the count dropping to zero, by close()/transfer/peer close, and now by destruction; the gate keeps it from being taken on a port nothing would ever release. Bun's internal ports (the Worker public port, the messaging hub ports, the stdio ports in src/js/node/worker_threads.ts and src/js/internal/worker/messaging.ts) add their listener first and unref() afterwards, so their behavior is unchanged; the upstream test-worker-ref*, test-worker-parent-port-ref and test-worker-stdio* tests below exercise them.
  • Verified with the new tests in test/js/node/worker_threads/worker_threads.test.ts: a hasRef() matrix over the registration paths (7 rows plus node's test-messageport-hasref sequence fail on main), the peer-closed gate, and spawned scripts plus a worker that must stay alive or exit on their own, including the four collected-port scripts (on(), addEventListener() and the peer-collected one fail on main; the onmessage one passes on main and guards the setter change). 8 of the 10 new tests fail on main (release and debug+ASAN); all pass with the change.
    • worker_threads.test.ts as a whole (132 tests), test/js/web/workers/message-channel, message-event, worker-postmessage-transfer, message-port-closed-leak, message-port-context-destroy-leak and worker-transfer-terminate-stress pass. The context-destroy fixture now calls port1.ref() because onmessage = alone no longer takes the self-reference that test measures.
    • 34 upstream tests pass unchanged: test-messagechannel, test-worker-message-port*, test-worker-message-channel, test-worker-workerdata-messageport, test-worker-message-event, test-worker-messaging, test-worker-onmessage*, test-worker-ref*, test-worker-parent-port-ref, test-worker-stdio*, test-worker-terminate-ref-public-port, test-worker-unref-from-message-during-exit. No upstream test becomes vendorable by itself: test-messageport-hasref also needs the MESSAGEPORT async_hooks resource (worker_threads: MESSAGEPORT async_hooks init, worker-visible warnings, data: URL module formats (+4 tests) #35366) and hasRef() around close() (MessagePort: keep hasRef() true until the 'close' event fires, and false inside it #38019).
  • Scope: the mirror image, where removing the last listener does not release an explicit .ref(), is MessagePort: release an explicit ref() when the last 'message' listener is removed #38009; the two compose (that one clears on the last removal, this one sets on the first add). MessagePort: pin the entangled peer while a side holds a loop ref #36434 contains a similar first-listener change inside a broader GC-pinning fix and is currently conflicting; this is only that piece, against current main. Not copied, and already different on main: node's removeAllListeners() bypasses its own hooks and leaves a listener-less port ref'd, and a non-callable onmessage value assigned to a port without a handler (onmessage = null, onmessage = {}) registers a handler entry and refs the port; in both node keeps the process alive with nothing that can run.

Background

  • A MessagePort "refs" its thread's event loop while it may still have something to deliver: a ref'd port keeps the process (or worker) alive, an unref'd one does not, and hasRef() reports which. Node keeps one flag on the libuv handle: ref()/unref() set and clear it, and setupPortReferencing sets it on the first 'message' listener and clears it on the last removal.
  • Bun holds two things behind that: m_hasRef, the explicit .ref() hold (an event-loop ref plus a reference to the port object itself, so a ref()'d port outlives a collected wrapper the way node's never-collected ports do), and a listener-driven event-loop ref held while m_isRefd && m_messageEventCount > 0. hasRef() reports either being held. m_isRefd is the piece this change turns into node's flag on the add side.
  • onDidChangeListener is the hook EventTarget invokes after a listener was actually added, removed, or the list was cleared; duplicate adds do not invoke it, so the count is exact. Node-style on()/once()/off() on a port are a shim in src/js/node/worker_threads.ts over the native addEventListener/removeEventListener, and the onmessage setter registers a listener through the same EventTarget machinery, which is why one native hook covers every registration form.
  • A port's JS wrapper is kept alive by hasPendingActivity() only while its peer is open or messages are queued for it, so once the peer closes an otherwise unreferenced listening port is garbage. The peer's close is delivered as a task posted to the port's thread that resolves a ThreadSafeWeakPtr to the port; a port collected in between is simply skipped, hence the release in the destructor.
  • MessagePortPipe marks a side ClosedByRequest only for an explicit close(), as opposed to the wrapper being garbage collected; that is what lets setRefd() refuse a port whose peer really closed without making .ref() depend on GC timing.
hasRef() probe: node v26.3.0, bun main, this PR (each row is a fresh channel, peer kept reachable)
scenario on port1 node main this PR
unref; on true false true
unref; addEventListener true false true
unref; addEventListener(object with handleEvent) true false true
unref; once true false true
unref; on; off; on true false true
unref; onmessage = f true true true
on g; unref; onmessage = f false true false
on f; onmessage = g; unref; onmessage = h false true false
onmessage = f; unref; onmessage = g true true true
onmessage = f; onmessage = {} false false false
onmessage = f; unref; onmessage = { handleEvent } false false false
on g; unref; on f false false false
on; unref false false false
on; unref; ref true true true
fresh false false false
unref; on('messageerror') / on('close') false false false
fresh; unref; ref; unref; on; off F,F,T,F,T,F F,F,T,F,F,F F,F,T,F,T,F
unref; onmessage = {} (node quirk, not copied) true false false

Process liveness, with an unref'd timer as the only other work: unref; on and unref; addEventListener stay alive in node and with this PR, exit on main; on g; unref; onmessage = f exits in node and with this PR, stays alive on main. 20 unreferenced listening ports whose peers are closed, followed by Bun.gc(true): node and this PR exit; main exits for onmessage = but hangs for on() and addEventListener(). Rows that differ only on the removal side (ref; on; off and friends) are unchanged here and belong to #38009.

Node's setupPortReferencing calls port.ref() when the first 'message'
listener is added, so a port that was unref()'d before anyone listened is
ref'd again once someone does. Bun only took the listener loop ref while
m_isRefd was still set, so after unref() a later on('message') kept the
port unref'd and the process exited with the listener installed.

The first 'message' listener now goes through the same gated path as
.ref() (setRefd()). The onmessage setter no longer takes a ref of its own:
installing a callable handler is a listener add, so a handler installed
next to other listeners leaves an unref() in force as in node; replacing
the only handler still refs, since node's setter re-registers a replaced
handler; anything non-callable still unrefs.

The listener loop ref is also released when the port itself is destroyed.
A listening port becomes collectable as soon as its peer closes, which can
happen before the posted peer-close notification (a weak pointer) runs, and
the ref then outlived the port and pinned the loop forever. The onmessage
path was shielded from this by the self-reference the setter used to take;
on()/addEventListener() ports were not.
@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: 5 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: c3b743f0-3c6a-4f98-848c-ff669cc165c8

📥 Commits

Reviewing files that changed from the base of the PR and between b7a0431 and dbc535c.

📒 Files selected for processing (5)
  • 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

Status: fix pushed; CI build 94537 is green on every lane that ran (linux glibc/musl/asan, windows), the two macOS test lanes expired before getting an agent and will be re-run, and the one red test (test-cluster-shared-leak.js on Windows aarch64, node:cluster) is unrelated to this change and has been reported separately.

Reproduced on main with node v26.3.0 as the reference: port.unref(); port.on("message", f) gives hasRef() === false and the process exits (node: true, stays alive); the full matrix is in the PR description. The new tests in test/js/node/worker_threads/worker_threads.test.ts fail on main (8 of 10) and pass with this branch on a debug+ASAN build; the related web worker files and 34 upstream port/ref/stdio tests pass unchanged.

Related: #38009 covers the removal side (last listener removed should drop an explicit ref()); the two compose.

@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 MessagePort's event-loop ref-counting semantics in native code (default m_isRefd flip, destructor-time release, removing the setter's jsRef()), and overlaps with #36434 / #38009, a human look would still be worthwhile.

Checked: setAttributeEventListener does swap in place on replace (so didSetMessageHandler() is the right hook for that case, and the double setRefd() on a fresh install is idempotent via m_listenerLoopRefActive); the destructor's updateListenerEventLoopRef() needs a live context, and every path that nulls the context (contextDestroyed(), disentangle()) clears m_isRefd first; internal ports in src/js/node/worker_threads.ts and src/js/internal/worker/messaging.ts add-then-unref, so the default flip does not change them.

Extended reasoning...

Overview

This PR aligns Bun's MessagePort ref semantics with Node's setupPortReferencing: the first 'message' listener now sets m_isRefd (via a new setRefd() extracted from jsRef()), the onmessage setter no longer takes a full jsRef() self-reference, m_isRefd defaults to false instead of true, and ~MessagePort() releases a lingering listener loop-ref so a port collected before its posted peerClosed() runs does not pin the loop forever. It also adjusts one existing leak-test fixture to call .ref() explicitly now that onmessage = alone no longer takes the self-ref that test measures. Ten new tests cover the hasRef() matrix, process-liveness, and the collected-port hang.

Security risks

None identified. This is event-loop lifetime bookkeeping; no untrusted input parsing, auth, or crypto is touched.

Level of scrutiny

High. This is native C++ reference-counting and GC-lifecycle code — precisely the category the review guide flags as most-blocked. The change is small in line count but semantically significant: it flips a default that every MessagePort instance sees, removes a self-reference the onmessage setter previously took, and adds a release in the destructor. Each of these has non-local consequences (internal ports in worker_threads.ts/messaging.ts, the context-destroy leak test, the GC-vs-peerClosed() race), and the PR description itself notes overlap with #36434 (a broader GC-pinning fix that is currently conflicting) and composition with #38009. A maintainer should confirm this piece is the one to land first and that the destructor release is safe on every thread that can drop the last reference.

Other factors

  • The reasoning is exceptionally thorough, the test matrix is comprehensive, and the PR verifies against 34 upstream Node tests plus the full worker_threads.test.ts suite.
  • I traced setAttributeEventListener in EventTarget.cpp: the in-place replaceJSFunctionForAttributeListener path does not fire onDidChangeListener, confirming didSetMessageHandler() is needed for the replace case; on a fresh install the hook fires first (count 0→1, calls setRefd()) and then didSetMessageHandler() calls setRefd() again with count already 1 — idempotent because updateListenerEventLoopRef() gates on m_listenerLoopRefActive.
  • The destructor's updateListenerEventLoopRef() needs scriptExecutionContext() to be non-null to actually unref. Every path that nulls the context (contextDestroyed()close(), disentangle()) clears m_isRefd and releases the ref first while the context is still valid, so the destructor path is only load-bearing when the port is collected with its context still alive — the case the fix targets.
  • Given the interaction with two other open PRs and the subtlety of the ref-counting model, I'm deferring rather than approving.

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 10:05 AM PT - Aug 13th, 2026

@robobun, your commit dbc535c has 1 failures in Build #94537 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 38144

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

bun-38144 --bun

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