Skip to content

Do not keep the main thread alive for a global 'message' listener - #41731

Closed
robobun wants to merge 1 commit into
mainfrom
robobun/91a99687/main-thread-message-listener-exit
Closed

robobun wants to merge 1 commit into
mainfrom
robobun/91a99687/main-thread-message-listener-exit

Conversation

@robobun

@robobun robobun commented Sep 6, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • On the main thread, self.onmessage = fn or addEventListener("message", fn) keeps the process alive forever. Nothing can dispatch to that listener (main-thread postMessage() has no target), so the process hangs. Node exits with code 0. Fixes Bun process doesn’t exit when globalThis.onmessage is set #24256.
  • The cause is GlobalEventScope::onDidChangeListenerImpl (src/jsc/bindings/GlobalEventScope.cpp:16). It refs the event loop while the global scope has a message listener. Fix worker event loop ref/unref + leak #4114 added that rule for workers, and the main-thread global shares the same class.

Fix

  • Take the ref only when clientData(vm)->isWorkerVM() is true. A worker keeps the existing behavior: a global message listener holds it open until the listener is removed.
  • Correct because only a worker has a parent (WorkerMessagingProxy) that dispatches message events onto the global scope. The flag is per VM, so it also covers a ShadowRealm or other extra global created on the main thread, which ScriptExecutionContext::isMainThread() (identifier == 1) does not.
  • Verified: test/js/web/web-globals.test.js (two new tests, the main-thread one times out on stock bun). Also ran test/js/web/workers/worker.test.ts, worker-terminate-lifetime.test.ts, and worker-late-completion.test.ts.

Background

  • GlobalEventScope is the EventTarget behind globalThis.addEventListener / self.onmessage. Every Zig::GlobalObject owns one, on the main thread and in workers.
  • EventTarget::onDidChangeListener is a hook that fires on each listener add, remove, or clear. MessagePort and GlobalEventScope use it to ref or unref the event loop so that a pending message listener keeps the thread alive.
  • JSVMClientData::isWorkerVM() is set once in JSVMClientData::create from whether a WorkerMessagingProxy created the VM.
Notes
  • fix(worker): don't keep the main thread alive for a global message listener #32482 (@0xfandom, June) is the earlier attempt at the same fix with isMainThread(). It still targets BunWorkerGlobalScope.cpp, which Worker / worker_threads: WebCore-shaped lifetimes, joined threads, one ordered VM teardown #37075 renamed to GlobalEventScope.cpp, so it has conflicted since then. This PR carries the same idea onto the renamed file and credits the author in the commit.
  • Main-thread postMessage(x) stays a no-op that returns undefined. Whether it should throw or self-dispatch (browser window.postMessage semantics) is a separate decision.
  • The two worker suites above show two local-only failures that also fail without this diff in this container: terminate() while fs.readFile completions keep arriving (5 s timeout under debug ASAN) and the dns.lookup() LSAN case (the node:fs Binding box, covered by the Bun::generateInternalModule suppression when the stack is deep enough). Neither touches a main-thread global listener. Latest main CI (build 111080) does not list them.

no test proof · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/web/web-globals.test.js

GlobalEventScope refs the event loop while the global scope has a
'message' listener, so a worker stays alive to receive messages from
its parent. The main thread used the same rule. It has no parent, so
nothing can ever dispatch to that listener, and `self.onmessage = fn`
or `addEventListener("message", fn)` kept the process alive forever.

Take the ref only when the VM belongs to a worker.

Fixes #24256

Co-authored-by: 0xfandom <kashyapshivank01@gmail.com>
@coderabbitai

coderabbitai Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

On-demand reviews are free for the next 14 days. After that, they cost $0.25 per reviewed file.

Or wait 1 minute for your next included review.

Check out review usage here.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 29056100-8d75-4ac6-929d-09e326a51f67

📥 Commits

Reviewing files that changed from the base of the PR and between d316760 and db2b5ee.

📒 Files selected for processing (2)
  • src/jsc/bindings/GlobalEventScope.cpp
  • test/js/web/web-globals.test.js

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

@github-actions github-actions Bot added the claude label Sep 6, 2026
@robobun

robobun commented Sep 6, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 11:42 AM PT - Sep 6th, 2026

⏳ @robobun, your commit db2b5ee is still building in Build #111657, but has 2 failures so far (All Failures):

@robobun

robobun commented Sep 6, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: ready for review.

Reproduced on 1.4.3-canary (d316760) with:

// keepalive.mjs
self.onmessage = e => { console.log("got", e.data); };
console.log("handler installed");

bun keepalive.mjs prints the line and never exits (killed after 5 s, rc 137). addEventListener("message", fn) alone does the same. Node exits with rc 0. With this branch both exit with rc 0, and a worker that installs a global message listener still stays alive until the listener is removed (second new test).

CI (build 111657): test/js/web/web-globals.test.js passes on every lane that ran (25/25). The two darwin x64 test jobs expired in the queue without running. The two red jobs are in files this diff does not touch: test/cli/run/run_command.test.ts (the --shell=system Ctrl+C PTY test, alpine aarch64 only) and test/js/node/test/parallel/test-crypto-dh-leak.js (RSS threshold on x64-asan, also red on main). Both are reported separately. The diff itself is green and needs a maintainer review.

@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 — the isWorkerVM() gate is set once at VM creation and never changes, so Add/Remove/Clear stay balanced by construction.

What was reviewed:

  • Refcount balance: m_isWorkerVM is assigned once in JSVMClientData::create and immutable, so every Add that refs has a matching Remove/Clear that passes the same gate.
  • Both entry paths (self.onmessage = and addEventListener("message", ...)) are covered by the main-thread test; the worker test guards the preserved keep-alive behavior through removeEventListener.
  • Tests follow harness conventions (test.concurrent, await using, concurrent pipe drain, output asserted before exit code, tempDir/bunExe/bunEnv).
Extended reasoning...

Overview

This PR changes GlobalEventScope::onDidChangeListenerImpl in src/jsc/bindings/GlobalEventScope.cpp to only ref/unref the event loop for global "message" listeners when the VM is a worker VM. Previously, adding self.onmessage or addEventListener("message", ...) on the main thread would keep the process alive forever even though nothing can dispatch to that listener. Two tests are added to test/js/web/web-globals.test.js: one asserting the main thread exits on its own with a global message listener installed, and one asserting a worker still stays alive until its message listener is removed.

Security risks

None. This is event-loop keep-alive bookkeeping with no untrusted input parsing, no auth/crypto, and no filesystem or network surface. The only observable effect is whether an idle main-thread process with a dangling message listener exits or hangs.

Level of scrutiny

Low-to-moderate. The diff is ~25 lines of C++ that adds a single guard and otherwise reflows the existing switch to early-return style. The critical question for any refcount change is balance across all terminal paths: here the gate (clientData(vm)->isWorkerVM()) is a per-VM boolean set exactly once in JSVMClientData::create (m_isWorkerVM = !!worker) and never mutated, so any Add that took a ref is guaranteed to have its corresponding Remove/Clear pass the same gate. The Clear arm's semantics are unchanged. The !context null check is defensive and harmless.

Other factors

The tests are well-constructed per repo conventions: placed in the existing web-globals.test.js alongside sibling worker/onmessage tests, use test.concurrent with await using spawns, drain stdout/stderr/exited via Promise.all, assert output before exit code, and cover both the setter and addEventListener paths on the main thread. The main-thread test would hang on stock Bun (satisfying the USE_SYSTEM_BUN=1 must-fail rule), and the worker test guards against regressing the original #4114 behavior. No CODEOWNERS cover these paths, and there are no outstanding third-party review objections in the timeline.

@0xfandom

0xfandom commented Sep 7, 2026

Copy link
Copy Markdown

Rebased #32482 onto current main, so it applies to GlobalEventScope.cpp now. I also took the per-VM isWorkerVM() check from here — it's a better gate than the context->isMainThread() one I had, since it says what we actually mean.

Verified locally: the two main thread exits with a global message listener cases pass on the debug build and both hit the 5s timeout with USE_SYSTEM_BUN=1. The one other failure in worker.test.ts (terminate() while fs.readFile completions keep arriving) reproduces on plain main too, so it isn't from this change.

So if you're still up for it, closing this one in favor of #32482 works for me. Thanks for the heads up about the rename.

@robobun

robobun commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator Author

This also fixes #42512 (same hang, reported against 1.4.2). That issue adds a real world case: @dbml/core bundles VS Code's setImmediate shim, which registers a global message listener when globalThis.postMessage exists, so any import of that package never exits on bun. I confirmed the repro from #42512 hangs on main at b993710 and that this diff is the fix I would write. Consider adding Fixes #42512 to the body.

@robobun

robobun commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

Closing in favor of #32482, as offered there on 2026-09-06. @0xfandom rebased #32482 onto current main and took the isWorkerVM() gate from this PR. The two PRs now make the same source change, and #32482 is the original PR for #24256.

The two GlobalEventScope.cpp hunks behave the same. Both return from onDidChangeListenerImpl before the switch when the context is null or clientData(vm)->isWorkerVM() is false. In a worker, Add, Remove, and Clear do what they did before. This PR also flattens the nesting and deletes a stale comment. That does not change behavior.

I built #32482 on current main (09bb546) and ran the tests of both PRs on that build. All of them pass, including the two tests from this PR. Without the GlobalEventScope.cpp change, the two main-thread cases of #32482 time out on the same build. The details are in #32482 (comment). The worker test from this PR (the worker exits after it removes the listener) is offered there.

The branch robobun/91a99687/main-thread-message-listener-exit stays in place, because #42567 is based on it. Do not delete the branch until #42567 has a new base.

@robobun robobun closed this Sep 13, 2026
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.

Bun process doesn’t exit when globalThis.onmessage is set

3 participants