Skip to content

MessagePort: fire 'close' on a port that is transferred away - #37991

Open
robobun wants to merge 7 commits into
mainfrom
farm/c95ae772/messageport-close-on-transfer
Open

MessagePort: fire 'close' on a port that is transferred away#37991
robobun wants to merge 7 commits into
mainfrom
farm/c95ae772/messageport-close-on-transfer

Conversation

@robobun

@robobun robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • A MessagePort listed in a transfer list never emits 'close' on the sender's side. Node emits it: port.on("close", ...) followed by other.postMessage(port, [port]) prints in node and prints nothing in bun. Same for worker.postMessage(port, [port]), new Worker(file, { workerData, transferList: [port] }) and parentPort.postMessage(port, [port]) inside a worker. This is the port1 closed line of Unexpected MessagePort+Worker behavior on Bun vs NodeJS #19862 that is still missing on main.
  • Cause: MessagePort::disentangle() (src/jsc/bindings/webcore/MessagePort.cpp), which every transfer path ends in, called removeAllEventListeners() and observeContext(nullptr) synchronously. The deferred close task that close() queues was never queued for a transfer, and after those two calls nothing could have been dispatched on the object anyway.

Fix

  • The tail of close() (queue a task that dispatches 'close' and then removes the listeners) becomes queueCloseEvent(); disentangle() calls it instead of removing the listeners and leaving the context. The pipe side is still handed to the receiver, so the peer is not notified and the channel keeps working through the received port; only the transferred-away object fires, once (m_closeEventDispatched).
  • disentangle() no longer detaches the object from its ScriptExecutionContext: the task dispatches through that context (EventTarget dispatch asserts one), and JSMessagePortOwner::isReachableFromOpaqueRoots only honors the task's pending activity while isContextStopped() is false, so leaving early would let a GC collect the wrapper and the listeners before the event fires. A closed port already stays attached until it is collected; a transferred one now does the same. Nothing else pins it afterwards (heap count of MessagePort returns to baseline in the new test).
  • Consequence of that: "transferred away" can no longer be recognized by a null context, only by m_isDetached. Everything that tells a dead object from a live one already keys on it (tryTakeMessage, peerClosed, dispatchOneMessage, dispatchEvent, virtualHasPendingActivity, jsRef, disentanglePorts, the transfer-list checks in SerializedScriptValue.cpp and Worker.cpp; stop()/contextDestroyed() go through close(), which returns early on it). The one place where the context used to be a second guard is tryTakeMessage (receiveMessageOnPort): the transferred-away object still refers to the pipe side its receiver now owns, so its isEntangled() check is what keeps it from taking the receiver's messages. That check gets a comment and is pinned by the local test (a message queued for the received port, receiveMessageOnPort(transferredAway) is undefined, the received port gets the message). Node returns undefined there as well once the port's 'close' has fired (v26.3.0 segfaults if it is called in the same tick as the transfer, so the test asserts it after the event).
  • Matches node: transferring a port is MessagePort::TransferForMessaging(), which is Close(); return Detach(); (node_messaging.cc#L921-L924); the handle's close callback then dispatches the close event on the JS object (io.js#L171-L174). So node's event is asynchronous, reaches listeners added right after the postMessage call, and does not touch the peer. Bun fires it at the same point it already fires close()'s event (a task: after the calling code and its microtasks, before setImmediate, whereas node fires both in the uv close phase, after setImmediate); the tests wait two setImmediate turns so the same scripts pass under node.
  • Every transfer entry point goes through disentangle() (MessagePort::postMessage, Worker::postMessage, the worker-global postMessage, the Worker constructor's transferList), so they are all covered by the one change. The ports bun transfers internally when constructing a node:worker_threads worker have no listeners; they now each queue one no-op task.
  • Verified with:
    • test/js/node/worker_threads/worker_threads.test.ts, new describe("a port transferred away fires 'close' on the sender's object"): local postMessage (listener order, listener added after the transfer, peer untouched, receiveMessageOnPort on the transferred-away object, channel still works in both directions, no second event when the channel really closes), a process whose last statement is the transfer (event fires, process exits on its own), and the three Worker paths (constructor transferList, worker.postMessage, worker-side parentPort.postMessage), each also passing a message through the port the other side received. 3 fail on the release build, pass with the fix; whole file 125 pass.
    • test/js/web/workers/message-channel.test.ts: 50 transferred ports made unreachable and GC'd while the event is pending all fire, and are collected afterwards. Fails before (0 fired), passes after.
    • test/js/web/workers/worker-postmessage-transfer.test.ts: Worker#postMessage and worker-side self.postMessage, with addEventListener("close"); the peers stay open and both received ports carry a message. Fails before, passes after.
    • message-port-pipe, message-port-closed-leak, message-port-context-destroy-leak, message-event, structured-clone, worker-transfer-list, worker-transfer-terminate-stress test files and the 26 upstream test-worker-message-* / test-messagechannel files in test/js/node/test/parallel pass. Node's own suite has no case for this event, so no previously failing upstream test is enabled by this.
  • Related open PRs touching MessagePort.cpp: structured clone: re-check the transfer list after serializing so a failed transfer detaches nothing #37966 (transfer-list re-validation) only edits postMessage() and is unaffected by this. node:worker_threads: receiveMessageOnPort drains the retained queue after close() #33638 (receiveMessageOnPort after close()) already conflicts with main on its own, and both of its MessagePort.cpp hunks need rework on top of this one: its m_pipe->close() has to stay specific to close() rather than go into queueCloseEvent(), which disentangle() also queues (the channel-intact tests here fail if it is put there), and it can no longer drop tryTakeMessage's isEntangled() check on the grounds that a transferred-away port has no context (the receiveMessageOnPort assertion here fails if it does).

Background

  • A MessageChannel in bun is one MessagePortPipe with two sides; each JS MessagePort object owns one side. Transferring a port (disentangle()) detaches the object from its side and ships the side to the receiver, which wraps it in a new MessagePort object. The old object is inert from then on; the peer is still entangled, now with the new object.
  • The same native MessagePort backs both globalThis.MessageChannel and node:worker_threads (the module adds on()/once() over addEventListener), and its 'close' event already follows node (the closing port fires it too, node:worker_threads: +48 Node.js tests passing — MessagePort, stdio, SHARE_ENV, exit codes, transfer semantics, postMessageToThread + inspector #31216). The HTML spec has no event for the transferred-away object; in bun it is the same object either way, so it fires for both APIs.
  • ActiveDOMObject::queueTaskKeepingObjectAlive posts a task on the object's ScriptExecutionContext and holds a pending activity on the object until it runs. hasPendingActivity() is what keeps a wrapper (and therefore the JS listener functions reachable only through it) alive across GC, but the wrapper's GC hook ignores it once the object has no context.
Repro scripts and output (node v26.3.0, bun 1.4.0 release, this branch)

Local channel, the scenario of the new postMessage to a local port test:

import { MessageChannel, receiveMessageOnPort } from "node:worker_threads";
const tick = () => new Promise(r => setImmediate(r));
const { port1, port2 } = new MessageChannel();
const carrier = new MessageChannel();
const events = [];
port1.on("close", () => events.push("port1 close (listener added before the transfer)"));
port2.on("close", () => events.push("port2 close"));
carrier.port1.postMessage(port1, [port1]);
port1.on("close", () => events.push("port1 close (listener added after the transfer)"));
events.push("postMessage returned");
await tick(); await tick();
port2.postMessage("hello");
events.push("receiveMessageOnPort(port1): " + receiveMessageOnPort(port1));
const received = await new Promise(resolve => carrier.port2.once("message", resolve));
events.push("received port got: " + await new Promise(resolve => received.once("message", resolve)));
received.postMessage("hi back");
events.push("port2 got: " + await new Promise(resolve => port2.once("message", resolve)));
received.on("message", () => {});
const receivedClosed = new Promise(resolve => received.once("close", resolve));
port2.close();
await receivedClosed;
await tick(); await tick();
console.log(events.join("\n"));
carrier.port1.close(); carrier.port2.close();

node v26.3.0 and this branch print the same thing:

postMessage returned
port1 close (listener added before the transfer)
port1 close (listener added after the transfer)
receiveMessageOnPort(port1): undefined
received port got: hello
port2 got: hi back
port2 close

bun 1.4.0:

postMessage returned
receiveMessageOnPort(port1): undefined
received port got: hello
port2 got: hi back
port2 close

Worker paths (the script from the transfers to and from a Worker test; lines sorted, since the routes complete independently and node orders the two parent-side close lines the other way round from bun). node v26.3.0 and this branch print all six and exit 0:

parentPort.postMessage: close
parentPort.postMessage: received port works
transferList: close
transferList: received port works
worker.postMessage: close
worker.postMessage: received port works

bun 1.4.0 prints only the three received port works lines and exits 0.


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

fails on main (without fix)
ASAN without fix: BUILD FAILED (no junit output)
$ 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 test/js/web/workers/message-channel.test.ts test/js/web/workers/worker-postmessage-transfer.test.ts
error: bindgenv2 emitted unexpected output type: /workspace/bun/build/debug/codegen/GeneratedSocketConfigBinaryType.h, /workspace/bun/build/debug/codegen/GeneratedSocketConfigHandlers.h, /workspace/bun/build/debug/codegen/GeneratedSocketConfig.h, /workspace/bun/build/debug/codegen/GeneratedSocketConfigTLS.h, /workspace/bun/build/debug/codegen/GeneratedALPNProtocols.h, /workspace/bun/build/debug/codegen/GeneratedSSLConfig.h, /workspace/bun/build/debug/codegen/GeneratedSSLConfigFile.h, /workspace/bun/build/debug/codegen/GeneratedSSLConfigSingleFile.h, /workspace/bun/build/debug/codegen/GeneratedFakeTimersConfig.h
error: script "bd" exited with code 1
__F:-1:S:0

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

test/js/web/workers/message-channel.test.ts:
(pass) simple usage [0.25ms]
(pass) transfer message port [0.41ms]
(pass) transfer array buffer [0.12ms]
(node:108433) Warning: The target port was posted to itself, and the communication channel was lost
(Use `bun --trace-warnings ...` to show where the warning was created)
(pass) non-transferable [1.84ms]
(pass) transfer message ports and post messages [0.40ms]
(pass) message channel created on main thread [6.38ms]
(pass) message channel created on other thread [5.59ms]
(node:108433) Warning: The target port was posted to itself, and the communication channel was lost
(pass) many message channels [1.42ms]
(pass) gc [1.82ms]
(pass) cloneable and transferable equals [3.11ms]
(pass) cloneable and non-transferable equals (BunFile) [0.43ms]
(pass) cloneable and non-transferable equals (net.BlockList) [4.86ms]
(pass) a pending close event survives GC after the port becomes unreachable [18.70ms]
(pass) a transferred-away port's pending close event survives GC after the port becomes unreachable [33.34ms]
(pass) a close event from the peer survives GC of the unreachable port [6.44ms]
(pass) 
... (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 test/js/web/workers/message-channel.test.ts test/js/web/workers/worker-postmessage-transfer.test.ts
bun test v1.4.0 (0b5c0bf11)

test/js/web/workers/message-channel.test.ts:
(pass) simple usage [19.04ms]
(pass) transfer message port [19.13ms]
(pass) transfer array buffer [9.64ms]
(node:117918) Warning: The target port was posted to itself, and the communication channel was lost
(Use `bun-debug --trace-warnings ...` to show where the warning was created)
(pass) non-transferable [76.53ms]
(pass) transfer message ports and post messages [28.51ms]
(pass) message channel created on main thread [195.29ms]
(pass) message channel created on other thread [261.14ms]
(node:117918) Warning: The target port was posted to itself, and the communication channel was lost
(pass) many message channels [68.15ms]
(pass) gc [129.00ms]
(pass) cloneable and transferable equals [195.92ms]
(pass) cloneable and non-transferable equals (BunFile) [20.11ms]
(pass) cloneable and non-transferable equals (net.BlockLis
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 1358ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/41] gen bindgenv2
[2/36] gen cpp.rs (cppbind)
[3/36] gen generated_host_exports.rs
generated_host_exports.rs: 93 exports (host=3, lazy=10, generic=80, rust=0); 239 extern-C blocks audited
[4/36] gen JS modules (bundle-modules)
Preprocess modules (15232ms)
Bundle modules (118ms)
Postprocesss modules (203ms)
Bundle Functions (1367ms)
Generate Code (41ms)

[17.00s] Bundled "src/js" for production
  2626 kb
  197 internal modules
  13 native modules
  91 internal functions across 17 files
[4/27] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

�[1m�[92m   Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m   Compiling�[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
�[1m�[92m   Compiling�[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr)
�[1m�[92m   Compiling�[0m bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl_sys)
�[1m�[92m   Compiling�[0m bun_safety v0.0.0 (/workspac
... (truncated)
diff hotspot
src/jsc/bindings/webcore/MessagePort.cpp           |  29 ++---
 src/jsc/bindings/webcore/MessagePort.h             |   4 +-
 test/js/node/worker_threads/worker_threads.test.ts | 132 +++++++++++++++++++++
 test/js/web/workers/message-channel.test.ts        |  35 +++++-
 .../workers/worker-postmessage-transfer.test.ts    |  57 +++++++++
 5 files changed, 237 insertions(+), 20 deletions(-)

gate history · 1 passed · 1 rejected · iteration 1

evidence per changed file
file                                                     reads  edits  tests
src/jsc/bindings/webcore/MessagePort.cpp                     6     13      0
src/jsc/bindings/webcore/MessagePort.h                       1      4      0
test/js/node/worker_threads/worker_threads.test.ts           7      5      0
test/js/web/workers/message-channel.test.ts                  4      6      0
test/js/web/workers/worker-postmessage-transfer.test.ts      2      2      0

Node closes the handle of a port listed in a transfer list, so the
sender's port object emits 'close' (asynchronously) while the channel
itself continues in the receiver's new port. disentangle() removed the
listeners and left the context synchronously, so nothing was ever
dispatched on that object. It now queues the same deferred close task
close() uses and stays attached to its context until collected, like a
closed port, so the pending task keeps the wrapper and its listeners
alive until the event has fired.
@robobun

robobun commented Aug 13, 2026

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

@robobun, your commit 0b5c0bf has 2 failures in Build #94607 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 37991

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

bun-37991 --bun

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Status: fix is up in this PR (code head 0aa542e; the commits after it are empty re-run commits), review threads resolved.

Reproduced on main (f426a8e) and the 1.4.0 release with a port in a postMessage transfer list, a worker.postMessage transfer list, a Worker constructor transferList, and a worker-side parentPort.postMessage transfer list: the sender's port object never emits 'close'; node v26.3.0 emits it in all four cases. Scripts and outputs are in the PR description.

Fix: MessagePort::disentangle() now queues the same deferred close task close() queues. Tests in worker_threads.test.ts, message-channel.test.ts and worker-postmessage-transfer.test.ts fail on the release build and pass with the change; the same scripts produce identical output under node.

CI note: build 94455 had one real red, worker-transfer-terminate-stress.test.ts on the x64 ASAN lane (Unchecked JS exception: handleTraps @ VMTraps.cpp:443 ... unchecked as of JSC__JSModuleLoader__loadAndEvaluateModule, a terminate() landing during worker startup). It is not from this diff: with this branch's MessagePort.cpp/.h swapped back to main's versions and the test run under load with BUN_JSC_validateExceptionChecks=1, the identical abort reproduces (3 of 180 runs; 1 of 36 on this branch). Reported separately as a main break. Every other lane passed; the macOS lanes have not been getting agents on any build.

@coderabbitai

coderabbitai Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 6b874fdb-e237-4826-acfd-953727c8f7f2

📥 Commits

Reviewing files that changed from the base of the PR and between 900c40b and 3ae5416.

📒 Files selected for processing (5)
  • 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-channel.test.ts
  • test/js/web/workers/worker-postmessage-transfer.test.ts

Walkthrough

Changes

MessagePort close lifecycle

Layer / File(s) Summary
Deferred close lifecycle
src/jsc/bindings/webcore/MessagePort.*
close() and disentangle() queue asynchronous close events. Transferred ports retain listeners and context state until event dispatch.
Close-event regression coverage
test/js/node/worker_threads/worker_threads.test.ts, test/js/web/workers/message-channel.test.ts, test/js/web/workers/worker-postmessage-transfer.test.ts
Tests cover transfer paths, event ordering, continued channel use, process exit, duplicate suppression, and garbage collection.

Suggested reviewers: jarred-sumner, 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 and concisely describes the main change: firing a close event when a MessagePort is transferred away.
Description check ✅ Passed The description explains the problem, fix, behavior, scope, background, and verification results in substantial detail.

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

@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: 3

🤖 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 `@test/js/node/worker_threads/worker_threads.test.ts`:
- Around line 1301-1318: The transfer tests must exercise receiver endpoints,
not only sender-side detachment. In
test/js/node/worker_threads/worker_threads.test.ts:1301-1318, preserve each peer
and add message round trips through ports received via workerData,
Worker.postMessage(), and parentPort.postMessage(); in
test/js/web/workers/worker-postmessage-transfer.test.ts:147-176, add a worker
handler that receives and uses the parent-transferred port, and round-trip
through the port received by the main thread. Cover the complete sibling-API and
alternate-mode matrix.
- Around line 1239-1257: Replace fixed setImmediate waits with promises resolved
by the expected close events. In
test/js/node/worker_threads/worker_threads.test.ts lines 1239-1257, create
close-event promises before the relevant transfer/close operations and await
both before asserting event order or counts. In
test/js/web/workers/worker-postmessage-transfer.test.ts lines 152-172, resolve
worker and main-thread assertions from their close listeners. In
test/js/web/workers/message-channel.test.ts lines 373-375, resolve the GC test
promise when fired reaches 50 and await it instead of four event-loop turns.

In `@test/js/web/workers/message-channel.test.ts`:
- Around line 359-360: Replace the dynamic CommonJS require in the heap
statistics setup with a module-scope import of heapStats from bun:jsc, then keep
count using that imported symbol. Do not otherwise change the MessagePort
counting behavior.
🪄 Autofix

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: 61d132b5-6bd0-4d5b-a6dd-588a4439ce9a

📥 Commits

Reviewing files that changed from the base of the PR and between 2a3b9c5 and 900c40b.

📒 Files selected for processing (5)
  • 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-channel.test.ts
  • test/js/web/workers/worker-postmessage-transfer.test.ts

Comment thread test/js/node/worker_threads/worker_threads.test.ts
Comment thread test/js/node/worker_threads/worker_threads.test.ts
Comment thread test/js/web/workers/message-channel.test.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.

I reviewed this PR and didn't find any bugs. Because it changes ActiveDOMObject lifetime behavior in GC-sensitive C++ (a transferred-away port now stays attached to its ScriptExecutionContext instead of calling observeContext(nullptr)), a human look would still be worthwhile.

What was reviewed:

  • queueCloseEvent() extraction is a pure refactor of close()'s tail; m_closeEventDispatched prevents a double fire when the peer later closes.
  • Staying attached to the context matches what close() already does, and contextDestroyed()/stop() on a detached (m_isDetached) port both no-op through close()'s early return.
  • Loop-ref accounting after transfer: m_isRefd = false releases the listener ref via updateListenerEventLoopRef(); jsRef() and addEventListener('message')'s re-attach both gate on isEntangled(), so no ref is re-taken while listeners linger until the task runs.
  • GC-safety: queueTaskKeepingObjectAlive's pending-activity count keeps hasPendingActivity() true independently of virtualHasPendingActivity() (which returns false once m_isDetached); the new heap-count test bounds retention afterwards.
Extended reasoning...

Overview

This PR makes a MessagePort fire 'close' on the sender's object when it is transferred away, matching Node.js. The core change is in src/jsc/bindings/webcore/MessagePort.cpp: the tail of close() that queues a deferred close-event task is extracted into queueCloseEvent(), and disentangle() now calls it instead of synchronously calling removeAllEventListeners() and detaching from the ScriptExecutionContext via willDestroyActiveDOMObject/willDestroyDestructionObserver/observeContext(nullptr). Three test files gain coverage for local transfers, worker transfers in both directions, GC survival of the pending event, and post-fire collectability.

Security risks

None identified. This is Node.js-compat event dispatch on an object that is already inert after transfer; no untrusted input parsing, auth, or crypto is involved.

Level of scrutiny

High. This is JSC-bindings C++ that touches wrapper-lifetime and GC visibility: it removes an explicit observeContext(nullptr) and the associated context-observer removals from disentangle(), relying instead on the same "stay attached until collected" behavior that close() already has. I traced through JSMessagePortOwner::isReachableFromOpaqueRoots (gates on !isContextStopped() && hasPendingActivity()), ActiveDOMObject::hasPendingActivity() (m_pendingActivityInstanceCount || virtualHasPendingActivity()), and queueTaskKeepingObjectAlive (holds a PendingActivity until the task runs), and the reasoning holds: the pending-activity count keeps the wrapper alive across GC while the task is queued, and once it runs the port is collectable (m_isDetached short-circuits virtualHasPendingActivity()). I also checked that leaving the port registered as an ActiveDOMObject/destruction observer is safe: contextDestroyed()close() no-ops on m_isDetached, and close() never detached from the context either, so this is not a new lifetime shape.

Other factors

The PR is thoroughly tested (listener-order, listener-added-after-transfer, peer-untouched, channel-still-works, process-exit, all four transfer entry points, a 50-iteration GC stress with Bun.gc(true), and a heapStats retention bound). Loop-ref bookkeeping after the deferred removeAllEventListeners() was checked: m_isRefd is cleared before the task, so updateListenerEventLoopRef() releases the listener ref immediately, and jsRef()/addEventListener('message')'s re-attach both gate on isEntangled() so lingering listeners cannot re-ref the loop. The one thing I would want a maintainer to sanity-check is the removal of the explicit willDestroyActiveDOMObject/willDestroyDestructionObserver calls — the argument that closed ports already behave this way is sound, but removing explicit teardown from a transfer path in wrapper-lifetime code is exactly the kind of change the repo's review guidelines flag for close reading.

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

@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::disentangle() to keep the transferred-away object attached to its ScriptExecutionContext (dropping the explicit willDestroyActiveDOMObject/observeContext(nullptr) teardown) and relies on queueTaskKeepingObjectAlive for wrapper liveness, a human look at the GC/lifetime implications would still be worthwhile.

What was reviewed:

  • queueCloseEvent() factoring and m_closeEventDispatched guarding a double fire when the received port later closes — covered by the local-channel test.
  • virtualHasPendingActivity() returns false once m_isDetached is set, so the only post-transfer pin is the queued task's pending activity; the new heapStats test bounds retention after it runs.
  • addEventListener("close") on an already-detached port skips registerCloseContext (guarded by isEntangled()), so the late listener just receives the queued event.
  • contextDestroyed() on a now-still-attached disentangled port: close() no-ops on m_isDetached, then falls through to ActiveDOMObject::contextDestroyed() — same as a close()d port.
Extended reasoning...

Overview

This PR makes a transferred-away MessagePort fire 'close' on the sender's object, matching Node's TransferForMessaging() semantics. The native change is small but structural: the tail of close() is factored into queueCloseEvent(), and disentangle() now calls it instead of synchronously calling removeAllEventListeners() and detaching from the ScriptExecutionContext via willDestroyActiveDOMObject / willDestroyDestructionObserver / observeContext(nullptr). Three test files gain coverage for the local-channel path, all three worker-transfer routes, the web Worker API in both directions, and a GC survival + collection bound test.

Security risks

None identified. This is event-dispatch timing and object-lifecycle management for an existing API; no untrusted-input parsing, auth, or crypto paths are touched.

Level of scrutiny

High. MessagePort transfer is on the hot path of every worker communication, and the change alters when a disentangled port leaves its context's ActiveDOMObject / destruction-observer lists (previously: at disentangle time; now: at destruction time, same as a close()d port). That is a deliberate, well-argued decision — the queued close task dispatches through the context, and JSMessagePortOwner::isReachableFromOpaqueRoots only honors pending activity while the object has a context — but it changes GC-visible lifetime and the set of objects the context iterates on stop/destroy. The PR description and the new heapStats test address the leak concern directly, and the m_isDetached early-return in virtualHasPendingActivity() means the object itself does not self-pin after the task runs. Still, this is exactly the class of change (JSC GC interaction, queueTaskKeepingObjectAlive, ActiveDOMObject lifecycle) that the repo guidelines flag for careful maintainer review, and it interacts with two other open PRs touching neighboring lines (#33638, #37966).

Other factors

The bug-hunting system found nothing. All CodeRabbit and comment-cop threads are resolved (comments were shortened in 3ae5416 / 8b56966; the receiver-round-trip and module-scope-import feedback was applied in e05767e). Test coverage is thorough — event ordering, listener-added-after-transfer, peer untouched, channel still works, no double fire, process-exit behavior, GC survival and post-fire collection, and every disentangle entry point (MessagePort::postMessage, Worker::postMessage, worker-global postMessage, Worker constructor transferList). The bounded setImmediate windows are justified (ordering bound, not a timing guess) and match existing close-event tests in the same files. Given the lifecycle change in a GC-sensitive C++ path, deferring to a maintainer rather than auto-approving.

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Follow-ups since the PR was opened, all on top of the same fix:

  • e05767e: the Worker tests now also pass a message through the port each side received on all three routes (constructor transferList, worker.postMessage, worker-side parentPort.postMessage), and the web test does the same in both directions, so the sender's 'close' is shown to leave the channel intact. heapStats is imported at module scope in message-channel.test.ts. The bounded two-setImmediate window stays: the event is a task that runs before the first setImmediate, and the exact lists also assert what must not fire (the peer on transfer, the transferred object again when the channel later closes).
  • 3ae5416, 8b56966: the comments added in MessagePort.cpp / MessagePort.h are down to one line each; no code change.

The first CI run (build 93875) had only unrelated flakes (each passed alone or on retry) plus two macOS jobs that were canceled before starting; the later pushes re-run it. All review threads are resolved. The PR description has been updated to describe the tests as they stand now.

The transferred-away object now keeps its context, so tryTakeMessage's
isEntangled() check is what keeps it from taking messages from the pipe
side its receiver owns. Pin that with a message queued for the received
port, and say so at the check.
@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Self-review outcome, addressed in 0aa542e:

  • Keeping the transferred-away object attached to its context removes the "no context" property that tryTakeMessage (receiveMessageOnPort) could previously have relied on as a second guard. Audited every place that distinguishes a dead object from a live one; all of them key on m_isDetached already (list in the description), so nothing in-tree changes behavior. The isEntangled() check in tryTakeMessage is now the only thing keeping a transferred-away object from taking messages off the pipe side its receiver owns, so the local test now queues a message for the received port, asserts receiveMessageOnPort(transferredAway) is undefined, and asserts the received port gets the message; the check has a one-line comment saying why it matters. With the check removed, the test fails at that assertion with the stolen message. Node returns undefined there as well once the port's 'close' has fired (v26.3.0 segfaults if it is called in the same tick as the transfer, so the test asserts it after the event).
  • The description's note about node:worker_threads: receiveMessageOnPort drains the retained queue after close() #33638 was wrong: that PR puts a pipe close inside the close task, which after this PR is queueCloseEvent() and is queued by disentangle() too, and it drops the tryTakeMessage check on the assumption this PR invalidates. Both hunks need rework on top of this one (it already conflicts with main on its own); the channel-intact tests and the new assertion fail if either is rebased as is. structured clone: re-check the transfer list after serializing so a failed transfer detaches nothing #37966 only touches postMessage() and is unaffected. Description updated accordingly.

@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 automated bug-hunting pass found no issues. Because it changes MessagePort lifetime semantics in the JSC bindings — a transferred-away port now stays registered as an ActiveDOMObject/ContextDestructionObserver on its original context instead of being detached, with the wrapper pinned across GC by the queued close task — a human look at the GC/context-teardown interactions would still be worthwhile.

What was reviewed:

  • disentangle() no longer calls observeContext(nullptr); verified virtualHasPendingActivity() still short-circuits on m_isDetached so only the queued task pins the wrapper, and contextDestroyed()/stop() on a detached port no-op through close()'s m_isDetached guard.
  • tryTakeMessage on a transferred-away port (which now keeps its context) — the isEntangled() check is what prevents it stealing the receiver's messages; pinned by the new test.
  • The heap-count leak bound in the new GC test and the m_closeEventDispatched guard against a second event when the channel later closes.
Extended reasoning...

Overview

The PR makes MessagePort::disentangle() queue the same deferred 'close' event that close() already queues, so a port listed in a transfer list fires 'close' on the sender's side (matching Node's TransferForMessaging). To make that possible it factors the tail of close() into queueCloseEvent() and — the substantive change — removes the removeAllEventListeners() / willDestroyActiveDOMObject / willDestroyDestructionObserver / observeContext(nullptr) calls from disentangle(), so the transferred-away object stays attached to its ScriptExecutionContext until it is collected (like a close()d port already does). Tests are added in three files covering local postMessage, all three Worker transfer routes, the web-Worker self.postMessage path, GC survival of the pending event, a leak bound after the event fires, and receiveMessageOnPort on the transferred-away object.

Security risks

None identified. This is event-dispatch and object-lifetime plumbing; no untrusted input parsing, auth, or crypto is involved.

Level of scrutiny

High. This is C++ in src/jsc/bindings/webcore/ touching GC lifetime — the repo's most-blocked review category. The change alters when a MessagePort unregisters from its context's ActiveDOMObject and DestructionObserver lists (previously: at transfer time; now: at destruction time via the base-class destructors). queueTaskKeepingObjectAlive holds a pending activity across GC; JSMessagePortOwner::isReachableFromOpaqueRoots only honors that while the object has a context, which is why the context detach had to go. I traced the surrounding guards (virtualHasPendingActivity() returns false on m_isDetached; contextDestroyed()close() early-returns on m_isDetached then calls ActiveDOMObject::contextDestroyed(); dispatchEvent/dispatchOneMessage/peerClosed/jsRef all key on m_isDetached) and did not find a path that now misbehaves, but the interaction with worker termination and cross-thread context teardown while a close task is pending is subtle enough that a maintainer familiar with the ActiveDOMObject lifecycle should confirm.

Other factors

The PR description is unusually thorough (mechanism, Node source references, every guard audited, interaction with two other open PRs on the same file). Tests are comprehensive and include a GC-stress test with a heap-count leak bound and a hang-guard subprocess test. All CodeRabbit and comment-cop threads are resolved (comments were shortened to one line each; receiver-side round-trips were added; heapStats moved to a module-scope import). CI build #94455 is running on the latest head. No prior claude review exists on this PR.

@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 GC-lifetime behavior in the JSC MessagePort bindings (a transferred-away port now stays registered with its ScriptExecutionContext instead of detaching via observeContext(nullptr)), a human look would still be worthwhile.

What was reviewed:

  • disentangle() now reuses close()'s deferred-event tail via queueCloseEvent(); m_closeEventDispatched prevents a second fire when the channel later closes.
  • Wrapper liveness across the queued task: queueTaskKeepingObjectAlive bumps m_pendingActivityInstanceCount, and JSMessagePortOwner::isReachableFromOpaqueRoots honors it while isContextStopped() is false — the new heap-count test confirms collection afterwards.
  • Callers that previously could distinguish a transferred port by a null context (tryTakeMessage, virtualHasPendingActivity, contextDestroyed, jsRef) all gate on m_isDetached/isEntangled() first, so behavior is unchanged; the new receiveMessageOnPort assertion pins the one place where that matters.
  • contextDestroyed() on a now-still-attached transferred port: close() early-returns on m_isDetached, matching what a close()d port already does.
Extended reasoning...

Overview

The PR makes a transferred-away MessagePort fire 'close' on the sender's object, matching Node's TransferForMessaging() (Close(); return Detach();). The native change is small: the tail of MessagePort::close() is factored into queueCloseEvent(), and disentangle() calls it instead of synchronously stripping listeners and calling observeContext(nullptr). Three test files gain coverage for the local-channel case, all three Worker transfer routes (constructor transferList, worker.postMessage, worker-side parentPort.postMessage), the web Worker/self.postMessage pair, and a GC-survival + collection-afterwards check.

Security risks

None identified. This is event-dispatch timing and GC-lifetime bookkeeping on an existing object; no parsing of untrusted input, no auth/crypto, no new external surface.

Level of scrutiny

Higher than a mechanical fix. The functional change is straightforward, but the implementation removes observeContext(nullptr) and the willDestroyActiveDOMObject/willDestroyDestructionObserver calls from disentangle(), so a transferred-away port now remains a ContextDestructionObserver and ActiveDOMObject on its original context until collected — the same as a close()d port already does, but it is a lifetime-invariant change in JSC bindings. The author audited every site that could have relied on the old "transferred ⇒ no context" property (list in the PR description) and added a test that pins the one load-bearing guard (isEntangled() in tryTakeMessage). I traced JSMessagePortOwner::isReachableFromOpaqueRoots and queueTaskKeepingObjectAlive to confirm the pending-activity token keeps the wrapper alive across the queued task and releases afterwards, and that virtualHasPendingActivity()'s m_isDetached early-return means nothing else pins it once the task has run.

Other factors

All review threads (CodeRabbit and comment-cop) are resolved: the receiver-side round-trip coverage was added, heapStats moved to a module import, and the multi-line comments were shortened. The CI note about worker-transfer-terminate-stress on ASAN was reproduced against main with this PR's .cpp/.h reverted, so it's pre-existing. The PR description also flags a genuine interaction with in-flight PR #33638 (its m_pipe->close() placement and its removal of the isEntangled() guard would both regress the tests added here), which is exactly the kind of cross-PR concern a maintainer should be aware of. Given the GC-sensitive nature of the change and the documented interaction with another open PR, deferring to a human reviewer.

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