diff --git a/electron/main.js b/electron/main.js index adc1f7b9..5da624af 100644 --- a/electron/main.js +++ b/electron/main.js @@ -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'); diff --git a/electron/memorySensor.js b/electron/memorySensor.js new file mode 100644 index 00000000..88b7f572 --- /dev/null +++ b/electron/memorySensor.js @@ -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 }; diff --git a/electron/memorySensor.test.js b/electron/memorySensor.test.js new file mode 100644 index 00000000..d4708c62 --- /dev/null +++ b/electron/memorySensor.test.js @@ -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); +}); diff --git a/electron/preload.js b/electron/preload.js index b16e668b..a8011822 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -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); diff --git a/frontend/src/shared/uxSignals.ts b/frontend/src/shared/uxSignals.ts index 9a862060..5c9615d6 100644 --- a/frontend/src/shared/uxSignals.ts +++ b/frontend/src/shared/uxSignals.ts @@ -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) => void) => () => void } | undefined)?.onMemoryAlert?.((info) => { + report('process', 'memory_alert', info); + }); return () => { window.removeEventListener('click', onClick, true); offWedge?.(); offObserver(); + offMem?.(); }; }