Files
electron-performance/src/monitor.ts
T
oxmc 8a8a824f3b Initial commit: tiered Chromium/V8 flags, GC/cache monitor, per-window memory helpers
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.
2026-07-26 03:10:19 -07:00

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,
};
}