fix(daemon): exit on uncaught exceptions instead of swallowing them (#81)

Co-authored-by: redshift <213178690+1ftredsh@users.noreply.github.com>
This commit is contained in:
redshift
2026-08-18 21:25:26 +00:00
committed by GitHub
co-authored by redshift
parent d7df050860
commit 9a6a676da1
3 changed files with 157 additions and 8 deletions
+104
View File
@@ -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");
});
});
+48
View File
@@ -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);
});
}
+5 -8
View File
@@ -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<void> {
// Install signal handlers before migration and wallet startup. If a signal