From 7d9fe3dc9419984cbc43822e545dff5366ebcf9b Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sun, 16 Aug 2026 00:31:54 -0700 Subject: [PATCH] [eric] electron: memory pressure now sheds weight (thumbnails pause, caches drop) instead of growing until macOS kills the app (ENG-320) --- backend/apps/help/changelog.py | 1 + electron/main.js | 10 ++++++ electron/memoryRelief.js | 59 ++++++++++++++++++++++++++++++++ electron/memoryRelief.test.js | 62 ++++++++++++++++++++++++++++++++++ electron/memorySensor.js | 2 ++ 5 files changed, 134 insertions(+) create mode 100644 electron/memoryRelief.js create mode 100644 electron/memoryRelief.test.js diff --git a/backend/apps/help/changelog.py b/backend/apps/help/changelog.py index 9934af29..2f700896 100644 --- a/backend/apps/help/changelog.py +++ b/backend/apps/help/changelog.py @@ -87,6 +87,7 @@ P_RELEASES: List[ReleaseNote] = [ "Brand-new apps stop dying at birth on busy machines. The first boot installs dependencies, which can take minutes; a fixed 60-second limit was killing exactly those boots.", "An agent that sent work to a browser can no longer hang forever when the finished result gets lost on the way back; it notices, recovers, and redoes the step.", "The app opens seconds faster on machines with a crowded system temp folder. File uploads moved into OpenSwarm's own folder, so startup no longer pays a toll that grew with years of temp-file clutter.", + "Heavy sessions no longer vanish without a trace. When memory climbs past the safe line the app now sheds weight itself: preview thumbnails pause and refetchable caches drop, instead of growing until the operating system kills it mid-task.", ], ), ReleaseNote( diff --git a/electron/main.js b/electron/main.js index ff100c5c..68bd39db 100644 --- a/electron/main.js +++ b/electron/main.js @@ -170,6 +170,13 @@ const http = require('http'); const affiliateTracking = require('./affiliateTracking'); const cdpRoutes = require('./cdp-routes'); const { releaseAgentPointerLock } = require('./releaseAgentPointerLock'); +const memoryRelief = require('./memoryRelief'); +memoryRelief.initMemoryRelief({ + clearCaches: async () => { + try { await session.defaultSession.clearCache(); } catch (_) {} + try { await session.fromPartition(BROWSER_PARTITION).clearCache(); } catch (_) {} + }, +}); const workflowsLifecycle = require('./workflowsLifecycle'); // Squirrel makes the APP create its own shortcuts: on --squirrel-install it must @@ -4022,6 +4029,9 @@ ipcMain.handle('capture-page', async (event, rect) => { // here too: skip a gone/crashed/loading sender and never encode an empty image, // returning null so the dashboard keeps its last good preview instead of dying. try { + // Under memory pressure a composite is exactly the allocation that gets the app SIGKILLed with + // no trace (observed live); callers already keep the last preview on null (ENG-320). + if (require('./memoryRelief').underMemoryPressure()) return null; const wc = event.sender; if (!wc || wc.isDestroyed() || wc.isCrashed() || wc.isLoading()) return null; const image = await wc.capturePage(rect || undefined); diff --git a/electron/memoryRelief.js b/electron/memoryRelief.js new file mode 100644 index 00000000..c3fd669a --- /dev/null +++ b/electron/memoryRelief.js @@ -0,0 +1,59 @@ +// Memory pressure stops being a telemetry event and starts being ACTED on (ENG-320). +// +// The sensor next door has watched sessions climb to 3-4GB for weeks and only ever reported it: +// 18 of 57 active users tripped it in one day, and the observed end state is macOS killing the +// app with NO trace at all (one such death was caught live mid-capturePage under heavy webviews). +// A silent kill costs the whole session; everything this module gives up under pressure is +// re-fetchable or cosmetic, which is the whole trade. +// +// Levers, in order of engagement when the cap is crossed: +// 1. thumbnails stop: capture-page composites GPU surfaces (big transient allocations, and the +// last logged act before the observed silent death); callers already treat +// null as "keep the last preview", so this degrades invisibly +// 2. HTTP caches drop: refetchable bytes, same clear the ENG-247 boot sweep does +// +// Hysteresis mirrors the sensor: enter at the cap, exit below 80% of it, one action set per +// episode so a session hovering at the line cannot thrash the caches. +'use strict'; + +let p_underPressure = false; +let p_episodes = 0; +let p_actions = null; + +/** Wire the action set once at boot; separated for tests and so this file stays require-clean. */ +function initMemoryRelief(actions) { + p_actions = actions || null; +} + +/** Called by the sensor with every sample. Returns true when the pressure state CHANGED. */ +function updateMemoryPressure(totalMb, capMb) { + if (!p_underPressure && totalMb >= capMb) { + p_underPressure = true; + p_episodes += 1; + try { console.error('[memory-relief] entering pressure mode at', totalMb, 'MB (episode', p_episodes + ')'); } catch (_) {} + if (p_actions) { + try { p_actions.clearCaches && p_actions.clearCaches(); } catch (_) {} + } + return true; + } + if (p_underPressure && totalMb < capMb * 0.8) { + p_underPressure = false; + try { console.error('[memory-relief] pressure cleared at', totalMb, 'MB'); } catch (_) {} + return true; + } + return false; +} + +/** The capture-page gate: while under pressure, thumbnails yield rather than composite. */ +function underMemoryPressure() { + return p_underPressure; +} + +/** Test hook. */ +function resetMemoryRelief() { + p_underPressure = false; + p_episodes = 0; + p_actions = null; +} + +module.exports = { initMemoryRelief, updateMemoryPressure, underMemoryPressure, resetMemoryRelief }; diff --git a/electron/memoryRelief.test.js b/electron/memoryRelief.test.js new file mode 100644 index 00000000..2b7c4819 --- /dev/null +++ b/electron/memoryRelief.test.js @@ -0,0 +1,62 @@ +// Run: node --test electron/memoryRelief.test.js +// +// ENG-320: memory pressure was detected and only reported; the observed end state is macOS killing +// the app with no trace, once caught live mid-capturePage. These pin the relief contract: enter at +// the cap, act ONCE per episode, gate thumbnails while under pressure, exit below 80% with +// hysteresis so a session hovering at the line cannot thrash caches, and the wire into the sensor +// and capture-page actually exists (an unconsulted flag is the ENG-284 anti-pattern). +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const relief = require('./memoryRelief'); + +test.beforeEach(() => relief.resetMemoryRelief()); + +test('crossing the cap enters pressure and fires the action set once', () => { + let cleared = 0; + relief.initMemoryRelief({ clearCaches: () => { cleared += 1; } }); + assert.equal(relief.updateMemoryPressure(2999, 3000), false); + assert.equal(relief.underMemoryPressure(), false); + assert.equal(relief.updateMemoryPressure(3000, 3000), true); + assert.equal(relief.underMemoryPressure(), true); + assert.equal(cleared, 1); + // Staying above the cap must not thrash: one action set per episode. + relief.updateMemoryPressure(3400, 3000); + relief.updateMemoryPressure(3600, 3000); + assert.equal(cleared, 1, 'clearing caches per sample would churn the disk the way ENG-247 did'); +}); + +test('exit needs 80% hysteresis, and a new episode acts again', () => { + let cleared = 0; + relief.initMemoryRelief({ clearCaches: () => { cleared += 1; } }); + relief.updateMemoryPressure(3200, 3000); + assert.equal(relief.updateMemoryPressure(2500, 3000), false, '2500 is above 80% of 3000; still under pressure'); + assert.equal(relief.underMemoryPressure(), true); + assert.equal(relief.updateMemoryPressure(2300, 3000), true, 'below 2400 clears'); + assert.equal(relief.underMemoryPressure(), false); + relief.updateMemoryPressure(3100, 3000); + assert.equal(cleared, 2, 'a genuine second episode earns a second cache clear'); +}); + +test('a throwing action never breaks the sensor path', () => { + relief.initMemoryRelief({ clearCaches: () => { throw new Error('disk gone'); } }); + assert.doesNotThrow(() => relief.updateMemoryPressure(3200, 3000)); + assert.equal(relief.underMemoryPressure(), true, 'the STATE must flip even when actions fail'); +}); + +test('no actions wired is safe (early boot)', () => { + assert.doesNotThrow(() => relief.updateMemoryPressure(9000, 3000)); + assert.equal(relief.underMemoryPressure(), true); +}); + +test('the sensor feeds it and capture-page consults it', () => { + const sensor = fs.readFileSync(path.join(__dirname, 'memorySensor.js'), 'utf8'); + assert.match(sensor, /updateMemoryPressure\(mb, TOTAL_MB_CAP\)/, 'unwired relief is telemetry with extra steps'); + const main = fs.readFileSync(path.join(__dirname, 'main.js'), 'utf8'); + const start = main.indexOf("ipcMain.handle('capture-page'"); + const beforeComposite = main.slice(start, main.indexOf('capturePage(', start)); + assert.match(beforeComposite, /underMemoryPressure\(\)\) return null/, 'the gate must run BEFORE the composite, the observed death site'); + assert.match(main, /initMemoryRelief\(/, 'actions must be wired at boot'); +}); diff --git a/electron/memorySensor.js b/electron/memorySensor.js index d0402406..11d8edd5 100644 --- a/electron/memorySensor.js +++ b/electron/memorySensor.js @@ -43,6 +43,8 @@ function startMemorySensor(app, getMainWindow) { let metrics; try { metrics = app.getAppMetrics(); } catch (_) { return; } const mb = totalMb(metrics); + // Detection without action is how sessions climbed to 4GB and died silently; relief acts on the same sample. + try { require('./memoryRelief').updateMemoryPressure(mb, TOTAL_MB_CAP); } catch (_) {} p_history.push(mb); if (p_history.length > GROWTH_WINDOW) p_history.shift(); const slope = slopeMbPerMin(p_history);