[eric] electron: a dead stdout pipe no longer turns the crash handler into an infinite crash-report writer (ENG-247 candidate writer)

This commit is contained in:
ciregenz
2026-08-11 21:32:28 -07:00
parent 62e707293e
commit 92f4e801ad
2 changed files with 49 additions and 3 deletions
+26 -1
View File
@@ -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;
}
}
+23 -2
View File
@@ -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);