From 9f1b64eb76fd6f85b557929121d5908aa03d2ce1 Mon Sep 17 00:00:00 2001 From: redshift <213178690+1ftredsh@users.noreply.github.com> Date: Tue, 18 Aug 2026 22:22:56 +0100 Subject: [PATCH] fix(daemon): exit on uncaught exceptions instead of swallowing them --- src/daemon/fatal-error.test.ts | 104 +++++++++++++++++++++++++++++++++ src/daemon/fatal-error.ts | 48 +++++++++++++++ src/daemon/index.ts | 13 ++--- 3 files changed, 157 insertions(+), 8 deletions(-) create mode 100644 src/daemon/fatal-error.test.ts create mode 100644 src/daemon/fatal-error.ts diff --git a/src/daemon/fatal-error.test.ts b/src/daemon/fatal-error.test.ts new file mode 100644 index 0000000..4378d49 --- /dev/null +++ b/src/daemon/fatal-error.test.ts @@ -0,0 +1,104 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { unlinkSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { exitOnUncaughtException } from "./fatal-error"; + +const fatalErrorModule = JSON.stringify(join(import.meta.dir, "fatal-error.ts")); + +const fixturePaths: string[] = []; + +afterEach(() => { + while (fixturePaths.length > 0) { + try { + unlinkSync(fixturePaths.pop()!); + } catch { + // Best effort cleanup of temp fixtures. + } + } +}); + +/** Run a fixture script in a real subprocess so process.exit is for real. */ +async function runFixture(source: string): Promise<{ + exitCode: number; + stdout: string; + stderr: string; +}> { + const scriptPath = join( + tmpdir(), + `routstrd-fatal-error-fixture-${crypto.randomUUID()}.ts`, + ); + fixturePaths.push(scriptPath); + await Bun.write(scriptPath, source); + const proc = Bun.spawn(["bun", scriptPath], { + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); + const exitCode = await proc.exited; + return { exitCode, stdout, stderr }; +} + +describe("exitOnUncaughtException", () => { + test("reports the error and exits with code 1", () => { + let exitCode: number | undefined; + const stderrLines: string[] = []; + const originalConsoleError = console.error; + console.error = (...args: unknown[]) => { + stderrLines.push(args.map(String).join(" ")); + }; + try { + exitOnUncaughtException(new Error("boom"), (code) => { + exitCode = code; + }); + } finally { + console.error = originalConsoleError; + } + expect(exitCode).toBe(1); + expect(stderrLines.join("\n")).toContain("boom"); + }); + + test("handles non-Error thrown values", () => { + let exitCode: number | undefined; + const originalConsoleError = console.error; + console.error = () => {}; + try { + exitOnUncaughtException("string failure", (code) => { + exitCode = code; + }); + } finally { + console.error = originalConsoleError; + } + expect(exitCode).toBe(1); + }); +}); + +describe("installGlobalErrorHandlers (subprocess)", () => { + test("an uncaught exception exits the process with code 1", async () => { + const { exitCode, stderr } = await runFixture(` + import { installGlobalErrorHandlers } from ${fatalErrorModule}; + installGlobalErrorHandlers(); + setTimeout(() => { + throw new Error("boom-uncaught"); + }, 10); + `); + expect(exitCode).toBe(1); + expect(stderr).toContain("boom-uncaught"); + }); + + test("an unhandled rejection is logged without exiting", async () => { + const { exitCode, stdout } = await runFixture(` + import { installGlobalErrorHandlers } from ${fatalErrorModule}; + installGlobalErrorHandlers(); + Promise.reject(new Error("boom-rejection")); + setTimeout(() => { + console.log("STILL-ALIVE"); + }, 50); + `); + expect(exitCode).toBe(0); + expect(stdout).toContain("STILL-ALIVE"); + }); +}); diff --git a/src/daemon/fatal-error.ts b/src/daemon/fatal-error.ts new file mode 100644 index 0000000..b19a5d1 --- /dev/null +++ b/src/daemon/fatal-error.ts @@ -0,0 +1,48 @@ +import { logger } from "../utils/logger"; + +/** + * Report an unrecoverable error, then exit. + * + * The daemon runs detached under a supervisor (pm2 via `routstrd service + * install`, or the spawning CLI). An uncaught exception means the call stack + * unwound unexpectedly mid-operation and process state can no longer be + * trusted. Swallowing it once left a daemon alive with nothing listening + * while it held both wallet PID locks, so every later start failed on the + * lock and liveness-only supervisors never restarted it. Exiting lets the + * supervisor bring up a healthy process instead. + * + * The file logger writes synchronously, so the record survives the exit. + * The error is mirrored to stderr because the daemon's stdout/stderr are + * redirected to debug.log, which is what the CLI surfaces when the daemon + * exits early and what pm2 captures. process.exit() runs the synchronous + * PID-lock exit hooks registered by claimPidFile, releasing the wallet + * locks on the way out. + * + * `exit` is injectable for tests. + */ +export function exitOnUncaughtException( + error: unknown, + exit: (code: number) => void = (code) => process.exit(code), +): void { + logger.error("UNCAUGHT EXCEPTION:", error); + // Full error object (not just .message): this path means a bug, so the + // stack is the most useful thing to have in debug.log. + console.error("UNCAUGHT EXCEPTION:", error); + exit(1); +} + +/** + * Install the process-level error handlers. Called once at daemon module + * load, before main() runs. + * + * Only uncaught exceptions are fatal. An unhandled rejection means a + * promise's failure went unobserved; the stack was not unwound and the + * process is still in a consistent state, so it is logged and the daemon + * keeps serving. + */ +export function installGlobalErrorHandlers(): void { + process.on("uncaughtException", (error) => exitOnUncaughtException(error)); + process.on("unhandledRejection", (reason) => { + logger.error("UNHANDLED REJECTION:", reason); + }); +} diff --git a/src/daemon/index.ts b/src/daemon/index.ts index 7281250..19518bd 100644 --- a/src/daemon/index.ts +++ b/src/daemon/index.ts @@ -67,17 +67,14 @@ import { legacyCocodPidPath, legacyCocodSocketPath, } from "./wallet/paths"; +import { installGlobalErrorHandlers } from "./fatal-error"; // Global error handlers — the daemon is spawned detached with stdout/stderr // redirected to a file, so without these, uncaught async errors would kill -// the process silently. Log to the file logger before exiting. -process.on("uncaughtException", (error) => { - logger.error("UNCAUGHT EXCEPTION:", error); -}); - -process.on("unhandledRejection", (reason) => { - logger.error("UNHANDLED REJECTION:", reason); -}); +// the process silently. Uncaught exceptions are fatal: the process state can +// no longer be trusted, so the daemon logs and exits for its supervisor to +// restart (see fatal-error.ts). +installGlobalErrorHandlers(); async function main(): Promise { // Install signal handlers before migration and wallet startup. If a signal