From f5ab94b818b3227b44811f2e05f706818319bf77 Mon Sep 17 00:00:00 2001 From: Nabzx Date: Mon, 21 Sep 2026 14:41:31 +0400 Subject: [PATCH 1/3] crashLogStore: a capped, local log of main-process crashes (#138) Uncaught exceptions and unhandled promise rejections, kept as a capped (20) JSON file - never sent anywhere. record() is deliberately synchronous, unlike settingsStore.js's shape: it gets called from inside an uncaughtException handler, where the process may be moments from exiting, so there's no Promise chain that might not finish. Claude-Session: https://claude.ai/code/session_0144sxo5CJiyk99vaR7sgjuj --- electron/crashLogStore.js | 70 +++++++++++++++++++++++ electron/crashLogStore.test.js | 101 +++++++++++++++++++++++++++++++++ 2 files changed, 171 insertions(+) create mode 100644 electron/crashLogStore.js create mode 100644 electron/crashLogStore.test.js diff --git a/electron/crashLogStore.js b/electron/crashLogStore.js new file mode 100644 index 0000000..89e0c4f --- /dev/null +++ b/electron/crashLogStore.js @@ -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 }; diff --git a/electron/crashLogStore.test.js b/electron/crashLogStore.test.js new file mode 100644 index 0000000..d4949a0 --- /dev/null +++ b/electron/crashLogStore.test.js @@ -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)); +}); From 91d41abd199af4f417224cf18c42e620dfb06860 Mon Sep 17 00:00:00 2001 From: Nabzx Date: Mon, 21 Sep 2026 14:41:57 +0400 Subject: [PATCH 2/3] wire crash logging into main, IPC + bridge (#138) main.js already had uncaughtException/unhandledRejection handlers from #294/#295 - deliberately not force-exiting the process (a dialog for a real error, silence during shutdown). Added crash-log recording inside those existing handlers rather than introducing new ones, so nothing about that established behaviour changes - this only adds a write to disk alongside what already happens. The store itself is lazily constructed on first crash (app.getPath() is never called at module load, only if a crash actually occurs). IPC: app:get-diagnostics (app/OS/Electron versions + the crash log, raw data - the renderer formats it), app:copy-diagnostics, app:clear-crash-log. Claude-Session: https://claude.ai/code/session_0144sxo5CJiyk99vaR7sgjuj --- electron/main.js | 52 +++++++++++++++++++++++++++++++++++++++ electron/preload.js | 3 +++ src/openstreamBridge.d.ts | 22 +++++++++++++++++ 3 files changed, 77 insertions(+) diff --git a/electron/main.js b/electron/main.js index 7087837..7a9f5f3 100644 --- a/electron/main.js +++ b/electron/main.js @@ -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"); @@ -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"); @@ -75,9 +77,29 @@ 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); } @@ -85,6 +107,15 @@ process.on("uncaughtException", (error) => { 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"; @@ -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(); diff --git a/electron/preload.js b/electron/preload.js index 8868a7c..7ad6d61 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -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"), diff --git a/src/openstreamBridge.d.ts b/src/openstreamBridge.d.ts index 0256f5e..75142ab 100644 --- a/src/openstreamBridge.d.ts +++ b/src/openstreamBridge.d.ts @@ -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 } @@ -118,6 +137,9 @@ declare global { getSetupProgress(): Promise; retryModelDownload(): Promise; restartModel(role: ModelRole): Promise<{ ok: boolean }>; + getDiagnostics(): Promise; + copyDiagnostics(text: string): Promise; + clearCrashLog(): Promise; }; settings: { get(): Promise; From 93c57704fde2ea54ad2a5423957dc4c79d11309b Mon Sep 17 00:00:00 2001 From: Nabzx Date: Mon, 21 Sep 2026 14:42:12 +0400 Subject: [PATCH 3/3] settings UI: a Diagnostics section with Copy diagnostics (#138) App/OS/Electron versions plus the crash log, formatted as plain text and copied to the clipboard on demand - ready to paste into a bug report. Passive only, per the scoping call: nothing surfaces proactively after a crash, the user has to come looking. A Clear crash log action shows once there's something to clear. Claude-Session: https://claude.ai/code/session_0144sxo5CJiyk99vaR7sgjuj --- src/pages/Settings.tsx | 75 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 74 insertions(+), 1 deletion(-) diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 7711e6d..c543258 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -3,7 +3,7 @@ import HotkeySettings from "../HotkeySettings"; import BreakSafeAppsSettings from "../BreakSafeAppsSettings"; import TermCorrectionsSettings from "../TermCorrectionsSettings"; import Toggle from "../components/Toggle"; -import type { OverlayPosition } from "../openstreamBridge"; +import type { Diagnostics, OverlayPosition } from "../openstreamBridge"; function CopyTranscriptSection() { const [enabled, setEnabled] = useState(null); @@ -486,6 +486,77 @@ function PostProcessSection() { ); } +// #138: local-only crash log, no telemetry service - see crashLogStore.js. +function formatDiagnosticsText(diagnostics: Diagnostics): string { + const lines = [ + `OpenStream ${diagnostics.appVersion}`, + `${diagnostics.platform} ${diagnostics.arch}, ${diagnostics.osRelease}`, + `Electron ${diagnostics.electronVersion}, Node ${diagnostics.nodeVersion}`, + "", + ]; + if (diagnostics.crashLog.length === 0) { + lines.push("No crashes recorded."); + } else { + const count = diagnostics.crashLog.length; + lines.push(`${count} crash${count === 1 ? "" : "es"} recorded (most recent first):`); + for (const entry of diagnostics.crashLog) { + lines.push("", `[${new Date(entry.at).toISOString()}] ${entry.type}: ${entry.message}`); + if (entry.stack) lines.push(entry.stack); + } + } + return lines.join("\n"); +} + +function DiagnosticsSection() { + const [diagnostics, setDiagnostics] = useState(null); + const [copied, setCopied] = useState(false); + + useEffect(() => { + window.openstream.app.getDiagnostics().then(setDiagnostics); + }, []); + + function copy() { + if (!diagnostics) return; + window.openstream.app.copyDiagnostics(formatDiagnosticsText(diagnostics)).then((ok) => { + if (!ok) return; + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }); + } + + function clear() { + window.openstream.app.clearCrashLog().then((crashLog) => { + setDiagnostics((current) => (current ? { ...current, crashLog } : current)); + }); + } + + const crashCount = diagnostics?.crashLog.length ?? 0; + + return ( +
+

Diagnostics

+

+ {diagnostics === null + ? "Loading…" + : crashCount === 0 + ? "No crashes recorded." + : `${crashCount} crash${crashCount === 1 ? "" : "es"} recorded.`}{" "} + Held locally - nothing is ever sent anywhere. Copy this into a bug report if OpenStream misbehaves. +

+
+ + {crashCount > 0 && ( + + )} +
+
+ ); +} + function StartupSection() { const [openAtLogin, setOpenAtLogin] = useState(null); @@ -560,6 +631,8 @@ export default function Settings() { + + );