Skip to content

Deliver a main-thread postMessage() to the global's own message listeners - #42567

Open
obuxim wants to merge 1 commit into
oven-sh:robobun/91a99687/main-thread-message-listener-exitfrom
obuxim:claude/main-thread-postmessage-self-dispatch
Open

obuxim wants to merge 1 commit into
oven-sh:robobun/91a99687/main-thread-message-listener-exitfrom
obuxim:claude/main-thread-postmessage-self-dispatch

Conversation

@obuxim

@obuxim obuxim commented Sep 13, 2026

Copy link
Copy Markdown

Stacked on #41731. #41731 fixes the hang of #42512. This PR fixes its other half: the message is delivered. Together they fix #42512.

Problem

  • On the main thread, postMessage(x) does nothing and returns undefined. jsFunctionPostMessage (src/jsc/bindings/webcore/Worker.cpp) finds no messaging proxy and returns. A global message listener never gets the message.
  • Code that finds globalThis.postMessage uses it as setImmediate. @dbml/core bundles VS Code's scheduler, which does this. On bun its callbacks never run. With Do not keep the main thread alive for a global 'message' listener #41731 alone the process exits, but the callbacks are still dropped.
  • In a browser, window.postMessage(x, "*") queues a message event on the window. Node has no global postMessage, so the same code takes its setTimeout path there.

Fix

  • With no messaging proxy, jsFunctionPostMessage runs the window post message steps against the global itself. It serializes the message with its transfer list, then posts a task that dispatches a MessageEvent on globalEventScope.
  • The call forms are Window's: postMessage(message, targetOrigin, transfer) and postMessage(message, { targetOrigin, transfer }). The worker form throws a TypeError on the shim's postMessage(x, "*") (checked in a worker on bun 1.4.2).
  • The main thread's origin is opaque, so "*" and "/" (the default: the caller's own origin) deliver. Any other valid URL names a different origin: the message is serialized, its buffers are transferred, and it is dropped. A string that does not parse throws a SyntaxError (step 5).
  • The posted task keeps the loop alive until it runs (el.tasks counts in is_event_loop_alive_excluding_immediates). The listener itself does not, after Do not keep the main thread alive for a global 'message' listener #41731. Microtasks drain between two messages, as in a browser (message 1, microtask 1, message 2, microtask 2 on a debug build).
  • Serializing and disentangling the ports moves into serializeMessage, which the worker path now calls too. That drops two RETURN_IF_EXCEPTION checks from the worker path that could not fire: they followed branches that already returned on an exception, and disentanglePorts reports through ExceptionOr, not the VM.
  • event.origin is "null", the serialization of an opaque origin. event.source is null: bun's MessageEvent.source holds only a MessagePort, where a browser sets the WindowProxy.
  • Types: globals.d.ts gets the two Window overloads and no longer says the call does nothing on the main thread. Docs: one sentence in docs/runtime/workers.mdx.
  • Verified: 5 new tests in test/js/web/web-globals.test.js. On a debug build of this branch the file passes (30 tests). With only Worker.cpp reverted, so Do not keep the main thread alive for a global 'message' listener #41731 alone, the 5 new tests fail on their assertions: no event, [] instead of the 6 delivered messages, detached 8 8 8 and no errors, and empty output where messageerror and the shim's callbacks should print. On bun 1.4.2 they time out, because the listener still holds the process open. Also ran test/js/web/workers/worker-postmessage-transfer.test.ts and test/js/web/workers/worker.test.ts (77 tests across the 3 files pass). Under BUN_JSC_validateExceptionChecks=1, a script that runs every new path on the main thread and one that runs the worker path through serializeMessage report no findings.

Background

  • jsFunctionPostMessage is the global postMessage of every Zig::GlobalObject. In a worker, WebWorker__getMessagingProxy returns the proxy to the parent. On the main thread it returns null.
  • GlobalEventScope is the EventTarget behind globalThis.addEventListener. Worker messages reach it through WorkerMessagingProxy::drainInbox. The new task mirrors that path, including messageerror when deserialization throws.
Notes

Why stacked on #41731. Without #41731, a global message listener holds the main thread open forever, so every new test hangs instead of failing on its assertion. After #41731 merges I rebase this branch on main.

The design choice. #41731 left it open: "Whether it should throw or self-dispatch (browser window.postMessage semantics) is a separate decision." Throwing, or removing globalThis.postMessage on the main thread as in Node, breaks code that calls it today without an error. This PR takes the browser behavior. If another option is preferred, the tests show every behavior that changes.

Differences from a browser. event.source is null (see above). An invalid targetOrigin throws a JS SyntaxError, where the spec has a DOMException named "SyntaxError": ExceptionCode::SyntaxError maps to a JS SyntaxError everywhere in bun, on purpose (src/jsc/bindings/JSDOMExceptionHandling.cpp, the comment above case ExceptionCode::SyntaxError), and new WebSocket("not a url") throws the same. A message to another origin is dropped before the task is posted, where the spec drops it inside the task. The two are not observably different.

The messageerror test uses the payload from #39408's tests: a resizable ArrayBuffer and a DataView whose offset is only in bounds after a getter resized the buffer. It serializes, then fails to deserialize.

Overlap with #41818. #41818 also rewrites the global postMessage declaration in globals.d.ts and adds lines to test/integration/bun-types/fixture/worker.ts. Whichever lands second rebases. The combined declaration set is #41818's (message, transfer) and (message, options?) for a worker, plus this PR's (message, targetOrigin, transfer?) and targetOrigin in options.

Local runs. test/integration/bun-types/bun-types.test.ts has 10 failures in my environment, the same set with and without this diff. None of them mention postMessage. They come from the @types/node the fixtures resolve here (fs/promises exists, tls BunConnectionOptions). The new fixture lines in test/integration/bun-types/fixture/worker.ts type-check.

🤖 Generated with Claude Code

https://claude.ai/code/session_019n9mLkdcWtSLUNt9XeJUs4

…ners

The main thread has no parent, so the global postMessage() returned
without doing anything and a global 'message' listener never got the
message. It now follows the window post message steps: the message is
serialized with its transfer list and a task dispatches a MessageEvent
on the global. "*" and "/" deliver; any other valid targetOrigin is a
different origin, so the message is dropped after serialization.

Code that uses globalThis.postMessage as setImmediate, like the VS Code
scheduler that @dbml/core bundles, now runs its callbacks.

The worker path shares the new serializeMessage helper.

Refs oven-sh#42512

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019n9mLkdcWtSLUNt9XeJUs4
@obuxim
obuxim requested a review from alii as a code owner September 13, 2026 05:30

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

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

@robobun

robobun commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator

A note about the base of this PR. #41731 is closed in favor of #32482. #32482 is the original PR for #24256. Since its rebase on 2026-09-07 it makes the same source change as #41731: the isWorkerVM() gate in GlobalEventScope::onDidChangeListenerImpl. Only one of the two can land.

Nothing changes for this PR today. The base branch robobun/91a99687/main-thread-message-listener-exit stays in place, so this PR stays open and its diff stays the same.

When #32482 lands:

  1. Rebase onto main without the Do not keep the main thread alive for a global 'message' listener #41731 commit: git rebase --onto origin/main db2b5ee676.
  2. Change the base of this PR to main. Use the Edit button next to the title, or gh pr edit 42567 --base main.

I tried the rebase on main plus #32482. It stops on one conflict, in test/js/web/web-globals.test.js. The incoming side of the conflict holds the two tests from #41731 and then your tests, because your tests sit directly after them. Keep your tests. The two tests from #41731 can stay or go: both pass with the change in #32482. The other four files apply cleanly.

The PR body refers to #41731 in several places. Those references become #32482.

This branch has not been deployed

No deployments
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.

2 participants