Skip to content

worker_threads: MessagePort.postMessage() returns true like Node.js#34011

Open
robobun wants to merge 12 commits into
mainfrom
farm/8c47b8ac/messageport-postmessage-return-bool
Open

worker_threads: MessagePort.postMessage() returns true like Node.js#34011
robobun wants to merge 12 commits into
mainfrom
farm/8c47b8ac/messageport-postmessage-return-bool

Conversation

@robobun

@robobun robobun commented Jul 12, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

In Node.js, MessagePort.postMessage() returns true when the sending port is live, and undefined when the sending port is already closed, has been transferred, or its peer's close has propagated. Bun was returning undefined unconditionally, so Node-targeting code that branches on the return value (if (!port.postMessage(msg)) ...) always took the failure branch.

Repro

import { MessageChannel } from "node:worker_threads";

const a = new MessageChannel();
a.port1.on("message", () => {});
const rOpen = a.port2.postMessage("x");        // node: true, bun (before): undefined

const b = new MessageChannel();
b.port1.close();
const rPeerClosed = b.port2.postMessage("y");  // node: true, bun (before): undefined

console.log(`open=${rOpen} peerClosed=${rPeerClosed}`);

Fix

MessagePort::postMessage now returns ExceptionOr<JSValue> instead of ExceptionOr<void>. The closed/detached state is sampled on entry (before serialization runs user JS) and also treats a propagated peer close (m_closeEventDispatched) as closed; that snapshot decides between jsUndefined() and jsBoolean(true). A port that becomes detached during serialization returns true with the message dropped, matching Node. The two JS binding overloads surface the value with toJS<IDLAny> instead of toJS<IDLUndefined>.

parentPort.postMessage returns true via the worker_threads.ts wrapper; the shared native jsFunctionPostMessage (which also backs the web-spec self.postMessage inside workers) is unchanged and still returns undefined.

Worker.prototype.postMessage and BroadcastChannel.prototype.postMessage are unchanged (still undefined), which also matches Node.

How did you verify your code works?

New test in test/js/node/worker_threads/worker_threads.test.ts covering open, peer-closed (sync true, then undefined after 'close' fires), self-closed, transferred, and parentPort for both the transferList and { transfer } overloads. The test fails without the src/ changes and passes with them. Existing message-channel.test.ts, message-port-pipe.test.ts, worker-transfer-list.test.ts, worker-postmessage-transfer.test.ts, worker.test.ts, broadcast-channel.test.ts, and test-worker-message-port.js all still pass.


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

fails on main (without fix)
ASAN without fix: 1 FAILED
$ 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/message-port-postmessage-return.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (ac5f75664)

test/js/node/worker_threads/message-port-postmessage-return.test.ts:
 5 |   // open peer
 6 |   {
 7 |     const { port1, port2 } = new MessageChannel();
 8 |     try {
 9 |       port1.on("message", () => {});
10 |       expect(port2.postMessage("x")).toBe(true);
                                          ^
error: expect(received).toBe(expected)

Expected: true
Received: undefined

      at <anonymous> (/workspace/bun/test/js/node/worker_threads/message-port-postmessage-return.test.ts:10:38)
      at <anonymous> (/workspace/bun/test/js/node/worker_threads/message-port-postmessage-return.test.ts:4:95)
(fail) MessagePort.postMessage() returns true, or undefined when the port is closed/deta
... (truncated)

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

test/js/node/worker_threads/message-port-postmessage-return.test.ts:
(pass) MessagePort.postMessage() returns true, or undefined when the port is closed/detached [32.48ms]

 1 pass
 0 fail
 10 expect() calls
Ran 1 test across 1 file. [211.00ms]
__F:0:S:0
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/message-port-postmessage-return.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (ac5f75664)

test/js/node/worker_threads/message-port-postmessage-return.test.ts:
(pass) MessagePort.postMessage() returns true, or undefined when the port is closed/detached [1788.78ms]

 1 pass
 0 fail
 10 expect() calls
Ran 1 test across 1 file. [3.88s]
__F:0:S:0

release with fix: all passed
$ bun scripts/build.ts --profile=release
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
[configured] bun-profile → bun (stripped) in 672ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/29] gen generated_host_exports.rs
generated_host_exports.rs: 91 exports (host=3, lazy=10, generic=78, rust=0); 243 extern-C blocks audited
[2/29] gen cpp.rs (cppbind)
[3/29] gen JS modules (bundle-modules)
Preprocess modules (6472ms)
Bundle modules (27ms)
Postprocesss modules (164ms)
Bundle Functions (659ms)
Generate Code (97ms)

[7.43s] Bundled "src/js" for production
  1913 kb
  162 internal modules
  12 native modules
  90 internal functions across 19 files
[3/20] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: component rus
... (truncated)
diff hotspot
src/js/node/worker_threads.ts                      |  4 +-
 src/jsc/bindings/webcore/JSMessagePort.cpp         |  4 +-
 src/jsc/bindings/webcore/MessagePort.cpp           | 14 ++--
 src/jsc/bindings/webcore/MessagePort.h             |  2 +-
 .../message-port-postmessage-return.test.ts        | 94 ++++++++++++++++++++++
 5 files changed, 109 insertions(+), 9 deletions(-)

gate history · 5 passed · 1 rejected · iteration 2

evidence per changed file
file                                                      reads  edits  tests
src/js/node/worker_threads.ts                                 2      1      0
src/jsc/bindings/webcore/JSMessagePort.cpp                    2      2      0
src/jsc/bindings/webcore/MessagePort.cpp                      7     11      0
src/jsc/bindings/webcore/MessagePort.h                        1      1      0
…/worker_threads/message-port-postmessage-return.test.ts      3      4      0

Node.js returns true from MessagePort.postMessage() when the sending port
is live, and undefined when the sending port is already closed or has been
transferred. Bun was returning undefined unconditionally, so code that
branches on the return value (if (!port.postMessage(msg)) ...) always took
the failure path.

MessagePort::postMessage now returns ExceptionOr<JSValue>: jsUndefined()
on the detached early-return and jsBoolean(true) on every other
non-throwing path. jsFunctionPostMessage (parentPort.postMessage /
self.postMessage inside a worker) now returns true after a successful
enqueue as well, since node's parentPort is a MessagePort.

Worker.postMessage and BroadcastChannel.postMessage are unchanged
(undefined), matching node.
@robobun

robobun commented Jul 12, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 4:14 AM PT - Jul 12th, 2026

@robobun, your commit ac5f756 has 1 failures in Build #72153 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 34011

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

bun-34011 --bun

@coderabbitai

coderabbitai Bot commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

MessagePort.postMessage() now returns true for successful sends and undefined for detached, closed, or exceptional paths. JavaScript bindings preserve the value, worker emulation returns true, and tests cover lifecycle and worker scenarios.

MessagePort return contract

Layer / File(s) Summary
MessagePort return contract
src/jsc/bindings/webcore/MessagePort.h, src/jsc/bindings/webcore/MessagePort.cpp
The method returns JSValue, producing true for successful sends and undefined for detached or exceptional paths.
JavaScript entrypoint conversions
src/jsc/bindings/webcore/JSMessagePort.cpp, src/js/node/worker_threads.ts
Bindings preserve the native return value, while fakeParentPort.postMessage forwards arguments and returns true.
Return value coverage
test/js/node/worker_threads/message-port-postmessage-return.test.ts
Tests cover open, closed, detached, transferred, and worker postMessage return values.
🚥 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 states the main change: MessagePort.postMessage now returns true to match Node.js.
Description check ✅ Passed The description includes both required sections and provides a detailed behavior change plus verification evidence.

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: 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/node/worker_threads/worker_threads.test.ts`:
- Around line 292-308: Wrap the worker test block centered on the Worker
instance w in a try/finally, registering cleanup before the asynchronous wait
and assertions. Move await w.terminate() into the finally block so the worker is
terminated when message handling, error rejection, or
expect(results).toEqual(...) fails.
🪄 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: 82b0654c-265c-4e64-a84f-4caa0ae61cca

📥 Commits

Reviewing files that changed from the base of the PR and between 8624c2b and 256721e.

📒 Files selected for processing (5)
  • src/jsc/bindings/webcore/JSMessagePort.cpp
  • src/jsc/bindings/webcore/MessagePort.cpp
  • src/jsc/bindings/webcore/MessagePort.h
  • src/jsc/bindings/webcore/Worker.cpp
  • test/js/node/worker_threads/worker_threads.test.ts

Comment thread test/js/node/worker_threads/worker_threads.test.ts 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/Worker.cpp Outdated
robobun added 2 commits July 12, 2026 06:53
- Revert jsFunctionPostMessage to return undefined (it also backs the
  web-spec self.postMessage inside workers); move the node-compat
  return true into the parentPort wrapper in worker_threads.ts.
- Gate the undefined return on m_closeEventDispatched as well, so a
  port whose peer's close has propagated returns undefined like node
  instead of true forever.
- Sample the detached state before serialization so a getter that
  closes or transfers the port mid-serialize still returns true,
  matching node.
- Test: extend the peer-closed case to await the 'close' event and
  assert undefined afterwards.
worker_threads.test.ts has pre-existing debug-build timeouts unrelated
to this change; putting the new coverage in a focused file lets the
gate verify fail-before/pass-after cleanly.
Comment thread src/jsc/bindings/webcore/MessagePort.cpp Outdated
autofix-ci Bot and others added 2 commits July 12, 2026 07:09
…end work

70bc291 early-returned before disentanglePorts() when only
m_closeEventDispatched was set (peer close propagated but self not
detached), so a MessagePort in the transfer list stayed entangled.
Compute the return value once from the entry-time snapshot and fall
through to disentangle/send gated on m_isDetached alone, preserving
pre-PR side-effect ordering while still returning undefined when the
peer's close has propagated.

@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/message-port-postmessage-return.test.ts`:
- Around line 4-43: Add a test case alongside the existing
MessagePort.postMessage() coverage that detaches the source port from a getter
invoked during message serialization, such as by closing or transferring it.
Assert that postMessage() returns true and that no message is delivered, while
retaining cleanup for all created ports.
- Around line 51-59: Update the Promise in the worker message collection flow to
listen for the worker’s "exit" event and settle based on the number of collected
messages: resolve only after exactly two messages have been received, and reject
when the worker exits before completing the protocol or sends unexpected extra
messages. Preserve rejection on the existing "error" event and ensure every
worker failure path settles the awaited operation.
- Around line 6-43: Wrap each MessageChannel test block in try/finally so
cleanup is registered before assertions or awaits execute. In the normal,
peer-closed, own-closed, and transferred-port cases, move or centralize closure
of every live port into the finally block, preserving the existing assertions
and close-event await behavior.
🪄 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: d0f1cacd-81b0-42cb-8d67-0a25cc2777e6

📥 Commits

Reviewing files that changed from the base of the PR and between 256721e and eaa527f.

📒 Files selected for processing (3)
  • src/js/node/worker_threads.ts
  • src/jsc/bindings/webcore/MessagePort.cpp
  • test/js/node/worker_threads/message-port-postmessage-return.test.ts

Comment thread test/js/node/worker_threads/message-port-postmessage-return.test.ts
Comment thread test/js/node/worker_threads/message-port-postmessage-return.test.ts
Comment thread test/js/node/worker_threads/message-port-postmessage-return.test.ts
Comment thread test/js/node/worker_threads/message-port-postmessage-return.test.ts Outdated
The synchronous delivered check was vacuous: MessagePort delivery is
always async, so delivered is false right after postMessage() whether
or not the message was enqueued. Await port1's 'close' event (which
fires after peer-close propagation flushes port1's inbox) so a
regression that re-enabled the send would actually fail the assertion.

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

LGTM — all four rounds of review feedback have been addressed and the final diff is minimal.

Checked: returnValue is sampled before SerializedScriptValue::create() and gates only the return value; the sole post-serialize early return is still !isEntangled(), so disentanglePorts()/m_pipe->send() run exactly as pre-PR. jsFunctionPostMessage is untouched, so web-spec self.postMessage still returns undefined. Test now awaits port1's 'close' before asserting delivered === false, so that assertion is no longer vacuous.

Extended reasoning...

Overview

Changes MessagePort::postMessage from ExceptionOr<void> to ExceptionOr<JSValue>, returning jsBoolean(true) when the port was live at call time and jsUndefined() when already detached/closed or the peer's close has propagated (m_closeEventDispatched). The two JSMessagePort.cpp overloads surface the value via toJS<IDLAny>. parentPort.postMessage returns true via the worker_threads.ts wrapper (native jsFunctionPostMessage unchanged). One new test file with 6 sub-cases.

Security risks

None. No untrusted-input parsing, no allocation/lifetime changes, no new state. returnValue is a stack-local tagged JSValue (jsUndefined/jsBoolean) — no rooting concerns.

Level of scrutiny

Medium — native C++ in the cross-thread MessagePort path, but the change is return-value-only. Control flow after serialization is byte-identical to pre-PR (the only early return is still if (!isEntangled())), so disentanglePorts(), the entangled-peer warn-and-close, and m_pipe->send() all run on exactly the same conditions as before. No new fields, no GC/refcount/lock changes.

Other factors

This PR went through four rounds of my own review: (1) peer-close propagation never flipping the return to undefined — fixed by folding m_closeEventDispatched into the entry-time snapshot; (2) sampling after serialization instead of before — fixed; (3) jsFunctionPostMessage leaking true into web-spec self.postMessage — reverted, moved to the JS wrapper; (4) the m_closeEventDispatched early return skipping disentanglePorts() — fixed by gating only the return value; plus a vacuous delivered assertion — fixed with a 'close'-event barrier. All resolved and re-verified in the current diff. CodeRabbit's test-hygiene comments (try/finally cleanup, worker exit handler, close-during-serialize coverage) were also applied. The bug hunting system found nothing on the final revision. Test fails on main and passes on the PR per the evidence block.

Comment thread src/jsc/bindings/webcore/MessagePort.cpp Outdated
close() sets m_isClosing before flushQueuedMessagesBeforeClose() runs
user handlers and only sets m_isDetached afterwards, so a flushed
handler calling postMessage() on the closing port saw true where node
returns undefined. Sibling of the m_closeEventDispatched case; the
disentangle/send path below is unaffected (still gated on m_isDetached).
Comment thread test/js/node/worker_threads/message-port-postmessage-return.test.ts Outdated
…rgence

Node delivers the message in the close-during-serialize case (data_ and
sibling_ stay non-null until the async OnClose callback); Bun's close()
detaches synchronously so the post-serialize guard drops it. That drop
is pre-existing and not what this PR changes, so don't lock it in. The
.toBe(true) on the return value is the load-bearing assertion here.

@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 further issues found — all six rounds of prior feedback have been addressed and the bug hunter is clean on ac5f756. Deferring to a human for the final sign-off since this touches the C++ MessagePort state machine and makes the shared Web/Node MessagePort.prototype.postMessage() return true (a minor Web-spec divergence that's probably fine given Bun's MessagePort is already Node-flavored, but worth a maintainer nod).

What was reviewed:

  • Return-value snapshot now covers m_isDetached || m_isClosing || m_closeEventDispatched, sampled before serialization; control flow (disentangle/send) is unchanged from pre-PR so transfer-list ports are still detached on the peer-closed path.
  • jsFunctionPostMessage (web self.postMessage) left returning undefined; parentPort.postMessage returns true via the worker_threads.ts wrapper. Worker.prototype.postMessage and BroadcastChannel.postMessage unchanged.
  • Test: try/finally cleanup on all channels and the worker, worker exit wired to reject, and the delivered assertion that enshrined a pre-existing Bun/Node divergence was dropped.
Extended reasoning...

Overview

Changes MessagePort::postMessage to return ExceptionOr<JSValue> instead of ExceptionOr<void>, computing jsBoolean(true) vs jsUndefined() from an entry-time snapshot of m_isDetached || m_isClosing || m_closeEventDispatched. The two JSMessagePort.cpp binding overloads switch toJS<IDLUndefined>toJS<IDLAny> to surface the value. parentPort.postMessage returns true via the JS wrapper in worker_threads.ts (the shared native jsFunctionPostMessage is untouched, so web self.postMessage stays undefined). A new test file covers open, peer-closed (sync and post-propagation), self-closed, transferred, close-during-serialization, and parentPort cases.

Security risks

None identified. No new input parsing, no allocation sizing, no auth/crypto/permission surfaces. The return value is a boolean/undefined derived from existing internal state flags.

Level of scrutiny

Medium-high. The native diff is small (~14 lines) and control flow is unchanged from pre-PR — only the return values differ. But it sits inside the MessagePort lifecycle state machine (m_isDetached/m_isClosing/m_closeEventDispatched/isEntangled()), which took six review iterations to get right: peer-close propagation, entry-time vs post-serialization sampling, the disentanglePorts() regression, the m_isClosing flush-window sibling, and two test-quality passes. That convergence history is reassuring, but also signals the semantics are subtle enough that a maintainer familiar with MessagePortPipe should confirm the final shape.

Other factors

  • Web-spec divergence: MessagePort.prototype.postMessage() is a WHATWG-spec void method; this PR makes it return true in Bun. That's consistent with Bun's already-Node-flavored MessagePort (it has .ref()/.unref()/.on()), and the PR carefully kept Worker.prototype.postMessage, BroadcastChannel.postMessage, and self.postMessage returning undefined. Still a small design call worth a human ack.
  • Test coverage: comprehensive, hermetic (try/finally on every channel and the worker, exit wired to reject), and verified to fail on main / pass on the branch. Existing message-channel.test.ts, worker.test.ts, broadcast-channel.test.ts, etc. reported passing.
  • Known limitation acknowledged by the author: a bare sender port with no listeners never learns the peer closed (no peerClosed() notification), so it keeps returning true — pre-existing property of Bun's peer-close notification path, out of scope here.

@robobun

robobun commented Jul 12, 2026

Copy link
Copy Markdown
Collaborator Author

The diff itself is green. The new test test/js/node/worker_threads/message-port-postmessage-return.test.ts passes on all lanes in every run, and the existing MessagePort/worker suites are unaffected.

Remaining CI failures are unrelated flakes:

  • build 72132: test-worker-message-port-transfer-terminate.js SIGABRT on x64-asan (a getOwnPropertyDescriptor exception-check assertion during the Worker.postMessage vs terminate() race that test deliberately stresses; this PR only touches MessagePort::postMessage's return value, not Worker::postMessage or serialization; 30 local ASAN runs pass and it did not recur in 72153), plus bake HMR / install-migration / napi gcUntil flakes.
  • build 72153: terminal-platform-gaps.test.ts (Windows ConPTY rendering) and bun-install-registry.test.ts (registry version mismatch), plus one broken infrastructure job.

Ready for a maintainer to merge.

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