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
7 changes: 7 additions & 0 deletions packages/gittensory-miner/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions packages/gittensory-miner/bin/gittensory-miner.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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.
Expand Down
10 changes: 10 additions & 0 deletions packages/gittensory-miner/lib/local-store.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
31 changes: 31 additions & 0 deletions packages/gittensory-miner/lib/process-lifecycle.d.ts
Original file line number Diff line number Diff line change
@@ -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;
106 changes: 106 additions & 0 deletions packages/gittensory-miner/lib/process-lifecycle.js
Original file line number Diff line number Diff line change
@@ -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;
}
28 changes: 28 additions & 0 deletions test/unit/miner-local-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }> = [];
Expand Down Expand Up @@ -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();

Expand Down
Loading