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
70 changes: 70 additions & 0 deletions electron/crashLogStore.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
const fs = require("fs");
const path = require("path");

// #138: a local, capped log of main-process crashes - uncaught exceptions and
// unhandled promise rejections - so a user can copy it into a bug report.
// Nothing is sent anywhere; this is a file on disk, not a telemetry service.
// Deliberately synchronous throughout (unlike settingsStore.js's shape):
// record() is called from inside an uncaughtException handler, where the
// process may be moments from exiting - no Promise chain, nothing that
// might not finish before that happens.
const MAX_ENTRIES = 20;

function createCrashLogStore({ filePath, maxEntries = MAX_ENTRIES }) {
let cache = null;

function load() {
if (cache) return cache;
try {
const raw = fs.readFileSync(filePath, "utf8");
const parsed = JSON.parse(raw);
cache = Array.isArray(parsed) ? parsed : [];
} catch {
cache = [];
}
return cache;
}

function persist(entries) {
const temporaryPath = `${filePath}.${process.pid}.tmp`;
fs.mkdirSync(path.dirname(filePath), { recursive: true });
try {
fs.writeFileSync(temporaryPath, JSON.stringify(entries, null, 2));
fs.renameSync(temporaryPath, filePath);
} catch (error) {
try {
fs.unlinkSync(temporaryPath);
} catch {
// The temporary file may not have been created.
}
throw error;
}
}

function list() {
return [...load()];
}

function record({ type, message, stack, at }) {
const entry = {
type: type === "unhandledRejection" ? "unhandledRejection" : "uncaughtException",
message: typeof message === "string" && message ? message : "unknown error",
stack: typeof stack === "string" && stack ? stack : null,
at: typeof at === "number" ? at : Date.now(),
};
const next = [entry, ...load()].slice(0, maxEntries);
persist(next);
cache = next;
return list();
}

function clear() {
persist([]);
cache = [];
return [];
}

return { record, list, clear };
}

module.exports = { createCrashLogStore, MAX_ENTRIES };
101 changes: 101 additions & 0 deletions electron/crashLogStore.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("fs");
const os = require("os");
const path = require("path");
const { createCrashLogStore } = require("./crashLogStore");

function tempFilePath() {
return path.join(fs.mkdtempSync(path.join(os.tmpdir(), "openstream-crashlog-")), "crash-log.json");
}

test("starts empty when no crash log file exists yet", () => {
const store = createCrashLogStore({ filePath: tempFilePath() });
assert.deepEqual(store.list(), []);
});

test("record adds an entry with defaults filled in", () => {
const store = createCrashLogStore({ filePath: tempFilePath() });
store.record({ type: "uncaughtException", message: "boom", stack: "at foo (bar.js:1:1)", at: 1000 });

const [entry] = store.list();
assert.equal(entry.type, "uncaughtException");
assert.equal(entry.message, "boom");
assert.equal(entry.stack, "at foo (bar.js:1:1)");
assert.equal(entry.at, 1000);
});

test("an unrecognised type falls back to uncaughtException, except unhandledRejection", () => {
const store = createCrashLogStore({ filePath: tempFilePath() });
store.record({ type: "somethingElse", message: "a" });
store.record({ type: "unhandledRejection", message: "b" });

const [second, first] = store.list();
assert.equal(first.type, "uncaughtException");
assert.equal(second.type, "unhandledRejection");
});

test("a missing message falls back to a placeholder rather than throwing", () => {
const store = createCrashLogStore({ filePath: tempFilePath() });
store.record({ type: "uncaughtException" });
assert.equal(store.list()[0].message, "unknown error");
assert.equal(store.list()[0].stack, null);
});

test("a missing at defaults to now", () => {
const before = Date.now();
const store = createCrashLogStore({ filePath: tempFilePath() });
store.record({ type: "uncaughtException", message: "boom" });
const after = Date.now();

const [entry] = store.list();
assert.ok(entry.at >= before && entry.at <= after);
});

test("newest entry is first", () => {
const store = createCrashLogStore({ filePath: tempFilePath() });
store.record({ type: "uncaughtException", message: "first", at: 1000 });
store.record({ type: "uncaughtException", message: "second", at: 2000 });
assert.deepEqual(
store.list().map((entry) => entry.message),
["second", "first"],
);
});

test("caps at maxEntries, dropping the oldest", () => {
const store = createCrashLogStore({ filePath: tempFilePath(), maxEntries: 3 });
for (let i = 0; i < 5; i++) {
store.record({ type: "uncaughtException", message: `crash ${i}`, at: i });
}
assert.deepEqual(
store.list().map((entry) => entry.message),
["crash 4", "crash 3", "crash 2"],
);
});

test("clear empties the log", () => {
const store = createCrashLogStore({ filePath: tempFilePath() });
store.record({ type: "uncaughtException", message: "boom" });
store.clear();
assert.deepEqual(store.list(), []);
});

test("persists to disk and a fresh store reads it back", () => {
const filePath = tempFilePath();
const store = createCrashLogStore({ filePath });
store.record({ type: "uncaughtException", message: "persisted", at: 42 });

const reopened = createCrashLogStore({ filePath });
assert.deepEqual(
reopened.list().map((entry) => entry.message),
["persisted"],
);
});

test("record is synchronous - no unresolved promise, safe to call right before process.exit", () => {
const store = createCrashLogStore({ filePath: tempFilePath() });
const result = store.record({ type: "uncaughtException", message: "boom" });
// If record() ever became async, this would be a Promise, not an array -
// this assertion is what actually enforces the synchronous contract.
assert.ok(Array.isArray(result));
});
52 changes: 52 additions & 0 deletions electron/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const {
} = require("electron");
const fs = require("fs");
const http = require("http");
const os = require("os");
const path = require("path");
const { performance } = require("node:perf_hooks");
const { computeOverlayPosition } = require("./overlayPosition");
Expand All @@ -27,6 +28,7 @@ const accessibilityHelper = require("./accessibilityHelper");
const { createSettingsStore } = require("./settingsStore");
const { createRecordingHistoryStore } = require("./recordingHistoryStore");
const { createPostProcessHook } = require("./postProcessHook");
const { createCrashLogStore } = require("./crashLogStore");
const { createIdleUnloader } = require("./idleUnloader");
const { createMediaPause } = require("./mediaPause");
const { createSoundCues } = require("./soundCues");
Expand Down Expand Up @@ -75,16 +77,45 @@ for (const signal of ["SIGTERM", "SIGINT", "SIGHUP"]) {
app.quit();
});
}
// #138: the same two handlers above already catch everything worth
// recording - a local, capped log (never sent anywhere) so a crash is
// diagnosable from a bug report instead of just a scrollback that's long
// gone. Lazily constructed on first crash rather than at module load, so
// app.getPath("userData") is never called before it's needed.
let crashLogStore = null;
function getCrashLogStore() {
if (!crashLogStore) {
crashLogStore = createCrashLogStore({
filePath: path.join(app.getPath("userData"), "crash-log.json"),
});
}
return crashLogStore;
}

process.on("uncaughtException", (error) => {
const stack = error && error.stack ? error.stack : String(error);
console.error(`[main] uncaught exception${quitting ? " during shutdown" : ""}:\n${stack}`);
try {
getCrashLogStore().record({ type: "uncaughtException", message: error && error.message, stack });
} catch (logError) {
console.error(`[main] failed to write the crash log: ${logError}`);
}
if (!quitting) {
dialog.showErrorBox("OpenStream hit an unexpected error", stack);
}
});
process.on("unhandledRejection", (reason) => {
const stack = reason && reason.stack ? reason.stack : String(reason);
console.error(`[main] unhandled promise rejection${quitting ? " during shutdown" : ""}:\n${stack}`);
try {
getCrashLogStore().record({
type: "unhandledRejection",
message: reason && reason.message ? reason.message : String(reason),
stack,
});
} catch (logError) {
console.error(`[main] failed to write the crash log: ${logError}`);
}
});

const isDev = process.env.NODE_ENV === "development";
Expand Down Expand Up @@ -1037,6 +1068,27 @@ ipcMain.handle("app:get-health", async () => {
};
});

// #138: raw data for the Settings "Copy diagnostics" action - formatting
// into pasteable text happens in the renderer (DiagnosticsSection.tsx),
// same split as History's own retentionSummary().
ipcMain.handle("app:get-diagnostics", () => ({
appVersion: app.getVersion(),
platform: process.platform,
arch: process.arch,
osRelease: os.release(),
electronVersion: process.versions.electron,
nodeVersion: process.versions.node,
crashLog: getCrashLogStore().list(),
}));

ipcMain.handle("app:copy-diagnostics", (event, text) => {
if (typeof text !== "string" || !text) return false;
clipboard.writeText(text);
return true;
});

ipcMain.handle("app:clear-crash-log", () => getCrashLogStore().clear());

ipcMain.handle("app:restart-model", (event, role) => {
// #254: the "Restart" affordance on the Home System panel.
if (role === "transcription") transcriptionHelper.restart();
Expand Down
3 changes: 3 additions & 0 deletions electron/preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@ contextBridge.exposeInMainWorld("openstream", {
getSetupProgress: () => ipcRenderer.invoke("app:get-setup-progress"),
retryModelDownload: () => ipcRenderer.invoke("app:retry-model-download"),
restartModel: (role) => ipcRenderer.invoke("app:restart-model", role),
getDiagnostics: () => ipcRenderer.invoke("app:get-diagnostics"),
copyDiagnostics: (text) => ipcRenderer.invoke("app:copy-diagnostics", text),
clearCrashLog: () => ipcRenderer.invoke("app:clear-crash-log"),
},
settings: {
get: () => ipcRenderer.invoke("settings:get"),
Expand Down
22 changes: 22 additions & 0 deletions src/openstreamBridge.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,25 @@ export type AppHealth = {
rewriteModel: ModelHealth;
};

// #138: one main-process crash - an uncaught exception or an unhandled
// promise rejection. Local only, never sent anywhere.
export type CrashLogEntry = {
type: "uncaughtException" | "unhandledRejection";
message: string;
stack: string | null;
at: number;
};

export type Diagnostics = {
appVersion: string;
platform: string;
arch: string;
osRelease: string;
electronVersion: string;
nodeVersion: string;
crashLog: CrashLogEntry[];
};

// #249: first-run model download.
export type SetupProgress =
| { phase: "check" | "download" | "done"; role: string; file: string; bytes: number; received?: number; total?: number }
Expand Down Expand Up @@ -118,6 +137,9 @@ declare global {
getSetupProgress(): Promise<SetupProgress | null>;
retryModelDownload(): Promise<void>;
restartModel(role: ModelRole): Promise<{ ok: boolean }>;
getDiagnostics(): Promise<Diagnostics>;
copyDiagnostics(text: string): Promise<boolean>;
clearCrashLog(): Promise<CrashLogEntry[]>;
};
settings: {
get(): Promise<StoredSettings>;
Expand Down
Loading
Loading