From 3017d73ad0a165c429a6d54ccf7804035a61ef29 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Fri, 7 Aug 2026 12:01:06 -0700 Subject: [PATCH] [eric] telemetry: a native crash reports its own cause on the next boot instead of leaving a file nobody reads --- electron/crashDumpScan.js | 131 +++++++++++++++++++++++++++++++++ electron/crashDumpScan.test.js | 110 +++++++++++++++++++++++++++ electron/main.js | 18 ++++- 3 files changed, 258 insertions(+), 1 deletion(-) create mode 100644 electron/crashDumpScan.js create mode 100644 electron/crashDumpScan.test.js diff --git a/electron/crashDumpScan.js b/electron/crashDumpScan.js new file mode 100644 index 00000000..ca2b6de9 --- /dev/null +++ b/electron/crashDumpScan.js @@ -0,0 +1,131 @@ +'use strict'; +// Reads the CAUSE out of Crashpad minidumps so a native crash stops being an invisible file. +// +// Before this, a main-process SIGSEGV ran none of our JS (uncaughtException is JS-only, +// child-process-gone is children-only), so the app vanished and left a .dmp nobody read. The boot +// beacon shipped a lifetime cumulative COUNT, which cannot answer what crashed, when, or during +// what. This parses the minidump header itself, which is a documented binary format, and reports +// one record per NEW dump since the last boot. +// +// Deliberately parses only the header + stream directory + exception/misc streams. That is enough +// for cause and timing, costs a few KB of reads, and cannot be confused by a truncated tail. + +const fs = require('fs'); +const path = require('path'); + +const MINIDUMP_MAGIC = 0x504d444d; // 'MDMP' +const STREAM_EXCEPTION = 6; +const STREAM_SYSTEM_INFO = 7; +const STREAM_MISC_INFO = 15; + +// Mach exception codes; the signal is what a user-facing report should say. +const MAC_EXC = { + 1: 'EXC_BAD_ACCESS (SIGSEGV/SIGBUS)', + 2: 'EXC_BAD_INSTRUCTION (SIGILL)', + 3: 'EXC_ARITHMETIC', + 5: 'EXC_BREAKPOINT (SIGTRAP)', + 6: 'EXC_SOFTWARE', + 10: 'EXC_CRASH (SIGABRT)', +}; + +function p_readStreams(fd, size) { + const head = Buffer.alloc(32); + fs.readSync(fd, head, 0, 32, 0); + if (head.readUInt32LE(0) !== MINIDUMP_MAGIC) return null; + const streamCount = head.readUInt32LE(8); + const streamRva = head.readUInt32LE(12); + const timeDateStamp = head.readUInt32LE(20); + if (streamCount > 4096 || streamRva + streamCount * 12 > size) return null; + const dir = Buffer.alloc(streamCount * 12); + fs.readSync(fd, dir, 0, dir.length, streamRva); + const streams = new Map(); + for (let i = 0; i < streamCount; i++) { + const off = i * 12; + streams.set(dir.readUInt32LE(off), { + size: dir.readUInt32LE(off + 4), + rva: dir.readUInt32LE(off + 8), + }); + } + return { streams, timeDateStamp }; +} + +function p_readExceptionStream(fd, s, fileSize) { + if (!s || s.rva + 24 > fileSize) return null; + const buf = Buffer.alloc(Math.min(s.size, 168)); + fs.readSync(fd, buf, 0, buf.length, s.rva); + // MINIDUMP_EXCEPTION_STREAM: ThreadId(4) __align(4) then MINIDUMP_EXCEPTION + const threadId = buf.readUInt32LE(0); + const code = buf.readUInt32LE(8); + const flags = buf.readUInt32LE(12); + // ExceptionAddress is 8 bytes at offset 24 within the exception record + let address = 0n; + try { address = buf.readBigUInt64LE(24); } catch (_) { address = 0n; } + return { threadId, code, flags, address: '0x' + address.toString(16) }; +} + +/** Parse one minidump for cause + timing. Returns null if the file is not a readable minidump. */ +function readDump(file) { + let fd = null; + try { + const st = fs.statSync(file); + fd = fs.openSync(file, 'r'); + const parsed = p_readStreams(fd, st.size); + if (!parsed) return null; + const exc = p_readExceptionStream(fd, parsed.streams.get(STREAM_EXCEPTION), st.size); + const crashedAt = parsed.timeDateStamp ? new Date(parsed.timeDateStamp * 1000).toISOString() : null; + return { + file: path.basename(file), + bytes: st.size, + crashed_at: crashedAt || new Date(st.mtimeMs).toISOString(), + mtime_ms: st.mtimeMs, + has_exception_stream: !!exc, + exception_code: exc ? exc.code : null, + exception_name: exc ? (MAC_EXC[exc.code] || `code ${exc.code}`) : null, + exception_address: exc ? exc.address : null, + faulting_thread_id: exc ? exc.threadId : null, + has_system_info: parsed.streams.has(STREAM_SYSTEM_INFO), + has_misc_info: parsed.streams.has(STREAM_MISC_INFO), + }; + } catch (_) { + return null; + } finally { + if (fd !== null) { try { fs.closeSync(fd); } catch (_) {} } + } +} + +/** Every .dmp under a Crashpad dir, newest first. */ +function listDumps(crashpadDir) { + const out = []; + const walk = (d) => { + let entries = []; + try { entries = fs.readdirSync(d, { withFileTypes: true }); } catch (_) { return; } + for (const e of entries) { + const p = path.join(d, e.name); + if (e.isDirectory()) walk(p); + else if (/\.dmp$/i.test(e.name)) out.push(p); + } + }; + walk(crashpadDir); + return out.sort((a, b) => { + try { return fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs; } catch (_) { return 0; } + }); +} + +/** + * Dumps written since `sinceMs`, parsed. `sinceMs` is the previous boot's watermark, so a relaunch + * reports only what actually happened while the user was away, not the lifetime pile. + */ +function newDumpsSince(crashpadDir, sinceMs, limit = 10) { + const rows = []; + for (const f of listDumps(crashpadDir)) { + let mt = 0; + try { mt = fs.statSync(f).mtimeMs; } catch (_) { continue; } + if (mt <= sinceMs) break; + const parsed = readDump(f); + if (parsed) rows.push(parsed); + if (rows.length >= limit) break; + } + return rows; +} + +module.exports = { readDump, listDumps, newDumpsSince, MINIDUMP_MAGIC }; diff --git a/electron/crashDumpScan.test.js b/electron/crashDumpScan.test.js new file mode 100644 index 00000000..2f41df79 --- /dev/null +++ b/electron/crashDumpScan.test.js @@ -0,0 +1,110 @@ +'use strict'; +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { readDump, listDumps, newDumpsSince, MINIDUMP_MAGIC } = require('./crashDumpScan'); + +// Builds a minimal but REAL minidump: header + stream directory + exception stream. Synthetic +// fixtures are the only way to assert the unhappy paths (truncated, wrong magic) deterministically. +function makeDump(file, { code = 1, address = 0x10n, threadId = 7, ts = 1754400000 } = {}) { + const excRva = 32 + 12; // header + one directory entry + const exc = Buffer.alloc(168); + exc.writeUInt32LE(threadId, 0); + exc.writeUInt32LE(code, 8); + exc.writeUInt32LE(0, 12); + exc.writeBigUInt64LE(address, 24); + + const header = Buffer.alloc(32); + header.writeUInt32LE(MINIDUMP_MAGIC, 0); + header.writeUInt32LE(0xa793, 4); + header.writeUInt32LE(1, 8); // stream count + header.writeUInt32LE(32, 12); // stream directory rva + header.writeUInt32LE(ts, 20); + + const dir = Buffer.alloc(12); + dir.writeUInt32LE(6, 0); // ExceptionStream + dir.writeUInt32LE(exc.length, 4); + dir.writeUInt32LE(excRva, 8); + + fs.writeFileSync(file, Buffer.concat([header, dir, exc])); +} + +const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'crashscan-')); +let passed = 0; +function t(name, fn) { + try { fn(); passed++; console.log(' ok ' + name); } + catch (e) { console.log(' FAIL ' + name + ': ' + e.message); process.exitCode = 1; } +} + +t('reads exception code, address and thread from a real header', () => { + const f = path.join(tmp, 'a.dmp'); + makeDump(f, { code: 1, address: 0x10n, threadId: 42 }); + const r = readDump(f); + assert.strictEqual(r.exception_code, 1); + assert.strictEqual(r.exception_address, '0x10'); + assert.strictEqual(r.faulting_thread_id, 42); + assert.match(r.exception_name, /EXC_BAD_ACCESS/); +}); + +t('a null-pointer crash reports address 0x0, not a missing field', () => { + const f = path.join(tmp, 'null.dmp'); + makeDump(f, { code: 1, address: 0x0n }); + assert.strictEqual(readDump(f).exception_address, '0x0'); +}); + +t('crash time comes from the dump header, not the file mtime', () => { + const f = path.join(tmp, 'ts.dmp'); + makeDump(f, { ts: 1700000000 }); + assert.strictEqual(readDump(f).crashed_at, new Date(1700000000 * 1000).toISOString()); +}); + +t('a non-minidump file is refused rather than half-parsed', () => { + const f = path.join(tmp, 'junk.dmp'); + fs.writeFileSync(f, Buffer.from('not a minidump at all, really')); + assert.strictEqual(readDump(f), null); +}); + +t('a truncated dump does not throw', () => { + const f = path.join(tmp, 'trunc.dmp'); + makeDump(f); + const buf = fs.readFileSync(f).subarray(0, 20); + fs.writeFileSync(f, buf); + assert.strictEqual(readDump(f), null); +}); + +t('a missing file is refused', () => { + assert.strictEqual(readDump(path.join(tmp, 'nope.dmp')), null); +}); + +t('newDumpsSince reports only dumps newer than the watermark', () => { + const d = fs.mkdtempSync(path.join(os.tmpdir(), 'cp-')); + const older = path.join(d, 'old.dmp'); + const newer = path.join(d, 'new.dmp'); + makeDump(older); makeDump(newer); + const t0 = Date.now() - 60000; + fs.utimesSync(older, new Date(t0 - 60000), new Date(t0 - 60000)); + fs.utimesSync(newer, new Date(), new Date()); + const rows = newDumpsSince(d, t0); + assert.strictEqual(rows.length, 1, 'only the dump after the watermark counts'); + assert.strictEqual(rows[0].file, 'new.dmp'); +}); + +t('a watermark in the future reports nothing (no false crash storm)', () => { + const d = fs.mkdtempSync(path.join(os.tmpdir(), 'cp2-')); + makeDump(path.join(d, 'x.dmp')); + assert.strictEqual(newDumpsSince(d, Date.now() + 600000).length, 0); +}); + +t('listDumps walks nested Crashpad layout (completed/, pending/)', () => { + const d = fs.mkdtempSync(path.join(os.tmpdir(), 'cp3-')); + fs.mkdirSync(path.join(d, 'completed'), { recursive: true }); + makeDump(path.join(d, 'completed', 'deep.dmp')); + assert.strictEqual(listDumps(d).length, 1); +}); + +t('a missing Crashpad dir is empty, not a throw', () => { + assert.deepStrictEqual(listDumps(path.join(tmp, 'does-not-exist')), []); +}); + +console.log(`\n${passed} passed`); diff --git a/electron/main.js b/electron/main.js index 5da624af..2e30008f 100644 --- a/electron/main.js +++ b/electron/main.js @@ -312,6 +312,22 @@ function countCrashDumps() { } catch (_) { return -1; } } +// A native main-process crash runs none of our JS, so the app just vanishes and leaves a .dmp +// nobody reads. On the next boot we read the CAUSE out of any dump written since last time. +function newCrashDumps() { + try { + const scan = require('./crashDumpScan'); + const base = path.join(app.getPath('userData'), 'Crashpad'); + const markFile = path.join(app.getPath('userData'), 'crash-scan.json'); + let since = 0; + try { since = JSON.parse(fs.readFileSync(markFile, 'utf8')).last_scan_ms || 0; } catch (_) { since = 0; } + // First run has no watermark; reporting the whole historical pile would look like a crash storm. + const rows = since ? scan.newDumpsSince(base, since, 5) : []; + try { fs.writeFileSync(markFile, JSON.stringify({ last_scan_ms: Date.now() })); } catch (_) {} + return rows; + } catch (_) { return []; } +} + // Fleet self-report: POST a compact boot outcome to the LOCAL backend, which forwards it via the existing service client (opt-out honored). No PII. Fire-and-forget, guarded. function sendBootBeacon() { try { @@ -323,7 +339,7 @@ function sendBootBeacon() { props: { sha: bi.shortSha, channel: bi.channel, version: app.getVersion(), os: process.platform, arch: process.arch, - perf: _perfValues, preflight: _preflightInfo, preflight2: _preflightVerdict ? { verdict: _preflightVerdict.verdict, totalMs: _preflightVerdict.totalMs, names: (_preflightVerdict.results || []).map((r) => `${r.name}:${r.status}`) } : null, crash_dumps: countCrashDumps(), + perf: _perfValues, preflight: _preflightInfo, preflight2: _preflightVerdict ? { verdict: _preflightVerdict.verdict, totalMs: _preflightVerdict.totalMs, names: (_preflightVerdict.results || []).map((r) => `${r.name}:${r.status}`) } : null, crash_dumps: countCrashDumps(), new_crashes: newCrashDumps(), }, }); const req = http.request({