Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 17 additions & 4 deletions packages/loopover-miner/lib/process-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,9 @@ export type InstallCliSignalHandlersOptions = {
/** Called (in addition to `log`) for uncaughtException/unhandledRejection specifically -- not the clean
* SIGINT/SIGTERM exits, which are not errors. AWAITED before the process exits, so it should both capture
* AND flush (see captureMinerErrorAndFlush in bin/loopover-miner.js) -- a synchronous capture alone only
* queues the event, which process.exit() would then likely never deliver. No-op default. Never expected to
* throw/reject. */
* queues the event, which process.exit() would then likely never deliver. No-op default. A synchronous throw
* or a rejected promise from this hook is caught and logged (`error capture failed: ...`) so that the cleanup
* sweep and the process exit always run -- a failing error sink can never leave the miner's stores open. */
captureError?: (error: unknown, context?: Record<string, unknown>) => void | Promise<void>;
/** Reinstall even if handlers were already installed (mainly for tests). */
force?: boolean;
Expand Down Expand Up @@ -119,16 +120,28 @@ export function installCliSignalHandlers(options: InstallCliSignalHandlersOption
// not require these handlers to be synchronous: nothing exits the process until this handler itself calls
// `exit()`, so awaiting first is safe. captureError's own default is a synchronous no-op, so `await`-ing it
// is a harmless no-op for every caller that doesn't pass one.
// captureError is a public injectable option whose "never throws/rejects" contract lived only in prose. Enforce
// it HERE, at the chokepoint: a synchronous throw or a rejected promise from the hook is caught and logged so
// runCleanup() + exit() always run -- otherwise a failing error sink would leave every registered store open and
// (on unhandledRejection) the async handler's own rejection would re-enter this same handler.
proc.on("uncaughtException", async (error: unknown) => {
log(`loopover-miner: uncaught exception: ${describeError(error)}`);
await captureError(error, { kind: "uncaughtException" });
try {
await captureError(error, { kind: "uncaughtException" });
} catch (captureFailure) {
log(`loopover-miner: error capture failed: ${describeError(captureFailure)}`);
}
runCleanup();
exit(1);
});

proc.on("unhandledRejection", async (reason: unknown) => {
log(`loopover-miner: unhandled promise rejection: ${describeError(reason)}`);
await captureError(reason, { kind: "unhandledRejection" });
try {
await captureError(reason, { kind: "unhandledRejection" });
} catch (captureFailure) {
log(`loopover-miner: error capture failed: ${describeError(captureFailure)}`);
}
runCleanup();
exit(1);
});
Expand Down
52 changes: 52 additions & 0 deletions test/unit/miner-process-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,58 @@ describe("loopover-miner process lifecycle / crash-safety (#4826)", () => {
expect(log.mock.calls.some((call) => String(call[0]).includes("cleanup boom"))).toBe(true);
});

it("REGRESSION (#9688): a REJECTING captureError on uncaughtException is caught -- cleanup still runs and exit(1) still fires", async () => {
const { proc, handlers, exit } = makeFakeProcess();
const log = vi.fn();
const store = { close: vi.fn() };
registerCleanupResource(store);
const captureError = vi.fn(() => Promise.reject(new Error("sink flush failed")));
installCliSignalHandlers({ process: proc, log, exit, captureError });

await handlers.get("uncaughtException")?.(new Error("kaboom"));

expect(captureError).toHaveBeenCalledTimes(1);
expect(store.close).toHaveBeenCalledTimes(1); // the registered store was NOT left open
expect(cleanupResourceCount()).toBe(0);
expect(exit).toHaveBeenCalledWith(1);
expect(log).toHaveBeenCalledWith(expect.stringContaining("error capture failed: "));
expect(log.mock.calls.some((call) => String(call[0]).includes("sink flush failed"))).toBe(true);
});

it("REGRESSION (#9688): a REJECTING captureError on unhandledRejection is caught -- cleanup still runs and exit(1) still fires", async () => {
const { proc, handlers, exit } = makeFakeProcess();
const log = vi.fn();
const store = { close: vi.fn() };
registerCleanupResource(store);
const captureError = vi.fn(() => Promise.reject(new Error("sink flush failed")));
installCliSignalHandlers({ process: proc, log, exit, captureError });

await handlers.get("unhandledRejection")?.("plain reason");

expect(captureError).toHaveBeenCalledTimes(1);
expect(store.close).toHaveBeenCalledTimes(1);
expect(cleanupResourceCount()).toBe(0);
expect(exit).toHaveBeenCalledWith(1);
expect(log).toHaveBeenCalledWith(expect.stringContaining("error capture failed: "));
});

it("REGRESSION (#9688): a captureError that throws SYNCHRONOUSLY is also caught -- cleanup still runs and exit(1) still fires", async () => {
const { proc, handlers, exit } = makeFakeProcess();
const log = vi.fn();
const store = { close: vi.fn() };
registerCleanupResource(store);
const captureError = vi.fn(() => {
throw new Error("sync sink boom");
});
installCliSignalHandlers({ process: proc, log, exit, captureError });

await handlers.get("uncaughtException")?.(new Error("kaboom"));

expect(store.close).toHaveBeenCalledTimes(1);
expect(exit).toHaveBeenCalledWith(1);
expect(log.mock.calls.some((call) => String(call[0]).includes("sync sink boom"))).toBe(true);
});

it("defaults to the real process when none is injected", () => {
withRealProcessCleanup(() => {
expect(installCliSignalHandlers({ log: vi.fn(), exit: vi.fn(), force: true })).toBe(true);
Expand Down