mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
[eric] electron: memory pressure now sheds weight (thumbnails pause, caches drop) instead of growing until macOS kills the app (ENG-320)
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user