Repository navigation
Conversation
…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.
|
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 configurationConfiguration used: Repository: oven-sh/bun/.coderabbit.yaml Review profile: ASSERTIVE Plan: Essentials Run ID: 📒 Files selected for processing (3)
Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 0 remain after this review. WalkthroughRun 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 ChangesUnhandled error exit handling
Suggested reviewers: Priority: ➖ Normal Merge Risk: ⚪ Minimal · up to 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)
Comment |
|
Updated 6:55 PM PT - Sep 25th, 2026
✅ @robobun, your commit 9b0bcbab2801963a0049a78f6c5a171180410ed0 passed in 🧪 To try this PR locally: bunx bun-pr 38116That installs a local version of the PR into your bun-38116 --bun |
|
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: Fail-before: 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 |
There was a problem hiding this comment.
LGTM — small, well-scoped Node compat fix.
What was reviewed:
- Verified
exit_handler.exit_code = 1moves from post-on_exit()toon_unhandled_rejection_before_close, mirroring whatVirtualMachine::uncaught_exceptionalready does at the same slot before invoking this handler. - Checked all
ANY_UNHANDLEDreaders: both remaining sites now only gate the version-note print, andexit_with_unhandled_notestill sets the code itself for the entry-load-failure path. - New tests cover the variant matrix (entry vs. later-turn rejection, prior
exitCodeoverwritten, 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'sprocess.exitCode = N) is covered by the two "exit listener can change the code" tests. - The negative-contract guard (
unhandledRejectionlistener installed → exit 0,beforeExitfires) confirms the write is placed in the run's handler rather than inVirtualMachine::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.servebehavior change (bareprocess.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.
|
This still reproduces on main at d316760 ( I started on the same bug before finding this PR, so I am standing down in favor of it. The branch is This branch merges cleanly with current main (checked with |
|
Overlap note. #42032 sets Two things stay unique to this PR and still fail on #42032's branch: |
…ed-rejection-exit-code
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.
…ed-rejection-exit-code
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.
There was a problem hiding this comment.
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--hotand--watch, an unhandled rejection still leaves 'exit' listeners seeing 0 and a bareprocess.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 inexit_handler.exit_code. Fix: setexit_handler.exit_code = 1in 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 throughprocess.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 andprocess.exitCodereads undefined, and a bareprocess.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 bareprocess.exit()or an…
|
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 |
Problem
bun runexits with 1, butexitlisteners are told0and a code they assign is ignored. Node passes1and honours the code.Run::startstores the code inprint_unhandled_version_note(src/runtime/cli/run_command.rs:1678) aftervm.on_exit()emittedexit. Regression of 1.1.22 (properly propogate exit code if process onexit handler throws #13058) and 1.2.9 (node:worker_threads low-hanging fruit #18758).Fix
Run::startstores the code right beforevm.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 afteron_exit()stays.test/js/node/process/process.test.jsandtest/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).Background
exit_handler.exit_codeis the VM's exit code:process.exitCode = nwrites it,on_exit()passes it toexitlisteners,global_exit()exits with it.Bun.servehandler error withouterror()is printed and the server continues. Node has no counterpart, so main's status stays.process.exitCode = 0after a printed error then exits with 0.Downsides
exitlistener that callsprocess.exit()after a printed error now exits with 1, not 0 (tape does this). A listener'sprocess.exitCodeafter a fatal error now decides the status.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.
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 namedan exit listener replaces the code ...expect 1.History of this PR. The first version (8ae50c6) stored the code in
on_unhandled_rejection_before_closeand removed the store at the end. A program that printed an uncaught error and then ranprocess.exitCode = 0exited with 0, and a server stopped byprocess.exit()after one failed request exited with 1. This version has tests for both.Releases, published binaries.
1 10 undefined0 undefined1 1Exit status, and in brackets what the
exitlistener prints (code process.exitCode). Debug builds with ASAN on main 36cd151.#42032is its head ed96331, and+42032is this PR merged with it.firstis 8ae50c6, measured on main 29d9638. An empty node cell means that node has no counterpart.exitCode = 5, then rejectionreportError()process.exit()process.exit(code)exitCode = 0exitCode = 0exitCode = 0, listener logsexitCode = failures ? 1 : 0exitCode = 7reportError(), thenexitCode = 0exitCode = 0exitCode = 0process.exit(code)process.exit()process.exit()process.exit(0)process.exit()process.exit()exitexitprocess.exit(7),exitlistener throwsprocess.exit(),exitlistener throwsexitCode = 4,exitlistener throwsexitCode = 0,exitlistener throwsuncaughtExceptionlistenerunhandledRejectionlistenerexitlistener assigns the code after a fatal error (6) or callsprocess.exit()(3). 7 move to node's value. 1 has no counterpart in node (the served error). 1 moves away from node: the decision in Downsides.exitCode = 0and a throwingexitlistener: Report a loop turn's rejections before the loop-alive check, and keep the exit code when an 'exit' listener throws #42032 alone exits with 0 there (main: 1), and with this PR it exits with 1.process.exit(7)with a throwingexitlistener stays 7. The source files and the test files merge with no conflict. Theprocess.exitCodetests of both PRs pass on the merged build (49 of 49).exitlistener runs the event loop exits with 1, also when the listener assigned a code.Bun.build()with an async plugin runs the event loop inside anexitlistener, so the case is real. Two tests pin it.uncaughtExceptionlistener, node calls the listener and the process lives. bun does not call it (A default-mode unhandled rejection does not reach anuncaughtExceptionlistener #44019). Theexitlisteners of that program are told 1 on this PR and 0 on main, and both exit with 1. An exit hook that assertscode === 0passes on main and fails on this PR.Reach. No issue is linked, and no user report is known. The change is visible to
process.on("exit")listeners. A replacedprocess.emitis not called forexiton 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.
Run::start, from the return ofhandle_rejected_promisesto the call ofglobal_exit: 17 instructions executed, main 13. The 4 new ones are one more instruction to load the error counter, one byte load ofANY_UNHANDLED, onetest, and one branch. +0 per event loop turn and per request.on_exit, 2 xfix_dead_code_elimination,global_exit). Exported symbols: 692 on both..text58,298,741 B,.bss1,810,040 B,bun80,991,816 B, equal on both.Run::start3,282 to 3,314 B.exit_with_unhandled_note170 to 55 B.print_unhandled_version_note146 to 139 B.exit. 1 more after it when the error is not fatal or was first printed duringexit.on_unhandled_rejection_before_closeis identical to main.strace,perfandvalgrindare not installed in the build container. The call list shows no new call.Tests.
unhandledRejectionlistener consumes, the two rows for a rejection first printed duringexit, andprocess.exit()after a served error.process.test.jshas 195 pass and 0 fail.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.tshad 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.Self-review, not adopted.
exit 1 1for theuncaughtExceptionrow: the value differs from node, so the test fails when A default-mode unhandled rejection does not reach anuncaughtExceptionlistener #44019 is fixed. The row is in the table above.Not in this PR.
process.exit()with no argument after an unhandled rejection exits with 0:process.exit()with no argument after an unhandled rejection exits with 0 #44020. Report a loop turn's rejections before the loop-alive check, and keep the exit code when an 'exit' listener throws #42032 fixes it.exitlistener that throws replaces the exit code with 1: Anexitlistener that throws replaces the exit code with 1 #44018. Report a loop turn's rejections before the loop-alive check, and keep the exit code when an 'exit' listener throws #42032 fixes the rows of the table.uncaughtExceptionlistener: A default-mode unhandled rejection does not reach anuncaughtExceptionlistener #44019.bun repl -e: Report a loop turn's rejections before the loop-alive check, and keep the exit code when an 'exit' listener throws #42032.--hotand--watchnever reach this code.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