MessagePort: don't drop messages when a GC runs during dispatch - #33051
MessagePort: don't drop messages when a GC runs during dispatch#33051robobun wants to merge 2 commits into
Conversation
drainAndDispatch() popped a message and decremented the pipe's queued count before deserializing and dispatching it. Once the peer port is closed, that count is all that keeps hasPendingActivity() true, so a GC triggered by the allocating deserialization could collect the JS wrapper (and with it the weakly held message listener) while the message was being delivered: release builds silently dropped the message and debug builds hit "ASSERTION FAILED: m_wrapper" in JSEventListener::ensureJSFunction. Release the QueuedOne unit only after dispatchOneMessage() returns, so the wrapper stays reachable for the whole pop to dispatch window. close() resets the state word, so the release is skipped if the handler closed the port. Regression from #29937 (v1.3.14).
|
Updated 3:44 AM PT - Jun 29th, 2026
❌ @robobun, your commit e613491 has 1 failures in
🧪 To try this PR locally: bunx bun-pr 33051That installs a local version of the PR into your bun-33051 --bun |
|
Found 3 issues this PR may fix:
🤖 Generated with Claude Code |
|
Checked all three; none is closed by this change, so I left the
This PR only changes when a popped message stops counting as pending activity for the GC. It does not touch event-loop refs and does not add |
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (1)
Walkthrough
ChangesIn-flight message pending activity fix
Suggested reviewers
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
There was a problem hiding this comment.
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 277-281: The GC-stress subprocess assertion in the
message-port-pipe test is over-constraining stderr by requiring it to be exactly
empty. Update the expectation in message-port-pipe.test.ts to keep validating
the combined result from the subprocess, but make stderr unconstrained so benign
debug/ASAN diagnostics do not fail the test. Use the existing test block around
the stdout/exitCode assertion to locate the check and preserve the stdout and
exitCode expectations.
🪄 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: fbab7f96-30ff-436b-b733-5eb4b8d96857
📒 Files selected for processing (3)
src/jsc/bindings/webcore/MessagePortPipe.cppsrc/jsc/bindings/webcore/MessagePortPipe.htest/js/web/workers/message-port-pipe.test.ts
There was a problem hiding this comment.
I didn't find any bugs and the reasoning looks sound, but this reorders a lock-protected state decrement in the MessagePort GC-liveness path, so it's worth a human sanity check on the close()/detach()-during-dispatch interactions.
Extended reasoning...
Overview
This PR moves the QueuedOne decrement in MessagePortPipe::drainAndDispatch() from before dispatchOneMessage() to after it, so that queuedCount > 0 (and therefore hasPendingActivity()) holds across the deserialize/dispatch window. It adds a queuedCount(st) > 0 guard to skip the decrement when a handler-triggered close() has already reset the state word, updates the header comment for the new invariant, and adds a GC-stress regression test in message-port-pipe.test.ts.
Security risks
None. This is purely an internal GC-liveness/ordering fix in the MessagePort dispatch loop; no parsing, auth, or externally-controlled-input surface is touched.
Level of scrutiny
Moderate-to-high. The diff is small (~12 logic lines) and the rationale is clearly explained, but it changes an invariant in a lock-protected atomic state machine that interacts with the JSC GC visitor, concurrent send() from the peer thread, and handler-driven close()/detach(). I traced the obvious cases (handler closes the port → state reset to Closed, guard skips; handler transfers the port → detach() preserves queuedCount, post-dispatch decrement still applies and the next loop iteration's s.port.get() != port check exits; concurrent peer send() only adds units so the guard can't mis-skip) and they look correct, but GC-lifetime + lock-ordering code in a core runtime primitive is exactly where a maintainer's eyes are valuable.
Other factors
The one CodeRabbit comment (don't constrain stderr in the GC-stress subprocess test) was addressed in e613491. The CI failures are build-rust infrastructure on several targets and unrelated to this C++/test change. The PR description is thorough, the regression test reproduces the failure on unfixed builds, and the author already verified the surrounding test files still pass. No CODEOWNERS entry matches these paths.
|
For whoever does that sanity check, the two interactions called out above, as I traced them:
In both cases the release (or its skip) happens before the loop re-checks identity, so a unit is never dropped twice and never survives a |
|
CI status for e613491, build 66696 (finished): 282 jobs passed, 4 failed. None of the 4 is related to this change:
The new regression test in |
Repro
With a small heap (
BUN_JSC_forceRAMSize=16777216) this reliably loses a message on bun 1.4.0 (fired=49/50on every run here), and a debug build aborts every run:"post a message, then close the sending port" is the normal one-shot pattern, so under memory pressure some fraction of those deliveries silently never happen.
Cause
MessagePortPipe::drainAndDispatch()pops a message and decrements the pipe's queued count under the lock, then deserializes and dispatches it.MessagePort::hasPendingActivity()isso once the peer is closed and the last message has been popped, nothing reports pending activity for the receiving port. Its
JSMessagePortwrapper is only kept alive throughJSMessagePortOwner::isReachableFromOpaqueRoots(), and the listener function is only marked (JSEventListener::visitJSFunction) when that wrapper is visited. Deserialization insideMessageEvent::create()allocates, so a GC there collects the wrapper and clears the listener's weakm_jsFunction; the subsequentdispatchEvent()finds no function and does nothing (or tripsASSERT(m_wrapper)on debug builds). The message is gone.Regression from #29937 (v1.3.14): before that,
hasPendingActivity()returnedm_entangled, which kept the wrapper alive across dispatch (and also forever after the peer closed, which is the leak that change fixed).Fix
Release the message's
QueuedOneunit only afterdispatchOneMessage()returns, soqueuedCount > 0holds for the whole pop to dispatch window and the wrapper (with its listeners) survives any GC in between. The release is skipped when the handler calledclose(), which already reset the state word; subtracting there would underflow the count.takeOne()(used byreceiveMessageOnPort) still decrements at pop time: there the port is an argument on the JS stack, so its wrapper is already rooted across the deserialize.BroadcastChannel::hasPendingActivity()does not depend on a queued count and is unaffected.Verification
New test in
test/js/web/workers/message-port-pipe.test.ts: 100 one-shot channels whose receiving ports are unreferenced, a 1 MiB string payload (its deserialization reports enough extra memory to trigger collections inside the dispatch window),BUN_JSC_forceRAMSizeto keep the heap small.Without the
src/change the test fails every run on a debug build (them_wrapperassertion above, exit 134) and on the released 1.4.0 (fired=98/100). With the change it passes, along with the rest ofmessage-port-pipe.test.ts,message-channel.test.ts,message-port-closed-leak.test.ts,message-port-context-destroy-leak.test.ts,message-event.test.ts,worker-postmessage-transfer.test.ts, andworker_threads.test.ts.Independent of the open close-event work (#32564, #32565): neither changes when the queued count is decremented, and a peer torn down without an explicit
close()never gets a close notification, so this window has to be closed in the drain loop itself. The repro above also never exits on its own because theonmessagesetter's event-loop ref is not released when the peer closes; that is the separate issue #32564 addresses, and it is why the test child callsprocess.exit(0)after printing its count.