Skip to content

Detect unsettled top-level await in entry-point loading instead of hanging - #30551

Open
robobun wants to merge 1 commit into
mainfrom
farm/dcb7437b/fix-19049-unsettled-tla-hang
Open

Detect unsettled top-level await in entry-point loading instead of hanging#30551
robobun wants to merge 1 commit into
mainfrom
farm/dcb7437b/fix-19049-unsettled-tla-hang

Conversation

@robobun

@robobun robobun commented May 12, 2026

Copy link
Copy Markdown
Collaborator

Fixes #19049
Fixes #14951
Fixes #33283

Reproduction

// hang.test.ts
await new Promise(() => setTimeout(() => {}, 50));
$ bun test hang.test.ts
bun test v1.3.14

hang.test.ts:
# … hangs forever at 100% CPU

The mock.module + node:http2 setup in the original #19049 report is a red herring — any test file with a top-level await on a promise that never settles hits this, including await new Promise(() => {}). Same for bun run (#14951). (#29546, a top-level await on AbortSignal.timeout, is not in scope: it already resolves on main today because the wait polls unref'd work; what this PR does with that case is the open question in the discussion below.)

Root cause

EventLoop::wait_for_promise loops on promise.status() == Pending with tick() + auto_tick(), and never checks whether anything can still make progress. Once the last ref'd handle goes away, auto_tick() falls into the !loop.is_active() branch which calls the non-blocking tick_without_idle(), so the whole wait degenerates into a busy-spin that burns one core forever.

Both load_entry_point (for bun run) and load_entry_point_for_test_runner (for bun test) go through this in their non-watch paths (src/jsc/VirtualMachine.rs), as does load_preloads (src/runtime/jsc_hooks.rs).

Fix

Add VirtualMachine::wait_for_module_promise, used only by the three entry-point loaders. It's the same tick() + auto_tick() loop but breaks out when nothing remains that could settle the promise after draining tasks and microtasks.

The liveness check is has_pending_loop_work() (active handles, tasks, pending refs, concurrent tasks, immediates), not is_event_loop_alive(): the latter short-circuits on unhandled_error_counter != 0, which in bun test persists across files, so an unhandled rejection in file A would misreport every later file with ordinary async TLA as "never resolved". Covered by a dedicated test.

wait_for_promise itself is unchangedexpect.rs:862 and other callers assert Unwrapped::Pending => unreachable!() after calling it, so changing its contract (as #27215 did) regresses the test runner. For the same reason #14950 (expect(p).resolves on a promise that can never settle) is out of scope here: that is the expect.rs call site, not entry-point loading.

Callers now handle a still-pending return:

bun test (src/runtime/cli/test_command.rs) — report the file as a load error and move on (or bail with the same teardown as a rejected load under --bail):

hang.test.ts:
error: Top-level await never resolved while loading hang.test.ts and nothing is keeping the event loop alive.

 0 pass
 1 fail

bun run (src/runtime/cli/run_command.rs) — after the main loop and on_before_exit, warn + exit 13 (matching Node.js). beforeExit handlers fire first and can resolve the await to continue normally, for any number of beforeExit → resolve → suspend again cycles. The warning names the module(s) actually suspended on their own await (via Bun__findStalledTopLevelAwait in ZigGlobalObject.cpp, which walks the module registry for EvaluatingAsync records with hasTLA and no pending async dependencies), one line per stalled module like Node. --print skips printing the internal module promise when the entry never settled.

$ bun entry.mjs   # entry → mid → leaf, leaf has the stuck await
Warning: Detected unsettled top-level await at /path/to/leaf.mjs
$ echo $?
13

Relationship to other PRs

This PR is the combined best of #29739 and the original approach here (per review): #29739's main-loop structure, rejected-promise dedup sentinel, and stalled-module registry walk, plus the wait_for_module_promise raw-work liveness check that bun test needs. #29549 (--hot) and #27215 (changed wait_for_promise itself, breaking expect) remain independent. #14950 is explicitly out of scope.

Rebase notes

Rebased past #32621, which removed all .zig porting-reference sources from src/. The earlier revisions of this PR kept the .zig mirrors (src/bun.js.zig, src/jsc/VirtualMachine.zig, src/runtime/cli/test_command.zig) in sync as a porting reference; those files no longer exist on main, so this PR is now Rust + C++ only. No behavior change from that resolution — the Rust implementation was already the compiled one.

Verification

$ bun bd test test/regression/issue/19049/19049.test.ts
 14 pass
 0 fail

$ USE_SYSTEM_BUN=1 bun test test/regression/issue/19049/19049.test.ts
# hangs on the first test without the fix (the bug), times out the subprocess

The suite covers: never-settling TLA in bun test (incl. after a timer fires, --bail, continuing to the next file, cross-file unhandled-rejection isolation, and the original mock.module + preload repro), and bun run exit 13 (entry, sub-import, naming the stalled leaf, one warning per stalled sibling, --preload, --print, and a beforeExit-resolves positive control).


[review] gate passed · iteration 28 · 6 files touched

fails on main (without fix)
ASAN without fix: 12 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" "test/regression/issue/19049/19049.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 (eda73e317)

test/regression/issue/19049/19049.test.ts:
35 |   test("reports an error instead of hanging (never-resolving Promise)", async () => {
36 |     using dir = tempDir("issue-19049-test", {
37 |       "hang.test.ts": `await new Promise(() => {});`,
38 |     });
39 |     const r = await run({ cmd: [bunExe(), "test", "hang.test.ts"], cwd: String(dir) });
40 |     expect(r.signalCode).toBeNull();
                              ^
error: expect(received).toBeNull()

Received: "SIGTERM"

      at <anonymous> (/workspace/bun/test/regression/issue/19049/19049.test.ts:40:26)
(fail) bun test: unsettled top-level await > reports an error instead of hanging (never-resolving Promise) [15081.19ms]
48 |   test("reports an error aft
... (truncated)

release without fix: 12 FAILED
bun test v1.4.0-canary.1 (1498d7b77)

test/regression/issue/19049/19049.test.ts:
35 |   test("reports an error instead of hanging (never-resolving Promise)", async () => {
36 |     using dir = tempDir("issue-19049-test", {
37 |       "hang.test.ts": `await new Promise(() => {});`,
38 |     });
39 |     const r = await run({ cmd: [bunExe(), "test", "hang.test.ts"], cwd: String(dir) });
40 |     expect(r.signalCode).toBeNull();
                              ^
error: expect(received).toBeNull()

Received: "SIGTERM"

      at <anonymous> (/workspace/bun/test/regression/issue/19049/19049.test.ts:40:26)
(fail) bun test: unsettled top-level await > reports an error instead of hanging (never-resolving Promise) [15006.91ms]
48 |   test("reports an error after a pending timer fires without resolving", async () => {
49 |     using dir = tempDir("issue-19049-timer", {
50 |       "timer.test.ts": `await new Promise(() => setTimeout(() => {}, 50));`,
51 |     });
52 |     const r = await run({ cmd: [bunExe(), "test", "timer.test.ts"], cwd: String(dir) });
53 |     expect(r.signalCode).toBeNull();
                              ^
error: expect(received).toBeNull()

Received: "SIGTE
... (truncated)
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/regression/issue/19049/19049.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 (eda73e317)

test/regression/issue/19049/19049.test.ts:
(pass) bun test: unsettled top-level await > reports an error instead of hanging (never-resolving Promise) [483.70ms]
(pass) bun test: unsettled top-level await > reports an error after a pending timer fires without resolving [487.28ms]
(pass) bun test: unsettled top-level await > continues to the next file [462.98ms]
(pass) bun test: unsettled top-level await > an unhandled rejection in one file does not taint async TLA in a later file [482.84ms]
(pass) bun test: unsettled top-level await > original repro: mock.module + preload [527.16ms]
(pass) bun test: unsettled top-level await > --bail bails out after an unsettled TLA failure [431.46ms]
(pass) bun run: unsettled t
... (truncated)

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)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     eda73e3176
  features     (none)

22 deps, 106 codegen, 1168 objects in 825ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1231] gen bindgenv2
[2/1231] install /workspace/bun
bun install v1.3.14 (0d9b296a)

Checked 124 installs across 170 packages (no changes) [57.00ms]
[3/1231] gen ErrorCode+*.h
[4/1231] fetch zlib
[zlib] up to date
[5/1231] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[6/1231] fetch tinycc
[tinycc] up to date
[7/1230] install /workspace/bun/packages/bun-error
bun install v1.3.14 (0d9b296a)

Checked 1 install across 2 packages (no changes) [13.00ms]
[8/1230] fetch picohttpparser
[picohttpparser] up to date
[9/1230] subst deps/zlib/zlib.h
[10/1230] gen .bind.ts → GeneratedBindings.cpp
[11/1230] ge
... (truncated)
diff hotspot
src/jsc/VirtualMachine.rs                 |  99 +++++++++++-
 src/jsc/bindings/ZigGlobalObject.cpp      |  29 ++++
 src/runtime/cli/run_command.rs            |  78 +++++++++-
 src/runtime/cli/test_command.rs           |  91 ++++++++---
 src/runtime/jsc_hooks.rs                  |  45 ++++--
 test/regression/issue/19049/19049.test.ts | 245 ++++++++++++++++++++++++++++++
 6 files changed, 543 insertions(+), 44 deletions(-)

gate history · 1 passed · 0 rejected · iteration 28

evidence per changed file
file                                       reads  edits  tests
src/jsc/VirtualMachine.rs                      8     10     52
src/jsc/bindings/ZigGlobalObject.cpp           1      1     52
src/runtime/cli/run_command.rs                 2      2     52
src/runtime/cli/test_command.rs                5      7     52
src/runtime/jsc_hooks.rs                       9     10     52
test/regression/issue/19049/19049.test.ts      8     18     52

@coderabbitai

coderabbitai Bot commented May 12, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Detects unsettled top-level await across module evaluation, test execution, and script shutdown by adding a VM wait helper that avoids busy-spins, explicit pending-promise handling in the test runner, runtime shutdown detection that warns and sets exit code 13, and regression tests.

Changes

Unsettled top-level await detection

Layer / File(s) Summary
VM module promise waiting with early-exit
src/jsc/VirtualMachine.zig
Introduces waitForModulePromise helper that breaks the promise-wait loop when the event loop is no longer alive, preventing infinite busy-spins. Applied to preload, test-runner, and normal entry-point loading flows.
Test command pending promise detection
src/cli/test_command.zig
Explicitly handles promise.status() == .pending as an unsettled top-level await error: reports the failure, flushes output, increments failure count, and applies bail logic including summary printing, JUnit write, and exit code 1.
Runtime eval-and-print unsettled TLA detection
src/bun.js.zig
After onBeforeExit, detects still-pending internal promises by performing an extra VM tick and event-loop auto-drain; if pending remains and no exit code is set, assigns exit code 13 and prints a “Detected unsettled top-level await” warning; rejected promises are reported and mapped to exit code 1 when unset.
Regression test suite
test/regression/issue/19049/19049.test.ts
Adds tests for bun test and bun run: never-resolving TLA, timer-delayed unresolved TLA, multi-file behavior, preload repro, entry/sub-import/preload unsettled TLA warnings and exit codes, and a beforeExit resolution positive control.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The code changes fully address the linked issues: #19049 (bun test crash/hang), #14951 (top-level await CPU spin), and #29546 (AbortSignal.timeout hang).
Out of Scope Changes check ✅ Passed All changes are scoped to entry-point loading paths (bun test, bun run, preloads) and directly address the unsettled top-level await detection problem.
Description check ✅ Passed The description explains the problem, root cause, fix, scope, related issues, and verification results in detail.
Title check ✅ Passed The title clearly and concisely describes the main change: detecting unsettled top-level await instead of hanging.

Warning

Review ran into problems

🔥 Problems

Git: Failed to clone repository. Please run the @coderabbitai full review command to re-trigger a full review. If the issue persists, set path_filters to include or exclude specific files.


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

@robobun

robobun commented May 12, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 3:58 AM PT - Aug 15th, 2026

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

@github-actions

Copy link
Copy Markdown
Contributor

Found 3 issues this PR may fix:

  1. awaiting top level no-op promise that never resolves is missing an error/warn, causing 100% cpu usage #14951 - Directly matches: top-level await new Promise(r => {}) in entry point causes 100% CPU busy-spin with no error/warning
  2. AbortSignal.timeout() abort event never fires when signal is passed to a plain JS Promise at top-level #29546 - AbortSignal.timeout() in a top-level await never fires and hangs forever due to entry-point TLA busy-spin
  3. bun test waits at 100% cpu usage for a promise that will resolve after expect() #14950 - bun test spins at 100% CPU waiting on a promise from expect(...).resolves that settles only after expect() returns, same entry-point TLA busy-spin

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

Fixes #14951
Fixes #29546
Fixes #14950

🤖 Generated with Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. runtime: exit 13 on unsettled top-level await instead of hanging #29739 - Same fix: exits with code 13 on unsettled top-level await by adding event-loop-liveness check in loadEntryPoint
  2. Exit unsettled top-level await instead of hanging / busy-looping #29549 - Same fix: adds waitForPromiseOrLoopExit to break out of busy-spin when nothing can wake the event loop
  3. fix: detect unsettled top-level await and exit instead of busy-waiting #27215 - Earliest attempt: modifies waitForPromise itself to check isEventLoopAlive() and exit with code 13

🤖 Generated with Claude Code

@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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/jsc/VirtualMachine.zig (1)

2290-2295: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Return pending preload promises instead of continuing startup.

After waitForModulePromise() returns, this only propagates .rejected. If a preload's top-level await is still .pending, loadPreloads() falls through and continues with later preloads or the main entrypoint, even though the helper's contract says callers must handle pending on return.

Suggested fix
         } else {
             this.eventLoop().performGC();
             this.waitForModulePromise(promise);
         }

-        if (promise.status() == .rejected)
-            return promise;
+        switch (promise.status()) {
+            .fulfilled => {},
+            .rejected, .pending => return promise,
+        }
     }
🤖 Prompt for 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.

In `@src/jsc/VirtualMachine.zig` around lines 2290 - 2295, After calling
waitForModulePromise() in loadPreloads(), the code only returns when
promise.status() == .rejected; change the control flow to return the promise
whenever its status is not .fulfilled (i.e., handle both .pending and
.rejected). Locate the promise handling after waitForModulePromise() in
VirtualMachine.loadPreloads (references: waitForModulePromise, promise.status())
and update the check to return the promise if status != .fulfilled so callers
receive pending preload promises instead of continuing startup.
🤖 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 `@src/jsc/VirtualMachine.zig`:
- Around line 1054-1065: Summary: waitForModulePromise currently returns early
using isEventLoopAlive(), which can be false due to unrelated
unhandled_error_counter and incorrectly abort module settlement. Fix: In
waitForModulePromise, replace the isEventLoopAlive() bail-out with a direct
"loop has work" check on the event loop internals (i.e., inspect the event
loop's pending work counters such as timers/refs/tasks/microtasks) instead of
calling isEventLoopAlive(); ensure you use this.eventLoop().tick()/autoTick()
loop semantics and only break when there is truly no pending work to drive the
promise (so unhandled_error_counter does not cause an early return).

---

Outside diff comments:
In `@src/jsc/VirtualMachine.zig`:
- Around line 2290-2295: After calling waitForModulePromise() in loadPreloads(),
the code only returns when promise.status() == .rejected; change the control
flow to return the promise whenever its status is not .fulfilled (i.e., handle
both .pending and .rejected). Locate the promise handling after
waitForModulePromise() in VirtualMachine.loadPreloads (references:
waitForModulePromise, promise.status()) and update the check to return the
promise if status != .fulfilled so callers receive pending preload promises
instead of continuing startup.
🪄 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: 29e9b59a-305d-4dc3-83ef-d9b4f2980353

📥 Commits

Reviewing files that changed from the base of the PR and between 314ffe3 and 5280356.

📒 Files selected for processing (4)
  • src/bun.js.zig
  • src/cli/test_command.zig
  • src/jsc/VirtualMachine.zig
  • test/regression/issue/19049/19049.test.ts

Comment thread src/jsc/VirtualMachine.zig Outdated
Comment thread src/jsc/VirtualMachine.zig Outdated
Comment thread src/jsc/VirtualMachine.zig Outdated
Comment thread src/bun.js.zig Outdated
Comment thread src/bun.js.zig Outdated
Comment thread src/runtime/cli/test_command.zig Outdated

@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/regression/issue/19049/19049.test.ts`:
- Around line 11-25: Change the test suite to run sequentially instead of
concurrently by replacing describe.concurrent with describe (so the child
processes spawned by run are not started in parallel), and add an explicit outer
test timeout greater than the child timeout (run's timeout = 15_000) — e.g., set
the suite or tests to 20_000 ms via your test framework's timeout API
(jest.setTimeout or test.timeout) so the overall test has a clear upper bound.
🪄 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: 02cae021-cb75-4bc4-b693-c92761e8eab4

📥 Commits

Reviewing files that changed from the base of the PR and between a0efa63 and 85cb41f.

📒 Files selected for processing (3)
  • src/bun.js.zig
  • src/jsc/VirtualMachine.zig
  • test/regression/issue/19049/19049.test.ts

Comment thread test/regression/issue/19049/19049.test.ts
Comment thread src/bun.js.zig Outdated
Comment thread src/bun.js.zig Outdated
Comment thread src/bun.js.zig Outdated
Comment thread src/bun.js.zig Outdated
Comment thread src/bun.js.zig Outdated
Comment thread src/bun.js.zig Outdated
@robobun

robobun commented May 12, 2026

Copy link
Copy Markdown
Collaborator Author

Status: waiting on a maintainer decision (see below)

Branch is rebased onto main at 0d48eb1 as a single commit; all earlier review findings are addressed (latest round: byte-split source lint, stale loader docs, comment trimming; comment-cop now reports nothing on the diff).

Blocking question, laid out with evidence in this comment: four tests merged to main after this PR was written (#34478 x3 in spawn.test.ts, #37666 x1 in abort.test.ts) pin that a top-level await on unref'd work still resolves. This PR currently applies Node's rule (exit 13 there too, verified identical to Node 26), so those four fail on every lane; that is the only CI red attributable to the diff. Either re-homing those tests (option A, recommended) or building an unref-aware liveness check (option B) is a behavior decision I would like a maintainer to make before I change tests main merged. I will implement whichever is chosen promptly.

Everything else: test/regression/issue/19049/19049.test.ts (14 cases) passes on every lane; the remaining reds in the last full build were retried flakes (bun-lock, test-cluster-shared-leak, test-child-process-fork-net-server). Fixes list updated per the Aug 13 triage (#33283 added, #29546 removed, #14950 de-linked).

Comment thread src/cli/test_command.zig Outdated
@alii

alii commented May 12, 2026

Copy link
Copy Markdown
Member

Should we close the duplicates? e.g. 29549

@robobun

robobun commented May 12, 2026

Copy link
Copy Markdown
Collaborator Author

@alii re: closing duplicates — here's how they compare:

This PR is the only one that covers bun test (#19049).

@alii

alii commented May 12, 2026

Copy link
Copy Markdown
Member

How does this compare to 29739? What are the best implementations?

@robobun

robobun commented May 12, 2026

Copy link
Copy Markdown
Collaborator Author

@alii comparing this PR to #29739 piece by piece:

Aspect #29739 (Dylan) #30551 (this PR) Better
bun test (#19049) not touched — still hangs loadEntryPointForTestRunner + test_command.zig .pending arm this PR (unique)
--preload with stuck TLA not touched — still hangs loadPreloads propagates .pending, names the preload in the error this PR (unique)
Liveness check in the wait loop isEventLoopAlive() raw handles/tasks/refs/immediates/concurrent_tasks this PRisEventLoopAlive() short-circuits on unhandled_error_counter != 0, which in bun test persists across files, so an unhandled rejection in file A makes every later file with ordinary async TLA bail with a bogus "never resolved". Verified regression, covered by a test here. (For bun run-only it doesn't matter.)
bun run main-loop structure while (true) { while (alive) {…}; onBeforeExit(); if (p pending) { tick(); if (alive) continue; } break; } — folds onBeforeExit into the main loop one-shot onBeforeExit() + tick + optional second onBeforeExit() after the resumption #29739 — the continue naturally handles any number of beforeExit → resolve → more work → beforeExit cycles. Mine handles one cycle; a second resolve-on-second-beforeExit hits a spurious exit 13 (🟡 review nit, very contrived).
Late-rejection dedup gates .rejected on pending_internal_promise_reported_at != hot_reload_counter gates on "was .pending at block entry" #29739 — the sentinel is robust regardless of when the .pending → .rejected transition happened (e.g. inside onBeforeExit()'s own tick loop). Mine covers the common case but has a narrow gap (🟡 nit).
Warning message Bun__findStalledTopLevelAwait (C++ registry walk) — names the exact module(s) suspended on their own TLA, so import "./mid"; mid imports "./leaf"; leaf has stuck await → warning says leaf.mjs, not entry.mjs. Matches Node's at <path> format. generic Detected unsettled top-level await; names the preload if it was a preload #29739 — strictly better UX.
Tests bun run scenarios incl. TLA cycles, sibling modules, process.exitCode interaction bun run + bun test + preload + cross-file unhandled_error_counter guard complementary

tl;dr the best implementation is this PR's scope/liveness + #29739's bun.js.zig structure + #29739's module-name detection. Concretely:

  • Keep from here: waitForModulePromise (with the raw-work check), the loadEntryPointForTestRunner / loadPreloads call sites, test_command.zig's .pending arm, the bun test + preload + cross-file regression tests.
  • Take from runtime: exit 13 on unsettled top-level await instead of hanging #29739: the while (true) { … onBeforeExit(); … continue; } main-loop restructure in bun.js.zig (replaces my post-onBeforeExit block entirely and closes the 🟡 nits), the pending_internal_promise_reported_at gate on the late-.rejected arm, and Bun__findStalledTopLevelAwait + reportUnsettledTopLevelAwait for the warning.

Happy to fold #29739's bun.js.zig + ZigGlobalObject.cpp changes into this PR if that's easier than merging both, or this can land first for the bun test fix and #29739 can rebase on top — loadEntryPoint's inline loop there would just become this.waitForModulePromise(promise) and the rest is additive.

@alii

alii commented May 12, 2026

Copy link
Copy Markdown
Member

@robobun can you make this PR be the best of both please

@robobun

robobun commented May 12, 2026

Copy link
Copy Markdown
Collaborator Author

@alii done in 6f3835a — this PR is now the combined best-of-both:

From #29739:

  • bun.js.zig main loop restructured to while (true) { drain; onBeforeExit(); if (entry pending) { tick; if alive continue } break } — handles any number of beforeExit → resolve → more work → beforeExit cycles, closes all the 🟡 edge-case review nits
  • .rejected arm gated on pending_internal_promise_reported_at != hot_reload_counter — robust dedup regardless of when the transition happened
  • Bun__findStalledTopLevelAwait (ZigGlobalObject.cpp) + reportUnsettledTopLevelAwait — walks the module registry so the warning names the module actually suspended on its own await:
    $ bun entry.mjs  # entry → mid → leaf, leaf has the stuck await
    Warning: Detected unsettled top-level await at /path/to/leaf.mjs
    

Kept from here:

  • waitForModulePromise with the raw-work liveness check (not isEventLoopAlive()) — needed for bun test so unhandled_error_counter from file A doesn't break file B's async TLA
  • loadEntryPointForTestRunner / loadPreloads / test_command.zig .pending arm — the bun test + preload coverage that runtime: exit 13 on unsettled top-level await instead of hanging #29739 doesn't have
  • Preload-specific error naming

10 regression tests (added one for the stalled-module naming), all pass. The Node parallel tests (test-exception-handler2, test-process-exception-capture*, process.test.js exitCode) still pass. zig:check-all clean on all platforms.

#29739 can be closed once this lands — everything in its diff is now here, plus the bun test side. #27215 is superseded. #29549's --hot/watcher work is still independent if that's wanted.

Comment thread src/bun.js.zig Outdated
Comment thread src/runtime/jsc_hooks.rs Outdated
@robobun

robobun commented May 28, 2026

Copy link
Copy Markdown
Collaborator Author

This also resolves #31501 (another report of await new Promise(() => {}) hanging at 100% CPU instead of warning + exiting 13 like Node) — worth adding to the Fixes list.

I independently reproduced #31501 and verified the bun run behavior this PR implements matches Node 24 across the cases that matter:

case Node this PR's approach
await new Promise(() => {}) warn + exit 13 warn + exit 13 ✅
same, but process.exitCode = 42 first exit 42 (user code wins) exit 42 ✅
process.on('exit') listener observes code 13 observes 13 ✅
TLA kept alive by a pending timer keeps running keeps running ✅
TLA resolved normally exit 0, no warning exit 0 ✅

Confirmed the beforeExit-can-resolve-the-await handling here (the loop { … on_before_exit; if pending { tick; if alive continue } } structure) is the right call — a naive "check pending once after the loop drains" misses the case where a beforeExit handler settles the promise. Deferring to this PR.

@robobun
robobun force-pushed the farm/dcb7437b/fix-19049-unsettled-tla-hang branch from 093cf1e to 1e62f44 Compare June 5, 2026 20:54
Comment thread src/bun.js.zig Outdated
Comment thread src/runtime/cli/test_command.rs Outdated
Comment thread src/runtime/jsc_hooks.rs Outdated
@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Triage of the open PRs for this behavior; this is the one being kept.

Nothing has landed on main. Checked at 165dc9f: bun file.mjs with await new Promise(() => {}) never exits and spins at 100% CPU (Node prints the warning and exits 13), and bun test on a file whose top-level await never settles hangs the same way. This PR's test file applied on top of main fails 12 of 14 cases (the two that pass are the positive controls). The linked issues were closed for other reasons: #14951 for its CPU-spin half, which still reproduces on main; #29546 as not reproducible (on Linux that repro resolves after the timeout today); #6592 because current @inquirer/* releases reject the prompt themselves on Ctrl+C. #19049, #32528 and #33283 are still open and are resolved by this PR.

Why this PR: it carries everything from #29739 (whose src/bun.js.zig and src/jsc/VirtualMachine.zig no longer exist on main since the Rust port, so that branch cannot be rebased) plus the bun test and --preload paths. Closed as duplicates of it: #30601, #33286, #36051. #36052 stays open for its module-loader change (#14708) and only needs to drop its copy of the exit-13 half once this lands. The process.emit dispatch that #30601 also carried lives in #32228.

Before it can merge:

Comment thread src/jsc/VirtualMachine.rs Outdated
Comment on lines +1189 to +1193
/// Raw liveness inputs shared by the predicates below: active platform
/// handles, tasks (including ones yielded to the next iteration),
/// concurrent tasks, and pending refs. `concurrent_tasks` closes a narrow
/// race where another thread pushed after `tick()`'s drain; `yield_tasks`
/// are only promoted by the next `auto_tick()`.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/jsc/VirtualMachine.rs Outdated
Comment on lines +1220 to +1228
/// Whether anything remains that could wake the loop and settle a
/// pending module promise: [`is_event_loop_alive`](Self::is_event_loop_alive)
/// minus its `unhandled_error_counter` short-circuit.
///
/// That counter persists across files in `bun test`, so an unhandled
/// rejection in file A would otherwise make every later file with
/// ordinary async TLA bail in
/// [`wait_for_module_promise`](Self::wait_for_module_promise) with a
/// spurious "never resolved" even though ref'd work would settle it.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/jsc/VirtualMachine.rs Outdated
Comment on lines +2068 to +2070
/// `loadPreloads()` — runs `--preload` scripts. Returns the first
/// non-fulfilled preload promise (rejected, or still pending with an
/// idle event loop — unsettled TLA) if any, else null. Errors propagate

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/jsc/VirtualMachine.rs Outdated
Comment on lines +2657 to +2670
/// Wait for a module's top-level-await promise to settle.
///
/// Unlike [`wait_for_promise`](Self::wait_for_promise), this breaks out
/// when nothing remains that could settle the promise (no active
/// handles/refs, no pending tasks or immediates). Without this, a module
/// containing e.g. `await new Promise(() => {})` makes the loader
/// busy-spin forever in `tick()` + `auto_tick()` once the last timer
/// fires and `auto_tick` degrades to a non-blocking `tickWithoutIdle`.
///
/// Callers must handle a still-`Pending` status on return: for `bun run`
/// this matches Node's exit-code-13 behavior; for `bun test` the file is
/// reported as a load error. `Err` has the same meaning as for
/// [`wait_for_promise`](Self::wait_for_promise): the VM can no longer run
/// the script that would settle the promise.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/jsc/VirtualMachine.rs Outdated
Comment on lines +2685 to +2688
// After draining tasks + microtasks, if nothing could still wake
// the loop the promise can never settle. Break instead of
// busy-spinning. See `has_pending_loop_work` for why this is NOT
// `is_event_loop_alive()`.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/jsc/VirtualMachine.rs Outdated
Comment on lines +2697 to +2702
/// Print a warning naming the module(s) whose body is suspended on its
/// own top-level await. Walks the JSC module registry for
/// `CyclicModuleRecord`s in `EvaluatingAsync` with `hasTLA` and no
/// pending async dependencies; falls back to the entry path if nothing
/// is found (e.g. eval mode). Matches Node's "Detected unsettled
/// top-level await at <path>".

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/jsc/VirtualMachine.rs Outdated
Comment on lines +2715 to +2716
// One warning line per stalled module (the C++ helper joins multiple
// specifiers with '\n'), matching Node's one-warning-per-module shape.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/jsc/bindings/ZigGlobalObject.cpp Outdated
Comment on lines +764 to +767
// Find module specifiers whose body is suspended on its own top-level await
// (status EvaluatingAsync, syntactically has TLA, and isn't waiting on any
// async dependency). Used to point the unsettled-TLA warning at the actual
// stalled module rather than the entry path.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/jsc/bindings/ZigGlobalObject.cpp Outdated
Comment on lines +780 to +781
// Skip modules that are EvaluatingAsync only because they're waiting
// on a dependency — the dependency is the actual culprit.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/runtime/cli/run_command.rs Outdated
Comment on lines +1488 to +1494
// Main loop + beforeExit, with handling for a still-pending entry
// module promise (top-level await that `wait_for_module_promise`
// bailed on because the loop was idle). A `beforeExit` handler
// may resolve the stuck await, and the resumed body may schedule
// more work and then suspend again — `continue` re-enters the
// whole cycle so Node's "beforeExit fires every time the loop
// drains" semantics hold for any number of rounds.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/runtime/cli/run_command.rs Outdated
Comment on lines +1506 to +1508
// A bare `resolve()` inside the handler queues a JSC
// microtask that `is_event_loop_alive()` doesn't
// count. Drain it so the module body resumes.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/runtime/cli/run_command.rs Outdated
Comment on lines +1518 to +1523
// When the entry module itself never settled (unsettled top-level
// await), `entry_point_result.value` holds the module pipeline's
// internal promise rather than the evaluated expression's value,
// so `--print` would emit a bogus `Promise { <pending> }` to
// stdout. Skip printing; the warning + exit code 13 below report
// it.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/runtime/cli/run_command.rs Outdated
Comment on lines +1575 to +1581
// The entry module's top-level await never settled and nothing
// is keeping the event loop alive. Match Node.js: warn + exit 13.
// Late `.rejected` (the resumed body threw) is reported here
// because the module-loader pipeline promise is pre-marked
// handled and never reaches `handle_rejected_promises()`; gate
// on `pending_internal_promise_reported_at` so an initial-load
// rejection already handled above isn't double-reported.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/runtime/cli/run_command.rs Outdated
Comment on lines +1596 to +1597
// `vm.global()` is `&'static`, decoupled from `vm`'s
// borrow so `uncaught_exception(&mut self, ..)` can reborrow.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/runtime/cli/test_command.rs Outdated
Comment on lines +3169 to +3172
/// `--bail` threshold reached on a file that failed to load (rejected, or
/// a top-level await that can never settle): print the summary, write the
/// JUnit/timings reports if requested, release per-file runner state, and
/// exit.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/runtime/cli/test_command.rs Outdated
Comment on lines +3340 to +3345
// Top-level await never settled and the event loop drained.
// Report it as a load error for this file and move on
// instead of hanging forever. If the pending promise came
// from a --preload, `load_preloads` put a "Top-level await
// in preload ..." entry in `vm.log` — print that first so
// the user sees which file is actually stuck.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/runtime/jsc_hooks.rs Outdated
Comment on lines +712 to +714
/// `loadPreloads()` — runs `--preload` scripts. Returns the first
/// non-fulfilled preload promise (rejected, or still pending with an idle
/// event loop — unsettled TLA) if any, else null.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/runtime/jsc_hooks.rs Outdated
Comment on lines +893 to +898
// Propagate both rejection and still-pending (unsettled TLA on an
// idle loop) so callers report it instead of silently continuing to
// later preloads / the entry point. For `Pending`, name the preload
// here — downstream reporting only knows the entry path. A wait cut
// short by a stop request also returns `Pending`; that is not an
// unsettled await, so it falls through to the stop check below.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto main at bb28fba (previously eda73e3, 943 commits). This one was not mechanical; summary of what changed and why:

Conflicts

  • VirtualMachine.rs (both entry-point loaders) and jsc_hooks.rs (load_preloads): main's Worker / worker_threads: WebCore-shaped lifetimes, joined threads, one ordered VM teardown #37075/One termination signal, one fold: Err(Thrown) always means an exception is pending, and each event-loop dispatcher takes it in one place #37275 changed wait_for_promise to check execution_forbidden() || !script_allowed() each iteration and return Err(Stopped) when the VM is being stopped. wait_for_module_promise shares that loop shape, so it now carries the same gate and returns Result<(), Stopped>; the three callers use it the same way main uses wait_for_promise there (let _ =, then inspect the promise status).
  • load_preloads: main added a post-wait "stop requested, return null" check. A stopped wait also leaves the promise Pending, so this PR's "Top-level await in preload ... never resolved" arm is now guarded on script_allowed() and otherwise falls through to main's check. Main's ordering for a rejected preload (rejection still returned even if stopped) is preserved.
  • test_command.rs: main added write_timings_if_needed() to the bail path; folded into bail_after_load_failure, which both load-failure arms still share.

Drift found while reviewing the auto-merged parts

  • Main's is_event_loop_alive gained a yield_tasks term (Worker / worker_threads: WebCore-shaped lifetimes, joined threads, one ordered VM teardown #37075). has_pending_loop_work() had silently fallen out of sync. Rather than keep two hand-synced field lists, the raw predicate now lives once in has_pending_loop_work_excluding_immediates() and is_event_loop_alive_excluding_immediates() is unhandled_error_counter == 0 && that (same inputs as before, just || instead of a sum). No test for the yield_tasks term: every current user of the yield queue (shell builtins, queueTaskAfterYield) also holds a keep-alive, so I could not construct a case where it changes the outcome; verified by building without the term and seeing await $\yes | head`` still pass. It is there to keep the predicates identical, not because it fixes a reachable bug.
  • JSPromise::status_ptr became crate-private; run_command.rs now uses the safe opaque_ref/opaque_mut accessors, which also removed the raw derefs in the late-rejection arm.

Verification on the rebased branch

  • test/regression/issue/19049/19049.test.ts: 14/14; bun-test.test.ts --bail cases: 5/5; clippy and rustfmt clean.
  • Because is_event_loop_alive was touched, I also ran worker.test.ts, worker-terminate-lifetime, worker-terminate-funnels, node-timers, process.test.js, and bun-test.test.ts against both this branch and a plain origin/main debug build in the same container. Timers and bun-test are green on both. The worker/process files are flaky in this environment on both builds (e.g. worker-terminate-funnels over 9 runs each: main failed in 5 runs, this branch in 3), with the same tests involved, so no difference attributable to this change.

Comment thread src/jsc/VirtualMachine.rs Outdated
Comment on lines +2687 to +2692
// busy-spinning. See `has_pending_loop_work` for why this is NOT
// `is_event_loop_alive()`.
if !self.has_pending_loop_work() {
break;
}
self.auto_tick();

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.

🔴 The three unref() + .exited with nothing else ref'd tests at test/js/bun/spawn/spawn.test.ts:810-844 still assert {stdout: "resolved\n", stderr: "", exitCode: 0}, but with wait_for_module_promise a top-level await p.exited on an unref'd subprocess now becomes exit 13 with the unsettled-TLA warning on stderr. The 2026-08-13 robobun triage on this PR explicitly flagged that #36051/#36052 had to update spawn.test.ts for this behavior change; per REVIEW.md ("grep the suite for assertions on the old behavior and update them in the same PR") those tests need updating here — either to expect exit 13 (matching Node) or to keep something ref'd until .exited resolves.

Extended reasoning...

What the bug is

wait_for_module_promise (this PR) breaks out of the entry-point wait loop when has_pending_loop_work() is false — without ever calling auto_tick() to poll the platform loop. That is the intended Node-compatible behavior (a top-level await on a promise that nothing ref'd can settle → exit 13), but it changes the observable outcome for the three existing tests in test/js/bun/spawn/spawn.test.ts:810-844 (describe("unref() + .exited with nothing else ref'd (Windows)")), and this PR does not touch that file.

The specific code path

Each of those tests spawns bun -e with a body of the form:

const opts = { cmd: [bunExe(), "-e", ""], stdio: ["ignore","ignore","ignore"] };
const p = Bun.spawn(opts); p.unref(); await p.exited;
console.log("resolved");

and asserts {stdout: "resolved\n", stderr: "", exitCode: 0, signalCode: null} (spawn.test.ts:837-842).

p.unref()disable_keeping_event_loop_alive() (src/spawn/process.rs) unrefs the pidfd poll (POSIX) / uv_process_t (Windows) from the platform loop's active count. With stdio all-ignore, nothing else is ref'd.

Pre-PR: load_entry_point called wait_for_promise, which spun tick() + auto_tick(); auto_tick() still polled the platform loop each iteration (spawn.test.ts:811-813 documents this: "us_loop_pump now forces one non-blocking iteration"), so the child's exit packet was eventually dequeued, .exited resolved, and the module completed with exit 0.

Post-PR: load_entry_point calls wait_for_module_promise (VirtualMachine.rs:2931). It runs tick() (drains microtasks — does not poll the platform loop) → status still Pendinghas_pending_loop_work(): platform_loop_opt().is_active() is false (only unref'd handles), no tasks/immediates/pending refs → break before ever reaching auto_tick(). Control returns to run_command.rs; the main while vm.is_event_loop_alive() loop's predicate is also false (same inputs minus the error counter), on_before_exit() has no listeners, the post-tick() re-check is still false → falls through to the new .Pending arm → report_unsettled_top_level_await() on stderr + exit_code = 13. The inner bun -e "" child hasn't exited yet under debug/ASAN startup latency, so its exit is never observed.

Why existing code doesn't prevent it

Nothing in this PR updates spawn.test.ts. The PR's changed-files list is 6 files (VirtualMachine.rs, ZigGlobalObject.cpp, run_command.rs, test_command.rs, jsc_hooks.rs, 19049.test.ts); spawn.test.ts is absent. The evidence gate only ran the new regression file. And the tests are not platform-gated — despite "(Windows)" in the describe title, they run on all platforms.

The PR's own timeline explicitly warned about this. The 2026-08-13 robobun triage comment says:

#36051 and #36052 both found that a top-level await on an unref'd subprocess's .exited becomes exit 13 with this change (as in Node) and updated test/js/bun/spawn/spawn.test.ts; worth checking during the rebase.

That rebase action item was not addressed.

Step-by-step proof

Take the first parametrized case ("unref() then await .exited"):

  1. Outer test spawns bun -e '<body>'.
  2. Inner bun evaluates the module: Bun.spawn(opts) registers a pidfd poll; p.unref() decrements the loop's active count to 0; await p.exited suspends the module → load_entry_point reaches wait_for_module_promise(promise).
  3. First iteration: status Pendingtick() (no queued tasks) → still Pendinghas_pending_loop_work(): active=false, active_tasks=0, tasks/yield/concurrent/immediates empty, has_pending_refs()=false → returns falsebreak. auto_tick() is never called.
  4. Back in Run::start: is_event_loop_alive() is false → main loop skipped → on_before_exit() (no listeners) → vm.tick() (nothing) → is_event_loop_alive() still false → break.
  5. entry_module_pending = true; the .Pending arm prints Warning: Detected unsettled top-level await at [eval] to stderr and sets exit code 13.
  6. Outer test receives {stdout: "", stderr: "Warning: Detected unsettled top-level await at ...\n", exitCode: 13} and the toEqual({stdout: "resolved\n", stderr: "", exitCode: 0, ...}) at line 837 fails.

All three parametrized cases (unref() then await .exited, .exited then unref() then await, onExit then unref()) hit the same path.

Impact

Three concrete CI test failures on merge. Per REVIEW.md: "When changing output/defaults/messages, grep the suite for assertions on the old behavior and update them in the same PR."

Fix

Update the three tests at spawn.test.ts:810-844 to reflect the new (Node-compatible) behavior — Node also exits 13 when the only thing keeping a top-level await alive is an unref'd child. Either:

  • change the assertions to expect {stdout: "", exitCode: 13} and stderr containing "Detected unsettled top-level await" (documenting the Node parity), or
  • if the intent of these tests was to verify the exit packet is dequeued, add a ref'd handle (e.g. process.on('beforeExit', () => p.ref()) or drop the unref()) so the module completes, and add a separate case documenting exit-13 for the fully-unref'd variant.

The former matches what #36051/#36052 did per the triage note.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed this is real; it also hits abort.test.ts (#37666) on every lane. Whether to re-home those tests (Node's rule, which this PR implements and #36052 also chose) or instead make the liveness check unref-aware is a behavior decision, since #34478 and #37666 pinned the current leniency; I have laid out both options with the evidence in #30551 (comment) and am waiting for a maintainer call before changing tests that main merged. Leaving this thread open until that lands.

Comment thread src/jsc/VirtualMachine.rs Outdated
Comment thread src/jsc/VirtualMachine.rs
@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

@alii, one design question surfaced by the rebase that I would rather not decide unilaterally, since it changes behavior that main currently tests.

What CI found (build 97918): four tests added to main after this PR was written now fail with it, all the same shape: a top-level await whose only remaining source of progress is unref'd.

With this PR those exit 13 with the unsettled-TLA warning. That is exactly what Node does for all four shapes (verified on Node 26: child.unref(); await exit, await on AbortSignal.timeout, await on an unref'd setTimeout all print the warning and exit 13). Released Bun resolves all of them, because today's loader busy-polls unref'd work until it completes, and #34478 / #37666 pinned that. The reported bugs this PR fixes (#19049, #14951, #32528, #33283) are all awaits that can never settle; either answer below fixes them.

Option A, Node's rule (what the PR currently implements): liveness = ref'd work, same predicate as process exit. Simple, platform-independent, and what #36051/#36052 independently chose. Cost: scripts that await unref'd work at top level go from "works" to exit 13, and the four tests above get re-homed so they keep covering what they were written for (the spawn ones move inside a bun test it(), as #36052 already drafted; the abort one fences on a ref'd setTimeout instead of a second AbortSignal.timeout). Also means this PR does not fix #29546 as its reporter framed it; it already resolves on main today, so it comes off the Fixes list either way.

Option B, keep Bun's leniency: "unsettleable" = nothing registered at all, unref'd included. No change to any tested behavior. Cost: a second liveness predicate. On POSIX it is num_polls > 0 plus a tag-filtered walk of the timer heap for setTimeout / AbortSignal.timeout nodes; it still misses unref'd Workers, unref'd fs.watch, and the waiter-thread subprocess fallback (those would exit 13 like Node); and on Windows libuv's loop always holds internal handles, so it needs a filtered uv_walk or new bookkeeping. Every future unref-able source that is not a poll has to be added to it or it produces a false "never resolved" on a working program.

I lean A: it is the rule the PR's own premise is built on, B is a fragile cross-platform predicate, and the four tests lose no coverage when re-homed. But A is a user-visible behavior change relative to what main tests today, so I want a maintainer's yes before changing those tests. If you would rather keep the leniency, I will build B instead. Until then the branch stays as is (CI red on exactly those four tests); the remaining review items (byte-split lint, stale docs, comment trimming) are going up separately.


Addendum: #33283 is the open tracking issue for exactly this half of the feature, and it frames today's "waits for unref'd timers" behavior as the divergence to remove, i.e. option A. #34478 and #37666, the PRs whose tests option A re-homes, were about the Windows IOCP pump and timer re-arming respectively; neither discusses top-level-await policy, and the re-homed tests keep covering both mechanics. I have added Fixes #33283 to the description and dropped #29546 from it either way.

@robobun
robobun force-pushed the farm/dcb7437b/fix-19049-unsettled-tla-hang branch from bb28fba to 0d48eb1 Compare August 15, 2026 12:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants