Skip to content

Exit 13 on an unsettled top-level await instead of hanging - #33286

Closed
robobun wants to merge 1 commit into
mainfrom
farm/d8fdd99a/unsettled-tla-exit-13
Closed

Exit 13 on an unsettled top-level await instead of hanging#33286
robobun wants to merge 1 commit into
mainfrom
farm/d8fdd99a/unsettled-tla-exit-13

Conversation

@robobun

@robobun robobun commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator

Fixes #33283

Repro

// entry.mjs
console.log("BEFORE");
await new Promise(() => {});   // or: await import(import.meta.url)
console.log("AFTER");
$ bun entry.mjs     # before: prints BEFORE, then parks forever (0% CPU, no output)
$ node entry.mjs
BEFORE
Warning: Detected unsettled top-level await at file:///.../entry.mjs:2
# exit 13

The self-import variant (await import(import.meta.url), or await import("bun:main") from the entry) is the same spec-level deadlock: the entry awaits its own evaluation promise.

Cause

The main-entry loader waits on the entry module's evaluation promise via wait_for_promise, which loops tick() + auto_tick() until the promise settles. When the top-level await can never settle and no ref'd handle remains, auto_tick() takes the !loop.is_active() branch (non-blocking tick_without_idle), so the wait degenerates into a busy-spin that never returns. Node stops waiting once the loop would otherwise block with the module promise still pending, prints a warning, and exits 13.

Fix

Scoped to the main entry (bun <file>, bun -e, bun -p); the test runner and workers keep their own termination semantics and are unchanged.

  • wait_for_module_promise replaces wait_for_promise in load_entry_point. Same tick/auto_tick loop, but it returns with the promise still pending once has_pending_loop_work() is false (nothing active, no task/ref/immediate could settle it). A ref'd timer keeps the loop alive, so a TLA that a timer later resolves still works.
  • Run::start detects the still-pending entry promise after beforeExit and reports + exits 13. A beforeExit handler still gets a chance to resolve the await first (and may schedule more work), matching Node's repeated-beforeExit behavior. --print emits the resolved value once the entry settles (and nothing for an unsettled one), never a bogus Promise { <pending> }. A late rejection (the resumed body throws) is reported via uncaughtException and exits 1 (or 0 if a handler swallows it).
  • Bun__findStalledTopLevelAwait (C++) walks the module registry to name the actual stalled module(s) (status EvaluatingAsync, syntactic TLA, not waiting on an async dependency), so the warning points at the leaf rather than the entry.

Warning goes to stderr: Warning: Detected unsettled top-level await at <module>, exit code 13. The source line + caret that Node also prints is omitted; the warning text and exit code are the parity that matters.

Intentionally deferred

--preload scripts with an unsettled top-level await still hang. load_preloads runs per-preload before the entry and would need its own "still pending" return value propagated back through reload_entry_point (and ideally name which preload stalled), which is the broader surface #30551 took on. Keeping this PR to the main entry per #33283; preloads are a follow-up.

bun --watch / --hot with an unsettled top-level await also still busy-spins: the watcher arm of load_entry_point keeps its own tick/auto_tick loop, and the right fix there is break-to-idle (return to the watcher's block-wait for file changes), not exit 13, since a watch process should stay alive. Deferred to a follow-up for the same reason.

When a beforeExit handler resolves a top-level await and the resumed body then schedules timers and/or throws, the exact interleaving of those timers vs uncaughtException vs repeated beforeExit emissions is best-effort rather than byte-for-byte Node parity: the module can resume inside on_before_exit's own drain (especially once process.nextTick has initialized its queue), which runs ahead of the report below. The observable outcome that matters (exit code 0/1 and the error message) is correct; matching Node's precise ordering in those multi-way cases would need the drain to be owned by a single loop, which is a follow-up.

Verification

test/js/node/process/unsettled-top-level-await.test.ts (14 cases): never-resolving promise, dynamic-import TLA cycle, import(import.meta.url) self-import, and unref'd timer all exit 13; a ref'd timer that settles and a beforeExit handler that settles both exit 0; bun -p/bun -e exit 13 on an unsettled await (and -p prints nothing, not Promise { <pending> }); the warning names the stalled leaf and lists stalled siblings; a resumed body that throws exits 1 (or 0 via uncaughtException handler). The spin cases hang on the released build and pass after the fix.

Prior art

This is the exit-13 half of #14951; the broader CPU-spin fix for condition-gated drive loops is tracked separately in #32014. It supersedes the broader/stale attempts #30551 (which also changed the test runner and preloads) and #30601 (inquirer-specific), and ports @dylan-conway's pre-Rust #29739 to the current tree, scoped to the main entry as #33283 asks.


[review] gate passed · iteration 4 · 4 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/js/node/process/unsettled-top-level-await.test.ts
bun test v1.4.0 (af8cb039c)

test/js/node/process/unsettled-top-level-await.test.ts:
(pass) unsettled top-level await > a ref'd timer keeps the loop alive and the await settles (exit 0) [1060.89ms]
22 |     timeout: SPAWN_TIMEOUT,
23 |   });
24 |   const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
25 |   // Hang guard: the process must exit on its own, not be killed by the spawn
26 |   // timeout (a reintroduced busy-spin would surface as a non-null signal here).
27 |   expect(proc.signalCode).toBeNull();
                               ^
error: expect(received).toBeNull()

Received: "SIGTERM"

      at run (/workspace/bun/test/js/node/process/unsettled-top-level-await.test.ts:27:27)
(fail) unsettled top-level await > await on a never-resolving promise exits 13 [20150.53ms]
22 |     timeout: SPAWN_TIMEOUT,
23 |   });
24 |   const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exi
... (truncated)

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

test/js/node/process/unsettled-top-level-await.test.ts:
(pass) unsettled top-level await > --print emits the value before beforeExit output for a non-TLA entry [35.66ms]
(pass) unsettled top-level await > a ref'd timer keeps the loop alive and the await settles (exit 0) [74.70ms]
22 |     timeout: SPAWN_TIMEOUT,
23 |   });
24 |   const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
25 |   // Hang guard: the process must exit on its own, not be killed by the spawn
26 |   // timeout (a reintroduced busy-spin would surface as a non-null signal here).
27 |   expect(proc.signalCode).toBeNull();
                               ^
error: expect(received).toBeNull()

Received: "SIGTERM"

      at run (/workspace/bun/test/js/node/process/unsettled-top-level-await.test.ts:27:27)
      at async <anonymous> (/workspace/bun/test/js/node/process/unsettled-top-level-await.test.ts:45:21)
22 |     timeout: SPAWN_TIMEOUT,
23 |   });
24 |   const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
25 |   // Hang guard: the process must exit on 
... (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/js/node/process/unsettled-top-level-await.test.ts
bun test v1.4.0 (af8cb039c)

test/js/node/process/unsettled-top-level-await.test.ts:
(pass) unsettled top-level await > await on a never-resolving promise exits 13 [462.83ms]
(pass) unsettled top-level await > dynamic-import top-level-await cycle exits 13 [437.43ms]
(pass) unsettled top-level await > self-import via import.meta.url exits 13 [447.72ms]
(pass) unsettled top-level await > await on an unref'd timer exits 13 [458.18ms]
(pass) unsettled top-level await > a ref'd timer keeps the loop alive and the await settles (exit 0) [485.05ms]
(pass) unsettled top-level await > a beforeExit handler can settle the await (exit 0) [421.52ms]
(pass) unsettled top-level await > warning names the stalled module, not the entry [457.56ms]
(pass) unsettled top-level await > warning lists every stalled sibling [456.55ms]
(pass) unsettled top-level await > --print prints a top-level await that beforeExit settles [459.89ms]
(pass) unsettled top-level await > --print emits the value before beforeE
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 670ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/7] gen generated_host_exports.rs
generated_host_exports.rs: 91 exports (host=3, lazy=10, generic=78, rust=0); 238 extern-C blocks audited
[2/7] gen cpp.rs (cppbind)
[2/7] 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    Blocking�[0m waiting for file lock on build directory
�[1m�[92m   Compiling�[0m bun_jsc v0.0.0 (/workspace/bun/src/jsc)
�[1m�[92m   Compiling�[0m bun_ast_jsc v0.0.0 (/workspace/bun/src/ast_jsc)
�[1m�[92m   Compiling�[0m bun_js_parser_jsc v0.0.0 (/workspace/bun/src/js_parser_jsc)
�[1m�[92m   Compiling�[0m bun_patch_jsc v0.0.0 (/workspace/bun/src/patch_jsc)
�[1m�[92m   Compiling�[0m bun_css_jsc v0.0.0 (/workspace/bun/src/css_jsc)
�[1m�[92m   Compiling�[0m bun_semver_jsc v0.0.0 (/workspace/bun/src/semver_jsc)
�[1m�[92m   Compiling�[0m bun_sys_jsc v0.0.0 (/workspace/bun/src/sys_jsc)
�[1m�[92m   Compiling�[0m bun_bundler_jsc v0.0.0 (/workspace/bun/src/bundler
... (truncated)
diff hotspot
src/jsc/VirtualMachine.rs                          |  93 ++++++++-
 src/jsc/bindings/ZigGlobalObject.cpp               |  30 +++
 src/runtime/cli/run_command.rs                     | 165 +++++++++++----
 .../node/process/unsettled-top-level-await.test.ts | 229 +++++++++++++++++++++
 4 files changed, 473 insertions(+), 44 deletions(-)

gate history · 1 passed · 0 rejected · iteration 4

evidence per changed file
file                                                    reads  edits  tests
src/jsc/VirtualMachine.rs                                  11     12      0
src/jsc/bindings/ZigGlobalObject.cpp                        2      6      0
src/runtime/cli/run_command.rs                             13     17      0
test/js/node/process/unsettled-top-level-await.test.ts      9     17      0

root cause · written by the author bot

The root cause was that Bun's promise-waiting path in load_entry_point kept the event loop ref'd while awaiting the entry module's top-level await, so a promise that never settles left the process parked indefinitely instead of exiting, diverging from Node's behavior of warning and exiting with code 13. The fix introduces a module-specific wait that detects when the entry promise is still pending and nothing else refs the event loop, uses a native module-map scan to identify the stalled async module and its awaiting location, and restructures the CLI shutdown path to emit the unsettled top-…

@coderabbitai

coderabbitai Bot commented Jul 2, 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

This PR adds unsettled top-level await handling across the VM, CLI, native module scan, and tests. It changes entry-point waiting, emits stalled-module warnings, updates exit handling, and adds coverage for settled and unsettled cases.

Changes

Unsettled top-level await detection and reporting

Layer / File(s) Summary
VM liveness and promise waiting
src/jsc/VirtualMachine.rs
Adds event-loop liveness checks, module-promise waiting, pending-entry evaluation checks, and unsettled top-level-await reporting, and switches entry loading to the new module-promise wait.
Native stalled-module scan
src/jsc/bindings/ZigGlobalObject.cpp
Adds Bun__findStalledTopLevelAwait, scanning the module map for cyclic async modules with no pending async dependencies and returning their specifiers.
CLI draining and exit handling
src/runtime/cli/run_command.rs
Reworks Run::start to gate --print output on internal promise state, drain pending entry top-level await across beforeExit rounds, report unsettled or rejected internal promises, and finish by draining remaining async work.
Unsettled top-level await tests
test/js/node/process/unsettled-top-level-await.test.ts
Adds a spawn-based test helper and coverage for unsettled and settled top-level await cases, stalled-module warning attribution, beforeExit resumption, and bun -p / bun -e CLI behavior.

Possibly related issues

Possibly related PRs

  • oven-sh/bun#30208: Both PRs adjust bun -p and entry-module print behavior around top-level await completion state.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title concisely captures the main change: exiting with code 13 for unsettled top-level await instead of hanging.
Description check ✅ Passed The description thoroughly explains the fix and verification, though it doesn't use the repo's exact template headings.

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

@robobun

robobun commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 1:41 PM PT - Jul 22nd, 2026

@robobun, your commit af8cb03 has 3 failures in Build #78003 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 33286

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

bun-33286 --bun

@github-actions

github-actions Bot commented Jul 2, 2026

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 (exit 13 on unsettled top-level await instead of hanging); Exit 13 on an unsettled top-level await instead of hanging #33286 explicitly ports this pre-Rust implementation
  2. Detect unsettled top-level await in entry-point loading instead of hanging #30551 - Implements identical detection of unsettled top-level await in entry-point loading; explicitly superseded by Exit 13 on an unsettled top-level await instead of hanging #33286
  3. runtime: exit on unsettled top-level await instead of spinning (Ctrl+C at @inquirer/prompts) #30601 - Same exit-instead-of-hang fix for unsettled TLA (@inquirer/prompts scenario); explicitly superseded by Exit 13 on an unsettled top-level await instead of hanging #33286

🤖 Generated with Claude Code

@robobun

robobun commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator Author

Flagged relationships are expected and called out in the description. This PR is the intended landing for #33283, not a redundant duplicate:

So #33286 supersedes the three rather than duplicating them; the others can be closed once this lands.

Comment thread src/jsc/VirtualMachine.rs Outdated
Comment thread src/runtime/cli/run_command.rs Outdated
@robobun
robobun force-pushed the farm/d8fdd99a/unsettled-tla-exit-13 branch from 35cb774 to f92358c Compare July 2, 2026 23:26
Comment thread src/runtime/cli/run_command.rs
Comment thread test/js/node/process/unsettled-top-level-await.test.ts
@robobun
robobun force-pushed the farm/d8fdd99a/unsettled-tla-exit-13 branch from f92358c to 443c719 Compare July 3, 2026 00:06

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/jsc/bindings/ZigGlobalObject.cpp`:
- Around line 699-703: The new header comment in
ZigGlobalObject::findStalledTopLevelAwaitModuleSpecifiers is too long and must
be trimmed to the 3-line limit. Condense the existing explanation so it still
captures the invariant about stalled TLA module specifiers, the
EvaluatingAsync/syntactic TLA condition, and that the result is an empty
BunString when nothing is stalled, while keeping the comment block to at most
three lines.
- Around line 720-722: The module specifier framing in the ZigGlobalObject
builder is unsafe because newline-separated values can be split incorrectly when
a specifier/path contains a newline. Update the code around the builder append
logic to stop using “\n” as the separator and instead emit a delimiter that
cannot appear in filesystem paths, or better, return a structured collection.
Then update the Rust consumer in VirtualMachine.rs to parse the new framing
format using the existing module-specifier handling path so it no longer
generates bogus warnings.

In `@src/jsc/VirtualMachine.rs`:
- Around line 1059-1069: Several newly added doc comments exceed the
repository’s 3-line limit and should be condensed. Shorten the explanatory
blocks around the helper logic in VirtualMachine (including the comments near
wait_for_module_promise and is_event_loop_alive) to no more than 3 lines each,
keeping only the essential rationale; move any extra background to the PR
description. Apply the same trimming to the other flagged comment blocks in this
diff so all comments stay within the repo guideline.
🪄 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: 927daaae-5905-452b-b12f-8d5768c09691

📥 Commits

Reviewing files that changed from the base of the PR and between 1498d7b and 443c719.

📒 Files selected for processing (4)
  • src/jsc/VirtualMachine.rs
  • src/jsc/bindings/ZigGlobalObject.cpp
  • src/runtime/cli/run_command.rs
  • test/js/node/process/unsettled-top-level-await.test.ts

Comment thread src/jsc/bindings/ZigGlobalObject.cpp Outdated
Comment thread src/jsc/bindings/ZigGlobalObject.cpp
Comment thread src/jsc/VirtualMachine.rs Outdated
@robobun
robobun force-pushed the farm/d8fdd99a/unsettled-tla-exit-13 branch from 443c719 to ea83d0b Compare July 3, 2026 00:18
Comment thread test/js/node/process/unsettled-top-level-await.test.ts Outdated
Comment thread src/runtime/cli/run_command.rs
@robobun
robobun force-pushed the farm/d8fdd99a/unsettled-tla-exit-13 branch 2 times, most recently from 59d26e1 to 10d2dcc Compare July 3, 2026 01:29
Comment thread src/runtime/cli/run_command.rs Outdated
Comment thread src/runtime/cli/run_command.rs Outdated
@robobun
robobun force-pushed the farm/d8fdd99a/unsettled-tla-exit-13 branch from 10d2dcc to 1f6207f Compare July 3, 2026 01:41

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/node/process/unsettled-top-level-await.test.ts`:
- Around line 111-124: The stalled-sibling warning test only checks for the
module names and exit code, but it does not verify the warning’s marker text.
Update the unsettled top-level await test in the relevant
`run`/`expect(r.stderr)` assertions to also check for the “Detected unsettled
top-level await” message, matching the existing sibling test and ensuring the
warning format is covered.
- Around line 4-9: Trim the leading comment in unsettled-top-level-await.test.ts
so it only briefly states the test purpose and expected Node behavior; remove
the issue URL, historical hang narration, and other bug-background details. Keep
the test body focused on setup/action/assertions, and ensure any remaining
comment is at most 3 lines while still pointing to the top-level await behavior
being verified.
- Around line 164-177: The unsettled top-level await test is using a weak stdout
assertion in the Bun.spawn scenario. Update the test in
unsettled-top-level-await.test.ts to assert that stdout is exactly empty for the
bun -p case, using the existing test body around Bun.spawn, proc.stdout.text(),
and the exitCode assertions, instead of only checking that stdout does not
contain "Promise".
🪄 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: 902cb4f9-2840-47e3-8412-8fd2637fa102

📥 Commits

Reviewing files that changed from the base of the PR and between 10d2dcc and 1f6207f.

📒 Files selected for processing (4)
  • src/jsc/VirtualMachine.rs
  • src/jsc/bindings/ZigGlobalObject.cpp
  • src/runtime/cli/run_command.rs
  • test/js/node/process/unsettled-top-level-await.test.ts

Comment thread test/js/node/process/unsettled-top-level-await.test.ts
Comment thread test/js/node/process/unsettled-top-level-await.test.ts
Comment thread test/js/node/process/unsettled-top-level-await.test.ts
@robobun
robobun force-pushed the farm/d8fdd99a/unsettled-tla-exit-13 branch from 1f6207f to 831adff Compare July 3, 2026 01:52
Comment thread test/js/node/process/unsettled-top-level-await.test.ts
@robobun
robobun force-pushed the farm/d8fdd99a/unsettled-tla-exit-13 branch from 831adff to 731b560 Compare July 3, 2026 02:06
Comment thread src/runtime/cli/run_command.rs
Comment thread src/jsc/VirtualMachine.rs
@robobun
robobun force-pushed the farm/d8fdd99a/unsettled-tla-exit-13 branch 2 times, most recently from c0bc147 to 7c94bc0 Compare July 3, 2026 03:42
Comment thread src/runtime/cli/run_command.rs Outdated
Comment thread src/runtime/cli/run_command.rs
Comment thread src/runtime/cli/run_command.rs Outdated
@robobun
robobun force-pushed the farm/d8fdd99a/unsettled-tla-exit-13 branch from 7c94bc0 to 603a1c2 Compare July 3, 2026 03:55
Comment thread src/runtime/cli/run_command.rs
@robobun

robobun commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator Author

Status: ready for review. The diff is green, all review threads are resolved, and the mechanical gate passes (ASAN build runs the new test file: fails without the fix, passes with it).

The red CI lanes are pre-existing flake unrelated to this change. Across builds #68097/#68102/#68106/#68121/#68146/#68150 the failures are a rotating set of test/cli/install/*, test/js/bun/spawn, webview, napi, postgres, and terminal tests on various platforms; none touch the three files this PR changes (VirtualMachine.rs, ZigGlobalObject.cpp, run_command.rs) or the new test/js/node/process/unsettled-top-level-await.test.ts, which passes on every lane.

test/js/node/process/unsettled-top-level-await.test.ts (13 cases) covers exit 13 for a never-resolving promise / dynamic-import TLA cycle / unref'd timer, exit 0 for a ref'd-timer or beforeExit resolution, the stalled-module warning attribution, late-rejection (exit 1 / uncaughtException), and bun -p/bun -e.

When the main entry module's top-level await never settles and nothing
else refs the event loop, Bun spun the entry loader forever (100% CPU)
instead of exiting. Node prints a warning and exits with code 13.

Detect the idle-loop + pending-entry-promise condition: the entry loader
(wait_for_module_promise) returns once no active handle, task, or ref
could still settle the promise, and Run::start reports the stalled
module(s) and exits 13. A beforeExit handler still gets a chance to
resolve the await first (Node parity), and --print emits the resolved
value once the entry settles rather than a pending promise. If the
resumed body then throws, the error is reported (running a
process.on('uncaughtException') handler if present, else exit 1),
matching Node. Scoped to the main entry; the test runner and workers
are unchanged.
@robobun
robobun force-pushed the farm/d8fdd99a/unsettled-tla-exit-13 branch from 603a1c2 to af8cb03 Compare July 22, 2026 17:30
@robobun

robobun commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto main (af8cb03, was 488 commits behind). Added a 14th test case for await import(import.meta.url) self-import. All 14 tests pass with the fix; 12 hang/fail on the released build. No regressions in run-eval.test.ts, process.test.js, dynamic-import-tla-cycle.test.ts, worker-top-level-await.test.ts, or bun-main-entry-point.test.ts.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No new issues found on 603a1c2 — all prior threads are resolved. Deferring to a human reviewer: this rewrites the main-entry shutdown sequence in Run::start (ordering of --print vs beforeExit, a new resolution loop, and late-rejection reporting), which is core-path enough that the intentional divergences called out in the description (best-effort beforeExit/uncaughtException interleaving, exit_code == 0 vs Node's nullish check, --watch/--preload deferred) deserve a maintainer sign-off.

What was reviewed

  • wait_for_module_promise / has_pending_loop_work: confirmed a ref'd timer keeps the loop alive (covered by the exit-0 test); the raw-ptr jsc_vm read avoids the borrow conflict as commented.
  • Bun__findStalledTopLevelAwait: no JS entry / no ThrowScope needed; toStringRef ownership is released via stalled.deref() on the Rust side.
  • The Rejected arm's pending_internal_promise_reported_at gate — verified it prevents double-reporting an initial-load rejection already handled in load_entry_point.
  • Test file: hang-guard via signalCode === null, pipes drained concurrently, describe.concurrent for the spawn matrix.
Extended reasoning...

Overview

Four files: VirtualMachine.rs adds has_pending_loop_work(), wait_for_module_promise(), entry_point_evaluation_is_pending(), and report_unsettled_top_level_await(), and swaps wait_for_promisewait_for_module_promise in the non-watcher arm of load_entry_point. ZigGlobalObject.cpp adds Bun__findStalledTopLevelAwait, which walks the JSC module map for EvaluatingAsync records with syntactic TLA and no pending async dependency. run_command.rs restructures the tail of Run::start: the old inline --print block is extracted to print_eval_result() + entry_point_print_ok(), on_before_exit() now fires before the entry-status check, a new resolution loop lets a beforeExit handler settle the TLA, and a status match reports Pending (warn + exit 13) or late Rejected (route through uncaught_exception). A 13-case test file spawns fixtures with a 20s kill timeout and asserts signalCode === null so a reintroduced hang fails cleanly.

Security risks

None identified. The new C++ function only reads the module registry (no JS callouts, no allocation from untrusted input); the NUL-join delimiter is safe because module specifiers cannot contain NUL. The Rust side dereferences jsc_vm/global/pending_internal_promise under documented SAFETY invariants that match existing usage in the same file. BunString returned from C++ is explicitly deref()'d after use.

Level of scrutiny

High. Run::start is the process-lifetime path for every bun <file> / bun -e / bun -p invocation, and this PR reorders --print, beforeExit, and the final drain relative to each other. The review history on this PR already surfaced three separate ordering edge cases (late-throw vs body-scheduled timers, Fulfilled-entry dropping a second beforeExit, nextTick-initialized drain path), one of which led to a revert (c0bc147603a1c2). The author has documented the residual ordering divergence as best-effort, and named --watch/--hot and --preload as intentionally deferred sibling arms — those are exactly the kind of scoping calls a maintainer should ratify rather than a bot.

Other factors

Test coverage is thorough for the stated scope (never-resolving promise, TLA cycle, self-import, unref'd timer, ref'd-timer resolution, beforeExit resolution, leaf-module attribution, sibling listing, late throw with and without an uncaughtException handler, -p/-e). All 13+ prior inline threads on this PR are marked resolved; the last one (explicit process.exitCode = 0 → exit 13 vs Node's exit 0) was closed as an intentional divergence. The bug-hunting pass on the current head found nothing new. Given the surface area and the explicit design trade-offs, this warrants a human look before merge.

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Closing as a duplicate of #30551. That PR carries the same main-entry change (wait_for_module_promise, the beforeExit re-drain, the --print handling and the stalled-module walk in ZigGlobalObject.cpp) plus the bun test and --preload paths this one deferred, and it is the PR a maintainer asked to be made the complete version, so keeping a single PR there. It needs a rebase (conflicts in VirtualMachine.rs, test_command.rs and jsc_hooks.rs), which is the only thing this narrower branch has over it.

Still reproducible on main at 165dc9f: the entry case never exits and spins at 100% CPU instead of exiting 13. #33283 stays open and is listed on #30551.

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.

Detect unsettled top-level await and exit like Node (exit 13 + warning)

2 participants