Skip to content

worker_threads: remove the global onmessage accessor inside a node worker - #36639

Closed
robobun wants to merge 5 commits into
mainfrom
claude/farm/7996a8d3/worker-threads-onmessage-double-fire
Closed

robobun wants to merge 5 commits into
mainfrom
claude/farm/7996a8d3/worker-threads-onmessage-double-fire

Conversation

@robobun

@robobun robobun commented Aug 1, 2026 •

Copy link
Copy Markdown
Collaborator

What does this PR do?

Fixes #7816
Fixes #29211
Fixes #25860
Fixes #19453
Fixes #29635
Fixes #25454

Node.js does not expose a global onmessage event-handler accessor inside a worker_threads worker. Bun installs one for Web Workers and left it in place for node workers as well. Emscripten's pthread worker shim sets up message handling like this:

parentPort.on('message', function(data) {
  onmessage({ data: data });
});
// ...
self.onmessage = (e) => { /* handle 'load', 'run', ... */ };

In Bun, parentPort is backed by the same global event target as self, so the same message was delivered to both the parentPort.on wrapper (which forwards to globalThis.onmessage) and directly to the self.onmessage event-handler attribute. The pthread load command therefore ran twice, and the second run hit Emscripten's legacyModuleProp abort trap:

RuntimeError: Aborted(Module.arguments has been replaced with plain arguments_ ...)

Fix

  • In a node:worker_threads worker (gated on the existing _isNodeWorker flag), delete globalThis.onmessage so self.onmessage = fn is a plain property assignment, matching Node.
  • Make parentPort.onmessage (and onmessageerror) track its own listener via self.addEventListener / removeEventListener instead of aliasing self.onmessage, so it keeps working after the global accessor is gone.

Web Workers (new globalThis.Worker(...)) still have the globalThis.onmessage accessor; only node-style workers change.

Verification

const { init } = require("z3-solver");
const { Context } = await init();
const Z3 = Context("z3");
const S = new Z3.Solver();
const X = Z3.Int.const("X");
S.add(X.sub(42).eq(0));
console.log(await S.check());            // sat
console.log(S.model().eval(X).toString()); // 42

Works with both z3-solver@4.12.4 (from #7816) and z3-solver@5.0.0.

Related

#26293, #29215 and #35655 are earlier, larger attempts at this same problem (real MessagePort for parentPort, separate event target with forwarders, and removing all Web Worker globals respectively). This PR is the minimal version: it only removes the one global that causes the double delivery.


[review] gate passed · iteration 0 · 3 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/worker_threads.test.ts
bun test v1.4.0 (3de4278eb)

test/js/node/worker_threads/worker_threads.test.ts:
(pass) support eval in worker [2836.13ms]
(pass) all worker_threads module properties are present [39.04ms]
(pass) markAsUncloneable and markAsUntransferable markers are private, unforgeable, and permanent [41.88ms]
(pass) all worker_threads worker instance properties are present [202.75ms]
(pass) threadId module and worker property is consistent [285.85ms]
(pass) receiveMessageOnPort works across threads [2431.95ms]
(pass) receiveMessageOnPort works as FIFO [14.17ms]
(pass) you can override globalThis.postMessage [2553.87ms]
(pass) support require in eval [2324.93ms]
cwd /workspace/bun
realpath test/js/node/worker_threads/fixture-argv.js
(pass) support require in eval for a file [2629.99ms]
(pass) support require in eval for a file that doesnt exist [2576.68ms]
(pass) support worker eval that throws [2643.32ms]
(pass) execArgv option > inherits the parent's execArgv when falsy or unspecified [11223.95ms]

... (truncated)

release without fix: BUILD FAILED (no junit output)
bun test v1.4.0-canary.1 (1498d7b77)

test/js/node/worker_threads/worker_threads.test.ts:

# Unhandled error between tests
-------------------------------
SyntaxError: Export named 'markAsUncloneable' not found in module 'node:worker_threads'.
-------------------------------


 0 pass
 1 fail
 1 error
Ran 1 test across 1 file. [710.00ms]
__F:-1: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/worker_threads.test.ts
bun test v1.4.0 (3de4278eb)

test/js/node/worker_threads/worker_threads.test.ts:
(pass) support eval in worker [2717.37ms]
(pass) all worker_threads module properties are present [41.05ms]
(pass) markAsUncloneable and markAsUntransferable markers are private, unforgeable, and permanent [46.96ms]
(pass) all worker_threads worker instance properties are present [268.60ms]
(pass) threadId module and worker property is consistent [301.68ms]
(pass) receiveMessageOnPort works across threads [2999.12ms]
(pass) receiveMessageOnPort works as FIFO [14.15ms]
(pass) you can override globalThis.postMessage [2224.87ms]
(pass) support require in eval [2921.62ms]
cwd /workspace/bun
realpath test/js/node/worker_threads/fixture-argv.js
(pass) support require in eval for a file [2903.13ms]
(pass) support require in eval for a file that doesnt exist [2508.63ms]
(pass) support worker eval that throws [2940.61ms]
(pass) execArgv option > inherits the parent's execArgv when falsy or unspecified [11481.61ms]

... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 1754ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/51] gen generated_host_exports.rs
generated_host_exports.rs: 94 exports (host=3, lazy=10, generic=81, rust=0); 239 extern-C blocks audited
[2/51] gen JS modules (bundle-modules)
Preprocess modules (21837ms)
Bundle modules (405ms)
Postprocesss modules (1173ms)
Bundle Functions (3626ms)
Generate Code (201ms)

[27.31s] Bundled "src/js" for production
  2561 kb
  193 internal modules
  13 native modules
  90 internal functions across 19 files
[2/38] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

�[1m�[92m   Compiling�[0m bun_resolver v0.0.0 (/workspace/bun/src/resolver)
�[1m�[92m   Compiling�[0m bun_router v0.0.0 (/workspace/bun/src/router)
�[1m�[92m   Compiling�[0m bun_bundler v0.0.0 (/workspace/bun/src/bundler)
�[1m�[92m   Compiling�[0m bun_standalone_graph v0.0.0 (/workspace/bun/src/standalone_graph)
�[1m�[92m   Compiling�[0m bun_transpiler v0.0.0 (/workspace/bun/src/transpiler)
�
... (truncated)
diff hotspot
src/js/node/worker_threads.ts                      | 41 +++++++++-------
 test/js/node/worker_threads/worker_threads.test.ts | 56 ++++++++++++++++++++++
 test/napi/node-napi-tests/test/common/index.js     |  4 +-
 3 files changed, 82 insertions(+), 19 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                                reads  edits  tests
src/js/node/worker_threads.ts                           5      8      0
test/js/node/worker_threads/worker_threads.test.ts      1      2      0
test/napi/node-napi-tests/test/common/index.js          3      2      0

…rker

Node.js does not expose a global `onmessage` event-handler accessor inside
a worker_threads worker, but Bun installs one for Web Workers and left it in
place for node workers as well. Emscripten's pthread worker shim registers
both `parentPort.on('message', d => onmessage({data: d}))` and
`self.onmessage = handler`; since parentPort is backed by the same global
event target, the handler ran once for each path and the pthread 'load'
command was processed twice, aborting with
"Module.arguments has been replaced with plain arguments_".

Delete `globalThis.onmessage` in node workers so `self.onmessage = fn`
is a plain property assignment, and make `parentPort.onmessage` track its
own listener via addEventListener/removeEventListener rather than aliasing
`self.onmessage`.

Fixes #7816
Fixes #29211
@coderabbitai

coderabbitai Bot commented Aug 1, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 7 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: a7cacd40-193a-47e6-a358-d8012f8706e7

📥 Commits

Reviewing files that changed from the base of the PR and between 5f7e62d and 3de4278.

📒 Files selected for processing (3)
  • src/js/node/worker_threads.ts
  • test/js/node/worker_threads/worker_threads.test.ts
  • test/napi/node-napi-tests/test/common/index.js

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

@robobun

robobun commented Aug 1, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 3:05 AM PT - Aug 1st, 2026

❌ @robobun, your commit 3de4278 has some failures in Build #86785 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 36639

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

bun-36639 --bun

@github-actions github-actions Bot added the claude label Aug 1, 2026
@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Found 4 issues this PR may fix:

  1. node:worker_threads: Messages delivered twice when both parentPort.on('message') and self.onmessage are registered #25860 - Directly describes the double-message-delivery bug when both parentPort.on('message') and self.onmessage are registered in a node worker
  2. Z3 doesn't work in Bun #19453 - z3-solver fails because Emscripten's pthread shim receives duplicate messages that corrupt its run-dependency counter
  3. z3-solver behavior different from Node #29635 - z3-solver behavior differs from Node due to the same duplicate message delivery in Emscripten pthread workers
  4. z3 optimizer crashes #25454 - z3 optimizer crashes from duplicate messages triggering Emscripten's Module.arguments replacement assertion

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #25860
Fixes #19453
Fixes #29635
Fixes #25454

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. Stop dispatching parent messages to self.onmessage in node:worker_threads workers #29215 - Also stops dispatching parent messages to self.onmessage in node worker_threads workers; fixes the same issues (RangeError: Maximum call stack size exceeded when using z3-solver #7816, Emscripten pthread-based WASM module fails to initialize in Bun while working in Node #29211)
  2. worker_threads: don't expose Web Worker globals on globalThis #35655 - Also removes Web Worker globals (including onmessage) from globalThis inside node worker_threads workers
  3. fix(worker_threads): use MessagePort for parentPort instead of dispatching to self #26293 - Also changes parentPort to stop dispatching to self, preventing duplicate message delivery

🤖 Generated with Claude Code

Comment thread src/resolver/resolver.rs Outdated
Comment thread src/resolver/resolver.rs Outdated
globalThis.onmessage is no longer defined inside a node:worker_threads worker,
so referencing it as a bare identifier threw a ReferenceError when the napi
test harness was loaded inside a worker.
@robobun
robobun force-pushed the claude/farm/7996a8d3/worker-threads-onmessage-double-fire branch from 6c682be to 7694a07 Compare August 1, 2026 05:38
Comment thread src/js/node/worker_threads.ts Outdated
Comment thread src/js/node/worker_threads.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.

Additional findings (outside current diff — PR may have been updated during review):

  • 🔴 src/resolver/resolver.rs:1949-1966 — This PR ships two unrelated, untested bundler-resolver behavior changes: the browser_map_overrides_builtin gate here (letting the importer's "browser" map override the builtin Node polyfill table under polyfill_node_globals), and treating jsnext:main like module for the auto-main require/import fallback at line ~5495. Neither is mentioned in the PR description or the commit message that added them (6c682be), and neither has a test/bundler/ test — grep -r jsnext:main test/ returns nothing. Please drop the resolver.rs hunk from this worker_threads PR (or move it to its own PR with bundler tests).

    Extended reasoning...

    What changed

    src/resolver/resolver.rs in this PR contains two independent behavioral changes to bundler module resolution, both landed in commit 6c682bec whose message is "test: reference onmessage/onerror via globalThis in napi common harness":

    1. Lines ~1949–2016 — a new browser_map_overrides_builtin check inside the polyfill_node_globals block. Previously, when polyfill_node_globals was on, an import of e.g. "buffer" unconditionally short-circuited to NodeFallbackModules::map() (or the disabled-node: stub). Now the resolver first consults the importer's enclosing package.json "browser" map via check_browser_map::<PackagePath>; if it matches, the polyfill/disabled-stub path is skipped and resolution falls through to check_package_path with the bare (un-node:-prefixed) name.
    2. Line ~5495 — the auto-main require/import fallback previously fired only for key == b"module"; it now also fires for key == b"jsnext:main", so require("pkg") on a package that lists jsnext:main + main (the comment names moment) will now resolve to the CJS main instead of the ESM jsnext:main file.

    Both are user-visible changes to which file a package import resolves to.

    Why this is unrelated to the PR

    The PR title, description, and both commit messages are exclusively about removing the global onmessage accessor inside a node:worker_threads worker to fix Emscripten pthread double-delivery (#7816 et al.). polyfill_node_globals is a bundler option (it controls browser-target polyfill substitution in Bun.build), not runtime worker resolution — z3-solver in a worker does not go through this code. The jsnext:main change is likewise pure bundler main-field ordering. Neither appears in the PR description; the commit that added them says only "reference onmessage/onerror via globalThis in napi common harness". These slipped in from another branch.

    No tests

    The PR touches exactly three files: src/resolver/resolver.rs, test/js/node/worker_threads/worker_threads.test.ts, and test/napi/node-napi-tests/test/common/index.js. There is no test/bundler/ or test/resolver/ addition. grep -r "jsnext:main" test/ returns zero hits; grep -r "polyfill_node_globals\|browser_map_overrides_builtin" test/ returns zero hits. Neither behavior is covered anywhere in the suite.

    Step-by-step: concrete effect of change (2)

    Take moment, whose package.json has "main": "./moment.js" and "jsnext:main": "./src/moment.js" but no "module" field. Under Bun.build({ target: "browser" }) with the default main-field list (which includes jsnext:main), a require("moment"):

    • Before this PR: the loop reaches key = "jsnext:main", auto_main && key == b"module" is false, so it takes the plain else branch and returns the jsnext:main ESM entry — require("moment") gets { default: … }.
    • After this PR: auto_main && (key == b"module" || key == b"jsnext:main") is true, so the auto-main fallback runs, finds "main", and (since kind == Require) returns ./moment.js — require("moment") gets the CJS export.

    That may well be the desired fix, but it is a real change in what bundles resolve to, shipped without a single test and hidden inside a worker_threads PR where nobody bisecting a bundler regression would look.

    REVIEW.md

    The repo's review rules are explicit on both counts:

    Every behavioral change ships an automated test in the same PR. "Verified manually", unnamed "existing tests", and benchmarks don't count, even for one-liners.

    don't ride file-wide standardization on a focused bugfix.

    Fix

    Drop the src/resolver/resolver.rs hunk from this PR entirely. If the two resolver changes are intentional, they belong in their own PR with itBundled tests under test/bundler/ covering (a) a "browser": { "buffer": "./shim.js" } map winning over the builtin polyfill under target: "browser", and (b) require("pkg") on a jsnext:main-only package resolving to main.

Comment thread test/js/node/worker_threads/worker_threads.test.ts
Comment thread src/js/node/worker_threads.ts Outdated
Comment thread src/js/node/worker_threads.ts Outdated
@robobun
robobun force-pushed the claude/farm/7996a8d3/worker-threads-onmessage-double-fire branch from b0c44ee to 9deeba9 Compare August 1, 2026 05:57
Comment thread src/js/node/worker_threads.ts Outdated
robobun added 2 commits August 1, 2026 06:18
…user function

Keeps the handler-attribute slot independent of addEventListener registrations
of the same function, matching Node's defineEventHandler semantics.

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

Both earlier inline findings are addressed and I didn't find new issues, but this changes user-visible behavior in the Node compat layer (removes globalThis.onmessage inside a node:worker_threads worker and reworks parentPort.onmessage semantics), and there are three other open PRs (#26293, #29215, #35655) taking different approaches to the same bug — worth a human deciding which approach lands.

What was reviewed

  • delete globalThis.onmessage is gated on _isNodeWorker; the accessor is a configurable own property (putDirectCustomAccessor(..., 0) in ZigGlobalObject.cpp:3187), so the delete succeeds and Web Workers are unaffected.
  • The new defineHandlerAttribute wrapper keeps the handler-attribute slot independent of parentPort.addEventListener (the earlier dedup concern).
  • The napi common/index.js change avoids a ReferenceError on bare onmessage when this harness is loaded inside a node worker after the accessor is deleted.
Extended reasoning...

Overview

The PR touches three files: src/js/node/worker_threads.ts (rewrites parentPort.onmessage/onmessageerror as self-tracking accessors backed by a stable wrapper on self.addEventListener, and deletes globalThis.onmessage inside a node worker), test/js/node/worker_threads/worker_threads.test.ts (two new tests: the Emscripten double-delivery repro and a parentPort.onmessage round-trip), and test/napi/node-napi-tests/test/common/index.js (references onmessage/onerror via globalThis. so the harness survives the deleted accessor).

Prior review

I left two inline findings on earlier revisions: (1) commit 6c682be had accidentally reverted the entire implementation — fixed by force-pushing the branch back to 8c5312c + 7694a07; (2) the first version of the setter passed the raw user function to self.addEventListener, so it deduped against parentPort.addEventListener('message', fn) — fixed in b526997 by registering a stable wrapper closure. Both are resolved in the current diff.

Security risks

None. This only affects which global properties exist inside a node-style worker and how parentPort.onmessage is wired to the global event target. No untrusted-input parsing, auth, or filesystem paths are touched.

Level of scrutiny

Medium. The mechanism is small and well-tested, but it is a user-visible Node-compat behavior change: code running inside a node:worker_threads worker that previously relied on globalThis.onmessage being an event-handler accessor will now see a plain (initially absent) property. That is the Node behavior and is exactly the point of the fix, but the repo's .claude/docs/landing-prs.md calls out Node/Web-compat and API-surface changes as needing deliberate review. fakeParentPort() runs for any non-main thread that loads this module, so parentPort.onmessage also stops aliasing self.onmessage inside a raw globalThis.Worker that imports worker_threads — a minor semantic shift on an already-incomplete emulation.

Other factors

The PR description explicitly positions this as the minimal alternative to three larger open PRs (#26293 real MessagePort for parentPort, #29215 separate event target with forwarders, #35655 remove all Web-Worker globals). A maintainer should decide which of those approaches to land rather than an automated approval picking one. The two new tests await real conditions (once(worker, 'message')), clean up via worker.terminate() in finally, and would fail on the pre-fix build (hadOnMessage true, count 2).

@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Closing: the double delivery this PR targeted was fixed on main by #37075 (merged as 9d519e8). In a node:worker_threads worker, parentPort is now a real MessagePort entangled with the parent Worker's public port, and worker.postMessage() posts to that port, so parent messages never reach the global scope's onmessage accessor in the first place. Removing the accessor is no longer needed to get Node's behavior.

Every issue this PR listed is closed as fixed by #37075: #7816, #19453, #25454, #25860, #29211, #29635. The issue comments include z3-solver 4.13 runs against main for #7816, #19453, #25454, #29211 and #29635.

Checked with a worker that registers both parentPort.on("message") and self.onmessage (the emscripten pthread pattern) and counts what one worker.postMessage() produces:

main also carries a test for this case, "a parent message reaches parentPort only, not self.onmessage, in a node worker" in test/js/node/worker_threads/worker_threads.test.ts. #29215, the other open PR for the same bug, is closed for the same reason.

script used for the counts above
import { Worker } from "node:worker_threads";

const src = `
  const { parentPort } = require("node:worker_threads");
  if (typeof globalThis.self === "undefined") globalThis.self = globalThis;
  const counts = { parentPort: 0, selfOnmessage: 0, globalAddEventListener: 0 };
  parentPort.on("message", (m) => { counts.parentPort++; self.onmessage({ data: m }); });
  self.onmessage = () => { counts.selfOnmessage++; };
  if (typeof self.addEventListener === "function") self.addEventListener("message", () => { counts.globalAddEventListener++; });
  parentPort.on("message", () => setImmediate(() => parentPort.postMessage(counts)));
`;

const w = new Worker(src, { eval: true });
const result = await new Promise((resolve, reject) => {
  w.once("message", resolve);
  w.once("error", reject);
  w.postMessage("ping");
});
await w.terminate();
console.log(JSON.stringify(result));
bun 1.3.14:        {"parentPort":1,"selfOnmessage":2,"globalAddEventListener":1}
main @ da3851e57:  {"parentPort":1,"selfOnmessage":1,"globalAddEventListener":0}
node v26.3.0:      {"parentPort":1,"selfOnmessage":1,"globalAddEventListener":0}

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

Labels

Projects

None yet

2 participants