Skip to content

Commit bc19293

Browse files
committed
Install real crash handlers so a detached throw terminates the process
process.on("uncaughtException"/"unhandledRejection") now write a best-effort crash report to ~/.corbits/projects/<slug>/errors/<timestamp>.txt and exit non-zero, matching what docs/IMPLEMENTATION.md already documented but the code never implemented. OpenTUI's own log-only handler for the same events still runs, but Node invokes every registered listener, so ours still exits the process afterward.
1 parent cf3bb84 commit bc19293

3 files changed

Lines changed: 114 additions & 6 deletions

File tree

src/crash/report.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { afterEach, describe, expect, test } from "bun:test";
2+
import { mkdtemp, readFile, readdir, rm } from "node:fs/promises";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
6+
import { crashReportDir, writeCrashReport } from "./report.js";
7+
8+
let home: string | undefined;
9+
10+
afterEach(async () => {
11+
if (home !== undefined) {
12+
await rm(home, { recursive: true, force: true });
13+
home = undefined;
14+
}
15+
});
16+
17+
describe("writeCrashReport", () => {
18+
test("writes a report under ~/.corbits/projects/<slug>/errors/", async () => {
19+
home = await mkdtemp(join(tmpdir(), "corbits-crash-"));
20+
const cwd = "/Users/dev/some project!!";
21+
const file = await writeCrashReport("uncaughtException", new Error("boom"), cwd, home);
22+
23+
expect(file).not.toBeNull();
24+
const dir = crashReportDir(cwd, home);
25+
const entries = await readdir(dir);
26+
expect(entries).toHaveLength(1);
27+
28+
const body = await readFile(join(dir, entries[0]!), "utf8");
29+
expect(body).toContain("kind: uncaughtException");
30+
expect(body).toContain(`cwd: ${cwd}`);
31+
expect(body).toContain("boom");
32+
});
33+
34+
test("returns null instead of throwing when the report cannot be written", async () => {
35+
// A path segment that is a file, not a directory, makes mkdir fail.
36+
home = await mkdtemp(join(tmpdir(), "corbits-crash-"));
37+
const blockerParent = join(home, ".corbits", "projects");
38+
await Bun.write(join(home, ".corbits"), "not a directory");
39+
const file = await writeCrashReport("unhandledRejection", "oops", "/whatever", home);
40+
expect(file).toBeNull();
41+
void blockerParent;
42+
});
43+
});

src/crash/report.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { mkdir, writeFile } from "node:fs/promises";
2+
import { homedir } from "node:os";
3+
import { join } from "node:path";
4+
5+
import { SETTINGS_DIR_NAME } from "../branding.js";
6+
7+
export type CrashKind = "uncaughtException" | "unhandledRejection";
8+
9+
// Deliberately independent of session/project-key.ts: a crash can happen
10+
// before config or git discovery ever runs, so this slug is just the raw cwd
11+
// with non-alphanumeric runs collapsed, not the hashed project key.
12+
function slugifyCwd(cwd: string): string {
13+
const slug = cwd.replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-+|-+$/g, "");
14+
return slug.length > 0 ? slug : "project";
15+
}
16+
17+
export function crashReportDir(cwd: string, home: string = homedir()): string {
18+
return join(home, SETTINGS_DIR_NAME, "projects", slugifyCwd(cwd), "errors");
19+
}
20+
21+
function describeError(error: unknown): string {
22+
return error instanceof Error ? (error.stack ?? error.message) : String(error);
23+
}
24+
25+
/**
26+
* Best-effort crash report writer. Failures here must never mask the
27+
* original crash, so every I/O error is swallowed and reported as null.
28+
*/
29+
export async function writeCrashReport(
30+
kind: CrashKind,
31+
error: unknown,
32+
cwd: string = process.cwd(),
33+
home: string = homedir(),
34+
): Promise<string | null> {
35+
try {
36+
const dir = crashReportDir(cwd, home);
37+
await mkdir(dir, { recursive: true });
38+
const now = new Date();
39+
const file = join(dir, `${now.toISOString().replace(/[:.]/g, "-")}.txt`);
40+
const body = `kind: ${kind}\ntime: ${now.toISOString()}\ncwd: ${cwd}\n\n${describeError(error)}\n`;
41+
await writeFile(file, body, "utf8");
42+
return file;
43+
} catch {
44+
return null;
45+
}
46+
}

src/index.ts

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { getLogger } from "@intx/log";
22
import { LOG_NAMESPACE_ROOT } from "./branding.js";
3+
import { writeCrashReport, type CrashKind } from "./crash/report.js";
34
import { loadConfig } from "./config/index.js";
45
import { ensureTelemetrySettings, globalSettingsPath } from "./config/settings.js";
56
import { flushPerfToOtel } from "./perf/index.js";
@@ -100,13 +101,31 @@ export async function main(argv: readonly string[]): Promise<number> {
100101
});
101102
}
102103

104+
async function handleFatal(kind: CrashKind, error: unknown): Promise<void> {
105+
process.stderr.write(`${kind}: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`);
106+
const file = await writeCrashReport(kind, error);
107+
if (file !== null) {
108+
process.stderr.write(`crash report written to ${file}\n`);
109+
}
110+
process.exit(1);
111+
}
112+
103113
if (import.meta.main) {
104-
// OpenTUI installs a process-global uncaughtException handler that only
105-
// logs, which suppresses Bun's default print-and-exit. Combined with
106-
// raw-mode stdin holding the event loop open, an escaped throw would
107-
// otherwise hang the process forever with the terminal still in the
108-
// alternate screen. Exiting explicitly here is the backstop for throws that
109-
// originate outside runTUI's own crash path.
114+
// OpenTUI installs a process-global uncaughtException/unhandledRejection
115+
// handler that only logs (opentui/core's Renderer.handleError), which
116+
// suppresses Bun's default print-and-exit. Combined with raw-mode stdin
117+
// holding the event loop open, an escaped throw would otherwise hang the
118+
// process forever with the terminal still in the alternate screen. Node
119+
// invokes every registered listener for the event regardless of order, so
120+
// these still run and terminate the process even though OpenTUI's own
121+
// listener never exits or rethrows.
122+
process.on("uncaughtException", (err) => {
123+
void handleFatal("uncaughtException", err);
124+
});
125+
process.on("unhandledRejection", (reason) => {
126+
void handleFatal("unhandledRejection", reason);
127+
});
128+
110129
let code: number;
111130
try {
112131
code = await main(process.argv.slice(2));

0 commit comments

Comments
 (0)