diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index d74574893a0a..b6807577ee16 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -1202,7 +1202,16 @@ impl VirtualMachine { > 0) } + /// An error nothing in script handled was reported and ends this run. + /// `--hot`/`--watch` report such errors too but run on until the next reload. + pub fn has_fatal_unhandled_error(&self) -> bool { + self.unhandled_error_counter > 0 && !self.is_watcher_enabled() + } + pub fn is_event_loop_alive(&self) -> bool { + if self.has_fatal_unhandled_error() { + return false; + } let el = self.event_loop_shared(); self.is_event_loop_alive_excluding_immediates() || !el.immediate_tasks.is_empty() diff --git a/test/js/node/process/process.test.js b/test/js/node/process/process.test.js index b2e793ed3f77..4da83440fe69 100644 --- a/test/js/node/process/process.test.js +++ b/test/js/node/process/process.test.js @@ -1,7 +1,7 @@ import { spawnSync, which } from "bun"; import { describe, expect, it } from "bun:test"; import { familySync } from "detect-libc"; -import { bunEnv, bunExe, isMacOS, isWindows, tempDir, tmpdirSync } from "harness"; +import { bunEnv, bunExe, forEachLine, isMacOS, isWindows, tempDir, tmpdirSync } from "harness"; import { basename, join, resolve } from "path"; const process_sleep = resolve(import.meta.dir, "process-sleep.js"); @@ -972,6 +972,105 @@ describe.concurrent(() => { }); }); + describe("fatal error while immediates are pending", () => { + // Node runs nothing after an uncaught exception or unhandled rejection that + // nothing handled: the process exits from the error itself. This immediate + // requeues itself forever; if the run loop keeps turning after the error it + // says so and exits 42 rather than spinning until the test times out. + const requeueingImmediate = ` + let errored = false; + let runsAfterError = 0; + setImmediate(function again() { + if (errored && ++runsAfterError === 50) { + console.log("immediates still running after the error"); + process.exit(42); + } + setImmediate(again); + });`; + + it("an uncaught exception exits 1 instead of being kept alive by a requeueing setImmediate", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `${requeueingImmediate} + process.on("exit", c => console.log("exit", c)); + setTimeout(() => { errored = true; throw new Error("boom"); }, 1);`, + ], + env: bunEnv, + stdio: ["inherit", "pipe", "pipe"], + }); + const [stderr, stdout, exitCode] = await Promise.all([proc.stderr.text(), proc.stdout.text(), proc.exited]); + expect(stdout).toBe("exit 1\n"); + expect(stderr).toInclude("error: boom"); + expect(exitCode).toBe(1); + }); + + it("an unhandled rejection exits 1 instead of being kept alive by a requeueing setImmediate", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `${requeueingImmediate} + setTimeout(() => { errored = true; Promise.reject(new Error("boom")); }, 1);`, + ], + env: bunEnv, + stdio: ["inherit", "pipe", "pipe"], + }); + const [stderr, stdout, exitCode] = await Promise.all([proc.stderr.text(), proc.stdout.text(), proc.exited]); + expect(stdout).toBe(""); + expect(stderr).toInclude("error: boom"); + expect(exitCode).toBe(1); + }); + + it("an unhandled rejection from a beforeExit listener exits 1 instead of being kept alive by a requeueing setImmediate", async () => { + // Same thing for the drain that follows 'beforeExit', which is a second + // run loop of its own. + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `process.once("beforeExit", () => { + ${requeueingImmediate} + errored = true; + Promise.reject(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(stdout).toBe(""); + expect(stderr).toInclude("error: boom"); + expect(exitCode).toBe(1); + }); + + it.each(["--hot", "--watch"])("%s still runs the immediates after the error", async flag => { + // The watcher modes print the error and keep the process alive for the + // next reload instead of exiting on it, so what was running keeps running. + using dir = tempDir("process-fatal-immediates", { + "index.js": `${requeueingImmediate} + setTimeout(() => { errored = true; throw new Error("boom"); }, 1);`, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), flag, "index.js"], + cwd: String(dir), + env: bunEnv, + stdio: ["ignore", "pipe", "pipe"], + }); + const stderr = proc.stderr.text(); + const stdoutLines = forEachLine(proc.stdout); + const { value: firstLine } = await stdoutLines.next(); + // What a watcher makes of the fixture's process.exit() is not under test + // here: stop the fixture ourselves once it has reported, then drain it. + proc.kill(); + await Array.fromAsync(stdoutLines); + expect(await stderr).toInclude("error: boom"); + expect(firstLine).toBe("immediates still running after the error"); + await proc.exited; + }); + }); + describe("process.onExit", () => { it("throwing inside preserves exit code", async () => { await using proc = Bun.spawn({