Skip to content

bun run: decide the exit code of a printed error before 'exit' listeners run - #38116

Open
robobun wants to merge 5 commits into
mainfrom
farm/8ffa8f1f/unhandled-rejection-exit-code
Open

robobun wants to merge 5 commits into
mainfrom
farm/8ffa8f1f/unhandled-rejection-exit-code

Conversation

@robobun

@robobun robobun commented Aug 13, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

Fix

  • Run::start stores the code right before vm.on_exit(). After an uncaught exception or unhandled rejection, the code the listeners leave is final, as in Node. After other printed errors the store after on_exit() stays.
  • Verified: test/js/node/process/process.test.js and test/js/bun/http/bun-serve-propagate-errors.test.ts. 15 of 19 new tests fail on main. Also 40 programs against node v26.3.0 (Notes).
  • Self-reviewed: 17 concerns raised, 15 addressed, 2 not adopted (Notes).

Background

  • exit_handler.exit_code is the VM's exit code: process.exitCode = n writes it, on_exit() passes it to exit listeners, global_exit() exits with it.
  • A Bun.serve handler error without error() is printed and the server continues. Node has no counterpart, so main's status stays.
  • Considered a store at report time (this PR's first version): process.exitCode = 0 after a printed error then exits with 0.

Downsides

  • An exit listener that calls process.exit() after a printed error now exits with 1, not 0 (tape does this). A listener's process.exitCode after a fatal error now decides the status.
  • Needs a maintainer's decision: a listener registered after a fatal error can assign 0, and the run exits with 0 (node and main: 1).
  • A run with no error executes 4 more instructions, once per process. Binary size is equal.
Notes

The decision in Downsides. This program prints the error and exits with 0 on this PR. Node exits at the rejection with 1. main exits with 1.

Promise.reject(new Error("oops"));
setImmediate(() => {
  process.on("exit", () => { process.exitCode = 0; });
});

If the answer is no, the store after on_exit() can stay for every printed error. Then no listener can replace the code, as on main, and the 5 tests named an exit listener replaces the code ... expect 1.

History of this PR. The first version (8ae50c6) stored the code in on_unhandled_rejection_before_close and removed the store at the end. A program that printed an uncaught error and then ran process.exitCode = 0 exited with 0, and a server stopped by process.exit() after one failed request exited with 1. This version has tests for both.

Releases, published binaries.

release rejection, listener is told listener assigns 98 listener assigns 0
1.1.21 1 1 98 0
1.1.22, 1.2.8 0 undefined 98 1
1.2.9, 1.4.2, main 0 undefined 1 1
this PR, node v26.3.0 1 1 98 0

Exit status, and in brackets what the exit listener prints (code process.exitCode). Debug builds with ASAN on main 36cd151. #42032 is its head ed96331, and +42032 is this PR merged with it. first is 8ae50c6, measured on main 29d9638. An empty node cell means that node has no counterpart.

program node main first this PR #42032 +42032
rejection, listener logs 1 (1 1) 1 (0 undefined) 1 (1 1) 1 (1 1) 1 (1 1) 1 (1 1)
rejection in a later turn 1 (1 1) 1 (0 undefined) 1 (1 1) 1 (1 1) 1 (1 1) 1 (1 1)
throw in a timer 1 (1 1) 1 (1 1) 1 (1 1) 1 (1 1) 1 (1 1) 1 (1 1)
exitCode = 5, then rejection 1 (1 1) 1 (5 5) 1 (1 1) 1 (1 1) 1 (1 1) 1 (1 1)
listener assigns 98 after a rejection 98 1 98 98 1 98
listener assigns 0 after a rejection 0 1 0 0 1 0
listener assigns 98 after a timer throw 98 1 98 98 1 98
listener assigns 0 after a timer throw 0 1 0 0 1 0
listener assigns 98 after reportError() 98 1 98 98 1 98
listener registered after the rejection assigns 0 1 1 0 0 1 0
rejection, listener calls process.exit() 1 0 1 1 1 1
rejection, listener calls process.exit(code) 1 0 1 1 1 1
timer throw, then exitCode = 0 1 1 0 1 1 1
rejection, then exitCode = 0 1 1 0 1 1 1
rejection, then exitCode = 0, listener logs 1 (1 1) 1 (0 0) 0 (0 0) 1 (1 1) 1 (0 0) 1 (1 1)
test script that ends with exitCode = failures ? 1 : 0 1 1 0 1 1 1
rejection, then exitCode = 7 1 (1 1) 1 (7 7) 7 (7 7) 1 (1 1) 1 (7 7) 1 (1 1)
reportError(), then exitCode = 0 1 (1 1) 1 (0 0) 0 (0 0) 1 (1 1) 1 (0 0) 1 (1 1)
EventTarget listener throws, then exitCode = 0 1 (1 1) 1 (0 0) 0 (0 0) 1 (1 1) 1 (0 0) 1 (1 1)
served error, natural exit 1 (0 undefined) 1 (1 1) 1 (1 1) 1 (0 undefined) 1 (1 1)
served error, then exitCode = 0 1 (0 0) 0 (0 0) 1 (1 1) 1 (0 0) 1 (1 1)
served error, listener assigns 98 1 98 1 1 1
served error, listener assigns 0 1 0 1 1 1
served error, listener calls process.exit(code) 0 1 1 0 1
served error, SIGTERM handler calls process.exit() 0 1 0 0 0
served error, then process.exit() 0 1 0 0 0
served error, then process.exit(0) 0 0 0 0 0
timer throw, then process.exit() 1 1 1 1 1 1
rejection, then process.exit() 1 0 1 0 1 1
rejection first printed during exit 1 1 1 1 1
listener assigns 98, rejection first printed during exit 1 1 1 1 1
process.exit(7), exit listener throws 7 1 1 1 7 7
process.exit(), exit listener throws 1 1 1 1 1 1
exitCode = 4, exit listener throws 4 1 1 1 4 4
rejection, listener assigns 98 and throws 98 1 1 1 98 98
served error, then exitCode = 0, exit listener throws 1 (0 0) not run 1 (1 1) 0 (0 0) 1 (1 1)
rejection, only an uncaughtException listener 0 (0 undefined) 1 (0 undefined) 1 (1 1) 1 (1 1) 1 (1 1) 1 (1 1)
rejection handled by an unhandledRejection listener 0 0 0 0 0 0
clean run 0 0 0 0 0 0
top-level throw, listener assigns 98 98 98 98 98 98 98

Reach. No issue is linked, and no user report is known. The change is visible to process.on("exit") listeners. A replaced process.emit is not called for exit on main or on this PR (node calls it). #12918 reports that for the signal-exit package.

Cost, release builds of main and of this PR, same flags.

  • No-error exit tail of Run::start, from the return of handle_rejected_promises to the call of global_exit: 17 instructions executed, main 13. The 4 new ones are one more instruction to load the error counter, one byte load of ANY_UNHANDLED, one test, and one branch. +0 per event loop turn and per request.
  • Calls on that path: the same 4 (on_exit, 2 x fix_dead_code_elimination, global_exit). Exported symbols: 692 on both.
  • Size: .text 58,298,741 B, .bss 1,810,040 B, bun 80,991,816 B, equal on both. Run::start 3,282 to 3,314 B. exit_with_unhandled_note 170 to 55 B. print_unhandled_version_note 146 to 139 B.
  • Per failing run: 1 byte store before exit. 1 more after it when the error is not fatal or was first printed during exit. on_unhandled_rejection_before_close is identical to main.
  • Not measured: syscall and allocation counts at run time. strace, perf and valgrind are not installed in the build container. The call list shows no new call.

Tests.

  • 15 of the 19 new tests fail on 1.4.3-canary.1+367d939d9 and on main. Four pass on main on purpose: the rejection that an unhandledRejection listener consumes, the two rows for a rejection first printed during exit, and process.exit() after a served error.
  • The first version of this PR fails 6 of the 19.
  • The full process.test.js has 195 pass and 0 fail.
  • Also pass: serve-reused-response, spawn/exit-code, workerd/html-rewriter, util/reportError, cli/hot/hot, console-write, process-nexttick, events/event-emitter, run-eval, preload-test, and 26 vendored node tests for process exit, uncaught exceptions and unhandled rejections.
  • module-graph-isolation.test.ts had timing failures while the machine was loaded (load average above 60 on 16 cores), on main and on this PR. A different test failed in each run. With a lower load and a 60 s timeout, main and an earlier build of this PR passed 412 of 412. The last build was not run with a low load.
  • Windows and macOS are not run here.

Self-review, not adopted.

Not in this PR.


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

…ot after 'exit'

A rejection reported by the event loop in the default mode made the
process exit 1, but the exit code was only written in the tail of
Run::start, after on_exit() had already emitted 'exit' with 0, so exit
listeners saw code 0 and process.exitCode undefined. The same late write
also overwrote any process.exitCode assignment made by an exit listener,
for rejections and for uncaught exceptions raised from the event loop.

Set the code in on_unhandled_rejection_before_close, at report time,
mirroring what VirtualMachine::uncaught_exception already does, and make
the tail only print the version note. Matches Node, where the default
mode raises the rejection as an uncaught exception, which sets
process.exitCode = 1 before 'exit' and honors what the listeners leave
in it.
@coderabbitai

coderabbitai Bot commented Aug 13, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository: oven-sh/bun/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 2e2efd5d-7706-47c0-8504-d411f03aeba6

📥 Commits

Reviewing files that changed from the base of the PR and between 9730163 and 9b0bcba.

📒 Files selected for processing (3)
  • src/runtime/cli/run_command.rs
  • test/js/bun/http/bun-serve-propagate-errors.test.ts
  • test/js/node/process/process.test.js

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 0 remain after this review.


Walkthrough

Run exit-code handling now distinguishes fatal from nonfatal unhandled errors around exit listeners. New subprocess tests cover error reporting, exit-listener assignments, and explicit calls to process.exit().

Changes

Unhandled error exit handling

Layer / File(s) Summary
Set exit codes around exit listeners
src/runtime/cli/run_command.rs
Run::start sets exit code 1 before exit listeners for unhandled errors. It restores code 1 afterward only for nonfatal errors. The note printer no longer changes the exit code.
Test exit codes after unhandled errors
test/js/bun/http/bun-serve-propagate-errors.test.ts, test/js/node/process/process.test.js
Subprocess tests cover Bun.serve() fetch errors and unhandled errors from rejections, throws, reportError, EventTarget listeners, and preload modules. They check exit-listener assignments and calls to process.exit().

Suggested reviewers: jarred-sumner

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to 9b0bc

The changed exit-code behavior is consistent with the covered error and listener scenarios. No actionable merge-blocking risk remains after normal checks.

🚥 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 clearly and concisely describes the main change: setting the exit code before 'exit' listeners run.
Description check ✅ Passed The description explains the problem, fix, background, tradeoffs, scope, and verification results. It does not use the template headings exactly, but it provides the required change summary and verifi…

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

@robobun

robobun commented Aug 13, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 6:55 PM PT - Sep 25th, 2026

✅ @robobun, your commit 9b0bcbab2801963a0049a78f6c5a171180410ed0 passed in Build #120805! 🎉


🧪   To try this PR locally:

bunx bun-pr 38116

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

bun-38116 --bun

@robobun

robobun commented Aug 13, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: reworked in 9b0bcba. The mechanism is new, so the title and the description are new too. The description has the one decision that a maintainer has to take.

Reproduced on 1.4.3-canary.1+367d939d9 and on main at 9730163:

bun -e "process.on('exit', c => console.log('exit arg=' + c + ' exitCode=' + process.exitCode)); Promise.reject(new Error('x'))"
# exit arg=0 exitCode=undefined, status 1      (node v26.3.0: exit arg=1 exitCode=1, status 1)

Fail-before: USE_SYSTEM_BUN=1 bun test test/js/node/process/process.test.js -t "after an error that nothing handled" fails 12 of 15. USE_SYSTEM_BUN=1 bun test test/js/bun/http/bun-serve-propagate-errors.test.ts fails 3 of 5. Both files pass with bun bd test.

Why the first version changed: it stored the exit code when the error was printed and removed the store at the end of the run. A program that printed an uncaught error and then ran process.exitCode = 0 exited with 0.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM — small, well-scoped Node compat fix.

What was reviewed:

  • Verified exit_handler.exit_code = 1 moves from post-on_exit() to on_unhandled_rejection_before_close, mirroring what VirtualMachine::uncaught_exception already does at the same slot before invoking this handler.
  • Checked all ANY_UNHANDLED readers: both remaining sites now only gate the version-note print, and exit_with_unhandled_note still sets the code itself for the entry-load-failure path.
  • New tests cover the variant matrix (entry vs. later-turn rejection, prior exitCode overwritten, exit-listener override for both rejection and event-loop throw, and a guard that a consumed rejection stays exit 0).
Extended reasoning...

Overview

Moves the exit_handler.exit_code = 1 write for the bun run unhandled-rejection path from print_unhandled_version_note (which runs after vm.on_exit() has emitted 'exit') into on_unhandled_rejection_before_close (which runs when the error is reported). print_unhandled_version_note now only prints and is shared with exit_with_unhandled_note. Net native change: one line added, one line removed, two lines deduplicated. Six new subprocess tests in process.test.js and one in bun-serve-propagate-errors.test.ts.

Security risks

None. This changes when a process-local integer is written relative to the 'exit' event dispatch. No parsing, no external input, no allocation, no FFI shape changes.

Level of scrutiny

Medium — process exit-code semantics are user-visible and affect CI/tooling, but the change is a one-line reordering that brings the rejection path in line with the existing exception path. I confirmed VirtualMachine::uncaught_exception already writes exit_handler.exit_code = 1 at line 1542 before calling on_unhandled_rejection, so this makes the two paths symmetric rather than introducing new behavior. All ANY_UNHANDLED readers were audited (only two, both now print-only). exit_with_unhandled_note retains its own exit_code = 1 write for the entry-load-failure path where the handler never fired.

Other factors

  • The second bug fixed for free (post-on_exit() write clobbering an exit listener's process.exitCode = N) is covered by the two "exit listener can change the code" tests.
  • The negative-contract guard (unhandledRejection listener installed → exit 0, beforeExit fires) confirms the write is placed in the run's handler rather than in VirtualMachine::unhandled_rejection, which the PR description correctly identifies as also feeding capture scopes.
  • Tests follow harness conventions: subprocess-spawned via runInlineFixture/Bun.spawn, stdout asserted before exit code, bunEnv/bunExe(), pipes drained concurrently.
  • The Bun.serve behavior change (bare process.exit() after a served 500 now exits 1) is called out and pinned by a test; it's consistent with what the process already did on natural exit and with --unhandled-rejections=warn-with-error-code.

@robobun

robobun commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator Author

This still reproduces on main at d316760 (1.4.3-canary.1+f42e98025), for every rejection-reason type (Error, string, undefined) and for a rejection from a later loop turn:

$ bun -e "process.on('exit', c => console.log(c, process.exitCode)); Promise.reject(new Error('x'))"
0 undefined        # status 1; node v26.3.0: 1 1, status 1

I started on the same bug before finding this PR, so I am standing down in favor of it. The branch is robobun/5bc78732/exit-code-unhandled-rejection (20d3618) for reference. It makes the same run_command.rs change (bump the code for a printed error before on_exit(), print_unhandled_version_note only prints). The one difference: it sets exit_code = 1 in the fatal tail of VirtualMachine::unhandled_rejection, next to the unhandled_error_counter bump, so one write covers the main thread and workers. Your point about capture scopes that swap the handler is a good reason to keep it in on_unhandled_rejection_before_close as this PR does, with the worker side in #34193.

This branch merges cleanly with current main (checked with git merge-tree). It needs a maintainer review.

@robobun

robobun commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator Author

Overlap note. #42032 sets exit_handler.exit_code = 1 in VirtualMachine::unhandled_rejection before it calls the handler. Of this PR's 6 process.exitCode cases, 4 pass on that branch (3 of them fail on main): 'exit' listeners see 1 1 after a rejection from the entry module or from a later turn, an earlier process.exitCode = 5 is replaced, and the listener-consumed control is unchanged.

Two things stay unique to this PR and still fail on #42032's branch: print_unhandled_version_note overwriting a code that an 'exit' listener assigned (the two "an exit listener can change the code" cases), and the Bun.serve no-error() path that calls the handler directly (bun-serve-propagate-errors.test.ts). The two PRs touch different files and do not conflict. If #42032 lands first, this one can shrink to the run_command.rs change plus those three tests.

Run::start stored exit code 1 for a printed unhandled error after
on_exit(). So the 'exit' listeners were told 0, and a code that a
listener assigned was overwritten. Store the code right before
on_exit(). Keep the store after on_exit() for an error that is first
printed while an 'exit' listener runs the event loop.

Remove the store at report time from on_unhandled_rejection_before_close.
With that store and no store at the end, a later `process.exitCode = 0`
cleared a printed error and the run exited with 0.
A Bun.serve handler error with no error() handler is printed, but it
does not end the run, and Node has no counterpart for it. Keep exit
code 1 for it whatever an 'exit' listener assigns, as before. The
listener is still told 1.

Put the tests in one concurrent table. Add the rows for a listener that
assigns 0, a listener that calls process.exit(), and process.exit()
after a served error.
@robobun robobun changed the title process: report exit code 1 to 'exit' listeners on an unhandled rejection bun run: decide the exit code of a printed error before 'exit' listeners run Sep 26, 2026

@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.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🟣 src/runtime/cli/run_command.rs — Under --hot and --watch, an unhandled rejection still leaves 'exit' listeners seeing 0 and a bare process.exit() exiting 0, although the PR description says the code "stays set" under --hot. The watcher arm at run_command.rs:1490 loops forever via tick_possibly_forever and never reaches the new writes at run_command.rs:1572 and :1580; rejections are only recorded in ANY_UNHANDLED at run_command.rs:1263, never in exit_handler.exit_code. Fix: set exit_handler.exit_code = 1 in on_unhandled_rejection_before_close at report time, as the description claims, so every exit route (watcher, process.exit(), natural tail) sees it.

    Why this was flagged

    Trigger: bun --hot server.js (or --watch) where an unhandled rejection is printed and the process later ends through process.exit() or a signal-driven exit listener. VirtualMachine.rs:4444 only increments the counter and calls the handler; the handler at run_command.rs:1253-1264 prints and sets ANY_UNHANDLED, touching no exit code. The watcher loop at run_command.rs:1490-1502 never falls through to the tail, so the only exit path is Bun__Process__exit, which passes whatever exit_code holds (0). Exit listeners receive 0 and process.exitCode reads undefined, and a bare process.exit() exits 0 after a printed error. Uncaught exceptions differ because VirtualMachine.rs:2160 sets exit_code = 1 itself, so the PR's claim holds only for that class. The behaviour is unchanged from base, but the PR description ("Under --hot the code likewise stays set after an error was reported") documents a fix that the diff does not contain, and the dismissal filed it as covered by the process.exit() finding, which was posted against a different population (non-watch runs). Remedy: record the code…

    Verification: pre-existing — the base already exits 0 by the same route under --hot/--watch; this PR's fix simply does not reach that path, while its description claims it does ("Under --hot the code likewise stays set after an error was reported"). Trigger: bun --hot app.js (or --watch) where a promise rejects unhandled in the default mode and the process later ends via a bare process.exit() or an…

@robobun

robobun commented Sep 26, 2026 •

Copy link
Copy Markdown
Collaborator Author

Both findings quote the description of the first version (8ae50c6), which stored the code at report time. The current description says the opposite. The Notes state that --hot and --watch never reach this code, and the table shows 0 for "served error, then process.exit()" and for "rejection, then process.exit()" on this PR. The second is #44020, and #42032 fixes it. The first exits with 0 on main, on this PR and on #42032, so no open PR changes it. A store at report time is the design this version moved away from: it let process.exitCode = 0 after a printed error exit with 0. No change is needed.

This branch has not been deployed

No deployments
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.

1 participant