Skip to content

runtime: make uncaught exceptions during top-level await immediately fatal - #34627

Closed
robobun wants to merge 4 commits into
mainfrom
claude/farm/e93f85a4/tla-uncaught-exception-fatal
Closed

robobun wants to merge 4 commits into
mainfrom
claude/farm/e93f85a4/tla-uncaught-exception-fatal

Conversation

@robobun

@robobun robobun commented Jul 18, 2026 •

Copy link
Copy Markdown
Collaborator

What

An uncaught exception thrown while the entry module is suspended in top-level await was not fatal: Bun printed the error, then resumed the suspended module and ran it to completion (potentially seconds of continued side effects), only exiting 1 at natural module end. Node exits immediately at the throw.

Repro

// a.mjs
const sleep = ms => new Promise(r => setTimeout(r, ms));
setTimeout(() => { throw new Error("boom-during-tla"); }, 50);
const t0 = Date.now();
while (Date.now() - t0 < 850) await sleep(25);
console.log("ALIVE 800ms after fatal error");   // bun: PRINTS; node: never reached
$ node a.mjs
Error: boom-during-tla
    at Timeout._onTimeout (a.mjs:2:26)
(exit 1, no further output)

$ bun a.mjs     # before
error: boom-during-tla
ALIVE 800ms after fatal error
(exit 1)

Any callback source triggers it (setTimeout throw, EventEmitter 'error' with no listener, setImmediate, Worker 'error'); the only condition is that the entry module is TLA-suspended when the exception reaches the top with no uncaughtException listener.

Cause

VirtualMachine::uncaught_exception records the fatal state (unhandled_error_counter += 1, exit_handler.exit_code = 1) and returns. The main drain loop in Run::start checks unhandled_error_counter via is_event_loop_alive() and bails, which is why a throw after TLA settles is already fatal. But load_entry_point waited on the TLA promise via the generic wait_for_promise, which only watches promise status and never consults unhandled_error_counter, so the suspended module kept running until the promise settled naturally.

Fix

Inline the wait loop in load_entry_point's non-watcher arm and break on unhandled_error_counter > 0, matching the main drain loop. The generic wait_for_promise is left unchanged since other callers (macros, HTMLRewriter, test expect, REPL) have different error semantics.

Sibling TLA-wait sites left as-is and why:

  • load_entry_point watcher arm: watch mode survives errors by design; the outer tick_possibly_forever() loop in Run::start keeps ticking regardless of unhandled_error_counter, so a guard here alone would be a no-op.
  • load_preloads non-watcher arm (jsc_hooks.rs): same underlying gap for a --preload module suspended in TLA, but stopping post-fatal execution there also requires reload_entry_point to bail before loading the main module and load_preloads to skip remaining preloads, which is a broader change than this PR's scope. Left for a follow-up.
  • load_entry_point_for_test_runner: bun test is designed to survive uncaught exceptions (records the failure, continues to the next file), so the guard does not belong there.

Controls verified to still hold:

  • Same throw after TLA completes: fatal in both (unchanged).
  • With process.on('uncaughtException', ...): both survive, module completes, exit 0.

Verification

$ USE_SYSTEM_BUN=1 bun test test/js/bun/spawn/exit-code.test.ts
(fail) uncaught exception during top-level await is immediately fatal
  expect(stdout).toBe("")
  + "UNREACHABLE-AFTER-FATAL\n"

$ bun bd test test/js/bun/spawn/exit-code.test.ts
 8 pass
 0 fail

Fixes #22546


[review] gate passed · iteration 1 · 5 files touched

fails on main (without fix)
ASAN without fix: 2 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/spawn/exit-code.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 (bd5ceb179)

test/js/bun/spawn/exit-code.test.ts:
(pass) process.exit(1) works [546.40ms]
(pass) await on a thrown value reports exit code 1 [482.12ms]
(pass) unhandled promise rejection reports exit code 1 [522.96ms]
(pass) handled promise rejection reports exit code 0 [156.10ms]
(pass) process.exit(0) works [478.44ms]
34 |     stdout: "pipe",
35 |     stderr: "pipe",
36 |   });
37 |   const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
38 |   // Module evaluation must not resume after the default-fatal throw.
39 |   expect(stdout).toBe("");
                      ^
error: expect(received).toBe(expected)

- ""
+ "UNREACHABLE-AFTER-FATAL
+ "

- Expected  - 1
+ Received  + 2

 
... (truncated)

release without fix: 2 FAILED
bun test v1.4.0-canary.1 (23bd4d697)

test/js/bun/spawn/exit-code.test.ts:
(pass) process.exit(1) works [22.32ms]
(pass) await on a thrown value reports exit code 1 [17.10ms]
(pass) unhandled promise rejection reports exit code 1 [13.59ms]
(pass) handled promise rejection reports exit code 0 [3.14ms]
(pass) process.exit(0) works [12.35ms]
34 |     stdout: "pipe",
35 |     stderr: "pipe",
36 |   });
37 |   const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
38 |   // Module evaluation must not resume after the default-fatal throw.
39 |   expect(stdout).toBe("");
                      ^
error: expect(received).toBe(expected)

- ""
+ "UNREACHABLE-AFTER-FATAL
+ "

- Expected  - 1
+ Received  + 2

      at <anonymous> (/workspace/bun/test/js/bun/spawn/exit-code.test.ts:39:18)
(fail) uncaught exception during top-level await is immediately fatal [279.01ms]
47 |     env: bunEnv,
48 |     stdout: "pipe",
49 |     stderr: "pipe",
50 |   });
51 |   const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
52 |   expect(stdout).toBe("");
                      ^
error: ex
... (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/bun/spawn/exit-code.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 (bd5ceb179)

test/js/bun/spawn/exit-code.test.ts:
(pass) process.exit(1) works [697.82ms]
(pass) await on a thrown value reports exit code 1 [583.92ms]
(pass) unhandled promise rejection reports exit code 1 [513.14ms]
(pass) handled promise rejection reports exit code 0 [165.86ms]
(pass) process.exit(0) works [594.35ms]
(pass) uncaught exception during top-level await is immediately fatal [544.16ms]
(pass) unhandled rejection during top-level await is immediately fatal (#22546) [516.65ms]
(pass) uncaught exception during top-level await is survivable with a listener [938.85ms]

 8 pass
 0 fail
 14 expect() calls
Ran 8 tests across 1 file. [7.12s]
__F:0:S:0

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) in 941ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/6] gen generated_host_exports.rs
generated_host_exports.rs: 91 exports (host=3, lazy=10, generic=78, rust=0); 244 extern-C blocks audited
[1/6] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)
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: component rust-std is up to date

  nightly-2026-05-06-x86_64-unknown-linux-gnu unchanged - rustc 1.97.0-nightly (e95e73209 2026-05-05)

info: checking for self-update (current version: 1.29.0)
�[1m�[92m   Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m   Compiling�[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
�[1m�
... (truncated)
diff hotspot
src/jsc/VirtualMachine.rs                          | 21 +++++++++--
 .../exit-code-uncaught-during-tla-fixture.mjs      |  9 +++++
 ...it-code-uncaught-during-tla-handled-fixture.mjs |  9 +++++
 ...code-unhandled-rejection-during-tla-fixture.mjs |  6 ++++
 test/js/bun/spawn/exit-code.test.ts                | 42 +++++++++++++++++++++-
 5 files changed, 84 insertions(+), 3 deletions(-)

gate history · 1 passed · 1 rejected · iteration 1

evidence per changed file
file                                                      reads  edits  tests
src/jsc/VirtualMachine.rs                                     6      2      0
…/js/bun/spawn/exit-code-uncaught-during-tla-fixture.mjs      1      2      0
…spawn/exit-code-uncaught-during-tla-handled-fixture.mjs      0      1      0
…wn/exit-code-unhandled-rejection-during-tla-fixture.mjs      0      1      0
test/js/bun/spawn/exit-code.test.ts                           3      4      0

…fatal

When the entry module is suspended in top-level await and a callback
(timer, EventEmitter 'error', setImmediate, etc.) throws with no
process.on('uncaughtException') listener installed, Bun printed the
error but then resumed the suspended module and ran it to completion,
only exiting 1 at natural module end. Node exits immediately at the
throw.

The default uncaught-exception path in VirtualMachine::uncaught_exception
records the error (unhandled_error_counter += 1, exit_code = 1) and
returns. The main drain loop in Run::start checks unhandled_error_counter
via is_event_loop_alive() and bails, which is why a throw after TLA has
settled is already fatal. But load_entry_point waited on the TLA promise
via the generic wait_for_promise, which only watches promise status and
never consults unhandled_error_counter, so the suspended module kept
running.

Inline the wait loop in load_entry_point's non-watcher arm and break on
unhandled_error_counter > 0, matching the main drain loop.
@coderabbitai

coderabbitai Bot commented Jul 18, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

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

Next review available in: 7 minutes

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

How can I continue?

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

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

How do review limits work?

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

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

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: d331218b-4e99-4a6a-92de-aec6bc61d93b

📥 Commits

Reviewing files that changed from the base of the PR and between 511caaa and bd5ceb1.

📒 Files selected for processing (5)
  • src/jsc/VirtualMachine.rs
  • test/js/bun/spawn/exit-code-uncaught-during-tla-fixture.mjs
  • test/js/bun/spawn/exit-code-uncaught-during-tla-handled-fixture.mjs
  • test/js/bun/spawn/exit-code-unhandled-rejection-during-tla-fixture.mjs
  • test/js/bun/spawn/exit-code.test.ts

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

@robobun

robobun commented Jul 18, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 7:30 AM PT - Jul 18th, 2026

❌ @robobun, your commit bd5ceb1 has 1 failures in Build #75380 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 34627

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

bun-34627 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. Top level await causes promise rejections inside global.setTimeout to be ignored, allowing execution to continue #22546 - Reports that promise rejections inside setTimeout during top-level await are ignored and execution continues instead of exiting fatally, which is exactly the behavior this PR fixes

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

Fixes #22546

🤖 Generated with Claude Code

Comment thread test/js/bun/spawn/exit-code-uncaught-during-tla-fixture.mjs Outdated
Comment thread test/js/bun/spawn/exit-code.test.ts Outdated
Comment thread src/jsc/VirtualMachine.rs
Comment thread src/jsc/VirtualMachine.rs

@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 issues found — the inlined wait loop is a faithful copy of wait_for_promise plus the unhandled_error_counter bail-out, and all earlier review nits are addressed. Deferring to a human only because this changes exit timing on the core bun <file> entry-point path (VirtualMachine::load_entry_point).

What was reviewed:

  • Confirmed the new loop matches EventLoop::wait_for_promise (event_loop.rs:963-977) line-for-line aside from the added unhandled_error_counter == 0 guard.
  • load_entry_point has a single caller (Run::start at run_command.rs:1506), so no other consumers are affected by the earlier bail.
  • Sibling TLA-wait sites (watcher arm, --preload, test-runner) are documented as intentional exclusions in the PR body; the earlier stderr-empty and 4-line-comment nits were fixed in 23bd4d6.
Extended reasoning...

Overview

The PR replaces one wait_for_promise call in VirtualMachine::load_entry_point's non-watcher arm with an inlined tick/auto_tick loop that additionally breaks when unhandled_error_counter > 0. This makes an uncaught exception (or unhandled rejection) that fires while the entry module is suspended in top-level await immediately fatal, matching Node. Three fixtures + three tests cover the fatal throw case, the #22546 unhandled-rejection case, and the negative control (listener installed → module completes, exit 0). Net Rust change is ~15 lines; the rest is tests.

Security risks

None. No parsing of untrusted input, no auth/crypto/permissions surface. The change only causes the process to exit sooner on a path that already ends in exit code 1 — it strictly reduces post-fatal side effects rather than introducing any.

Level of scrutiny

High: load_entry_point is the core entry for bun run <file>, and this alters when the runtime stops driving the event loop during module evaluation. The mechanics look correct — I diffed the inlined loop against EventLoop::wait_for_promise and the only semantic delta is the new guard, which mirrors what is_event_loop_alive() already gates on in Run::start's main drain loop. load_entry_point has exactly one caller (run_command.rs:1506), so scope is contained. Still, a human maintainer should confirm this is the right layer for the fix (vs. e.g. threading a flag into wait_for_promise itself) and that the documented deferral of the --preload sibling is acceptable.

Other factors

All three inline nits from earlier review rounds are resolved (fixture comment reflowed to 3 lines, expect(stderr).toBe("") replaced with .not.toContain(...), watcher-arm exclusion commented in-source). The two additional sibling sites (load_preloads non-watcher arm, load_entry_point_for_test_runner) are now documented in the PR body with rationale. The gate evidence shows fails-without-fix / passes-with-fix on both ASAN debug and release. Tests follow harness conventions (bunEnv, concurrent pipe drain, exitCode asserted last, no exact-empty stderr).

@robobun

robobun commented Jul 18, 2026

Copy link
Copy Markdown
Collaborator Author

CI on build 75380: the new test (test/js/bun/spawn/exit-code.test.ts) passed on every lane. The two red jobs are unrelated to this change:

  • :debian: 13 x64-asan: test-worker-message-port-transfer-terminate.js SIGABRT in JSObject::getOwnPropertyDescriptor, pre-existing on main.
  • :darwin: 14 x64: agent darwin-pita-x64-1 exited -1 with ENOENT posix_spawn .../bun-darwin-x64-profile/bun-profile and Cannot find module .../test/bake/client-fixture.mjs (missing artifacts/checkout on the box).

Remaining flaky tests (complex-workspace, es-module-lexer, express-memory-leak, node-net) all passed on retry.

Ready for review.

Jarred-Sumner pushed a commit that referenced this pull request Jul 18, 2026
## What

`process.on('beforeExit', ...)` fired after a default-fatal uncaught
exception. Node never emits `'beforeExit'` in that case: its
fatal-exception path is effectively `process.exit(1)`, and the docs say
`'beforeExit'` is "not emitted for conditions causing explicit
termination, such as calling process.exit() or uncaught exceptions".

## Repro

```js
process.on('beforeExit', () => console.log('BEFOREEXIT'));
process.on('exit', c => console.log('EXIT', c));
setTimeout(() => { throw new Error('boom'); }, 1);
```

| | stdout | exit |
|---|---|---|
| Node v26.3.0 | `EXIT 1` | 1 |
| Bun (before) | `BEFOREEXIT` then `EXIT 1` | 1 |
| Bun (after) | `EXIT 1` | 1 |

## Cause

`VirtualMachine::uncaught_exception()` has two paths when no
`'uncaughtException'` listener handled the error:

- If `exit_on_uncaught_exception` is set, it hard-exits via
`process_exit(global, 1)`. But that flag is only armed inside
`Process__dispatchOnBeforeExit`, i.e. *after* `beforeExit` has already
fired.
- Otherwise it records the fatal state (`unhandled_error_counter += 1`,
`exit_handler.exit_code = 1`), prints the error and returns. The main
drain loop in `Run::start` then falls out (because
`is_event_loop_alive()` sees the nonzero counter) and unconditionally
calls `vm.on_before_exit()`.

## Fix

Gate both dispatch sites in `on_before_exit()` on
`unhandled_error_counter == 0` so `'beforeExit'` only fires on a natural
drain:

- Entry: skip entirely when we arrived via a fatal throw. Arm
`exit_on_uncaught_exception` ourselves here since the skipped
`Process__dispatchOnBeforeExit` was its sole setter; without it a throw
from an `'exit'` listener after a fatal throw would fall through and let
subsequent `'exit'` listeners run.
- Re-dispatch inside the drain loop: skip when work scheduled by a
`beforeExit` listener itself threw. On the main thread
`exit_on_uncaught_exception` already hard-exits this case; the guard
covers workers where that flag's hard-exit is main-thread-only.

Controls verified:
- With `process.on('uncaughtException', ...)` installed: `beforeExit`
still fires, exit 0. (New regression-guard test.)
- After a fatal throw, a throw from the first `'exit'` listener still
stops subsequent `'exit'` listeners. (New regression-guard test; would
regress without the flag-arming.)
- `test/cli/hot/hot.test.ts` including "should recover from errors"
passes; the watcher loop is unaffected since it proceeds to
`tick_possibly_forever()` either way, and `exit_on_uncaught_exception`
was already armed by the previous unconditional dispatch.
- All `test-process-beforeexit*` / `test-process-exit*` /
`test-worker-*beforeexit*` / `test-promises-unhandled-*` Node parallel
tests pass.

### Side effect: unhandled rejections

`unhandled_error_counter` is also bumped on the unhandled-rejection path
(`Mode::Bun` fallthrough, `Mode::Throw`), so this guard now also skips
`'beforeExit'` when an unhandled rejection is processed before the drain
loop exits (e.g. `Promise.reject(...); setImmediate(() => {});`). That
is a Node-parity improvement on that path, but it is not the whole
story: Bun's default mode still processes some rejections only at
`handle_rejected_promises()` *after* `on_before_exit()`, so
`setTimeout(() => Promise.reject(...), 1)` is unchanged. The full fix
(routing the default unhandled-rejection mode through
`uncaughtException`) is #32814's backed-out scope and needs the
watcher/worker hardening first; no test pins the partial behavior here.

## Verification

```
$ USE_SYSTEM_BUN=1 bun test test/js/node/process/process.test.js -t "is skipped after a fatal"
(fail) process.onBeforeExit > is skipped after a fatal uncaught exception
  + "beforeExit
  + exit 1
  "

$ bun bd test test/js/node/process/process.test.js -t "is skipped after a fatal"
(pass) process.onBeforeExit > is skipped after a fatal uncaught exception
```

Found while investigating #34627; reproduces with or without top-level
`await`.

<!-- robobun:evidence:begin -->

---

**no test proof** · iteration 1 · Platform-specific test(s) that do not
run on this machine. Deferring to CI, which covers all platforms:
test/js/node/process/process.test.js

<!-- robobun:evidence:end -->
@robobun

robobun commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

Closing as part of a cleanup of stale pull requests. This PR has had no new commits since 2026-07-18, it conflicts with main, and its last CI run failed. This is not a judgment on the fix itself. The linked issue (#22546) stays open. If the problem still reproduces on a current build, reopen this PR after a rebase or open a new one against main.

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.

Top level await causes promise rejections inside global.setTimeout to be ignored, allowing execution to continue

1 participant