mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-29 04:54:49 +02:00
[eric] telemetry: a native crash reports its own cause on the next boot instead of leaving a file nobody reads
This commit is contained in:
@@ -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 };
|
||||
@@ -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`);
|
||||
+17
-1
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user