Extracted and generalized from hand-tuned memory optimizations in an Electron app template: memory/GPU/Raspberry-Pi-tiered command-line flags, a runtime GC + total- process memory monitor with pressure-triggered deep clean, and per-window hide/ minimize memory helpers.
237 lines
8.3 KiB
TypeScript
237 lines
8.3 KiB
TypeScript
import { getSystemInfo } from './detect';
|
|
import { enableMainProcessGc } from './gc';
|
|
import type { ElectronAppLike, Logger, MemoryMonitorHandle, MemoryMonitorOptions, SystemInfo, TotalMemoryUsage } from './types';
|
|
|
|
const defaultLogger: Logger = {
|
|
log: () => {},
|
|
debug: () => {},
|
|
error: (...args: unknown[]) => console.error(...args),
|
|
};
|
|
|
|
const DEFAULT_DEEP_CLEAN_STORAGES = ['serviceworkers', 'cachestorage', 'shadercache'];
|
|
|
|
function defaultIntervals(system: SystemInfo) {
|
|
const isOldPi = system.pi.isRaspberryPi && system.pi.generation <= 3;
|
|
const isPi4 = system.pi.isRaspberryPi && system.pi.generation === 4;
|
|
|
|
return {
|
|
memoryCheckIntervalMs: isOldPi ? 3000 : 5000,
|
|
cacheClearIntervalMs: isOldPi ? 300_000 : 600_000,
|
|
highMemoryThresholdPercent: isOldPi ? 75 : 85,
|
|
gcCooldownMs: isOldPi ? 5000 : 10_000,
|
|
gcIntervalMs: isOldPi ? 10_000 : isPi4 ? 20_000 : system.memoryTier === 'low' ? 15_000 : 30_000,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Rough tiered defaults for "this app is using too much total memory". Deliberately
|
|
* generous — these gate an invasive action (wiping service worker/cache storage across
|
|
* every window), so false positives are more costly than firing a bit late.
|
|
*/
|
|
function defaultHighTotalMemoryThresholdMB(system: SystemInfo): number {
|
|
if (system.pi.isRaspberryPi) {
|
|
if (system.pi.generation <= 3) {
|
|
return system.pi.memoryTier === 'verylow' ? 200 : system.pi.memoryTier === 'low' ? 400 : 700;
|
|
}
|
|
return system.pi.generation === 4 ? 1200 : 2000;
|
|
}
|
|
return system.memoryTier === 'low' ? 800 : system.memoryTier === 'medium' ? 1500 : 3000;
|
|
}
|
|
|
|
function getMemoryUsage() {
|
|
const mem = process.memoryUsage();
|
|
const heapUsedMB = Math.round(mem.heapUsed / 1024 / 1024);
|
|
const heapTotalMB = Math.round(mem.heapTotal / 1024 / 1024);
|
|
return {
|
|
heapUsedMB,
|
|
heapTotalMB,
|
|
rssMB: Math.round(mem.rss / 1024 / 1024),
|
|
heapUsedPercent: (mem.heapUsed / mem.heapTotal) * 100,
|
|
};
|
|
}
|
|
|
|
function getTotalMemoryUsage(app: ElectronAppLike): TotalMemoryUsage {
|
|
const byType: Record<string, number> = {};
|
|
let totalKB = 0;
|
|
for (const metric of app.getAppMetrics()) {
|
|
const kb = metric.memory?.workingSetSize ?? 0;
|
|
totalKB += kb;
|
|
byType[metric.type] = (byType[metric.type] ?? 0) + Math.round(kb / 1024);
|
|
}
|
|
return { totalRssMB: Math.round(totalKB / 1024), byType };
|
|
}
|
|
|
|
/**
|
|
* Requires Electron-like `app`/`BrowserWindow`. Kept as loosely-typed params (rather
|
|
* than importing 'electron') so this package doesn't need electron as a hard
|
|
* build-time dependency beyond the peerDependency declaration.
|
|
*/
|
|
interface BrowserWindowLike {
|
|
isDestroyed(): boolean;
|
|
webContents: {
|
|
session: {
|
|
clearCache(): Promise<void>;
|
|
clearStorageData(options?: { storages?: string[] }): Promise<void>;
|
|
};
|
|
};
|
|
}
|
|
interface BrowserWindowCtorLike {
|
|
getAllWindows(): BrowserWindowLike[];
|
|
}
|
|
|
|
export interface ElectronHandles {
|
|
app: ElectronAppLike;
|
|
BrowserWindow?: BrowserWindowCtorLike;
|
|
}
|
|
|
|
/**
|
|
* Starts the runtime memory-management loop: periodic unconditional GC, pressure-triggered
|
|
* GC when main's own heap usage crosses a threshold, periodic session cache clearing, and
|
|
* a pressure-triggered "deep clean" (cache + service-worker/cache-storage wipe + GC) when
|
|
* *total* RSS across every process — not just main's heap — crosses an absolute threshold.
|
|
* That total-memory check matters: for a webview/browser-style app almost all memory lives
|
|
* in renderer processes, which process.memoryUsage() can't see at all.
|
|
* Call after app.whenReady(). BrowserWindow is only needed if clearCache/deepClean stay
|
|
* enabled (both default on).
|
|
*/
|
|
export function startMemoryMonitor(electron: ElectronHandles, options: MemoryMonitorOptions = {}): MemoryMonitorHandle {
|
|
const { app, BrowserWindow } = electron;
|
|
const {
|
|
enabled = true,
|
|
clearCache = true,
|
|
exposeGc = true,
|
|
deepClean: deepCleanEnabled = true,
|
|
deepCleanStorages = DEFAULT_DEEP_CLEAN_STORAGES,
|
|
logger = defaultLogger,
|
|
system = getSystemInfo(),
|
|
} = options;
|
|
|
|
const defaults = defaultIntervals(system);
|
|
const memoryCheckIntervalMs = options.memoryCheckIntervalMs ?? defaults.memoryCheckIntervalMs;
|
|
const cacheClearIntervalMs = options.cacheClearIntervalMs ?? defaults.cacheClearIntervalMs;
|
|
const highMemoryThresholdPercent = options.highMemoryThresholdPercent ?? defaults.highMemoryThresholdPercent;
|
|
const gcCooldownMs = options.gcCooldownMs ?? defaults.gcCooldownMs;
|
|
const gcIntervalMs = options.gcIntervalMs ?? defaults.gcIntervalMs;
|
|
const highTotalMemoryThresholdMB = options.highTotalMemoryThresholdMB ?? defaultHighTotalMemoryThresholdMB(system);
|
|
const deepCleanCooldownMs = options.deepCleanCooldownMs ?? Math.max(gcCooldownMs * 4, 60_000);
|
|
|
|
const timers: NodeJS.Timeout[] = [];
|
|
let lastGcTime = Date.now();
|
|
let lastDeepCleanTime = 0;
|
|
|
|
const forceGc = () => {
|
|
if (typeof global.gc !== 'function') return;
|
|
const before = getMemoryUsage();
|
|
global.gc();
|
|
lastGcTime = Date.now();
|
|
const after = getMemoryUsage();
|
|
logger.log(`GC: ${before.heapUsedMB}MB -> ${after.heapUsedMB}MB (freed ${before.heapUsedMB - after.heapUsedMB}MB)`);
|
|
};
|
|
|
|
const deepClean = async () => {
|
|
if (!BrowserWindow) {
|
|
logger.error('deepClean: no BrowserWindow class was provided; nothing to clean.');
|
|
return;
|
|
}
|
|
for (const win of BrowserWindow.getAllWindows()) {
|
|
if (!win || win.isDestroyed()) continue;
|
|
try {
|
|
await win.webContents.session.clearCache();
|
|
await win.webContents.session.clearStorageData({ storages: deepCleanStorages });
|
|
} catch (err) {
|
|
logger.error('Deep clean failed for a window:', err);
|
|
}
|
|
}
|
|
logger.log(`Deep clean complete (storages: ${deepCleanStorages.join(', ')})`);
|
|
forceGc();
|
|
};
|
|
|
|
if (!enabled) {
|
|
return {
|
|
stop: () => {},
|
|
forceGc,
|
|
getMemoryUsage,
|
|
getTotalMemoryUsage: () => getTotalMemoryUsage(app),
|
|
deepClean,
|
|
};
|
|
}
|
|
|
|
if (exposeGc) {
|
|
enableMainProcessGc();
|
|
}
|
|
if (typeof global.gc !== 'function') {
|
|
logger.log(
|
|
"global.gc is unavailable and could not be self-enabled; periodic/pressure GC will no-op. " +
|
|
'This usually means V8 flag manipulation is locked down in this environment.'
|
|
);
|
|
}
|
|
|
|
if (gcIntervalMs && gcIntervalMs > 0) {
|
|
logger.log(`Periodic GC enabled (interval: ${gcIntervalMs}ms)`);
|
|
timers.push(setInterval(forceGc, gcIntervalMs));
|
|
}
|
|
|
|
timers.push(
|
|
setInterval(() => {
|
|
const usage = getMemoryUsage();
|
|
if (usage.heapUsedPercent > 70) {
|
|
logger.debug(`Memory usage: ${Math.round(usage.heapUsedPercent)}% heap, ${usage.rssMB}MB RSS`);
|
|
}
|
|
if (
|
|
usage.heapUsedPercent > highMemoryThresholdPercent &&
|
|
Date.now() - lastGcTime > gcCooldownMs &&
|
|
typeof global.gc === 'function'
|
|
) {
|
|
logger.log(`High memory pressure detected (${Math.round(usage.heapUsedPercent)}%), forcing GC`);
|
|
forceGc();
|
|
}
|
|
|
|
if (deepCleanEnabled) {
|
|
const total = getTotalMemoryUsage(app);
|
|
if (total.totalRssMB > highTotalMemoryThresholdMB && Date.now() - lastDeepCleanTime > deepCleanCooldownMs) {
|
|
logger.log(`High total memory detected (${total.totalRssMB}MB across all processes), running deep clean`);
|
|
lastDeepCleanTime = Date.now();
|
|
deepClean();
|
|
}
|
|
}
|
|
}, memoryCheckIntervalMs)
|
|
);
|
|
|
|
if (options.deepCleanIntervalMs && options.deepCleanIntervalMs > 0) {
|
|
logger.log(`Periodic deep clean enabled (interval: ${options.deepCleanIntervalMs}ms)`);
|
|
timers.push(
|
|
setInterval(() => {
|
|
lastDeepCleanTime = Date.now();
|
|
deepClean();
|
|
}, options.deepCleanIntervalMs)
|
|
);
|
|
}
|
|
|
|
if (clearCache) {
|
|
if (!BrowserWindow) {
|
|
logger.error('startMemoryMonitor: clearCache is enabled but no BrowserWindow class was provided; skipping cache clearing.');
|
|
} else {
|
|
timers.push(
|
|
setInterval(() => {
|
|
for (const win of BrowserWindow.getAllWindows()) {
|
|
if (win && !win.isDestroyed()) {
|
|
win.webContents.session
|
|
.clearCache()
|
|
.then(() => logger.debug('Cleared browser cache'))
|
|
.catch((err: unknown) => logger.error('Failed to clear cache:', err));
|
|
}
|
|
}
|
|
}, cacheClearIntervalMs)
|
|
);
|
|
}
|
|
}
|
|
|
|
return {
|
|
stop: () => timers.forEach(clearInterval),
|
|
forceGc,
|
|
getMemoryUsage,
|
|
getTotalMemoryUsage: () => getTotalMemoryUsage(app),
|
|
deepClean,
|
|
};
|
|
}
|