mirror of
https://github.com/Routstr/routstrd.git
synced 2026-10-05 20:38:22 +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,
|
legacyCocodPidPath,
|
||||||
legacyCocodSocketPath,
|
legacyCocodSocketPath,
|
||||||
} from "./wallet/paths";
|
} from "./wallet/paths";
|
||||||
|
import { installGlobalErrorHandlers } from "./fatal-error";
|
||||||
|
|
||||||
// Global error handlers — the daemon is spawned detached with stdout/stderr
|
// Global error handlers — the daemon is spawned detached with stdout/stderr
|
||||||
// redirected to a file, so without these, uncaught async errors would kill
|
// redirected to a file, so without these, uncaught async errors would kill
|
||||||
// the process silently. Log to the file logger before exiting.
|
// the process silently. Uncaught exceptions are fatal: the process state can
|
||||||
process.on("uncaughtException", (error) => {
|
// no longer be trusted, so the daemon logs and exits for its supervisor to
|
||||||
logger.error("UNCAUGHT EXCEPTION:", error);
|
// restart (see fatal-error.ts).
|
||||||
});
|
installGlobalErrorHandlers();
|
||||||
|
|
||||||
process.on("unhandledRejection", (reason) => {
|
|
||||||
logger.error("UNHANDLED REJECTION:", reason);
|
|
||||||
});
|
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
// Install signal handlers before migration and wallet startup. If a signal
|
// Install signal handlers before migration and wallet startup. If a signal
|
||||||
|
|||||||
Reference in New Issue
Block a user