From ab1856c6b09faa2e26177fe2d3ba8ac00d744635 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 21 Jul 2026 09:09:21 +0000 Subject: [PATCH 1/6] process: drain microtasks after emitting 'exit' on natural termination Node performs a final microtask checkpoint after emitting the 'exit' event on a natural event-loop drain, so Promise.resolve().then(cb), queueMicrotask(cb), and the first-await continuation of an async exit listener all run before the process terminates. This checkpoint only happens for natural exit; process.exit() and fatal uncaught exceptions skip it, and process.nextTick callbacks are not drained. Bun was emitting 'exit' and terminating immediately without the checkpoint, so microtasks queued by an 'exit' listener were dropped. Thread a drain_microtasks flag through Process__dispatchOnExit. Rust's on_exit() passes (unhandled_error_counter == 0) so natural exit drains and fatal-error exit does not; process.exit() and the worker uncaught-exception path pass false. The m_isExiting guard in dispatchExitInternal ensures a later on_exit() reached via process.reallyExit never reaches the drain. --- src/jsc/VirtualMachine.rs | 8 +- src/jsc/bindings/BunProcess.cpp | 16 +++- src/jsc/web_worker.rs | 2 +- test/js/node/process/process.test.js | 105 +++++++++++++++++++++++++++ 4 files changed, 122 insertions(+), 9 deletions(-) diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 5d715e84b68f..d5a2ccc443ec 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -368,7 +368,7 @@ unsafe extern "C" { safe fn Bun__emitHandledPromiseEvent(global: &JSGlobalObject, promise: JSValue) -> bool; safe fn Process__dispatchOnBeforeExit(global: &JSGlobalObject, code: u8); - safe fn Process__dispatchOnExit(global: &JSGlobalObject, code: u8); + safe fn Process__dispatchOnExit(global: &JSGlobalObject, code: u8, drain_microtasks: bool); safe fn Bun__closeAllSQLiteDatabasesForTermination(); safe fn Bun__closeAllNodeSqliteDatabasesForTermination(global: &JSGlobalObject); safe fn Bun__WebView__closeAllForTermination(); @@ -503,9 +503,9 @@ impl ExitHandler { /// parent via `container_of` would escape the provenance of `&mut self` /// (which only covers the `ExitHandler` field). Callers pass the VM /// reference instead; the body re-enters JS so no `&mut` is held. - pub fn dispatch_on_exit(vm: &VirtualMachine) { + pub fn dispatch_on_exit(vm: &VirtualMachine, drain_microtasks: bool) { let exit_code = vm.exit_handler.exit_code; - Process__dispatchOnExit(vm.global(), exit_code); + Process__dispatchOnExit(vm.global(), exit_code, drain_microtasks); if vm.worker.is_none() { Bun__closeAllSQLiteDatabasesForTermination(); Bun__closeAllNodeSqliteDatabasesForTermination(vm.global()); @@ -1513,7 +1513,7 @@ impl VirtualMachine { } } - ExitHandler::dispatch_on_exit(self); + ExitHandler::dispatch_on_exit(self, self.unhandled_error_counter == 0); self.is_shutting_down = true; // Make sure we run new cleanup hooks introduced by running cleanup diff --git a/src/jsc/bindings/BunProcess.cpp b/src/jsc/bindings/BunProcess.cpp index 81f7487465e0..6d2985f51a29 100644 --- a/src/jsc/bindings/BunProcess.cpp +++ b/src/jsc/bindings/BunProcess.cpp @@ -281,7 +281,7 @@ static JSValue constructProcessReleaseObject(VM& vm, JSObject* processObject) return release; } -static void dispatchExitInternal(JSC::JSGlobalObject* globalObject, Process* process, int exitCode) +static void dispatchExitInternal(JSC::JSGlobalObject* globalObject, Process* process, int exitCode, bool drainMicrotasks) { if (process->m_isExiting) return; @@ -301,6 +301,14 @@ static void dispatchExitInternal(JSC::JSGlobalObject* globalObject, Process* pro MarkedArgumentBuffer arguments; arguments.append(jsNumber(exitCode)); emitter.emit(event, arguments); + + // Node performs a final microtask checkpoint after emitting 'exit' on a + // natural drain (not process.exit() or fatal exception), so promise + // reactions and queueMicrotask callbacks queued by the listener run + // before termination. process.nextTick does not. + if (drainMicrotasks && !vm.hasTerminationRequest()) { + vm.drainMicrotasks(); + } } JSC_DEFINE_CUSTOM_SETTER(Process_defaultSetter, (JSC::JSGlobalObject * globalObject, JSC::EncodedJSValue thisValue, JSC::EncodedJSValue value, JSC::PropertyName propertyName)) @@ -843,7 +851,7 @@ extern "C" void Process__dispatchOnBeforeExit(Zig::GlobalObject* globalObject, u } } -extern "C" void Process__dispatchOnExit(Zig::GlobalObject* globalObject, uint8_t exitCode) +extern "C" void Process__dispatchOnExit(Zig::GlobalObject* globalObject, uint8_t exitCode, bool drainMicrotasks) { if (!globalObject->hasProcessObject()) { return; @@ -852,7 +860,7 @@ extern "C" void Process__dispatchOnExit(Zig::GlobalObject* globalObject, uint8_t auto* process = globalObject->processObject(); if (exitCode > 0) process->m_isExitCodeObservable = true; - dispatchExitInternal(globalObject, process, exitCode); + dispatchExitInternal(globalObject, process, exitCode, drainMicrotasks); } JSC_DEFINE_HOST_FUNCTION(Process_functionUptime, (JSC::JSGlobalObject * lexicalGlobalObject, JSC::CallFrame* callFrame)) @@ -875,7 +883,7 @@ JSC_DEFINE_HOST_FUNCTION(Process_functionExit, (JSC::JSGlobalObject * globalObje RETURN_IF_EXCEPTION(throwScope, {}); auto exitCode = Bun__getExitCode(bunVM(zigGlobal)); - Process__dispatchOnExit(zigGlobal, exitCode); + Process__dispatchOnExit(zigGlobal, exitCode, false); // process.reallyExit(exitCode); auto reallyExitVal = process->get(globalObject, Identifier::fromString(vm, "reallyExit"_s)); diff --git a/src/jsc/web_worker.rs b/src/jsc/web_worker.rs index ec51c28cb96f..924ba109136e 100644 --- a/src/jsc/web_worker.rs +++ b/src/jsc/web_worker.rs @@ -1587,7 +1587,7 @@ fn on_unhandled_rejection( // they may change process.exitCode). Run them before arming termination — a pending // termination exception makes dispatchExitInternal skip 'exit' (as terminate() should), // and its processIsExiting guard stops shutdown() from running them twice. - virtual_machine::ExitHandler::dispatch_on_exit(vm); + virtual_machine::ExitHandler::dispatch_on_exit(vm, false); let _ = worker.set_requested_terminate(); // Do NOT call `worker.shutdown()` here — // `shutdown()` RETURNS, so calling it here would destroy diff --git a/test/js/node/process/process.test.js b/test/js/node/process/process.test.js index 1ac9c5baeb46..ffff7a802209 100644 --- a/test/js/node/process/process.test.js +++ b/test/js/node/process/process.test.js @@ -712,6 +712,111 @@ describe.concurrent(() => { }); describe("process.onExit", () => { + it("drains microtasks queued by the listener on natural exit", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `process.on("exit", async () => { + console.log("exit-listener"); + Promise.resolve().then(() => console.log("pt-in-exit")); + queueMicrotask(() => console.log("qm-in-exit")); + await Promise.resolve(); + console.log("after-await-in-exit"); + });`, + ], + env: bunEnv, + stdio: ["inherit", "pipe", "pipe"], + }); + const [stderr, stdout, exitCode] = await Promise.all([proc.stderr.text(), proc.stdout.text(), proc.exited]); + expect(stderr).toBe(""); + expect(stdout).toBe("exit-listener\npt-in-exit\nqm-in-exit\nafter-await-in-exit\n"); + expect(exitCode).toBe(0); + }); + + it("does not drain microtasks queued by the listener on process.exit()", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `process.on("exit", () => { + console.log("exit-listener"); + queueMicrotask(() => console.log("qm-in-exit")); + }); + process.exit(0);`, + ], + env: bunEnv, + stdio: ["inherit", "pipe", "pipe"], + }); + const [stderr, stdout, exitCode] = await Promise.all([proc.stderr.text(), proc.stdout.text(), proc.exited]); + expect(stderr).toBe(""); + expect(stdout).toBe("exit-listener\n"); + expect(exitCode).toBe(0); + }); + + it("does not drain microtasks queued by the listener after a fatal uncaught exception", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `process.on("exit", () => { + console.log("exit-listener"); + queueMicrotask(() => console.log("qm-in-exit")); + }); + throw new Error("boom");`, + ], + env: bunEnv, + stdio: ["inherit", "pipe", "pipe"], + }); + const [stderr, stdout, exitCode] = await Promise.all([proc.stderr.text(), proc.stdout.text(), proc.exited]); + expect(stderr).toInclude("error: boom"); + expect(stdout).toBe("exit-listener\n"); + expect(exitCode).toBe(1); + }); + + it("does not drain process.nextTick queued by the listener on natural exit", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `process.on("exit", () => { + console.log("exit-listener"); + process.nextTick(() => console.log("nt-in-exit")); + queueMicrotask(() => console.log("qm-in-exit")); + });`, + ], + env: bunEnv, + stdio: ["inherit", "pipe", "pipe"], + }); + const [stderr, stdout, exitCode] = await Promise.all([proc.stderr.text(), proc.stdout.text(), proc.exited]); + expect(stderr).toBe(""); + expect(stdout).toBe("exit-listener\nqm-in-exit\n"); + expect(exitCode).toBe(0); + }); + + it("drains microtasks queued by the listener on a worker's natural exit", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const { Worker } = require("worker_threads"); + const w = new Worker(\` + process.on("exit", () => { + console.log("exit-listener"); + queueMicrotask(() => console.log("qm-in-exit")); + }); + \`, { eval: true }); + w.on("exit", () => console.log("worker-done"));`, + ], + env: bunEnv, + stdio: ["inherit", "pipe", "pipe"], + }); + const [stderr, stdout, exitCode] = await Promise.all([proc.stderr.text(), proc.stdout.text(), proc.exited]); + expect(stderr).toBe(""); + expect(stdout).toBe("exit-listener\nqm-in-exit\nworker-done\n"); + expect(exitCode).toBe(0); + }); + it("throwing inside preserves exit code", async () => { await using proc = Bun.spawn({ cmd: [bunExe(), "-e", `process.on("exit", () => {throw new Error("boom")});`], From f92e65e6f155dd3b3521af77db6adbd2bd0b1c96 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 21 Jul 2026 09:24:41 +0000 Subject: [PATCH 2/6] review: gate drain on worker !requested_terminate; combined-object test assertions Also gate the post-'exit' microtask drain on the worker's requested_terminate flag so worker.terminate() does not drain even though shutdown() clears the JSC termination request before on_exit(). Switch the four new stderr-empty assertions to the combined {stdout, stderr, exitCode} form per the file's existing convention. --- src/jsc/VirtualMachine.rs | 10 ++++++++- test/js/node/process/process.test.js | 32 +++++++++++++++++----------- 2 files changed, 29 insertions(+), 13 deletions(-) diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index d5a2ccc443ec..7700917ab22e 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -1513,7 +1513,15 @@ impl VirtualMachine { } } - ExitHandler::dispatch_on_exit(self, self.unhandled_error_counter == 0); + // Drain microtasks queued by the 'exit' listener only on a natural + // event-loop drain: not after a fatal error, and not when a worker + // reached here via terminate()/process.exit() (both set + // requested_terminate before shutdown() calls us). + let natural = self.unhandled_error_counter == 0 + && !self + .worker_ref() + .is_some_and(|w| w.has_requested_terminate()); + ExitHandler::dispatch_on_exit(self, natural); self.is_shutting_down = true; // Make sure we run new cleanup hooks introduced by running cleanup diff --git a/test/js/node/process/process.test.js b/test/js/node/process/process.test.js index ffff7a802209..a4e593423b90 100644 --- a/test/js/node/process/process.test.js +++ b/test/js/node/process/process.test.js @@ -729,9 +729,11 @@ describe.concurrent(() => { stdio: ["inherit", "pipe", "pipe"], }); const [stderr, stdout, exitCode] = await Promise.all([proc.stderr.text(), proc.stdout.text(), proc.exited]); - expect(stderr).toBe(""); - expect(stdout).toBe("exit-listener\npt-in-exit\nqm-in-exit\nafter-await-in-exit\n"); - expect(exitCode).toBe(0); + expect({ stdout, stderr, exitCode }).toEqual({ + stdout: "exit-listener\npt-in-exit\nqm-in-exit\nafter-await-in-exit\n", + stderr: "", + exitCode: 0, + }); }); it("does not drain microtasks queued by the listener on process.exit()", async () => { @@ -749,9 +751,11 @@ describe.concurrent(() => { stdio: ["inherit", "pipe", "pipe"], }); const [stderr, stdout, exitCode] = await Promise.all([proc.stderr.text(), proc.stdout.text(), proc.exited]); - expect(stderr).toBe(""); - expect(stdout).toBe("exit-listener\n"); - expect(exitCode).toBe(0); + expect({ stdout, stderr, exitCode }).toEqual({ + stdout: "exit-listener\n", + stderr: "", + exitCode: 0, + }); }); it("does not drain microtasks queued by the listener after a fatal uncaught exception", async () => { @@ -789,9 +793,11 @@ describe.concurrent(() => { stdio: ["inherit", "pipe", "pipe"], }); const [stderr, stdout, exitCode] = await Promise.all([proc.stderr.text(), proc.stdout.text(), proc.exited]); - expect(stderr).toBe(""); - expect(stdout).toBe("exit-listener\nqm-in-exit\n"); - expect(exitCode).toBe(0); + expect({ stdout, stderr, exitCode }).toEqual({ + stdout: "exit-listener\nqm-in-exit\n", + stderr: "", + exitCode: 0, + }); }); it("drains microtasks queued by the listener on a worker's natural exit", async () => { @@ -812,9 +818,11 @@ describe.concurrent(() => { stdio: ["inherit", "pipe", "pipe"], }); const [stderr, stdout, exitCode] = await Promise.all([proc.stderr.text(), proc.stdout.text(), proc.exited]); - expect(stderr).toBe(""); - expect(stdout).toBe("exit-listener\nqm-in-exit\nworker-done\n"); - expect(exitCode).toBe(0); + expect({ stdout, stderr, exitCode }).toEqual({ + stdout: "exit-listener\nqm-in-exit\nworker-done\n", + stderr: "", + exitCode: 0, + }); }); it("throwing inside preserves exit code", async () => { From 21e9d757660badd43cbf3dd0f30a70fa0ba802e5 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 21 Jul 2026 10:08:29 +0000 Subject: [PATCH 3/6] review: condense exit-drain comments to three lines --- src/jsc/VirtualMachine.rs | 7 +++---- src/jsc/bindings/BunProcess.cpp | 5 ++--- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 7700917ab22e..32e50dcccd94 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -1513,10 +1513,9 @@ impl VirtualMachine { } } - // Drain microtasks queued by the 'exit' listener only on a natural - // event-loop drain: not after a fatal error, and not when a worker - // reached here via terminate()/process.exit() (both set - // requested_terminate before shutdown() calls us). + // Node drains microtasks after 'exit' only on a natural drain: not + // after a fatal error, and not for a worker that reached here via + // terminate()/process.exit() (both set requested_terminate first). let natural = self.unhandled_error_counter == 0 && !self .worker_ref() diff --git a/src/jsc/bindings/BunProcess.cpp b/src/jsc/bindings/BunProcess.cpp index 6d2985f51a29..fa9b15823635 100644 --- a/src/jsc/bindings/BunProcess.cpp +++ b/src/jsc/bindings/BunProcess.cpp @@ -303,9 +303,8 @@ static void dispatchExitInternal(JSC::JSGlobalObject* globalObject, Process* pro emitter.emit(event, arguments); // Node performs a final microtask checkpoint after emitting 'exit' on a - // natural drain (not process.exit() or fatal exception), so promise - // reactions and queueMicrotask callbacks queued by the listener run - // before termination. process.nextTick does not. + // natural drain (not process.exit() or fatal exception); process.nextTick + // is not drained. if (drainMicrotasks && !vm.hasTerminationRequest()) { vm.drainMicrotasks(); } From dea74d5564cd0715894f410839edcec26cd5477f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 21 Jul 2026 10:39:16 +0000 Subject: [PATCH 4/6] review: thread natural-exit intent explicitly through on_exit() The previous heuristic (unhandled_error_counter == 0 plus worker requested_terminate) missed the exit_on_uncaught_exception fast-exit path: a beforeExit listener that throws with no uncaughtException handler reaches Bun__Process__exit -> on_exit() before the counter is bumped, so microtasks drained where Node does not. Give on_exit() an explicit 'natural: bool' so each caller states whether it is on the natural-drain path. Bun__Process__exit and all error paths pass false; the worker shutdown path passes !has_requested_terminate(). --- src/jsc/VirtualMachine.rs | 13 ++++--------- src/jsc/web_worker.rs | 2 +- src/runtime/bake/production.rs | 2 +- src/runtime/cli/repl_command.rs | 4 ++-- src/runtime/cli/run_command.rs | 4 ++-- src/runtime/node/node_process.rs | 2 +- test/js/node/process/process.test.js | 20 ++++++++++++++++++++ 7 files changed, 31 insertions(+), 16 deletions(-) diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 32e50dcccd94..f89ebab21d0b 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -1492,7 +1492,7 @@ impl VirtualMachine { } } - pub fn on_exit(&mut self) { + pub fn on_exit(&mut self, natural: bool) { // Write CPU profile if profiling was enabled - do this FIRST before any // shutdown begins. Grab the config and null it out to make this // idempotent. @@ -1513,14 +1513,9 @@ impl VirtualMachine { } } - // Node drains microtasks after 'exit' only on a natural drain: not - // after a fatal error, and not for a worker that reached here via - // terminate()/process.exit() (both set requested_terminate first). - let natural = self.unhandled_error_counter == 0 - && !self - .worker_ref() - .is_some_and(|w| w.has_requested_terminate()); - ExitHandler::dispatch_on_exit(self, natural); + // Node drains microtasks after 'exit' only on a natural event-loop + // drain: callers on explicit/fatal paths pass `natural = false`. + ExitHandler::dispatch_on_exit(self, natural && self.unhandled_error_counter == 0); self.is_shutting_down = true; // Make sure we run new cleanup hooks introduced by running cleanup diff --git a/src/jsc/web_worker.rs b/src/jsc/web_worker.rs index 924ba109136e..20328d894389 100644 --- a/src/jsc/web_worker.rs +++ b/src/jsc/web_worker.rs @@ -1258,7 +1258,7 @@ impl WebWorker { // re-sets it for the JSC VM teardown. vm.jsc_vm().clear_has_termination_request(); vm.is_shutting_down = true; - vm.on_exit(); + vm.on_exit(!self.has_requested_terminate()); if let Some(hooks) = runtime_hooks() { (hooks.cron_clear_all_teardown)(vm); // Drain `TimeoutObject`s from this worker's timer heap before diff --git a/src/runtime/bake/production.rs b/src/runtime/bake/production.rs index d3d6e31ae5b1..d0588a22274b 100644 --- a/src/runtime/bake/production.rs +++ b/src/runtime/bake/production.rs @@ -236,7 +236,7 @@ pub fn build_command(ctx: Context) -> crate::Result<()> { if vm.exit_handler.exit_code == 0 { vm.exit_handler.exit_code = 1; } - vm.on_exit(); + vm.on_exit(false); vm.global_exit(); } Err(e) => return Err(e), diff --git a/src/runtime/cli/repl_command.rs b/src/runtime/cli/repl_command.rs index faf25548c8fd..ffcc67fce682 100644 --- a/src/runtime/cli/repl_command.rs +++ b/src/runtime/cli/repl_command.rs @@ -226,7 +226,7 @@ impl<'a, 'r> ReplRunner<'a, 'r> { vm.print_error_like_object_to_console(exception); } vm.exit_handler.exit_code = 1; - vm.on_exit(); + vm.on_exit(false); vm.global_exit(); } @@ -252,7 +252,7 @@ impl<'a, 'r> ReplRunner<'a, 'r> { } // Clean up - vm.on_exit(); + vm.on_exit(true); vm.global_exit(); } diff --git a/src/runtime/cli/run_command.rs b/src/runtime/cli/run_command.rs index 28ed08bfbaeb..c05c2f238019 100644 --- a/src/runtime/cli/run_command.rs +++ b/src/runtime/cli/run_command.rs @@ -1638,7 +1638,7 @@ impl Run { vm.on_unhandled_rejection = Run::on_unhandled_rejection_before_close; vm.global().handle_rejected_promises(); - vm.on_exit(); + vm.on_exit(true); if ANY_UNHANDLED.load(Ordering::Relaxed) { print_unhandled_version_note(vm); @@ -1707,7 +1707,7 @@ fn dump_build_error(vm: &mut VirtualMachine) { )] fn exit_with_unhandled_note(vm: &mut VirtualMachine) -> ! { vm.exit_handler.exit_code = 1; - vm.on_exit(); + vm.on_exit(false); if ANY_UNHANDLED.load(Ordering::Relaxed) { bun_sourcemap::SavedSourceMap::MissingSourceMapNoteInfo::print(); pretty_errorln!("\n{}", Global::unhandled_error_bun_version_string,); diff --git a/src/runtime/node/node_process.rs b/src/runtime/node/node_process.rs index bae48904bf5c..6e4cfce544dc 100644 --- a/src/runtime/node/node_process.rs +++ b/src/runtime/node/node_process.rs @@ -58,7 +58,7 @@ pub extern "C" fn exit(global_object: &JSGlobalObject, code: u8) { // instead to terminate the worker sooner worker.exit(); } else { - vm.on_exit(); + vm.on_exit(false); vm.global_exit(); } } diff --git a/test/js/node/process/process.test.js b/test/js/node/process/process.test.js index a4e593423b90..c4c7cdfa46e4 100644 --- a/test/js/node/process/process.test.js +++ b/test/js/node/process/process.test.js @@ -778,6 +778,26 @@ describe.concurrent(() => { expect(exitCode).toBe(1); }); + it("does not drain microtasks queued by the listener when a beforeExit listener throws", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `process.on("beforeExit", () => { throw new Error("boom"); }); + process.on("exit", () => { + console.log("exit-listener"); + queueMicrotask(() => console.log("qm-in-exit")); + });`, + ], + env: bunEnv, + stdio: ["inherit", "pipe", "pipe"], + }); + const [stderr, stdout, exitCode] = await Promise.all([proc.stderr.text(), proc.stdout.text(), proc.exited]); + expect(stderr).toInclude("error: boom"); + expect(stdout).toBe("exit-listener\n"); + expect(exitCode).toBe(1); + }); + it("does not drain process.nextTick queued by the listener on natural exit", async () => { await using proc = Bun.spawn({ cmd: [ From d2a48ce6a57d54daf2272acd9b91aabd175118b5 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 21 Jul 2026 11:22:56 +0000 Subject: [PATCH 5/6] review: repl error paths pass on_exit(false) eval_script() reports its exception without routing through uncaught_exception(), so unhandled_error_counter stays 0 and the belt-and-suspenders check would not have suppressed the drain. Hoist had_error across both the eval and interactive branches and pass it. --- src/runtime/cli/repl_command.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/runtime/cli/repl_command.rs b/src/runtime/cli/repl_command.rs index ffcc67fce682..5efb5881c65a 100644 --- a/src/runtime/cli/repl_command.rs +++ b/src/runtime/cli/repl_command.rs @@ -230,10 +230,11 @@ impl<'a, 'r> ReplRunner<'a, 'r> { vm.global_exit(); } + let mut had_error = false; if !this.eval_script.is_empty() || this.eval_and_print { // Non-interactive: evaluate the -e/--eval or -p/--print script, // drain the event loop, and exit - let had_error = this.repl.eval_script(this.eval_script, this.eval_and_print); + had_error = this.repl.eval_script(this.eval_script, this.eval_and_print); Output::flush(); if had_error { // Only overwrite on error so `process.exitCode = N` in the @@ -248,11 +249,12 @@ impl<'a, 'r> ReplRunner<'a, 'r> { // Interactive: run the REPL loop if let Err(err) = this.repl.run_with_vm(Some(VirtualMachine::get())) { bun_core::pretty_errorln!("REPL error: {}", err.name()); + had_error = true; } } // Clean up - vm.on_exit(true); + vm.on_exit(!had_error); vm.global_exit(); } From bcbe14841bcc1a6b53208f94e6bf9a792369005f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 21 Jul 2026 11:52:40 +0000 Subject: [PATCH 6/6] review: sweep pending rejections after the post-'exit' microtask drain vm.drainMicrotasks() flushes the JSC microtask queue but not m_aboutToBeNotifiedRejectedPromises, so a Promise.reject() created inside an 'exit' listener never reached unhandledRejection (and, with no handler, exited 0 where Node exits 1). Call handleRejectedPromises() after the drain so the rejection is reported. --- src/jsc/bindings/BunProcess.cpp | 1 + test/js/node/process/process.test.js | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/src/jsc/bindings/BunProcess.cpp b/src/jsc/bindings/BunProcess.cpp index fa9b15823635..270b8cd3d2a0 100644 --- a/src/jsc/bindings/BunProcess.cpp +++ b/src/jsc/bindings/BunProcess.cpp @@ -307,6 +307,7 @@ static void dispatchExitInternal(JSC::JSGlobalObject* globalObject, Process* pro // is not drained. if (drainMicrotasks && !vm.hasTerminationRequest()) { vm.drainMicrotasks(); + defaultGlobalObject(globalObject)->handleRejectedPromises(); } } diff --git a/test/js/node/process/process.test.js b/test/js/node/process/process.test.js index c4c7cdfa46e4..17b3ea7d386c 100644 --- a/test/js/node/process/process.test.js +++ b/test/js/node/process/process.test.js @@ -798,6 +798,29 @@ describe.concurrent(() => { expect(exitCode).toBe(1); }); + it("dispatches unhandledRejection for a promise rejected inside the listener on natural exit", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `process.on("unhandledRejection", r => console.log("ur:", r)); + process.on("exit", () => { + console.log("exit-listener"); + Promise.reject("boom"); + queueMicrotask(() => console.log("qm-in-exit")); + });`, + ], + env: bunEnv, + stdio: ["inherit", "pipe", "pipe"], + }); + const [stderr, stdout, exitCode] = await Promise.all([proc.stderr.text(), proc.stdout.text(), proc.exited]); + expect({ stdout, stderr, exitCode }).toEqual({ + stdout: "exit-listener\nqm-in-exit\nur: boom\n", + stderr: "", + exitCode: 0, + }); + }); + it("does not drain process.nextTick queued by the listener on natural exit", async () => { await using proc = Bun.spawn({ cmd: [