mirror of
https://github.com/Routstr/routstrd.git
synced 2026-10-05 12:28:23 +00:00
fix(daemon): exit on uncaught exceptions instead of swallowing them
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user