Skip to content

fix(runtime): deliver MessageChannel/MessagePort messages on the same thread#5530

Merged
proggeramlug merged 3 commits into
mainfrom
fix/message-channel-same-thread-delivery
Jun 22, 2026
Merged

fix(runtime): deliver MessageChannel/MessagePort messages on the same thread#5530
proggeramlug merged 3 commits into
mainfrom
fix/message-channel-same-thread-delivery

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

Problem

MessagePort.postMessage was a no-op stub (messaging.rs installed noop1/noop2, onmessage = null), so messages were silently dropped:

const mc = new MessageChannel();
mc.port1.onmessage = (e) => console.log(e.data);
mc.port2.postMessage("ping");   // onmessage NEVER fired

This breaks the extremely common MessageChannel-as-macrotask-scheduler pattern (port1.onmessage = cb; port2.postMessage(0) to schedule a task — used by React's scheduler, setImmediate polyfills, async yielders). The scheduled callback never runs, so a program that drives its startup through such a scheduler hangs forever with the event loop idle on the channel.

Fix

Implement same-thread MessageChannel/MessagePort delivery (HTML/Node semantics):

  • Entanglement: new MessageChannel() links port1 ↔ port2.
  • Per-port state (side table keyed by port pointer): entangled-pair link, FIFO incoming VecDeque, started/closed flags, the onmessage value, and addEventListener("message") listeners.
  • postMessage(data): enqueues the value on the entangled port and, if it's started, schedules a task (macrotask) — reusing the existing setImmediate callback-timer queue (crate::timer::js_set_immediate_callback_args), which the event loop already pumps — that dispatches a message event ({ data, type: "message" }) to that port, invoking onmessage and all listeners with this = the port.
  • Lifecycle: a port starts implicitly when onmessage is assigned (getter/setter) or .start()/addEventListener is called; queued messages then flush in FIFO order. .close() stops delivery.
  • GC: a named root scanner marks queued values, handlers, and listeners so they survive event-loop turns.

Structured clone is a pass-through for now (a transfer-list argument is accepted without crashing); deep clone / transfer semantics are follow-ups.

Verification

Standalone: basic delivery, bidirectional, FIFO order, addEventListener alongside onmessage, message posted before the handler is assigned, and the scheduler-chaining pattern (a handler that re-posts) all run to completion. cargo test -p perry-runtime --lib --test-threads=1: 1067 passed. New e2e issue_message_channel_same_thread_delivery.rs (4 cases) passes; the pre-existing worker_threads MessagePort delegation test still passes (factory path unaffected).

Summary by CodeRabbit

  • New Features

    • Implemented same-thread MessageChannel/MessagePort delivery with entangled ports.
    • Added per-port FIFO message queuing and asynchronous delivery.
    • Enabled onmessage and "message" event handling via addEventListener/removeEventListener, including proper start/close lifecycle behavior.
  • Tests

    • Added regression coverage for same-thread delivery ordering, queued messages before assigning handlers, and combined verification of onmessage vs addEventListener callbacks, including cases where handlers trigger follow-up sends.

Previously MessagePort.postMessage was a no-op and onmessage was a plain
null field, so messages were silently dropped. A program that uses a
MessageChannel for async scheduling (port1.onmessage = cb;
port2.postMessage(0)) would idle forever because the scheduled callback
never ran.

Entangle the port1/port2 pair on channel creation and track per-port
delivery state (entangled link, FIFO incoming queue, started/closed
flags, onmessage handler, addEventListener listeners) in a process-global
side table keyed by the port object pointer, GC-rooted so held JS values
survive across event-loop turns.

postMessage structured-clones the value (pass-through; tolerates a
transfer-array arg), enqueues it on the entangled port, and — once that
port is started — schedules a macrotask via the existing setImmediate
callback-timer queue that dispatches a MessageEvent-shaped object
({ data, type: 'message' }) to the onmessage handler and any
addEventListener('message', ...) listeners. Delivery is FIFO, one
macrotask per message. A port starts implicitly on onmessage assignment
(now a getter/setter), .start(), or addEventListener('message'); messages
posted before start queue and flush in order once started. .close() stops
delivery.
@coderabbitai

coderabbitai Bot commented Jun 21, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: e40324f8-d6ca-4661-a987-c40efee32ed1

📥 Commits

Reviewing files that changed from the base of the PR and between b0a4774 and 59b7876.

📒 Files selected for processing (1)
  • crates/perry-runtime/src/messaging.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • crates/perry-runtime/src/messaging.rs

📝 Walkthrough

Walkthrough

Implements same-thread MessageChannel/MessagePort message delivery in the perry runtime. A process-global PORT_STATES side table (Mutex<HashMap> of PortState) tracks entanglement, message queues, start/close state, and handlers. Delivery is scheduled via setImmediate. Four regression tests verify ordering and handler semantics.

Changes

MessageChannel/MessagePort same-thread delivery

Layer / File(s) Summary
Global side table, PortState, and GC integration
crates/perry-runtime/src/messaging.rs
Adds imports for HashMap, VecDeque, Mutex, and atomic types; defines PortState struct and PORT_STATES side table; registers a GC root scanner (port_states_root_scanner_mut) to keep queued values, onmessage, and listener closures alive.
Port pointer resolution, entanglement, and MessageEvent construction
crates/perry-runtime/src/messaging.rs
Adds helpers to resolve the this port pointer, entangle two ports, perform structured_clone, detect callable values, construct MessageEvent-shaped objects, and invoke the onmessage handler.
Macrotask delivery and port operation wiring
crates/perry-runtime/src/messaging.rs
Implements deliver_one_message, schedule_delivery via setImmediate, port_post_message, start_port, port_close, and port_onmessage getter/setter; setter starts the port and flushes queued messages.
Port prototype wiring and channel construction
crates/perry-runtime/src/messaging.rs
Implements message-type filtering and listener management; adds install_port_methods() to register the onmessage accessor and postMessage/start/close/addEventListener/removeEventListener; refactors message_port_object() and updates js_message_channel_new() to entangle the two ports.
Regression tests
crates/perry/tests/issue_message_channel_same_thread_delivery.rs
Adds test harness (locate binary, compile TypeScript, run, assert stdout) and four tests covering basic delivery, FIFO ordering with macrotask repost, pre-assignment queuing, and dual addEventListener/onmessage firing.

Sequence Diagram(s)

sequenceDiagram
  participant TS as TypeScript Program
  participant port_post_message
  participant PORT_STATES as PORT_STATES (PortState queue)
  participant setImmediate as setImmediate scheduler
  participant deliver_one_message
  participant onmessage as onmessage / listeners

  TS->>port_post_message: port2.postMessage(data)
  port_post_message->>PORT_STATES: structured_clone(data), enqueue to port1.queue
  alt port1 is started and not closed
    port_post_message->>setImmediate: schedule_delivery(port1)
  end
  setImmediate-->>deliver_one_message: fire macrotask turn
  deliver_one_message->>PORT_STATES: dequeue one MessageEvent
  deliver_one_message->>onmessage: call onmessage(event) + call each listener(event)
  alt more messages in queue
    deliver_one_message->>setImmediate: schedule_delivery(port1) again
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Poem

🐇 Hoppy news from the warren today,
Two ports entangled, messages on their way!
A side table guards each queued little note,
setImmediate ferries each byte that I wrote.
FIFO and handlers, both fire in turn—
The channel delivers, no messages burn! 🎉

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and concisely describes the main change: implementing same-thread message delivery for MessageChannel/MessagePort, which is the core fix addressing the no-op stub issue described in the PR objectives.
Description check ✅ Passed The PR description is comprehensive and well-structured, covering the problem, fix, verification, and addressing all template requirements including changes, related issues, test plan, and checklist.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
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.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/message-channel-same-thread-delivery

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

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 `@crates/perry-runtime/src/messaging.rs`:
- Around line 360-371: The issue is that in the port_close function, when a port
is closed, the PortState entry remains in the PORT_STATES map indefinitely with
only the closed flag set to true and the queue cleared. Instead of just marking
the state as closed, the entry should be removed from the map when the port
closes. Modify the logic within the with_port_states closure to call
map.remove(&self_ptr) after setting state.closed = true, or implement logic to
only remove the entry when both the port and its entangled partner are closed to
prevent unbounded memory growth in long-running applications.
- Around line 138-181: The `entangled` field in the `PortState` struct is stored
as a raw `usize` pointer but is not visited by the GC scanner in the
`port_states_root_scanner_mut` function, creating a use-after-free risk if the
entangled port is collected while still referenced. Convert the `entangled`
field from `usize` to `f64` to use NaN-boxing like the other pointer fields
(`onmessage` and `listeners`), then add a visitor call for this field in
`port_states_root_scanner_mut` using `visitor.visit_nanbox_f64_slot()` to ensure
the entangled port is properly rooted by the garbage collector. Update any code
that reads or writes to the `entangled` field to work with the f64 NaN-boxed
representation instead of raw pointer arithmetic.
🪄 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: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: fb0e9754-5b5f-4ec4-b790-626daaca05f6

📥 Commits

Reviewing files that changed from the base of the PR and between d032369 and 623e032.

📒 Files selected for processing (2)
  • crates/perry-runtime/src/messaging.rs
  • crates/perry/tests/issue_message_channel_same_thread_delivery.rs

Comment thread crates/perry-runtime/src/messaging.rs
Comment thread crates/perry-runtime/src/messaging.rs
Address CodeRabbit review on the same-thread MessageChannel delivery:

- Root the entangled-partner address in the GC scanner (visit_usize_slot)
  so the paired port survives event-loop turns and its address is rewritten
  on evacuation. Also rewrite the PORT_STATES keys — they are port heap
  addresses, so a moved port would otherwise leave a stale key and silently
  drop later messages.
- Reclaim PORT_STATES entries on close() once the channel is dead (no
  partner, or partner already closed/gone) instead of leaving the entry in
  the map forever, so long-running apps that churn through short-lived
  channels don't accumulate port state without bound.

perry-runtime lib tests: 1067 passed (--test-threads=1). The 4 e2e cases in
issue_message_channel_same_thread_delivery still pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Addressed both CodeRabbit findings in b0a4774:

1. Entangled port not rooted by the GC — the scanner now visits state.entangled with visit_usize_slot, which both roots the partner port (keeps it alive across event-loop turns) and rewrites the address if the port is evacuated. I kept the field as a raw usize rather than NaN-boxing it because it doubles as the PORT_STATES key and is never dereferenced as a pointer — visit_usize_slot is the idiomatic tool here (same pattern as object/native_this_alias.rs) and achieves the identical rooting/rewrite intent.

While there I also fixed a related latent issue: PORT_STATES is keyed by each port's heap address, but the keys weren't being rewritten on evacuation. The scanner now re-keys any moved port so later this-pointer lookups don't miss and silently drop messages.

2. Closed ports never removedport_close now reclaims the side-table entry once the channel is dead (port has no partner, or its partner is already closed/gone), removing both ends so churning through short-lived MessageChannels no longer grows PORT_STATES without bound.

Validation: cargo test -p perry-runtime --lib --test-threads=1 → 1067 passed; the 4 e2e cases in issue_message_channel_same_thread_delivery still pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@proggeramlug
proggeramlug merged commit 315e46a into main Jun 22, 2026
15 checks passed
@proggeramlug
proggeramlug deleted the fix/message-channel-same-thread-delivery branch June 22, 2026 04:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant