diff --git a/electron/crashReports.js b/electron/crashReports.js index 6e41b706..65f617b2 100644 --- a/electron/crashReports.js +++ b/electron/crashReports.js @@ -6,9 +6,17 @@ const fs = require('fs'); const MAX_REPORTS = 30; const LOG_TAIL_BYTES = 64 * 1024; +// A fault that repeats is the normal case, not the rare one, and each report costs a 64KB log read +// plus a 68KB write. Without these two caps one stuck fault turns the reporter into a disk hog: a +// live 1.7.6-exp2 loop wrote 30 identical reports in 36ms. Same fault inside the window is counted, +// not rewritten, and a session can never spend more than CAP reports total. +const DEDUPE_WINDOW_MS = 60_000; +const MAX_REPORTS_PER_SESSION = 20; let p_app = null; let p_notify = null; +let p_written = 0; +const p_lastByFingerprint = new Map(); function init(app, notifyFn) { p_app = app; @@ -43,7 +51,21 @@ function prune(dir) { } catch (_) {} } +// Kind plus the first stack frame: enough to tell two different faults apart, stable across laps of +// the same one (the timestamps and line noise below it are not). +function fingerprint(kind, details) { + const stack = details && typeof details === 'object' ? String(details.stack || details.message || '') : String(details || ''); + return kind + '|' + stack.split('\n').slice(0, 2).join('|').slice(0, 300); +} + function writeCrashReport(kind, details) { + const now = Date.now(); + const fp = fingerprint(kind, details); + const seen = p_lastByFingerprint.get(fp); + if (seen && now - seen.at < DEDUPE_WINDOW_MS) { seen.count += 1; return null; } + if (p_written >= MAX_REPORTS_PER_SESSION) return null; + p_lastByFingerprint.set(fp, { at: now, count: 1 }); + p_written += 1; try { const dir = reportsDir(); const stamp = new Date().toISOString().replace(/[:.]/g, '-'); @@ -56,6 +78,8 @@ function writeCrashReport(kind, details) { arch: process.arch, electron: process.versions.electron, details, + // How many identical faults this report stands for, so dedupe hides nothing. + repeats: (p_lastByFingerprint.get(fp) || {}).count || 1, backendLogTail: backendLogTail(), }; fs.writeFileSync(file, JSON.stringify(report, null, 2)); @@ -68,7 +92,8 @@ function writeCrashReport(kind, details) { } return file; } catch (err) { - console.error('[crash-reports] failed to write report:', err && err.message); + // Logging is exactly what may have failed upstream, so it cannot be allowed to throw from here. + try { console.error('[crash-reports] failed to write report:', err && err.message); } catch (_) {} return null; } } diff --git a/electron/main.js b/electron/main.js index 401f2a36..0455299d 100644 --- a/electron/main.js +++ b/electron/main.js @@ -1,4 +1,16 @@ const { app, components, BrowserWindow, ipcMain, shell, session, dialog, crashReporter, powerMonitor, Menu, clipboard, globalShortcut } = require('electron'); +// Whoever was reading our stdout can go away (terminal closed, launcher exited) and then every +// console call throws EPIPE. Unhandled, that lands in uncaughtException, whose FIRST line is a +// console.error, which throws EPIPE again: a crash handler that crashes, forever, writing a 68KB +// report per lap. Measured live on 1.7.6-exp2: 30 reports in 36ms, ~56MB/s of disk. Listening here +// means the EPIPE is handled and never becomes a crash at all; after one, logging is simply off. +function p_muteConsole() { + const noop = () => {}; + for (const k of ['log', 'warn', 'error', 'info', 'debug', 'trace']) console[k] = noop; +} +for (const stream of [process.stdout, process.stderr]) { + stream.on('error', (err) => { if (err && (err.code === 'EPIPE' || err.code === 'ERR_STREAM_DESTROYED')) p_muteConsole(); }); +} const whisperService = require('./voice/whisperService'); const { createStreamingSession } = require('./voice/streamingSession'); const whisperModels = require('./voice/whisperModels'); @@ -104,9 +116,18 @@ try { // Capture every main-process throw we can. Without these, a throw inside an IPC handler or BrowserWindow event listener can die silently and look indistinguishable from a renderer crash in the trace. const crashReports = require('./crashReports'); crashReports.init(app, null); +// Reentrancy guard: anything this handler does can itself throw, and a handler that re-enters is an +// infinite loop with a disk write in it. One lap at a time, and the logging is optional. +let p_handlingUncaught = false; process.on('uncaughtException', (err) => { - console.error('[diag][main:uncaughtException]', err && err.stack || err); - crashReports.writeCrashReport('main-uncaught-exception', { message: String(err && err.message || err), stack: String(err && err.stack || '') }); + if (p_handlingUncaught) return; + p_handlingUncaught = true; + try { + try { console.error('[diag][main:uncaughtException]', err && err.stack || err); } catch (_) { p_muteConsole(); } + crashReports.writeCrashReport('main-uncaught-exception', { message: String(err && err.message || err), stack: String(err && err.stack || '') }); + } finally { + p_handlingUncaught = false; + } }); process.on('unhandledRejection', (reason) => { console.error('[diag][main:unhandledRejection]', reason && reason.stack || reason);