[eric] telemetry: memory sensor reports cap crossings and leak-shaped growth, silent on healthy sessions

This commit is contained in:
ciregenz
2026-08-07 00:08:53 -07:00
parent 29f7bd5837
commit 7a1518df32
5 changed files with 110 additions and 0 deletions
+4
View File
@@ -1478,6 +1478,10 @@ function createWindow() {
// Frozen-but-not-crashed is the silent class no crash log sees; Chromium's own unresponsive
// signal costs nothing and the report fires from the renderer AFTER it recovers.
let wedgeStartedAt = 0;
try {
const { startMemorySensor } = require('./memorySensor');
startMemorySensor(app, () => mainWindow);
} catch (e) { console.warn('[diag] memory sensor unavailable:', e && e.message); }
mainWindow.webContents.on('unresponsive', () => {
wedgeStartedAt = Date.now();
console.error('[diag][main] renderer unresponsive');
+69
View File
@@ -0,0 +1,69 @@
// Memory/compute overload sensor: the quiet death nobody reports, where RSS climbs until macOS
// kills a renderer or the fans spin up. Idle-scheduled, unref'd, and it EMITS ONLY on threshold
// crossings, so a healthy session ships nothing at all.
'use strict';
const SAMPLE_MS = 60_000;
// Crossed once, reported once: a leak is a trend, not a per-minute alarm.
const TOTAL_MB_CAP = 3000;
const GROWTH_MB_PER_MIN = 40;
const GROWTH_WINDOW = 10;
let p_timer = null;
let p_history = [];
let p_capReported = false;
let p_growthReported = false;
function totalMb(metrics) {
let kb = 0;
for (const m of metrics) kb += (m.memory && m.memory.workingSetSize) || 0;
return Math.round(kb / 1024);
}
/** Least-squares slope in MB/min over the sample window; a straight climb is the leak signature. */
function slopeMbPerMin(history) {
const n = history.length;
if (n < 4) return 0;
const meanX = (n - 1) / 2;
const meanY = history.reduce((a, b) => a + b, 0) / n;
let num = 0;
let den = 0;
for (let i = 0; i < n; i += 1) {
num += (i - meanX) * (history[i] - meanY);
den += (i - meanX) * (i - meanX);
}
return den === 0 ? 0 : num / den;
}
function startMemorySensor(app, getMainWindow) {
if (p_timer) return;
p_timer = setInterval(() => {
let metrics;
try { metrics = app.getAppMetrics(); } catch (_) { return; }
const mb = totalMb(metrics);
p_history.push(mb);
if (p_history.length > GROWTH_WINDOW) p_history.shift();
const slope = slopeMbPerMin(p_history);
const send = (reason, extra) => {
const win = getMainWindow();
if (win && !win.isDestroyed()) {
try { win.webContents.send('diag:memory', { reason, total_mb: mb, procs: metrics.length, slope_mb_min: Math.round(slope), ...extra }); } catch (_) {}
}
console.error('[diag][memory]', reason, 'total_mb=' + mb, 'procs=' + metrics.length, 'slope=' + Math.round(slope));
};
if (!p_capReported && mb >= TOTAL_MB_CAP) { p_capReported = true; send('cap_crossed', {}); }
if (p_capReported && mb < TOTAL_MB_CAP * 0.8) p_capReported = false;
if (!p_growthReported && p_history.length >= GROWTH_WINDOW && slope >= GROWTH_MB_PER_MIN) {
p_growthReported = true;
send('growth_suspect', { window_min: GROWTH_WINDOW });
}
}, SAMPLE_MS);
p_timer.unref?.();
}
function stopMemorySensor() {
if (p_timer) { clearInterval(p_timer); p_timer = null; }
p_history = [];
}
module.exports = { startMemorySensor, stopMemorySensor, slopeMbPerMin, totalMb, TOTAL_MB_CAP, GROWTH_MB_PER_MIN };
+27
View File
@@ -0,0 +1,27 @@
// The leak detector's math, pinned: a flat session reports nothing, a straight climb is caught.
const assert = require('node:assert/strict');
const { test } = require('node:test');
const { slopeMbPerMin, totalMb, GROWTH_MB_PER_MIN } = require('./memorySensor');
test('a flat memory profile has no slope, so nothing is ever reported', () => {
assert.ok(Math.abs(slopeMbPerMin([900, 905, 898, 902, 900, 903, 899, 901, 900, 902])) < 1, 'jitter is not a trend');
});
test('a steady climb is caught above the growth threshold', () => {
const climbing = Array.from({ length: 10 }, (_, i) => 800 + i * 60);
assert.ok(slopeMbPerMin(climbing) >= GROWTH_MB_PER_MIN, 'a 60MB/min climb must exceed the threshold');
});
test('a single spike is not a leak', () => {
const spike = [900, 900, 900, 900, 2000, 900, 900, 900, 900, 900];
assert.ok(slopeMbPerMin(spike) < GROWTH_MB_PER_MIN, 'one spike must not read as a trend');
});
test('too few samples never guesses', () => {
assert.equal(slopeMbPerMin([900, 2000, 3000]), 0);
});
test('totals sum every process in MB', () => {
assert.equal(totalMb([{ memory: { workingSetSize: 1024 * 500 } }, { memory: { workingSetSize: 1024 * 300 } }]), 800);
assert.equal(totalMb([{}, { memory: {} }]), 0);
});
+6
View File
@@ -152,6 +152,12 @@ contextBridge.exposeInMainWorld('openswarm', {
openApplication: (name) => ipcRenderer.invoke('open-application', name),
getUpdateStatus: () => ipcRenderer.invoke('get-update-status'),
getCrashRecoveryInfo: () => ipcRenderer.invoke('get-crash-recovery-info'),
// Threshold-crossing memory alerts (cap or leak-shaped growth); silent on a healthy session.
onMemoryAlert: (cb) => {
const listener = (_e, info) => cb(info);
ipcRenderer.on('diag:memory', listener);
return () => ipcRenderer.removeListener('diag:memory', listener);
},
// Fires after the renderer RECOVERS from a Chromium-detected freeze, with how long it was wedged.
onWedge: (cb) => {
const listener = (_e, info) => cb(info);
+4
View File
@@ -67,9 +67,13 @@ export function installUxSignals(): () => void {
report('process', 'wedge_recovered', { wedge_ms: info?.ms ?? -1, source: 'chromium' });
});
const offObserver = installWedgeObserver();
const offMem = (bridge.openswarm as { onMemoryAlert?: (cb: (i: Record<string, number | string>) => void) => () => void } | undefined)?.onMemoryAlert?.((info) => {
report('process', 'memory_alert', info);
});
return () => {
window.removeEventListener('click', onClick, true);
offWedge?.();
offObserver();
offMem?.();
};
}