Skip to content

Fix lost wakeup when a worker message arrives during a nested event-loop wait - #37190

Open
robobun wants to merge 6 commits into
mainfrom
farm/d809f9df/worker-drain-wakeup
Open

Fix lost wakeup when a worker message arrives during a nested event-loop wait#37190
robobun wants to merge 6 commits into
mainfrom
farm/d809f9df/worker-drain-wakeup

Conversation

@robobun

@robobun robobun commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

Fixes #37189

Repro

// worker replies to every request with an error
await call().catch(() => {});                     // first worker round trip
await expect(call()).rejects.toThrow("boom");     // hangs forever on 1.3.14+

The second assertion never settles; the test times out and bun test then hangs and must be killed. A same-thread MessageChannel RPC hits the identical deadlock. Regression introduced in 1.3.14 by #29937; 1.3.13 is fine.

Cause

expect(p).rejects awaits synchronously: the matcher enters EventLoop.waitForPromise, which spins the event loop from inside the current dispatch. The drain loops from #29937 keep drainScheduled set for the whole dispatch, including drainMicrotasks(), and Worker::enqueueToParent / MessagePortPipe::send skip posting a wakeup task while the flag is set.

So when the test continuation (run from the previous message's microtask drain, with the drain loop still on the native stack) issues a second call and parks in waitForPromise, the reply is appended to the inbox with no wakeup. The code that would clear the flag sits below the nested wait on the same stack, so the wait parks forever and every later message on that channel is silently queued.

Fix

Clear the flag before dispatching any user JS, so a racing send posts a fresh drain task (at most one extra no-op drain per racing send):

  • Worker.cpp drainInbox: clear drainScheduled up front, and pop messages one at a time under the lock instead of swapping the whole queue into a local batch, so a nested drain task observes the shared queue and delivery stays FIFO.
  • MessagePortPipe.cpp drainAndDispatch: hand DrainScheduled off to a new Dispatching state bit before dispatching. MessagePort::hasPendingActivity() needs the dispatch window covered for GC wrapper liveness (the in-hand message is invisible to the queued count), so it now checks DrainScheduled | Dispatching. Dispatching is cleared when the owning drain finishes, or by detach()/close(); a stale drain never clears it after losing ownership, so it cannot clobber a new owner's drain after a transfer.

Verification

  • New test test/regression/issue/37189.test.ts covers both the Worker and the MessageChannel shape. Both child processes deadlock and get killed by the spawn timeout on the unfixed build; both print OK and exit 0 with the fix.
  • All 5 repro tests from the issue pass with the fix, including the microtask-barrier variant (test 5).
  • Existing suites pass: test/js/web/workers/ (message-port-pipe, message-channel, message-event, leak tests, worker.test.ts, worker-postmessage-transfer), test/js/node/worker_threads/ (incl. transfer-terminate stress), and the node parallel MessagePort tests. The only failure seen (worker-terminate-lifetime.test.ts dns.lookup LeakSanitizer report) reproduces identically on main without this diff.

[review] gate passed · iteration 0 · 6 files touched

fails on main (without fix)
ASAN without fix: 2 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/web/workers/message-port-pipe.test.ts "test/regression/issue/37189.test.ts"
bun test v1.4.0 (2e5bc025c)

test/regression/issue/37189.test.ts:
(fail) expect().rejects settles when the rejection arrives from a Worker message during a nested wait [5004.71ms]
  ^ this test timed out after 5000ms.
(fail) expect().rejects settles when the rejection arrives from a MessageChannel message during a nested wait [5006.08ms]
  ^ this test timed out after 5000ms.

test/js/web/workers/message-port-pipe.test.ts:
(pass) MessagePort pipe > microtasks run between message events (task-source semantics) [13.33ms]
(pass) MessagePort pipe > messages buffered before start() are delivered on start() [8.35ms]
(pass) MessagePort pipe > receiveMessageOnPort pops in FIFO order [126.51ms]
(pass) MessagePort pipe > close() inside onmessage handler still delivers already-queued messages [11.90ms]
(pass) MessagePort pipe > messages queued on a port follow it across transfer [15.35ms]
(pass) MessagePort pipe > same-context re-attach inside handler: inbox follow
... (truncated)

release without fix: 2 failed, 3 skipped
bun test v1.4.0-canary.1 (45ee9556a)

test/regression/issue/37189.test.ts:
(fail) expect().rejects settles when the rejection arrives from a Worker message during a nested wait [5001.16ms]
  ^ this test timed out after 5000ms.
(fail) expect().rejects settles when the rejection arrives from a MessageChannel message during a nested wait [5000.06ms]
  ^ this test timed out after 5000ms.

test/js/web/workers/message-port-pipe.test.ts:
(pass) MessagePort pipe > microtasks run between message events (task-source semantics) [0.33ms]
(pass) MessagePort pipe > messages buffered before start() are delivered on start() [0.14ms]
(pass) MessagePort pipe > receiveMessageOnPort pops in FIFO order [1.07ms]
(pass) MessagePort pipe > close() inside onmessage handler still delivers already-queued messages [0.22ms]
(pass) MessagePort pipe > messages queued on a port follow it across transfer [0.29ms]
(pass) MessagePort pipe > same-context re-attach inside handler: inbox follows new wrapper [0.19ms]
(pass) MessagePort pipe > chained transfer delivers through every hop [0.23ms]
(pass) MessagePort pipe > objectTypeCounts drop after close + GC; peer-open pins listening port [23.39ms]
(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/web/workers/message-port-pipe.test.ts "test/regression/issue/37189.test.ts"
bun test v1.4.0 (2e5bc025c)

test/regression/issue/37189.test.ts:
(pass) expect().rejects settles when the rejection arrives from a MessageChannel message during a nested wait [301.88ms]
(pass) expect().rejects settles when the rejection arrives from a Worker message during a nested wait [521.63ms]

test/js/web/workers/message-port-pipe.test.ts:
(pass) MessagePort pipe > microtasks run between message events (task-source semantics) [13.37ms]
(pass) MessagePort pipe > messages buffered before start() are delivered on start() [8.56ms]
(pass) MessagePort pipe > receiveMessageOnPort pops in FIFO order [132.73ms]
(pass) MessagePort pipe > close() inside onmessage handler still delivers already-queued messages [12.48ms]
(pass) MessagePort pipe > messages queued on a port follow it across transfer [15.40ms]
(pass) MessagePort pipe > same-context re-attach inside handler: inbox follows new wrapper [11.30ms]
(pass) MessagePort pipe > chained transfer delivers th
... (truncated)

release with fix: 3 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     2e5bc025c9
  features     baseline

22 deps, 107 codegen, 1176 objects in 720ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1238] install /workspace/bun
bun install v1.4.0-canary.1 (45ee9556a)

Checked 107 installs across 153 packages (no changes) [12.00ms]
[2/1238] install /workspace/bun/packages/bun-error
bun install v1.4.0-canary.1 (45ee9556a)

Checked 1 install across 2 packages (no changes) [5.00ms]
[3/1238] gen bindgenv2
[4/1238] install /workspace/bun/src/node-fallbacks
bun install v1.4.0-canary.1 (45ee9556a)

Checked 129 installs across 147 packages (no changes) [5.00ms]
[5/1238] gen .bind.ts → GeneratedBindings.cpp
[6/1238] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[7/1238] fetch tinycc
[tinycc] up to date
[8/1237] gen ErrorCode+*.h
[9/1237] fetch picohttpparser
[picohttpparser] up to date
[10/1237] fetch zlib
[zlib] up to date
[11/1237] subst deps/zlib/zlib.h
[12/1237] subst deps/libjpeg-turbo/jconfig.h
[13/1237] fetch nodejs (prebuilt)
[nodejs] up to d
... (truncated)
diff hotspot
src/jsc/bindings/webcore/MessagePort.cpp      |   8 +--
 src/jsc/bindings/webcore/MessagePortPipe.cpp  |  70 ++++++++++++------
 src/jsc/bindings/webcore/MessagePortPipe.h    |   3 +-
 src/jsc/bindings/webcore/Worker.cpp           |  72 +++++++++----------
 test/js/web/workers/message-port-pipe.test.ts |  33 +++++++++
 test/regression/issue/37189.test.ts           | 100 ++++++++++++++++++++++++++
 6 files changed, 223 insertions(+), 63 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                           reads  edits  tests
src/jsc/bindings/webcore/MessagePort.cpp           4      4      0
src/jsc/bindings/webcore/MessagePortPipe.cpp       2      4      0
src/jsc/bindings/webcore/MessagePortPipe.h         1      1      0
src/jsc/bindings/webcore/Worker.cpp                5      5      0
test/js/web/workers/message-port-pipe.test.ts      1      2      0
test/regression/issue/37189.test.ts                1      3      0

root cause · written by the author bot

The drain task kept the wakeup flag set for its entire run, including while user message handlers executed, so a message arriving while a handler was parked in a nested event-loop wait saw a drain as already scheduled, skipped posting a wakeup, and the wait never resumed. The fix clears the flag before any dispatch and pops messages one at a time under the inbox lock, so any send that lands mid-drain posts a fresh wakeup task that can drain it, preserving FIFO order across nested drains. When the per-drain budget is exhausted, the drain reclaims the flag only if no racing send already did, …

…oop wait

The coalesced drain loops from #29937 kept the drain-scheduled flag set
while dispatching user JS. A message sent while a handler's continuation
was parked in a nested event-loop wait (bun:test's expect().rejects) was
appended to the inbox without posting a wakeup task, so the wait never
woke and the channel stayed wedged for the rest of the process.

Clear the flag before any user JS runs, so a racing send posts a fresh
drain task (at most one no-op drain per racing send):

- Worker's drainInbox clears drainScheduled up front and now pops
  messages one at a time under the lock instead of swapping the whole
  queue out, so a nested drain observes the shared queue and delivery
  stays FIFO.
- MessagePortPipe::drainAndDispatch hands DrainScheduled off to a new
  Dispatching bit before dispatching; the bit keeps the port's
  hasPendingActivity() true across the dispatch window that
  DrainScheduled used to cover, and is cleared on detach/close or when
  the owning drain finishes.

Fixes #37189
Comment thread src/jsc/bindings/webcore/MessagePort.cpp Outdated
Comment thread src/jsc/bindings/webcore/MessagePortPipe.cpp Outdated
Comment thread src/jsc/bindings/webcore/MessagePortPipe.cpp Outdated
Comment thread src/jsc/bindings/webcore/MessagePortPipe.cpp Outdated
Comment thread src/jsc/bindings/webcore/MessagePortPipe.cpp Outdated
Comment thread src/jsc/bindings/webcore/Worker.cpp Outdated
Comment thread src/jsc/bindings/webcore/Worker.cpp Outdated
Comment thread src/jsc/bindings/webcore/Worker.cpp Outdated
@coderabbitai

coderabbitai Bot commented Aug 8, 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

Changes

Message dispatch wakeup handling

Layer / File(s) Summary
MessagePort dispatch state and cleanup
src/jsc/bindings/webcore/MessagePortPipe.h, src/jsc/bindings/webcore/MessagePort.cpp, src/jsc/bindings/webcore/MessagePortPipe.cpp
MessagePort now tracks active dispatch separately from scheduled drains. Cleanup preserves state ownership across detach, listener removal, context changes, and termination.
Worker inbox draining
src/jsc/bindings/webcore/Worker.cpp
Worker::drainInbox removes messages individually under the queue lock, clears the wakeup state before dispatch, preserves FIFO ordering, and avoids redundant rescheduling.
Nested-wait and budget regression coverage
test/regression/issue/37189.test.ts, test/js/web/workers/message-port-pipe.test.ts
Tests cover rejection delivery during nested waits and ordered delivery across a 2,500-message drain-budget sequence. They verify clean subprocess completion and resource cleanup.

Possibly related PRs

  • oven-sh/bun#36314: Both changes modify Worker and MessagePort drain-state and scheduling logic.
  • oven-sh/bun#36434: Both changes modify MessagePort lifetime and pending-activity state handling.
  • oven-sh/bun#36831: Both changes address nested event-loop reentrancy during active message dispatch.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the primary lost-wakeup fix for nested worker message waits.
Description check ✅ Passed The description includes the cause, fix, verification steps, regression details, and relevant test results.
Linked Issues check ✅ Passed The changes address issue #37189 across Worker and MessagePort delivery, preserve ordering, and add regression coverage.
Out of Scope Changes check ✅ Passed All code and test changes directly support the linked issue and stated objectives; no unrelated changes are evident.

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

🤖 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 `@src/jsc/bindings/webcore/MessagePortPipe.cpp`:
- Around line 118-135: Update the drain implementation around the Dispatching
transitions and finish lambda to track whether the current invocation set
Dispatching, such as with an ownsDispatching flag or dispatch depth. Only the
invocation that acquired ownership may clear the shared bit, including the
empty-inbox, budget-exhaustion, and finish() paths; nested drains must leave the
outer drain’s Dispatching state intact.

In `@test/regression/issue/37189.test.ts`:
- Around line 60-64: Update expectExitsCleanly to consume proc.stderr
concurrently with proc.stdout and proc.exited, preventing the child from
blocking on an undrained pipe. Assert the complete stderr result alongside the
existing stdout and exitCode expectations, preserving the required ordered
stage-output checks.
🪄 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: 2984f69b-168d-4bb1-9cfc-d7e137b99b6f

📥 Commits

Reviewing files that changed from the base of the PR and between 52bf09c and 42d9ee4.

📒 Files selected for processing (5)
  • src/jsc/bindings/webcore/MessagePort.cpp
  • src/jsc/bindings/webcore/MessagePortPipe.cpp
  • src/jsc/bindings/webcore/MessagePortPipe.h
  • src/jsc/bindings/webcore/Worker.cpp
  • test/regression/issue/37189.test.ts

Comment thread src/jsc/bindings/webcore/MessagePortPipe.cpp Outdated
Comment thread test/regression/issue/37189.test.ts
A nested drain on the same side would clear the shared Dispatching bit
while the outer drain's dispatch was still on the stack, reopening the
GC window it exists to cover. Only the invocation that set the bit
clears it now. Also trims review-flagged comments and drains stderr in
the regression test's child processes.
Comment thread src/jsc/bindings/webcore/MessagePort.cpp
Comment thread src/jsc/bindings/webcore/MessagePortPipe.cpp
Comment thread src/jsc/bindings/webcore/MessagePortPipe.cpp
Comment thread src/jsc/bindings/webcore/MessagePortPipe.cpp
Comment thread src/jsc/bindings/webcore/MessagePortPipe.cpp
Comment thread src/jsc/bindings/webcore/Worker.cpp
Comment thread src/jsc/bindings/webcore/Worker.cpp
Comment thread test/regression/issue/37189.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 reworks the cross-thread wakeup/drain state machine for both MessagePortPipe and Worker inboxes — atomics, lock protocol, and a new state bit that participates in hasPendingActivity() GC liveness — a human look from someone who owns this subsystem would still be worthwhile.

What was reviewed:

  • DrainScheduledDispatching handoff and ownsDispatching so a nested drain can't clear the outer invocation's GC-liveness bit; detach()/close() reset it and stale drains bail on the ctxId/port check without touching flags.
  • Worker::drainInbox switch from batch-swap to per-message pop: FIFO holds under a nested drain, and the budget-exhaustion path now checks drainScheduled before reclaiming it so a racing send's task isn't double-scheduled.
  • Regression test: both spawns now drain stdout/stderr/exit concurrently and assert the combined object (prior nit addressed in baf23c3).
Extended reasoning...

Overview

The PR fixes a lost-wakeup deadlock (#37189) introduced in 1.3.14: when a message handler parks in a nested event-loop wait (expect().rejectswaitForPromise), a reply arriving on the same channel was queued without a wakeup because DrainScheduled stayed set for the whole dispatch. The fix clears DrainScheduled before user JS runs and introduces a separate Dispatching bit (MessagePortPipe only) to preserve the GC-liveness window that DrainScheduled used to cover in hasPendingActivity(). Worker::drainInbox is reworked from swap-the-whole-queue to per-message pops so a nested drain observes the shared queue and delivery stays FIFO. Five files touched: MessagePortPipe.{h,cpp}, MessagePort.cpp, Worker.cpp, and a new regression test.

Security risks

None identified. No untrusted-input parsing, no auth/crypto, no path handling. The change is internal event-loop scheduling and atomic state management.

Level of scrutiny

High. This is exactly the category REVIEW.md flags as most-blocked: cross-thread atomics with weakened orderings, a lock protocol shared between sender and receiver threads, a new state bit read locklessly by the concurrent GC visitor, and re-entrancy via nested event-loop spins. The invariants are subtle — ownsDispatching tracking so a nested drain doesn't strip the outer's liveness bit, detach()/close() resetting Dispatching so a stale drain that bails on the ctxId/port identity check leaves flags to the new owner, and the budget-exhaustion reschedule now conditional on whether a racing send already claimed DrainScheduled. I traced these paths and they look correct, but the failure modes (dropped wakeup → hang; premature Dispatching clear → GC severs wrapper mid-dispatch) are severe and hard to catch in CI.

Other factors

  • The PR description is thorough and the mechanism explanation is convincing; existing worker/MessagePort suites are reported passing.
  • Two CodeRabbit findings (nested-drain Dispatching ownership; undrained stderr in the test) were both addressed in follow-up commits, as was my own prior inline nit on the same stderr issue.
  • The Worker::drainInbox change also removes the batch-swap optimization the deleted comment justified ("sustained producer would otherwise make every per-message pop a contended acquire") — that's a deliberate correctness-over-throughput trade the author explains, but a maintainer may want to weigh it.
  • No prior human review on the PR yet; suggested reviewers are cirospaciari / jarred-sumner who own this area.

…ield handoff

The per-pop rewrite left terminated workers' undelivered messages in the
shared inbox until ~Worker, so transferred ports stranded there kept
their peers' loop refs alive. Swap the queue out and destruct it in the
termination branch, restoring the prompt close notification the old
batch-drop provided. Adds a deterministic MessageChannel chain test that
spends the drain budget while an in-handler send holds the continuation
task, checking order across the handoff.
Comment thread src/jsc/bindings/webcore/Worker.cpp

@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 reworks the cross-thread drain/wakeup protocol in MessagePortPipe and Worker::drainInbox — atomics, lock scope, GC-liveness bits, and nested-reentrancy ownership — a human look would still be worthwhile.

What was reviewed:

  • The DrainScheduledDispatching handoff and ownsDispatching per-invocation guard — a nested drain leaves the outer's bit intact, and detach()/close() reset it so a stale drain can't clobber a new owner.
  • hasPendingActivity() now checks DrainScheduled | Dispatching, so the wrapper stays live across the window where the in-hand message is invisible to the queued count.
  • The budget-yield path in both loops: if a racing send already set DrainScheduled the loop returns without double-scheduling; the new message-port-pipe.test.ts self-feeding test exercises this.
  • Worker::drainInbox switching from batch-swap to per-message pop under the lock — trades the old "avoid contended acquires" fast path for FIFO correctness under nested drains; worth a maintainer glance at the perf implication the deleted comment called out.
Extended reasoning...

Overview

The PR fixes a deadlock regression (#37189) introduced by #29937: when a message handler's continuation parks in a nested event-loop wait (expect().rejectswaitForPromise), a reply arriving on the same channel was appended with no wakeup because DrainScheduled stayed set for the whole dispatch. The fix clears DrainScheduled before user JS runs and introduces a separate Dispatching bit (with per-invocation ownership) to keep hasPendingActivity() true across the dispatch window. Worker::drainInbox is restructured from swap-whole-queue to per-message pop so a nested drain observes the shared queue and delivery stays FIFO. Tests cover both the Worker and same-thread MessageChannel shapes plus the budget-yield handoff.

Security risks

None. This is internal event-loop / message-dispatch plumbing; no untrusted input parsing, auth, or crypto is touched.

Level of scrutiny

High. This is concurrency-critical native code: per-side locks, atomic state words with release/acquire ordering, GC-thread lockless reads via hasPendingActivity(), and reentrant nested-drain semantics. The ownsDispatching logic and its interaction with detach() / same-context re-attach is subtle enough that CodeRabbit flagged it and a follow-up commit was needed. The switch from batch-swap to per-message locking in Worker::drainInbox deletes a comment that explicitly justified the old shape on contention grounds — a maintainer should confirm that trade-off is acceptable.

Other factors

The bug-hunting pass found nothing. All prior bot feedback (comment-cop, CodeRabbit's Dispatching-ownership concern, my own stderr-drain nit) has been addressed in commits baf23c3 and 2e5bc02. The PR description states the existing worker/MessagePort suites pass and the one observed failure reproduces on main. Given the complexity class (cross-thread + GC liveness + reentrancy), this is not a change I can approve without human review.

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/jsc/bindings/webcore/MessagePortPipe.cpp (1)

195-204: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Revalidate the execution context before draining microtasks.

dispatchOneMessage() runs user JavaScript. That code can destroy the context before globalObject->drainMicrotasks() uses the captured pointer. Re-fetch port->scriptExecutionContext() and its global object after dispatch. If either is unavailable or no longer matches the active port, call finish() and return.

🤖 Prompt for 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.

In `@src/jsc/bindings/webcore/MessagePortPipe.cpp` around lines 195 - 204, Update
dispatchOneMessage() after user-code dispatch and before
globalObject->drainMicrotasks(): re-fetch the active port’s
scriptExecutionContext() and global object, then compare them with the captured
context/global object. If either is unavailable or no longer matches the active
port, call finish() and return; otherwise drain microtasks using the revalidated
objects.

Source: Coding guidelines

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

Outside diff comments:
In `@src/jsc/bindings/webcore/MessagePortPipe.cpp`:
- Around line 195-204: Update dispatchOneMessage() after user-code dispatch and
before globalObject->drainMicrotasks(): re-fetch the active port’s
scriptExecutionContext() and global object, then compare them with the captured
context/global object. If either is unavailable or no longer matches the active
port, call finish() and return; otherwise drain microtasks using the revalidated
objects.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 76dd8b3a-9ddf-4d80-93da-be002ed36750

📥 Commits

Reviewing files that changed from the base of the PR and between 2e5bc02 and e215131.

📒 Files selected for processing (1)
  • src/jsc/bindings/webcore/MessagePortPipe.cpp

@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 reworks the cross-thread drain/wakeup state machine (new Dispatching bit, per-invocation ownership, GC-visitor liveness in hasPendingActivity) and changes reentrancy semantics under nested event-loop waits, a human look would still be worthwhile.

What was reviewed

  • The DrainScheduledDispatching handoff and ownsDispatching tracking — nested drains correctly leave the outer invocation's bit alone; detach()/close() clear it so a stale drain can't clobber a new owner.
  • Worker::drainInbox per-message pop under the lock — FIFO is preserved when a nested drain runs; the budget-yield path checks drainScheduled before reclaiming to avoid double-scheduling.
  • hasPendingActivity() now checks DrainScheduled | Dispatching — the in-hand-message GC window stays covered.
  • Regression tests spawn with a 15s SIGKILL timeout and drain stdout/stderr/exit concurrently; the self-feeding-chain test exercises the budget-yield handoff.
Extended reasoning...

Overview

The PR fixes a deadlock introduced in 1.3.14 where a Worker/MessagePort message arriving while the receiver is parked in a nested event-loop wait (e.g. expect(p).rejects inside a message continuation) is enqueued without posting a wakeup task, because drainScheduled stayed set for the entire dispatch. The fix splits the state: DrainScheduled now covers only "posted task not yet started", and a new Dispatching bit covers the dispatch loop for GC-liveness purposes. Worker::drainInbox switches from swap-whole-queue to per-message pops under the lock so nested drains stay FIFO. Touches MessagePortPipe.{h,cpp}, MessagePort.cpp, Worker.cpp, plus a new regression test and one added test in message-port-pipe.test.ts.

Security risks

None identified. This is internal event-loop / message-dispatch machinery with no parsing of untrusted input, no auth/crypto, no resource-limit changes. The state bits are internal and not user-controllable.

Level of scrutiny

High. This is subtle cross-thread concurrency code: atomic state bits read locklessly by the GC visitor (hasPendingActivity), lock/atomic interactions, ownership tracking across nested reentrant drains, and detach/transfer semantics. The ownsDispatching mechanism was added in response to a CodeRabbit finding about nested drains prematurely clearing the outer drain's Dispatching bit — the kind of edge case that's easy to miss. The change to per-message locking in Worker::drainInbox also trades the previous batch-swap contention optimization for correctness under nesting, which is the right call but changes hot-path lock behavior.

Other factors

  • Test coverage is solid: two subprocess regression tests (Worker + MessageChannel shapes) that deadlock/SIGKILL on the unfixed build, plus a 2500-message self-feeding chain that exercises the budget-yield handoff. The PR body shows both tests failing on main and passing with the fix in both debug+ASAN and release.
  • All prior review feedback (comment-cop, CodeRabbit's ownsDispatching and stderr-drain findings, my earlier stderr note) has been addressed and marked resolved.
  • The termination path now explicitly drains and drops the queue outside the lock so transferred ports' peers see 'close' promptly — a behavior improvement over the old code.
  • No bugs surfaced in the automated hunt, but the interaction surface (nested waits × transfer × GC × budget-yield × termination) is large enough that a maintainer familiar with the #29937 design should confirm the state-machine changes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

1 participant