diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index effa646a8..38fbc449f 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -329,7 +329,7 @@ session; that tree re-write is inherent to git and left as residual cost. ### Crash Logging -`index.ts` installs `uncaughtException` and `unhandledRejection` handlers (and catches a rejected `main`). Each writes a best-effort crash report to `~/.corbits/projects//errors/.txt`, where the slug is the cwd with non-alphanumeric runs collapsed to `-`. The file records the failure kind, an ISO timestamp, the cwd, and the stack. The logger swallows its own errors so it can never mask the original crash, then exits non-zero after printing a one-line message to stderr. +`index.ts` installs `uncaughtException` and `unhandledRejection` handlers (and catches a rejected `main`). Each calls `writeCrashReport` (`crash/report.ts`) to write a best-effort report to `~/.corbits/projects//errors/.txt`, using the same `projectKeyFor`/`projectSessionsRoot` (`session/project-key.ts`) that keys that project's session directories, so a crash report lands next to the session's `run.json` and transcript rather than under a separately computed slug. The file records the failure kind, an ISO timestamp, the cwd, and the stack. `projectSessionsRoot` shells out to git with no timeout, so the handler never calls it directly — `primeCrashReporting` resolves and caches the directory once at startup (right after config load), and `writeCrashReport` only ever reads that cached value; if priming never ran or failed, it falls back to an `unresolved` bucket rather than touching git mid-crash. `writeCrashReport` swallows its own errors and returns `null` without logging; the handler in `index.ts` is what prints the one-line failure notice to stderr when that happens, then exits non-zero. ### Event Stream diff --git a/src/crash/report.test.ts b/src/crash/report.test.ts new file mode 100644 index 000000000..23c029f54 --- /dev/null +++ b/src/crash/report.test.ts @@ -0,0 +1,84 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtemp, readFile, readdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { projectSessionsRoot } from "../session/project-key.js"; +import { crashReportDir, primeCrashReporting, writeCrashReport } from "./report.js"; + +let home: string | undefined; + +afterEach(async () => { + if (home !== undefined) { + await rm(home, { recursive: true, force: true }); + home = undefined; + } +}); + +describe("primeCrashReporting", () => { + test("resolves the project root exactly once; crashReportDir never re-resolves it", () => { + home = "/tmp/corbits-crash-key-check"; + let resolverCalls = 0; + primeCrashReporting("/Users/dev/some project!!", home, (_cwd, h) => { + resolverCalls += 1; + return join(h, "primed-root"); + }); + expect(resolverCalls).toBe(1); + + const first = crashReportDir(home); + const second = crashReportDir(home); + expect(resolverCalls).toBe(1); // reading the cached dir does no further resolution + expect(first).toBe(second); + expect(first).toBe(join(home, "primed-root", "errors")); + }); + + test("a resolver that throws (simulating a hung or failing git) never propagates and never runs again", () => { + home = "/tmp/corbits-crash-key-check-2"; + let resolverCalls = 0; + primeCrashReporting("/Users/dev/some project!!", home, () => { + resolverCalls += 1; + throw new Error("git hung"); + }); + expect(resolverCalls).toBe(1); + + // The crash path must fall back without ever calling the resolver again. + const dir = crashReportDir(home); + expect(resolverCalls).toBe(1); + expect(dir).toContain("unresolved"); + }); + + test("matches the real project-key scheme when given the real resolver", () => { + home = "/tmp/corbits-crash-key-check-3"; + const cwd = "/Users/dev/some project!!"; + primeCrashReporting(cwd, home); + expect(crashReportDir(home)).toBe(join(projectSessionsRoot(cwd, home), "errors")); + }); +}); + +describe("writeCrashReport", () => { + test("writes a report under the primed project directory", async () => { + home = await mkdtemp(join(tmpdir(), "corbits-crash-")); + const cwd = "/Users/dev/some project!!"; + primeCrashReporting(cwd, home); + const file = await writeCrashReport("uncaughtException", new Error("boom"), cwd, home); + + expect(file).not.toBeNull(); + const dir = crashReportDir(home); + const entries = await readdir(dir); + expect(entries).toHaveLength(1); + + const body = await readFile(join(dir, entries[0]!), "utf8"); + expect(body).toContain("kind: uncaughtException"); + expect(body).toContain(`cwd: ${cwd}`); + expect(body).toContain("boom"); + }); + + test("returns null instead of throwing when the report cannot be written", async () => { + // A path segment that is a file, not a directory, makes mkdir fail. + home = await mkdtemp(join(tmpdir(), "corbits-crash-")); + primeCrashReporting("/Users/dev/some project!!", home, () => join(home!, "blocked-root")); + await Bun.write(join(home, "blocked-root"), "not a directory"); + const file = await writeCrashReport("unhandledRejection", "oops", "/whatever", home); + expect(file).toBeNull(); + }); +}); diff --git a/src/crash/report.ts b/src/crash/report.ts new file mode 100644 index 000000000..60cb6d59d --- /dev/null +++ b/src/crash/report.ts @@ -0,0 +1,67 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +import { SETTINGS_DIR_NAME } from "../branding.js"; +import { projectSessionsRoot } from "../session/project-key.js"; + +export type CrashKind = "uncaughtException" | "unhandledRejection"; + +// projectSessionsRoot shells out to git synchronously with no timeout. A +// crash handler must never call it directly: a hung or corrupted git would +// block process.exit forever, the exact failure this module exists to +// prevent. primeCrashReporting resolves the root once during ordinary +// startup, well before any crash, and the handler only ever reads the +// cached value below with no I/O of its own. +let primedSessionsRoot: string | null = null; + +export function primeCrashReporting( + cwd: string, + home: string = homedir(), + resolveSessionsRoot: (cwd: string, home: string) => string = projectSessionsRoot, +): void { + try { + primedSessionsRoot = resolveSessionsRoot(cwd, home); + } catch { + primedSessionsRoot = null; + } +} + +// A crash before priming ever ran (or a priming failure) must still resolve +// to a path with no git call, so the report goes to an unresolved bucket +// rather than blocking mid-crash on project-root resolution. +function fallbackSessionsRoot(home: string): string { + return join(home, SETTINGS_DIR_NAME, "projects", "unresolved"); +} + +export function crashReportDir(home: string = homedir()): string { + return join(primedSessionsRoot ?? fallbackSessionsRoot(home), "errors"); +} + +function describeError(error: unknown): string { + return error instanceof Error ? (error.stack ?? error.message) : String(error); +} + +/** + * Best-effort crash report writer. Never logs on its own; failures here + * must never mask the original crash, so every I/O error is swallowed and + * reported as null for the caller to log. + */ +export async function writeCrashReport( + kind: CrashKind, + error: unknown, + cwd: string = process.cwd(), + home: string = homedir(), +): Promise { + try { + const dir = crashReportDir(home); + await mkdir(dir, { recursive: true }); + const now = new Date(); + const file = join(dir, `${now.toISOString().replace(/[:.]/g, "-")}.txt`); + const body = `kind: ${kind}\ntime: ${now.toISOString()}\ncwd: ${cwd}\n\n${describeError(error)}\n`; + await writeFile(file, body, "utf8"); + return file; + } catch { + return null; + } +} diff --git a/src/index.ts b/src/index.ts index 378cc1c0c..6fecafdd8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ import { getLogger } from "@intx/log"; import { LOG_NAMESPACE_ROOT } from "./branding.js"; +import { primeCrashReporting, writeCrashReport, type CrashKind } from "./crash/report.js"; import { loadConfig } from "./config/index.js"; import { ensureTelemetrySettings, globalSettingsPath } from "./config/settings.js"; import { flushPerfToOtel } from "./perf/index.js"; @@ -20,6 +21,11 @@ export async function mainWithRunners( runners: Runners, ): Promise { const config = await loadConfig(argv, { allowUnconfigured: true }); + // Resolve the crash-report directory once, up front, while the process is + // healthy. This is the only place project-key resolution (which shells + // out to git) may happen on the crash path — the handler itself must + // never call it, or a hung git would block the exit it exists to force. + primeCrashReporting(config.cwd); // Exec has no Ink banner; unconfigured TUI goes to onboarding without the // main-screen notice. Surface fail-open diagnostics on stderr for those // paths so junk local files are never silent. @@ -100,13 +106,33 @@ export async function main(argv: readonly string[]): Promise { }); } +async function handleFatal(kind: CrashKind, error: unknown): Promise { + process.stderr.write(`${kind}: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`); + const file = await writeCrashReport(kind, error); + if (file !== null) { + process.stderr.write(`crash report written to ${file}\n`); + } else { + process.stderr.write("failed to write crash report\n"); + } + process.exit(1); +} + if (import.meta.main) { - // OpenTUI installs a process-global uncaughtException handler that only - // logs, which suppresses Bun's default print-and-exit. Combined with - // raw-mode stdin holding the event loop open, an escaped throw would - // otherwise hang the process forever with the terminal still in the - // alternate screen. Exiting explicitly here is the backstop for throws that - // originate outside runTUI's own crash path. + // OpenTUI installs a process-global uncaughtException/unhandledRejection + // handler that only logs (opentui/core's Renderer.handleError), which + // suppresses Bun's default print-and-exit. Combined with raw-mode stdin + // holding the event loop open, an escaped throw would otherwise hang the + // process forever with the terminal still in the alternate screen. Node + // invokes every registered listener for the event regardless of order, so + // these still run and terminate the process even though OpenTUI's own + // listener never exits or rethrows. + process.on("uncaughtException", (err) => { + void handleFatal("uncaughtException", err); + }); + process.on("unhandledRejection", (reason) => { + void handleFatal("unhandledRejection", reason); + }); + let code: number; try { code = await main(process.argv.slice(2));