Skip to content

Fire the close event on MessagePort#32565

Open
robobun wants to merge 9 commits into
mainfrom
farm/ac509d10/messageport-close-event
Open

Fire the close event on MessagePort#32565
robobun wants to merge 9 commits into
mainfrom
farm/ac509d10/messageport-close-event

Conversation

@robobun

@robobun robobun commented Jun 21, 2026

Copy link
Copy Markdown
Collaborator

What

Closing one end of a MessageChannel (or a node:worker_threads MessagePort) now dispatches a close event on both the port that was closed and its entangled peer. The event is delivered asynchronously, after any already-queued messages on that port have drained, matching Node's node:worker_threads semantics and the HTML MessagePort close event.

Fixes #32563.

Repro

import { MessageChannel } from 'node:worker_threads';
const { port1, port2 } = new MessageChannel();

port2.on('message', (message) => console.log(message));
port2.on('close', () => console.log('closed!'));

port1.postMessage('foobar');
port1.close();

Node prints foobar then closed!. Before this change Bun printed only foobar and exited immediately; now it matches Node.

Cause

MessagePort::close() marked the side closed and removed its listeners, but never notified the entangled peer and never dispatched a close event on either port. Nothing kept the event loop alive for the notification either, so a program awaiting close exited first.

Fix

  • MessagePortPipe::close() wakes the entangled peer (only when it is attached and no drain is already in flight) so it can emit close after its inbox drains.
  • MessagePortPipe::drainAndDispatch() dispatches close once the inbox empties and the peer has closed, so queued messages are always delivered first.
  • MessagePort::close() schedules the closing port's own close event on its context before tearing down listeners.
  • hasPendingActivity() keeps the JS wrapper (and its close listener) alive until a pending close has been delivered. The event fires at most once per port.

Ports without a close listener keep their previous behavior (close listener tracking mirrors the existing message-listener bookkeeping), so the fix adds no work to the message hot path.

Verification

New tests in test/js/web/workers/message-port-pipe.test.ts (node MessageChannel and the Web API addEventListener("close")):

  • close fires on the peer after queued messages drain (the issue repro)
  • close fires on both ports; the queued message is delivered before close
  • the closing port fires its own close
  • close fires at most once
  • a channel with no close listener still closes and the process exits cleanly

Fail-before / pass-after confirmed: the close tests fail on the released build (USE_SYSTEM_BUN=1) and pass with the fix; the existing message-port-pipe, message-channel, message-port-closed-leak, and message-port-context-destroy-leak suites remain green.

Closing one end of a MessageChannel (or a node:worker_threads
MessagePort) now dispatches a `close` event on both the port that was
closed and its entangled peer. It is delivered asynchronously, after
any already-queued messages on that port have drained, matching Node's
node:worker_threads semantics and the HTML MessagePort `close` event.

Previously the event never fired: close() marked the side closed and
removed listeners without notifying the peer or dispatching `close`, so
code awaiting `close` (and the process staying alive for it) missed it
and exited early.

- MessagePortPipe::close() wakes the entangled peer (after its inbox
  drains) so it can emit `close`.
- MessagePortPipe::drainAndDispatch() dispatches `close` once the inbox
  empties and the peer has closed.
- MessagePort::close() schedules the closing port's own `close` event
  before tearing down its listeners.
- hasPendingActivity() keeps the wrapper alive until a pending `close`
  has been delivered.

Fixes #32563
@robobun

robobun commented Jun 21, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 12:32 PM PT - Jun 21st, 2026

@robobun, your commit 26cb6e43057cc351b1224e31af262c70dd2df1d7 passed in Build #63824! 🎉


🧪   To try this PR locally:

bunx bun-pr 32565

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

bun-32565 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 3 issues this PR may fix:

  1. Worker messages and close events often lost if worker calls process.exit immediately after postMessage #14144 - Close events are reported as lost when a worker calls process.exit after postMessage; this PR implements the missing close event dispatch on MessagePort and its entangled peer
  2. Bun doesn't finish the execution when all workers' ports are closed #11760 - Process hangs after parentPort.close() because the port lifecycle never completes; this PR's hasPendingActivity() rework and close-event teardown should let the event loop drain and exit
  3. Bun exits instead of waiting when MessageChannel from node:worker_threads is used #32562 - Bun exits prematurely when a MessageChannel is active; the hasPendingActivity() fix in this PR changes how the port decides whether to keep the event loop alive

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #14144
Fixes #11760
Fixes #32562

🤖 Generated with Claude Code

@robobun

robobun commented Jun 21, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks, but I checked each of these against this branch's build and none are fixed here, so I'm not adding the Fixes lines. This PR only implements the MessagePort close event (#32563).

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. fix(MessagePort): emit "close" event on both ports when one side is closed #27691 - Implements the same feature: firing the close event on both ports when MessagePort.close() is called, modifying the same MessagePort.cpp/.h files
  2. MessagePort alignment with Nodejs  #19941 - Adds dispatchCloseEvent() to MessagePort.cpp/.h to fire the close event on both the closing port and its peer — same mechanism and same missing feature
  3. node:worker_threads: +48 Node.js tests passing — MessagePort, stdio, SHARE_ENV, exit codes, transfer semantics, postMessageToThread + inspector #31216 - Includes native dispatchCloseEvent() implementation in MessagePort.cpp/.h and MessagePortPipe.cpp as part of a broader worker_threads overhaul, directly overlapping with the close-event dispatch changes

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

MessagePort gains asynchronous close event dispatch: closing a port schedules the close event to fire via the script execution context rather than tearing down listeners synchronously, allowing close events to fire after queued messages drain. MessagePortPipe is updated so closing one side proactively wakes the peer for a drain, and the drain loop emits the peer close event only after all queued messages are delivered. A comprehensive test suite validates close sequencing, ordering, listener lifecycle, and wrapper object lifetime.

Changes

MessagePort async close event dispatch

Layer / File(s) Summary
Close-event state flags and method declarations
src/jsc/bindings/webcore/MessagePort.h, src/jsc/bindings/webcore/MessagePort.cpp
Adds public method dispatchCloseEventFromPeer() and private helper declarations startForClose, canRunScript, dispatchCloseEvent, dispatchCloseEventSelf, scheduleCloseEvent to the header. Introduces member flags m_hasCloseEventListener and m_closeEventDispatched. Extends addEventListener to call startForClose() and set the close-listener flag when a close listener is registered; extends removeEventListener to clear the flag once all close listeners are removed.
Async close-event dispatch implementation
src/jsc/bindings/webcore/MessagePort.cpp
Adds Event.h include and refactors MessagePort::close() to call scheduleCloseEvent(), deferring listener teardown until async dispatch completes or performing immediate teardown if scheduling fails. Implements close lifecycle: startForClose attaches port to pipe for close-only listening, canRunScript gates script execution checks, dispatchCloseEvent performs one-time guarded dispatch even when detached, dispatchCloseEventSelf runs the async task body, and dispatchCloseEventFromPeer handles peer-driven close with strong reference preservation across JS handler execution. Refactors hasPendingActivity to keep wrapper alive when a close listener exists and close has not dispatched (based on pipe and peer state), with early-return on null script context.
Pipe drain ordering and peer close wake-up
src/jsc/bindings/webcore/MessagePortPipe.h, src/jsc/bindings/webcore/MessagePortPipe.cpp
Adds wakePeerForClose(uint8_t side) method to schedule a drain on the entangled peer when Attached and no drain is scheduled, ensuring the peer can deliver its close event after queued messages drain. Updates drainAndDispatch with emptied flag tracking whether inbox becomes empty while Attached, calling dispatchCloseEventFromPeer only when emptied is true and peer is not open. Refines drain-loop termination to avoid marking emptied for ports listening only for close (no message listener), preserving buffered messages so pending close does not overtake undelivered messages. Broadens attach drain-scheduling condition to trigger when either queued messages exist or peer is already closed, supporting late-added close listeners.
Close-event test suite
test/js/web/workers/message-port-pipe.test.ts
Adds describe("MessagePort close event") suite with run(src) subprocess helper covering: peer close fires asynchronously after message drain; both-port close preserves message-before-close ordering; own port close event dispatch and Web API addEventListener("close") callback support; at-most-once close emission across repeated calls; clean exit when peer has no close listener; close dispatch when peer has only close listener; close delivery when listener added after peer closure; message preservation in close-only endpoints via receiveMessageOnPort; and GC regression test confirming wrapper objects are collected after closing and adding close listeners post-closure.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Fire the close event on MessagePort' clearly and concisely summarizes the main change of implementing close event dispatch for MessagePort.
Description check ✅ Passed The PR description exceeds template requirements, providing detailed 'What', 'Cause', and 'Fix' sections, along with verification methodology and test coverage documentation.
Linked Issues check ✅ Passed The PR fully addresses the requirements in issue #32563 by implementing close event dispatch on both MessagePort endpoints asynchronously after queued messages drain, matching Node.js semantics.
Out of Scope Changes check ✅ Passed All changes are scoped to close-event implementation in MessagePort, MessagePortPipe, and new tests; no unrelated modifications or feature creep detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/web/workers/message-port-pipe.test.ts`:
- Around line 412-419: Reorder the assertions in the test to place stdout
assertions before the exitCode check. Move the expect(exitCode).toBe(0)
assertion to the end of the test block, after all the log assertions (after the
final expect statement that checks log.indexOf("port2 message:
foobar")).toBeLessThan). This ensures that if the test fails, the log contents
will be visible in the error output, making debugging easier.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: d7879bcc-9add-49a3-a1a0-3693aa371090

📥 Commits

Reviewing files that changed from the base of the PR and between 602d956 and 7c71633.

📒 Files selected for processing (4)
  • src/jsc/bindings/webcore/MessagePort.cpp
  • src/jsc/bindings/webcore/MessagePort.h
  • src/jsc/bindings/webcore/MessagePortPipe.cpp
  • test/js/web/workers/message-port-pipe.test.ts

Comment thread test/js/web/workers/message-port-pipe.test.ts
@robobun

robobun commented Jun 21, 2026

Copy link
Copy Markdown
Collaborator Author

On the duplicate check:

This PR is a small, focused fix for just #32563 against the current pipe-based code, so it can land on its own if the larger overhaul is still in flight. Happy to defer to maintainers on ordering, and to close this if #31216 supersedes it.

Comment thread src/jsc/bindings/webcore/MessagePort.cpp Outdated
Comment thread src/jsc/bindings/webcore/MessagePort.cpp
Two leaks in the initial close-event implementation, both pinning the JS
wrapper for the lifetime of the context:

- A close listener added after close() (when there was no listener at
  close time) set m_hasCloseEventListener while the pipe side was already
  Closed, and hasPendingActivity()'s close branch (which runs before the
  detached check) then pinned the wrapper forever. close() now marks the
  close consumed when no dispatch is scheduled, and hasPendingActivity()
  pins only while a close is actually pending: a scheduled self-close, or
  an attached port whose peer has closed.

- A port with only a close listener was never attached to the pipe, so
  the peer's close() never woke it (close never fired, diverging from
  Node) and hasPendingActivity() pinned it. A close listener now attaches
  the port without starting message delivery, and attach() schedules a
  drain when the peer has already closed so a late close listener still
  fires. The drain leaves messages buffered for a port with no message
  listener rather than dispatching them to no one.
Comment thread src/jsc/bindings/webcore/MessagePortPipe.cpp
Comment thread src/jsc/bindings/webcore/MessagePort.cpp
…e-pending

Fixes a leak caught by CI and two issues raised in review, all in the
close-event paths:

- Leak (CI): ~MessagePort runs during worker VM teardown and called
  MessagePortPipe::close(), whose peer-wake scheduled a drain task that
  never runs once the event loop is gone (and pins a Ref to the pipe,
  cascading the leak). The peer-wake is moved out of the pipe's close()
  into MessagePortPipe::wakePeerForClose(), which MessagePort::close()
  invokes only when the context can still run JS. Destructor and
  teardown paths no longer schedule anything.

- hasPendingActivity() no longer pins a close-only port that is sitting
  on undelivered buffered messages: with no message listener the inbox
  never drains, so the drain has no path to dispatch 'close' (it stays
  blocked behind the messages, matching Node). Pin only when a message
  listener exists or the inbox is already empty.

- close() followed by on('close') on the same port now fires: the
  self-close task is posted whenever JS can run rather than only when a
  close listener already exists. The task is a no-op dispatch with no
  listener and then tears the port down, so the wrapper is still
  collected.
Comment thread src/jsc/bindings/webcore/MessagePortPipe.cpp Outdated
Comment thread src/jsc/bindings/webcore/MessagePort.cpp
Comment thread src/jsc/bindings/webcore/MessagePort.cpp
robobun and others added 2 commits June 21, 2026 17:24
…y unstarted ports

Addresses two more wrapper-leak regressions and a stale comment from review:

- hasPendingActivity() pinned a port whenever its peer was Closed and it
  had a close listener, assuming a drain was in flight to dispatch the
  close. That only holds when the peer closed via MessagePort::close()
  (wakePeerForClose) or an attach after the peer closed. A peer closed
  via ~MessagePort, ~TransferredMessagePort (dropped in transit), or
  contextDestroyed() with canRunScript()==false never schedules a drain,
  so the close could never fire and the wrapper leaked. Gate the peer
  sub-clause on DrainScheduled: the pin now exists only while the drain
  that would dispatch the close does.

- The drain buffered messages for any port with no message listener,
  keyed on m_hasMessageEventListener. An explicitly started() port with
  no listener (port.start(), or onmessage set then cleared) then stranded
  the buffered message and leaked via the message-listener pin, because
  re-adding a listener calls start() which no-ops once m_started is set.
  Key the buffer decision on !started() instead: an unstarted (close-only)
  port buffers, a started port dispatches to no one per the HTML spec,
  which keeps its inbox draining and collectable.

- Refresh the close() and hasPendingActivity() comments that still
  described the pre-"always post the self-close task" behavior.

Tests extend the collectability guard with the drop-in-transit and
explicitly-started scenarios.
Comment thread test/js/web/workers/message-port-pipe.test.ts Outdated
Comment thread src/jsc/bindings/webcore/MessagePort.cpp Outdated
…shold

Review nits, no behavior change:

- The canRunScript() gate and wakePeerForClose() comments claimed the
  drain task "would never run and would leak" during teardown, but the
  task posts to the peer's (live) context. Reword to the real rationale:
  firing the peer's close on teardown/GC is intentionally unsupported,
  and hasPendingActivity() matches by not pinning such a port.

- The dispatchCloseEventFromPeer() comment credited a hasPendingActivity
  branch that is actually false during the dispatch (DrainScheduled is
  cleared and m_closeEventDispatched set beforehand). The object is kept
  alive by the drain's RefPtr + our Ref; the wrapper by event.target on
  the JS stack.

- The collectability guard created 10 ports/iteration (not ~8) and used
  `leaked > N`, which a single-scenario regression (~N leaked) would slip
  past. Tighten to `> N / 2`, still far above post-settle GC residue.
Comment thread src/jsc/bindings/webcore/MessagePortPipe.cpp Outdated
Comment thread test/js/web/workers/message-port-pipe.test.ts Outdated
…eue race

A cross-thread TOCTOU could drop the last message of the canonical
worker_threads shutdown pattern (postMessage(x); close()) when the
receiving port has a close listener. After the drain loop emptied the
inbox, cleared DrainScheduled, and released the lock, the peer thread
could re-enqueue a message and re-arm a drain before this thread read
the peer-closed state; it then dispatched close (which clears the inbox)
and the freshly posted drain no-oped on the ctxId mismatch, losing x.

Re-check under the side lock right before dispatching: only fire close
if the inbox is still empty and no drain was re-armed (new helper
readyToDispatchClose). Otherwise the freshly scheduled drain delivers
the message and dispatches close itself. Applied at both dispatch sites
(the empty-inbox fast path and the post-drain path).

Also drop the explicit 60s timeout from the collectability test: the
earlier hang was an unrelated onmessage keep-alive (now avoided), and
the test runs in ~1.7s, within the default budget.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  • 🔴 src/jsc/bindings/webcore/MessagePortPipe.cpp:222-226 — The !isOtherSideOpen(side) clause added here cannot tell a peer closed via explicit port.close() from one closed by ~MessagePort (GC), ~TransferredMessagePort (drop-in-transit), or contextDestroyed() — all set the same Closed bit. So a close listener added after the peer was GC'd/dropped runs startForClose()attach(), schedules a drain, and fires dispatchCloseEventFromPeer() — making GC timing observable from JS and contradicting the PR's own documented invariant in MessagePort::close() / wakePeerForClose() ("a close via teardown or GC deliberately does NOT fire the peer's close"). Consider a separate ClosedByScript bit that only MessagePort::close() sets, and check that here instead of !isOtherSideOpen().

    Extended reasoning...

    What the bug is

    attach() was extended in c76e234 to schedule a drain when !isOtherSideOpen(side) so that a close listener added after the peer's explicit close() still fires. But isOtherSideOpen() reads only state(1-side) & Closed, and MessagePortPipe::close() stores that same Closed bit regardless of why the side closed: an explicit port.close() from script, ~MessagePort (GC of an unreferenced peer), ~TransferredMessagePort (port dropped in transit), and the cascade worklist all funnel into the same s.state.store(Closed, …). So attach() cannot distinguish "the peer called close()" from "the peer was garbage-collected / dropped", and schedules a close-dispatching drain in both cases.

    Step-by-step proof (GC path)

    let { port1, port2 } = new MessageChannel();
    port1 = null;
    Bun.gc(true); await Bun.sleep(0);
    port2.addEventListener('close', () => console.log('fired'));
    1. port1 has no listeners → hasPendingActivity() falls through to !m_hasMessageEventListener → returns false. locallyEntangledPort() is nullptr (MessagePort.h), so port2 does not keep port1's wrapper alive. port1's wrapper is collectable.
    2. GC collects it → ~MessagePort runs, !m_isDetachedm_pipe->close(0) → side 0 state = Closed. ~MessagePort does not call wakePeerForClose() (by design — that lives only in MessagePort::close()).
    3. port2.addEventListener('close', cb)startForClose() (its !isEntangled() guard is m_isDetached, which is false) → m_pipe->attach(1, …).
    4. attach() at line 226: !(st & DrainScheduled) && (queuedCount(st) > 0 || !isOtherSideOpen(1)). queuedCount = 0, but !isOtherSideOpen(1) = (state(0) & Closed) ≠ 0true. DrainScheduled is set, drain posted.
    5. Drain runs: port non-null, inbox empty → emptied = true, limit = 0emptied && !isOtherSideOpen(1)port->dispatchCloseEventFromPeer(). Guards: m_isDetached=false, m_closeEventDispatched=false, m_hasCloseEventListener=true → fires 'close'.

    Whether 'fired' prints depends entirely on whether GC ran between dropping port1 and adding the listener — i.e., GC timing is directly observable from JS.

    Deterministic variant (no GC needed)

    The ~TransferredMessagePort path reaches the identical state without depending on GC: transfer a port through a carrier whose destination is closed, and ~TransferredMessagePort calls pipe->close(side) directly. A close listener added to the entangled peer afterwards hits the same !isOtherSideOpen clause in attach() and fires close. This also produces the inconsistency: a close listener added before the drop-in-transit never fires (no drain is scheduled — the design choice in resolved #3448795284), but one added after does. Whether close fires depends on listener-add timing relative to the peer's non-script death.

    Why this is a problem

    This directly contradicts the PR's own documented design intent in two places:

    • MessagePort::close() (line ~118): "A close via context teardown … or GC … deliberately does NOT fire the peer's 'close'".
    • wakePeerForClose() (line ~243): "firing the peer's 'close' on teardown/GC is intentionally not supported".

    attach()'s !isOtherSideOpen() clause undermines that invariant for the late-listener case. And for the GC path specifically, the HTML spec rationale for not firing close on GC is precisely that GC must not be observable from script — which this violates. (One verifier noted recent WHATWG changes do disentangle on GC; even granting that, the inconsistency — listener-before-death never fires, listener-after-death fires — still makes GC timing observable, and still contradicts this PR's stated design either way.)

    Why nothing prevents it

    MessagePortPipe::close() is called identically from all paths and stores only Closed — no provenance. attach() reads only state(1-side) & Closed via isOtherSideOpen(). startForClose()'s only guard is !isEntangled() (this side's m_isDetached), which is false here. dispatchCloseEventFromPeer() has no "was this a script close?" check.

    Suggested fix

    Add a separate state bit (e.g. ClosedByScript) that only MessagePort::close() (gated on canRunScript()) sets alongside Closed, and have attach()'s late-listener clause check that bit instead of !isOtherSideOpen(). The drain's post-loop emptied && !isOtherSideOpen(side) check can stay as-is, since it is only reached when a drain was scheduled (which already implies a script close or queued messages). Alternatively, if the deviation is acceptable, update the comments in MessagePort::close() / wakePeerForClose() to note that a late-added close listener can still observe a non-script close.

Comment thread src/jsc/bindings/webcore/MessagePort.cpp Outdated
A close listener added to a port after its peer died could fire 'close'
regardless of how the peer died, because attach()/the drain derived
"peer closed" from the bare Closed bit — which is set identically by an
explicit port.close(), by ~MessagePort (GC), by ~TransferredMessagePort
(drop-in-transit), and by context teardown. That made GC/drop/teardown
timing observable from JS (fire iff a listener was added after the
death) and contradicted the documented invariant that only a script
close() fires the peer's 'close'.

Add a ClosedByScript state bit that MessagePort::close() sets (alongside
Closed) only when the context can run JS; the destructor, drop-in-transit,
and teardown paths leave it unset. attach()'s late-listener wake and the
drain's close-dispatch now gate on the peer being ClosedByScript rather
than merely Closed, so a peer that died by GC/drop/teardown never
surfaces a 'close' event and is never pinned waiting for one.

Also refresh the startForClose() comment (the peer wake is done by
wakePeerForClose(), not MessagePortPipe::close()).
@robobun

robobun commented Jun 21, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed the 🔴 GC-observability finding (the review-body "additional findings", which has no inline thread) in 26cb6e4.

Added a ClosedByScript pipe state bit that MessagePort::close() sets (alongside Closed) only when the context can run JS. ~MessagePort (GC), ~TransferredMessagePort (drop-in-transit), and context teardown leave it unset. attach()'s late-listener wake and the drain's close dispatch now gate on the peer being ClosedByScript rather than merely Closed, so a peer that died by GC/drop/teardown never surfaces a close event (consistent regardless of when a late listener is added) and is never pinned waiting for one. This matches the documented invariant. Added a deterministic drop-in-transit regression test.

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

No new issues found on 26cb6e4 — all prior threads look addressed — but given the cross-thread locking, GC-lifetime (hasPendingActivity), and the open ordering question vs #31216, this still warrants a human pass before landing.

Extended reasoning...

Overview

This PR implements the close event on MessagePort for both the closing port and its entangled peer, touching four C++ files in src/jsc/bindings/webcore/ (MessagePort.{cpp,h}, MessagePortPipe.{cpp,h}) and adding ~250 lines of tests. It introduces a new ClosedByScript pipe state bit, a peer-wake mechanism (wakePeerForClose), a TOCTOU re-check helper (readyToDispatchClose), reworks hasPendingActivity() to pin the wrapper across a pending close dispatch, and changes the drain loop to buffer messages on unstarted ports.

Security risks

None identified. No untrusted input parsing, auth, crypto, or filesystem/network surface. The changes are confined to in-process event dispatch and lifetime management.

Level of scrutiny

High. This is hand-written cross-thread concurrency code in the JSC bindings layer: per-side locks, release/acquire atomics, GC-thread-concurrent hasPendingActivity() reads, and task scheduling across ScriptExecutionContexts. The review history bears this out — six iterative rounds surfaced and fixed multiple wrapper-pin leaks, a started-port message-stranding regression, a cross-thread TOCTOU that could silently drop the last message on the canonical postMessage(); close() shutdown pattern, and a GC-observability issue (now resolved via the ClosedByScript bit). All threads are resolved and the current revision passed the bug hunt cleanly, but the density of subtle issues found along the way is itself a signal that a human familiar with this subsystem should sign off.

Other factors

  • The author explicitly flagged overlap with the in-flight #31216 (broader worker_threads overhaul touching the same files) and deferred ordering to maintainers — that is a merge-strategy decision a human needs to make.
  • Test coverage is good: 12 new subprocess tests covering ordering, at-most-once, close-only peers, late listeners, drop-in-transit, and a 4-scenario GC collectability guard with a tightened threshold.
  • The cross-thread TOCTOU fix (readyToDispatchClose) was reasoned correct but explicitly not covered by a deterministic test (acknowledged as untriggerable without instrumentation).
  • Behavior change: close() now always posts an async self-close task (even with no listener), and adding a close listener now attaches the port to the pipe — both are intentional and Node/spec-aligned, but worth a human confirming the broader worker_threads / WPT suites are unaffected.

@robobun

robobun commented Jun 21, 2026

Copy link
Copy Markdown
Collaborator Author

Agreed this wants a human pass. State for a 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.

Bun exits even if MessageChannel from node:worker_threads is still working and has unfinished tasks

1 participant