From ac17c52417eda6febcc1be1ec0245906b9bda0eb Mon Sep 17 00:00:00 2001 From: Andriy Polanski Date: Sun, 12 Jul 2026 22:51:23 +0000 Subject: [PATCH 1/2] feat(miner): add signal and crash handling to the CLI (#4826) --- packages/gittensory-miner/README.md | 7 + .../gittensory-miner/bin/gittensory-miner.js | 5 + packages/gittensory-miner/lib/local-store.js | 10 + .../lib/process-lifecycle.d.ts | 31 +++ .../gittensory-miner/lib/process-lifecycle.js | 106 +++++++++ packages/gittensory-miner/package.json | 2 +- test/unit/miner-local-store.test.ts | 28 +++ test/unit/miner-process-lifecycle.test.ts | 207 ++++++++++++++++++ 8 files changed, 395 insertions(+), 1 deletion(-) create mode 100644 packages/gittensory-miner/lib/process-lifecycle.d.ts create mode 100644 packages/gittensory-miner/lib/process-lifecycle.js create mode 100644 test/unit/miner-process-lifecycle.test.ts diff --git a/packages/gittensory-miner/README.md b/packages/gittensory-miner/README.md index 7ccd90c770..af57fa7a0e 100644 --- a/packages/gittensory-miner/README.md +++ b/packages/gittensory-miner/README.md @@ -99,6 +99,13 @@ else `XDG_CONFIG_HOME` (falling back to `~/.config`), joined with `gittensory-mi its file with `0700`/`0600` permissions and a shared `PRAGMA busy_timeout` so two instances on the same file serialize writes instead of racing. +Opening a store through `local-store.js` also registers it with the CLI's crash-safety chokepoint +(`process-lifecycle.js`): the entrypoint calls `installCliSignalHandlers()` once at startup, so a `SIGINT`/`SIGTERM` +mid-run — or an uncaught exception / unhandled rejection — closes every still-open ledger cleanly and exits with a +conventional code (130/143 for signals, non-zero for a crash) instead of dying mid-write. A store's normal `close()` +unregisters itself first, so the happy path never double-closes and a long-running `loop` never accumulates stale +handles. Cleanup only — no command business logic is affected. (#4826) + The "PR portfolio" `manage status` renders is currently a **read-time join**, not a dedicated table: `collectManageStatus` reads `portfolio-queue.js` rows (via the `pr:{number}` identifier convention) and joins them against `event-ledger.js`'s free-form `manage_pr_update` JSON events at query time, on every read. Decision: keep diff --git a/packages/gittensory-miner/bin/gittensory-miner.js b/packages/gittensory-miner/bin/gittensory-miner.js index d58fc260cc..1fe136edc5 100755 --- a/packages/gittensory-miner/bin/gittensory-miner.js +++ b/packages/gittensory-miner/bin/gittensory-miner.js @@ -15,6 +15,7 @@ import { runPlanCli } from "../lib/plan-store-cli.js"; import { runClaimCli } from "../lib/claim-ledger-cli.js"; import { runQueueCli } from "../lib/portfolio-queue-cli.js"; import { runOrbExportCli } from "../lib/orb-export.js"; +import { installCliSignalHandlers } from "../lib/process-lifecycle.js"; import { runStateCli } from "../lib/run-state-cli.js"; import { runInit } from "../lib/laptop-init.js"; import { runDoctor, runStatus } from "../lib/status.js"; @@ -25,6 +26,10 @@ import { } from "../lib/update-check.js"; import { resolveMinerVersion } from "../lib/version.js"; +// Register signal + crash handlers once, before any command runs, so an interrupted run closes its open ledgers +// cleanly instead of dying mid-write (#4826). Covers every subcommand below, including the local ones. +installCliSignalHandlers(); + const cliArgs = process.argv.slice(2); // `status` and `doctor` are strictly local, offline commands — their contract is to make NO network calls. diff --git a/packages/gittensory-miner/lib/local-store.js b/packages/gittensory-miner/lib/local-store.js index cca582e729..7b093c4aef 100644 --- a/packages/gittensory-miner/lib/local-store.js +++ b/packages/gittensory-miner/lib/local-store.js @@ -2,6 +2,7 @@ import { chmodSync, mkdirSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, join } from "node:path"; import { DatabaseSync } from "node:sqlite"; +import { registerCleanupResource } from "./process-lifecycle.js"; // Shared path-resolution + DB-open boilerplate for the package's local SQLite stores (#4272). This is a DRY pass // only, not a merge: run-state.js, claim-ledger.js, portfolio-queue.js, and event-ledger.js each keep their own @@ -48,5 +49,14 @@ export function openLocalStoreDb(resolvedPath, options = {}) { const db = new DatabaseSync(resolvedPath); if (!isMemory) chmodSync(resolvedPath, 0o600); db.exec(`PRAGMA busy_timeout = ${busyTimeoutMs}`); + // Crash-safety (#4826): register every opened store so a SIGINT/SIGTERM/uncaught-exception handler can close it + // mid-run instead of leaving it half-written. The normal `close()` unregisters first, so the happy path never + // double-closes and a long-running `loop` doesn't accumulate stale references. + const unregister = registerCleanupResource(db); + const originalClose = db.close.bind(db); + db.close = () => { + unregister(); + return originalClose(); + }; return db; } diff --git a/packages/gittensory-miner/lib/process-lifecycle.d.ts b/packages/gittensory-miner/lib/process-lifecycle.d.ts new file mode 100644 index 0000000000..e6ee826b31 --- /dev/null +++ b/packages/gittensory-miner/lib/process-lifecycle.d.ts @@ -0,0 +1,31 @@ +/** Process lifecycle / crash-safety for the miner CLI (#4826). Local stores register on open and the CLI installs + * signal/error handlers once at startup so an interrupted run closes every open ledger cleanly. */ + +/** A closable store (`{ close() }`) or a plain cleanup callback. */ +export type CleanupResource = { close: () => void } | (() => void); + +/** The subset of `process` the handlers use; injectable for tests. */ +export type ProcessLike = { + on: (event: string, listener: (...args: unknown[]) => void) => unknown; + exit: (code?: number) => void; +}; + +export type InstallCliSignalHandlersOptions = { + process?: ProcessLike; + log?: (message: string) => void; + exit?: (code: number) => void; + /** Reinstall even if handlers were already installed (mainly for tests). */ + force?: boolean; +}; + +/** Register a resource to close on exit; returns an idempotent unregister function. */ +export function registerCleanupResource(resource: CleanupResource | null | undefined): () => void; + +export function cleanupResourceCount(): number; + +export function closeAllCleanupResources(options?: { onError?: (error: unknown) => void }): void; + +/** Install signal + error handlers once. Returns false if already installed (and `force` was not set). */ +export function installCliSignalHandlers(options?: InstallCliSignalHandlersOptions): boolean; + +export function resetProcessLifecycleForTesting(): void; diff --git a/packages/gittensory-miner/lib/process-lifecycle.js b/packages/gittensory-miner/lib/process-lifecycle.js new file mode 100644 index 0000000000..cbc8472448 --- /dev/null +++ b/packages/gittensory-miner/lib/process-lifecycle.js @@ -0,0 +1,106 @@ +/** Process lifecycle / crash-safety for the miner CLI (#4826). The CLI dispatches through a chain of bare + * `process.exit()` calls with no cleanup hook, so a SIGINT/SIGTERM mid-run — or an uncaught exception — used to + * kill the process mid-write, leaving whatever local SQLite ledger it was touching in an undefined state. This + * module is the single cleanup chokepoint: local stores register themselves when opened (see `local-store.js`), and + * `installCliSignalHandlers` (called once at CLI startup) flushes/closes every still-open resource before exiting + * cleanly on a signal, and logs + exits non-zero on an uncaught exception / unhandled rejection instead of crashing + * silently. Cleanup ONLY — no command business logic lives here. Every dependency (`process`, `log`, `exit`) is + * injectable so the handlers are unit-testable without actually signalling the test runner. */ + +// 128 + signal number, the conventional shell exit code for a process terminated by that signal (SIGINT=2 -> 130, +// SIGTERM=15 -> 143). +const SIGNAL_EXIT_CODES = Object.freeze({ SIGINT: 130, SIGTERM: 143 }); + +/** Resources to close on exit. A resource is either a `{ close() }` object (e.g. an open SQLite store) or a plain + * cleanup function. Held in insertion order so cleanup is deterministic. */ +const cleanupResources = new Set(); +let handlersInstalled = false; + +/** Render any thrown value as a single log-safe string, preferring an Error's stack. */ +function describeError(value) { + if (value instanceof Error) return value.stack ?? value.message; + return String(value); +} + +/** + * Register a resource to be closed on clean exit or crash. Returns an idempotent unregister function (call it from + * the resource's own normal `close()` so a resource closed during the happy path is not double-closed at exit). + */ +export function registerCleanupResource(resource) { + if (resource === null || resource === undefined) return () => {}; + cleanupResources.add(resource); + return () => { + cleanupResources.delete(resource); + }; +} + +/** Number of currently-registered cleanup resources (exposed for tests / diagnostics). */ +export function cleanupResourceCount() { + return cleanupResources.size; +} + +/** + * Close every registered resource, swallowing each individual failure (a store that fails to close must not stop + * the others from closing) and reporting it via `options.onError`. Idempotent: the registry is emptied afterwards. + */ +export function closeAllCleanupResources(options = {}) { + const onError = typeof options.onError === "function" ? options.onError : null; + for (const resource of [...cleanupResources]) { + try { + if (typeof resource === "function") resource(); + else resource.close(); + } catch (error) { + if (onError) onError(error); + } + } + cleanupResources.clear(); +} + +/** + * Install top-level signal + error handlers once. On SIGINT/SIGTERM: close all resources and exit with the + * conventional 128+signal code. On uncaughtException/unhandledRejection: log the error, close all resources, and + * exit non-zero. No-op (returns false) if already installed unless `options.force` is set. All of `process`, `log`, + * and `exit` are injectable for testing. + */ +export function installCliSignalHandlers(options = {}) { + const proc = options.process ?? process; + const log = typeof options.log === "function" ? options.log : (message) => console.error(message); + const exit = typeof options.exit === "function" ? options.exit : (code) => proc.exit(code); + + if (handlersInstalled && options.force !== true) return false; + handlersInstalled = true; + + const runCleanup = () => { + closeAllCleanupResources({ + onError: (error) => log(`gittensory-miner: cleanup error while exiting: ${describeError(error)}`), + }); + }; + + for (const [signal, code] of Object.entries(SIGNAL_EXIT_CODES)) { + proc.on(signal, () => { + log(`gittensory-miner: received ${signal}, closing open resources and exiting.`); + runCleanup(); + exit(code); + }); + } + + proc.on("uncaughtException", (error) => { + log(`gittensory-miner: uncaught exception: ${describeError(error)}`); + runCleanup(); + exit(1); + }); + + proc.on("unhandledRejection", (reason) => { + log(`gittensory-miner: unhandled promise rejection: ${describeError(reason)}`); + runCleanup(); + exit(1); + }); + + return true; +} + +/** Test-only: clear the registry and the installed flag so each test starts from a clean lifecycle. */ +export function resetProcessLifecycleForTesting() { + cleanupResources.clear(); + handlersInstalled = false; +} diff --git a/packages/gittensory-miner/package.json b/packages/gittensory-miner/package.json index c22d5b2c49..3a5c579ba9 100644 --- a/packages/gittensory-miner/package.json +++ b/packages/gittensory-miner/package.json @@ -33,7 +33,7 @@ "expected-engine.version" ], "scripts": { - "build": "node --check bin/gittensory-miner.js && node --check bin/gittensory-miner-mcp.js && node --check lib/ams-policy.js && node --check lib/attempt-cli.js && node --check lib/attempt-input-builder.js && node --check lib/attempt-log.js && node --check lib/attempt-runner.js && node --check lib/attempt-worktree.js && node --check lib/calibration-run.js && node --check lib/calibration-types.js && node --check lib/calibration.js && node --check lib/ci-poller.js && node --check lib/claim-adjudication.js && node --check lib/claim-ledger-cli.js && node --check lib/claim-ledger-expiry.js && node --check lib/claim-ledger.js && node --check lib/cli.js && node --check lib/coding-agent-construction.js && node --check lib/coding-agent-house-rules.js && node --check lib/coding-task-spec.js && node --check lib/deny-check.js && node --check lib/deny-hook-synthesis.js && node --check lib/deny-hooks.js && node --check lib/deployment-docs-audit.js && node --check lib/discover-cli.js && node --check lib/event-ledger-cli.js && node --check lib/event-ledger.js && node --check lib/execute-local-write.js && node --check lib/feasibility-cli.js && node --check lib/gate-verdict-poller.js && node --check lib/governor-action-mode.js && node --check lib/governor-chokepoint-persisted.js && node --check lib/governor-chokepoint.js && node --check lib/governor-kill-switch.js && node --check lib/governor-ledger-cli.js && node --check lib/governor-ledger.js && node --check lib/governor-open-pr.js && node --check lib/governor-run-halt.js && node --check lib/governor-state.js && node --check lib/governor-write-rate-limit.js && node --check lib/harness-submission-trigger.js && node --check lib/laptop-init.js && node --check lib/live-issue-snapshot.js && node --check lib/local-store.js && node --check lib/loop-cli.js && node --check lib/loop-closure.js && node --check lib/loop-reentry.js && node --check lib/manage-poll.js && node --check lib/manage-status.js && node --check lib/metrics-cli.js && node --check lib/miner-goal-spec.js && node --check lib/opportunity-fanout.js && node --check lib/opportunity-ranker.js && node --check lib/orb-export.js && node --check lib/plan-store-cli.js && node --check lib/plan-store.js && node --check lib/portfolio-dashboard.js && node --check lib/portfolio-discovery.js && node --check lib/portfolio-queue-cli.js && node --check lib/portfolio-queue-manager.js && node --check lib/portfolio-queue.js && node --check lib/portfolio-queue-expiry.js && node --check lib/pr-disposition-poller.js && node --check lib/pr-outcome.js && node --check lib/prediction-ledger.js && node --check lib/pretooluse-hook.js && node --check lib/rejection-signal.js && node --check lib/rejection-state-machine.js && node --check lib/rejection-templates.js && node --check lib/replay-objective-anchor.js && node --check lib/replay-snapshot.js && node --check lib/replay-task-generation.js && node --check lib/repo-clone.js && node --check lib/run-state-cli.js && node --check lib/run-state.js && node --check lib/self-review-context.js && node --check lib/slop-assessment.js && node --check lib/stack-detection.js && node --check lib/status.js && node --check lib/submission-freshness-check.js && node --check lib/update-check.js && node --check lib/version.js && node --check lib/worktree-allocator.js" + "build": "node --check bin/gittensory-miner.js && node --check bin/gittensory-miner-mcp.js && node --check lib/ams-policy.js && node --check lib/attempt-cli.js && node --check lib/attempt-input-builder.js && node --check lib/attempt-log.js && node --check lib/attempt-runner.js && node --check lib/attempt-worktree.js && node --check lib/calibration-run.js && node --check lib/calibration-types.js && node --check lib/calibration.js && node --check lib/ci-poller.js && node --check lib/claim-adjudication.js && node --check lib/claim-ledger-cli.js && node --check lib/claim-ledger-expiry.js && node --check lib/claim-ledger.js && node --check lib/cli.js && node --check lib/coding-agent-construction.js && node --check lib/coding-agent-house-rules.js && node --check lib/coding-task-spec.js && node --check lib/deny-check.js && node --check lib/deny-hook-synthesis.js && node --check lib/deny-hooks.js && node --check lib/deployment-docs-audit.js && node --check lib/discover-cli.js && node --check lib/event-ledger-cli.js && node --check lib/event-ledger.js && node --check lib/execute-local-write.js && node --check lib/feasibility-cli.js && node --check lib/gate-verdict-poller.js && node --check lib/governor-action-mode.js && node --check lib/governor-chokepoint-persisted.js && node --check lib/governor-chokepoint.js && node --check lib/governor-kill-switch.js && node --check lib/governor-ledger-cli.js && node --check lib/governor-ledger.js && node --check lib/governor-open-pr.js && node --check lib/governor-run-halt.js && node --check lib/governor-state.js && node --check lib/governor-write-rate-limit.js && node --check lib/harness-submission-trigger.js && node --check lib/laptop-init.js && node --check lib/live-issue-snapshot.js && node --check lib/local-store.js && node --check lib/loop-cli.js && node --check lib/loop-closure.js && node --check lib/loop-reentry.js && node --check lib/manage-poll.js && node --check lib/manage-status.js && node --check lib/metrics-cli.js && node --check lib/miner-goal-spec.js && node --check lib/opportunity-fanout.js && node --check lib/opportunity-ranker.js && node --check lib/orb-export.js && node --check lib/plan-store-cli.js && node --check lib/plan-store.js && node --check lib/portfolio-dashboard.js && node --check lib/portfolio-discovery.js && node --check lib/portfolio-queue-cli.js && node --check lib/portfolio-queue-manager.js && node --check lib/portfolio-queue.js && node --check lib/portfolio-queue-expiry.js && node --check lib/pr-disposition-poller.js && node --check lib/pr-outcome.js && node --check lib/prediction-ledger.js && node --check lib/pretooluse-hook.js && node --check lib/process-lifecycle.js && node --check lib/rejection-signal.js && node --check lib/rejection-state-machine.js && node --check lib/rejection-templates.js && node --check lib/replay-objective-anchor.js && node --check lib/replay-snapshot.js && node --check lib/replay-task-generation.js && node --check lib/repo-clone.js && node --check lib/run-state-cli.js && node --check lib/run-state.js && node --check lib/self-review-context.js && node --check lib/slop-assessment.js && node --check lib/stack-detection.js && node --check lib/status.js && node --check lib/submission-freshness-check.js && node --check lib/update-check.js && node --check lib/version.js && node --check lib/worktree-allocator.js" }, "dependencies": { "@jsonbored/gittensory-engine": "*", diff --git a/test/unit/miner-local-store.test.ts b/test/unit/miner-local-store.test.ts index 40062e72d6..d88b16aff7 100644 --- a/test/unit/miner-local-store.test.ts +++ b/test/unit/miner-local-store.test.ts @@ -16,6 +16,11 @@ import { resolvePortfolioQueueDbPath, } from "../../packages/gittensory-miner/lib/portfolio-queue.js"; import { closeDefaultRunStateStore, initRunStateStore, resolveRunStateDbPath } from "../../packages/gittensory-miner/lib/run-state.js"; +import { + cleanupResourceCount, + closeAllCleanupResources, + resetProcessLifecycleForTesting, +} from "../../packages/gittensory-miner/lib/process-lifecycle.js"; const roots: string[] = []; const dbs: Array<{ close(): void }> = []; @@ -95,6 +100,29 @@ describe("gittensory-miner shared local-store helper (#4272)", () => { expect(db.prepare("SELECT name FROM sqlite_master WHERE name = 't'").get()).toEqual({ name: "t" }); }); + it("openLocalStoreDb registers the store for crash-safe cleanup and unregisters it on normal close (#4826)", () => { + resetProcessLifecycleForTesting(); + expect(cleanupResourceCount()).toBe(0); + const db = openLocalStoreDb(":memory:"); + expect(cleanupResourceCount()).toBe(1); + db.close(); + // The normal close() path unregisters, so the happy path never leaks a stale handle or double-closes at exit. + expect(cleanupResourceCount()).toBe(0); + }); + + it("closeAllCleanupResources closes a store left open at crash time (#4826)", () => { + resetProcessLifecycleForTesting(); + const db = openLocalStoreDb(":memory:"); + db.exec("CREATE TABLE t (id INTEGER)"); + expect(cleanupResourceCount()).toBe(1); + + closeAllCleanupResources(); + + expect(cleanupResourceCount()).toBe(0); + // The DB really is closed now: a subsequent operation throws instead of silently touching a half-written file. + expect(() => db.exec("SELECT 1")).toThrow(); + }); + it("regression: the four migrated stores still resolve to independent files, and each on-disk file only has its own table (#4272)", () => { const configDir = tempRoot(); diff --git a/test/unit/miner-process-lifecycle.test.ts b/test/unit/miner-process-lifecycle.test.ts new file mode 100644 index 0000000000..8a19fcc99f --- /dev/null +++ b/test/unit/miner-process-lifecycle.test.ts @@ -0,0 +1,207 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + cleanupResourceCount, + closeAllCleanupResources, + installCliSignalHandlers, + registerCleanupResource, + resetProcessLifecycleForTesting, +} from "../../packages/gittensory-miner/lib/process-lifecycle.js"; + +type Listener = (...args: unknown[]) => void; + +/** A fake `process` that captures the last-registered listener per event so tests can invoke it directly. */ +function makeFakeProcess() { + const handlers = new Map(); + const exit = vi.fn(); + const proc = { + on(event: string, listener: Listener) { + handlers.set(event, listener); + return proc; + }, + exit, + }; + return { proc, handlers, exit }; +} + +const SIGNAL_EVENTS = ["SIGINT", "SIGTERM", "uncaughtException", "unhandledRejection"]; + +/** Run `fn`, then strip any listeners it added to the REAL process (only relevant to the default-process test). */ +function withRealProcessCleanup(fn: () => void) { + const before = new Map(SIGNAL_EVENTS.map((event) => [event, new Set(process.rawListeners(event))])); + try { + fn(); + } finally { + for (const event of SIGNAL_EVENTS) { + for (const listener of process.rawListeners(event)) { + if (!before.get(event)?.has(listener)) process.removeListener(event, listener as Listener); + } + } + } +} + +beforeEach(() => { + resetProcessLifecycleForTesting(); +}); + +afterEach(() => { + resetProcessLifecycleForTesting(); + vi.restoreAllMocks(); +}); + +describe("gittensory-miner process lifecycle / crash-safety (#4826)", () => { + it("registerCleanupResource ignores null and undefined but still returns a callable no-op", () => { + expect(cleanupResourceCount()).toBe(0); + expect(() => registerCleanupResource(null)()).not.toThrow(); + expect(() => registerCleanupResource(undefined)()).not.toThrow(); + expect(cleanupResourceCount()).toBe(0); + }); + + it("registers a resource and unregisters it via the returned handle", () => { + const resource = { close: vi.fn() }; + const unregister = registerCleanupResource(resource); + expect(cleanupResourceCount()).toBe(1); + unregister(); + expect(cleanupResourceCount()).toBe(0); + // Idempotent: a second unregister is harmless and does not close anything. + unregister(); + expect(resource.close).not.toHaveBeenCalled(); + }); + + it("closeAllCleanupResources closes both object and function resources, then empties the registry", () => { + const store = { close: vi.fn() }; + const fnResource = vi.fn(); + registerCleanupResource(store); + registerCleanupResource(fnResource); + expect(cleanupResourceCount()).toBe(2); + + closeAllCleanupResources(); + + expect(store.close).toHaveBeenCalledTimes(1); + expect(fnResource).toHaveBeenCalledTimes(1); + expect(cleanupResourceCount()).toBe(0); + }); + + it("swallows a failing close and reports it via onError, without stopping the other closes", () => { + const boom = { + close: () => { + throw new Error("close failed"); + }, + }; + const ok = { close: vi.fn() }; + registerCleanupResource(boom); + registerCleanupResource(ok); + const onError = vi.fn(); + + closeAllCleanupResources({ onError }); + + expect(onError).toHaveBeenCalledTimes(1); + expect((onError.mock.calls[0][0] as Error).message).toBe("close failed"); + expect(ok.close).toHaveBeenCalledTimes(1); + expect(cleanupResourceCount()).toBe(0); + }); + + it("swallows a failing close even when no onError handler is provided", () => { + registerCleanupResource(() => { + throw new Error("nope"); + }); + expect(() => closeAllCleanupResources()).not.toThrow(); + expect(() => closeAllCleanupResources({ onError: "not-a-function" as unknown as () => void })).not.toThrow(); + }); + + it("installs SIGINT/SIGTERM/uncaughtException/unhandledRejection once and reports whether it did", () => { + const { proc } = makeFakeProcess(); + expect(installCliSignalHandlers({ process: proc, log: vi.fn(), exit: vi.fn() })).toBe(true); + // Already installed, no force -> no-op. + expect(installCliSignalHandlers({ process: proc, log: vi.fn(), exit: vi.fn() })).toBe(false); + // force reinstalls. + expect(installCliSignalHandlers({ process: proc, log: vi.fn(), exit: vi.fn(), force: true })).toBe(true); + }); + + it("on SIGINT closes registered resources and exits 130, using the default log + exit", () => { + const { proc, handlers, exit } = makeFakeProcess(); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const store = { close: vi.fn() }; + registerCleanupResource(store); + + installCliSignalHandlers({ process: proc }); + handlers.get("SIGINT")?.(); + + expect(store.close).toHaveBeenCalledTimes(1); + expect(cleanupResourceCount()).toBe(0); + expect(exit).toHaveBeenCalledWith(130); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("received SIGINT")); + }); + + it("on SIGTERM exits 143 through the injected exit + log", () => { + const { proc, handlers } = makeFakeProcess(); + const log = vi.fn(); + const exit = vi.fn(); + installCliSignalHandlers({ process: proc, log, exit }); + + handlers.get("SIGTERM")?.(); + + expect(exit).toHaveBeenCalledWith(143); + expect(log).toHaveBeenCalledWith(expect.stringContaining("received SIGTERM")); + }); + + it("logs an uncaught exception's stack and exits non-zero", () => { + const { proc, handlers } = makeFakeProcess(); + const log = vi.fn(); + const exit = vi.fn(); + installCliSignalHandlers({ process: proc, log, exit }); + + const error = new Error("kaboom"); + handlers.get("uncaughtException")?.(error); + + expect(log).toHaveBeenCalledWith(expect.stringContaining("uncaught exception")); + expect(log.mock.calls[0][0] as string).toContain(error.stack); + expect(exit).toHaveBeenCalledWith(1); + }); + + it("falls back to an error's message when it has no stack", () => { + const { proc, handlers } = makeFakeProcess(); + const log = vi.fn(); + installCliSignalHandlers({ process: proc, log, exit: vi.fn() }); + + const error = new Error("stackless"); + Object.defineProperty(error, "stack", { value: undefined }); + handlers.get("uncaughtException")?.(error); + + expect(log.mock.calls[0][0] as string).toContain("stackless"); + }); + + it("stringifies a non-Error unhandled rejection reason and exits non-zero", () => { + const { proc, handlers } = makeFakeProcess(); + const log = vi.fn(); + const exit = vi.fn(); + installCliSignalHandlers({ process: proc, log, exit }); + + handlers.get("unhandledRejection")?.("plain string reason"); + + expect(log).toHaveBeenCalledWith(expect.stringContaining("plain string reason")); + expect(exit).toHaveBeenCalledWith(1); + }); + + it("reports a cleanup failure that happens during signal handling via the log", () => { + const { proc, handlers } = makeFakeProcess(); + const log = vi.fn(); + registerCleanupResource(() => { + throw new Error("cleanup boom"); + }); + installCliSignalHandlers({ process: proc, log, exit: vi.fn() }); + + handlers.get("SIGINT")?.(); + + expect(log).toHaveBeenCalledWith(expect.stringContaining("cleanup error while exiting: ")); + expect(log.mock.calls.some((call) => String(call[0]).includes("cleanup 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); + for (const event of SIGNAL_EVENTS) { + expect(process.rawListeners(event).length).toBeGreaterThan(0); + } + }); + }); +}); From 2fb12fc4eaee0539d46c813500a0cc0d41de9e47 Mon Sep 17 00:00:00 2001 From: Andriy Polanski Date: Sun, 12 Jul 2026 22:58:52 +0000 Subject: [PATCH 2/2] fix --- test/unit/miner-process-lifecycle.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/unit/miner-process-lifecycle.test.ts b/test/unit/miner-process-lifecycle.test.ts index 8a19fcc99f..4f47227252 100644 --- a/test/unit/miner-process-lifecycle.test.ts +++ b/test/unit/miner-process-lifecycle.test.ts @@ -95,7 +95,7 @@ describe("gittensory-miner process lifecycle / crash-safety (#4826)", () => { closeAllCleanupResources({ onError }); expect(onError).toHaveBeenCalledTimes(1); - expect((onError.mock.calls[0][0] as Error).message).toBe("close failed"); + expect((onError.mock.calls[0]?.[0] as Error).message).toBe("close failed"); expect(ok.close).toHaveBeenCalledTimes(1); expect(cleanupResourceCount()).toBe(0); }); @@ -154,7 +154,7 @@ describe("gittensory-miner process lifecycle / crash-safety (#4826)", () => { handlers.get("uncaughtException")?.(error); expect(log).toHaveBeenCalledWith(expect.stringContaining("uncaught exception")); - expect(log.mock.calls[0][0] as string).toContain(error.stack); + expect(log.mock.calls[0]?.[0] as string).toContain(error.stack); expect(exit).toHaveBeenCalledWith(1); }); @@ -167,7 +167,7 @@ describe("gittensory-miner process lifecycle / crash-safety (#4826)", () => { Object.defineProperty(error, "stack", { value: undefined }); handlers.get("uncaughtException")?.(error); - expect(log.mock.calls[0][0] as string).toContain("stackless"); + expect(log.mock.calls[0]?.[0] as string).toContain("stackless"); }); it("stringifies a non-Error unhandled rejection reason and exits non-zero", () => {