process: skip 'beforeExit' after a fatal uncaught exception - #34639
Conversation
Node's fatal-exception path is effectively process.exit(1), which never emits 'beforeExit'. Bun incremented unhandled_error_counter and set exit_code = 1 but then fell through to on_before_exit(), so a 'beforeExit' listener observed the error state and ran user code that should not have run. Gate on_before_exit() on unhandled_error_counter == 0 so it only fires on a natural drain. The existing exit_on_uncaught_exception flag (armed inside Process__dispatchOnBeforeExit) continues to cover the post-beforeExit throw case.
WalkthroughChangesbeforeExit error handling
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
|
Updated 9:41 AM PT - Jul 18th, 2026
✅ @robobun, your commit 7d5a2ec2e7409bf4f63ebfacaf7f30ba443f0f6f passed in 🧪 To try this PR locally: bunx bun-pr 34639That installs a local version of the PR into your bun-34639 --bun |
|
Reproduced with Fix: gate both dispatch sites in CI: |
The main-thread case already hard-exits via exit_on_uncaught_exception; this covers workers where that flag falls through.
There was a problem hiding this comment.
LGTM — the follow-up commit gates the re-dispatch on the same counter, addressing the worker-thread sibling site I flagged.
What was reviewed:
- Traced all four
on_before_exit()callers (main run loop, watcher, worker, repl); each correctly proceeds past the new early-return, and the watcher still reachestick_possibly_forever(). - Confirmed
unhandled_error_counteris only bumped on genuinely fatal paths (unhandled exception/rejection, recursive-handler, hot-reload rejection), so the guard cannot suppressbeforeExitafter a handled throw — the second new test pins that. - Tests drain stdout/stderr/exited concurrently, assert exact stdout before exit code, and use targeted stderr checks rather than exact-empty.
Extended reasoning...
Overview
Two-file change: src/jsc/VirtualMachine.rs gains an early-return in on_before_exit() when unhandled_error_counter > 0, plus the same condition on the inner re-dispatch (added in 6056186 after my earlier review). test/js/node/process/process.test.js gains two subprocess tests inside the existing process.onBeforeExit describe block — one asserting beforeExit is skipped after a fatal async throw, and a regression guard that it still fires when an uncaughtException listener swallows the throw.
Security risks
None. This is purely event-emission ordering for process lifecycle; no untrusted input parsing, no new syscalls or FFI surface.
Level of scrutiny
Medium. VirtualMachine.rs is core, but the diff is a narrow, additive guard on a counter that is_event_loop_alive() already reads for the same purpose. I traced every unhandled_error_counter += 1 site (:1071/:1079/:1392/:1438/:3361/:3446) — all represent fatal states in which Node would not emit beforeExit, so the gate can't over-suppress. The handled-exception path at :1416–1420 does not bump the counter, so beforeExit still fires when a listener swallows the error (verified by the second test, and consistent with the existing zeroExitWithUncaughtHandler test).
Other factors
- My earlier inline concern (the re-dispatch at :1476 wasn't gated, so a worker could re-emit after a fatal) was addressed in 6056186; the author noted the worker's
on_unhandled_rejectionalready arms termination first, but agreed the symmetric guard is the right shape. That thread is resolved. - The watcher caller (
run_command.rs:1571) still falls through totick_possibly_forever(); the PR description reportshot.test.ts"should recover from errors" passes, and any lingeringunhandled_error_counterbehavior across hot reloads is pre-existing and unchanged by this diff. - Tests follow harness conventions:
Bun.spawn+bunEnv,Promise.allon all three pipes, exactstdoutbeforeexitCode,toInclude/not.toIncludeon stderr rather than exact-empty. - The bug hunting system found nothing on this revision.
The skipped Process__dispatchOnBeforeExit call was the sole setter of that flag, so a throw from an 'exit' listener (after a fatal throw) fell through instead of hard-exiting and subsequent exit listeners ran. Arm it ourselves to preserve the pre-existing behavior on that path.
There was a problem hiding this comment.
My earlier concern (the un-gated re-dispatch on the worker path) was addressed in 6056186, and 8aa9fd5 arms exit_on_uncaught_exception in the early-return so the exit-listener-throws-after-fatal case is preserved. I didn't find further issues, but this is VM shutdown logic reached from four different caller contexts (main run loop, watcher loop, worker exit, repl) with an acknowledged partial side-effect on the unhandled-rejection path, so it's worth a human look.
What was reviewed:
- Traced all four
on_before_exit()callers; the watcher path's flag-arming is behavior-preserving (previously armed via the unconditionalProcess__dispatchOnBeforeExit→Bun__VirtualMachine__exitDuringUncaughtException). - Confirmed
exit_on_uncaught_exceptionis set-only (never cleared), so arming it on the early-return branch cannot regress a later reset. - Checked
unhandled_error_counterwrite sites; the gate correctly excludes the handled-by-uncaughtExceptionpath (counter isn't bumped there), matching the second new test.
Extended reasoning...
Overview
This PR gates both 'beforeExit' dispatch sites in VirtualMachine::on_before_exit() on unhandled_error_counter == 0, so the event is skipped after a default-fatal uncaught exception (Node parity). The early-return branch also arms exit_on_uncaught_exception since the skipped Process__dispatchOnBeforeExit was that flag's sole setter. Three subprocess tests are added to test/js/node/process/process.test.js covering the fatal case, the handled-by-uncaughtException control, and the exit-listener-throws-after-fatal regression guard.
Security risks
None. This is process-lifecycle event ordering; no untrusted input, auth, or crypto is involved.
Level of scrutiny
High. on_before_exit() is called from four distinct contexts — the main run loop (run_command.rs:1631), the watcher loop (run_command.rs:1571, in an infinite loop), the worker drain (web_worker.rs:1196), and the repl (repl_command.rs:245). Each has different semantics around what happens after the call returns, and exit_on_uncaught_exception once armed is never cleared. The change is small and I traced each caller: the watcher path is behavior-preserving on the flag (it was already being armed by the previous unconditional dispatch), and the PR reports hot.test.ts "should recover from errors" passes. The worker re-dispatch guard I raised earlier was added in 6056186. The repl/bun -e path is what the new tests exercise directly.
Other factors
- My prior inline finding is resolved; the current diff gates both dispatch sites symmetrically.
- The PR description candidly notes a partial side-effect:
unhandled_error_counteris also bumped on some unhandled-rejection paths, so those now also skipbeforeExit. That's directionally correct for Node parity but incomplete (deferred to #32814's scope), and no test pins the partial behavior. A maintainer should confirm they're comfortable shipping that intermediate state. - Tests follow the repo conventions (subprocess spawn with concurrent pipe drain, exact stdout assertions, exit-code asserted last) and sit alongside the existing
process.onBeforeExitdescribe block. - Given this is core VM shutdown ordering, I'm deferring rather than approving so a human can sanity-check the watcher/worker interactions and the unhandled-rejection side-effect.
There was a problem hiding this comment.
My earlier concern (the un-gated re-dispatch on workers) was addressed in 6056186, and 8aa9fd5 correctly arms exit_on_uncaught_exception in the early-return branch so the watcher and exit-listener-throws paths are behavior-preserving. No further issues found, but this is process-lifecycle/shutdown code with acknowledged side effects on the unhandled-rejection path and interactions with #32814's backed-out scope, so it's worth a maintainer's eye.
What was reviewed:
- Traced all four
on_before_exit()callers (run_command watcher + non-watcher, repl, web_worker) — the early return + explicit flag-arming preserves prior behavior at each. - Checked that the
--watchloop's post-fatal state is unchanged: the flag was already armed by the previous unconditional dispatch, now armed explicitly. - Confirmed the handled-exception path (
uncaughtExceptionlistener present) leavesunhandled_error_counterat 0, sobeforeExitstill fires — covered by the new test.
Extended reasoning...
Overview
The PR gates both beforeExit dispatch sites in VirtualMachine::on_before_exit() on unhandled_error_counter == 0, so 'beforeExit' is skipped after a default-fatal uncaught exception (matching Node's documented behavior). The entry guard also arms exit_on_uncaught_exception to preserve the side effect the skipped Process__dispatchOnBeforeExit call would have had. Three subprocess tests cover the fatal-skip, handled-still-fires, and exit-listener-throws-after-fatal cases.
Security risks
None. This is process-lifecycle event ordering; no untrusted input parsing, no auth/crypto/permissions.
Level of scrutiny
High. on_before_exit() is called from four places (main run loop, watcher loop, REPL, worker) and interacts with uncaught_exception(), exit_on_uncaught_exception, the worker termination path, and the unhandled-rejection counter. The PR description itself calls out a side effect on unhandled-rejection semantics that partially improves Node parity but overlaps with #32814's backed-out scope. Shutdown-sequence bugs in a runtime tend to manifest as hangs, double-emits, or wrong exit codes only under specific listener/throw combinations.
Other factors
- My prior review flagged the missing guard on the inner-loop re-dispatch (worker case); that was addressed in 6056186 with the same
unhandled_error_counter == 0check, and the author noted the worker path already terminates viaon_unhandled_rejectionbefore control returns there — so the guard is defensive but consistent. - 8aa9fd5 added the
exit_on_uncaught_exception = truearming plus a regression test proving a throw from an'exit'listener after a fatal still stops subsequent listeners. I verified this preserves the watcher-loop invariant (the flag was previously armed by the unconditional dispatch; now armed explicitly on the same path). - Test coverage is solid for the main-thread cases and follows the existing subprocess-test conventions in that file (drain both pipes, assert stdout before exit code).
- The acknowledged partial effect on unhandled rejections and the cross-reference to backed-out work in #32814 make this a good candidate for a maintainer familiar with that history to sign off.
What
process.on('beforeExit', ...)fired after a default-fatal uncaught exception. Node never emits'beforeExit'in that case: its fatal-exception path is effectivelyprocess.exit(1), and the docs say'beforeExit'is "not emitted for conditions causing explicit termination, such as calling process.exit() or uncaught exceptions".Repro
EXIT 1BEFOREEXITthenEXIT 1EXIT 1Cause
VirtualMachine::uncaught_exception()has two paths when no'uncaughtException'listener handled the error:exit_on_uncaught_exceptionis set, it hard-exits viaprocess_exit(global, 1). But that flag is only armed insideProcess__dispatchOnBeforeExit, i.e. afterbeforeExithas already fired.unhandled_error_counter += 1,exit_handler.exit_code = 1), prints the error and returns. The main drain loop inRun::startthen falls out (becauseis_event_loop_alive()sees the nonzero counter) and unconditionally callsvm.on_before_exit().Fix
Gate both dispatch sites in
on_before_exit()onunhandled_error_counter == 0so'beforeExit'only fires on a natural drain:exit_on_uncaught_exceptionourselves here since the skippedProcess__dispatchOnBeforeExitwas its sole setter; without it a throw from an'exit'listener after a fatal throw would fall through and let subsequent'exit'listeners run.beforeExitlistener itself threw. On the main threadexit_on_uncaught_exceptionalready hard-exits this case; the guard covers workers where that flag's hard-exit is main-thread-only.Controls verified:
process.on('uncaughtException', ...)installed:beforeExitstill fires, exit 0. (New regression-guard test.)'exit'listener still stops subsequent'exit'listeners. (New regression-guard test; would regress without the flag-arming.)test/cli/hot/hot.test.tsincluding "should recover from errors" passes; the watcher loop is unaffected since it proceeds totick_possibly_forever()either way, andexit_on_uncaught_exceptionwas already armed by the previous unconditional dispatch.test-process-beforeexit*/test-process-exit*/test-worker-*beforeexit*/test-promises-unhandled-*Node parallel tests pass.Side effect: unhandled rejections
unhandled_error_counteris also bumped on the unhandled-rejection path (Mode::Bunfallthrough,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 athandle_rejected_promises()afteron_before_exit(), sosetTimeout(() => Promise.reject(...), 1)is unchanged. The full fix (routing the default unhandled-rejection mode throughuncaughtException) is #32814's backed-out scope and needs the watcher/worker hardening first; no test pins the partial behavior here.Verification
Found while investigating #34627; reproduces with or without top-level
await.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