Skip to content

Instantly share code, notes, and snippets.

@ara4n
Created November 27, 2025 08:06
Show Gist options
  • Select an option

  • Save ara4n/0a834d14f6e59f55b4df197647ea2fbc to your computer and use it in GitHub Desktop.

Select an option

Save ara4n/0a834d14f6e59f55b4df197647ea2fbc to your computer and use it in GitHub Desktop.
Poor man's profiling in Electron
diff --git a/src/electron-main.ts b/src/electron-main.ts
index 8ff217f..e9a33f3 100644
--- a/src/electron-main.ts
+++ b/src/electron-main.ts
@@ -21,6 +21,7 @@ import {
session,
protocol,
desktopCapturer,
+ WebContents,
} from "electron";
// eslint-disable-next-line n/file-extension-in-import
import * as Sentry from "@sentry/electron/main";
@@ -74,6 +75,212 @@ if (argv["help"]) {
app.exit();
}
+let heapMonitorInterval: NodeJS.Timeout | null = null;
+let lastSize: number = 0;
+const THRESHOLDS = [ 1000, 1300 ];
+//const THRESHOLDS = [900];
+//const THRESHOLDS = [ 50 ];
+let triggers = [...THRESHOLDS];
+const RESET_THRESHOLD = 800;
+let isRunning = false;
+
+async function setupHeapMonitoring(window: BrowserWindow): Promise<void> {
+ try {
+ // Attach Electron's built-in debugger
+ window.webContents.debugger.attach('1.3');
+
+ const dbugger = window.webContents.debugger;
+
+ // Enable heap profiler
+ await dbugger.sendCommand('HeapProfiler.enable');
+ await dbugger.sendCommand('Runtime.enable');
+
+ console.log('Heap monitoring enabled');
+ let lastRun = new Date();
+ let lastHeap = 0;
+ let count = 0;
+
+ heapMonitorInterval = setInterval(() => {
+ // we deliberately run this as a non-async function, to avoid there being an event loop tick
+ // resulting in overlapping intervals when the heap is thrashing.
+ if (isRunning) return;
+ isRunning = true;
+
+ let now;
+
+ // ...and then we have to run this async given it calls async stuff.
+ (async () => {
+ now = new Date();
+ try {
+ const { usedSize, totalSize } = await dbugger.sendCommand('Runtime.getHeapUsage') as {
+ usedSize: number;
+ totalSize: number;
+ };
+
+ const heapUsedMB = (usedSize / 1024 / 1024).toFixed(2);
+ const heapTotalMB = (totalSize / 1024 / 1024).toFixed(2);
+
+ if (now.getSeconds() != lastRun.getSeconds() || (usedSize - lastHeap) > 50 * 1024 * 1024) {
+ console.log(`${new Date().toISOString()}: Heap: ${heapUsedMB}MB / ${heapTotalMB}MB`);
+ }
+ lastHeap = usedSize;
+
+ if (usedSize / 1024 / 1024 < RESET_THRESHOLD) {
+ // reset our triggers
+ triggers = [...THRESHOLDS];
+ }
+
+ if (triggers.length > 0) {
+ let trigger = triggers[0];
+
+ if (usedSize / 1024 / 1024 > trigger) {
+ console.warn(`${new Date().toISOString()}: ⚠️ Heap ${heapUsedMB}MB / ${heapTotalMB}MB exceeded ${trigger}MB! Trying to GC...`);
+ await dbugger.sendCommand('HeapProfiler.collectGarbage');
+
+ {
+ const { usedSize, totalSize } = await dbugger.sendCommand('Runtime.getHeapUsage') as {
+ usedSize: number;
+ totalSize: number;
+ };
+ const heapUsedMB = (usedSize / 1024 / 1024).toFixed(2);
+ const heapTotalMB = (totalSize / 1024 / 1024).toFixed(2);
+ if (usedSize / 1024 / 1024 > trigger) {
+ console.warn(`${new Date().toISOString()}: ⚠️ Heap ${heapUsedMB}MB / ${heapTotalMB}MB still exceeded ${trigger}MB! Waiting for sampling...`);
+
+ // // for allocation profiling (potentially after a given logline):
+ //
+ // const chunkHandler = async (_event: any, method: string, params: any) => {
+ // if (method == 'Runtime.consoleAPICalled' &&
+ // params.args[0]?.value?.startsWith("FetchHttpApi: <-- GET https://matrix.org/_matrix/client/v3/rooms/!OGEhHVWSdvArJzumhm%3Amatrix.org/initialSync")
+ // ) {
+ // console.log("chunkHandler:", method, params);
+ // count++;
+ // if (count == 2) {
+ // console.warn(`${new Date().toISOString()}: Explosion imminent, sampling...`);
+ // // Start sampling heap profiler (allocation sampling)
+ // await dbugger.sendCommand('HeapProfiler.startSampling', {
+ // samplingInterval: 32768, // bytes between samples
+ // includeObjectsCollectedByMajorGC: false,
+ // includeObjectsCollectedByMinorGC: false
+ // });
+
+ // setTimeout(() => stopAllocationProfiling(window.webContents), 5000);
+ // }
+ // }
+ // };
+ // dbugger.on('message', chunkHandler);
+
+ //setTimeout(() => stopAllocationProfiling(window.webContents), 500);
+ //setTimeout(() => stopAllocationProfiling(window.webContents), 60 * 1000);
+
+ console.warn(`${new Date().toISOString()}: ⚠️ Heap ${heapUsedMB}MB / ${heapTotalMB}MB still exceeded ${trigger}MB! Snapshotting...`);
+ await takeHeapSnapshot(dbugger, heapUsedMB);
+ triggers.shift();
+ }
+ }
+ }
+ }
+ } catch (err) {
+ console.error('Error checking heap:', err);
+ }
+
+ lastRun = now;
+ isRunning = false;
+ })();
+ }, 100);
+
+ // Handle debugger detach
+ dbugger.on('detach', (_event, reason) => {
+ console.log(`Debugger detached: ${reason}`);
+ if (heapMonitorInterval) {
+ clearInterval(heapMonitorInterval);
+ heapMonitorInterval = null;
+ }
+ });
+
+ // Clean up on window close
+ window.on('closed', () => {
+ if (heapMonitorInterval) {
+ clearInterval(heapMonitorInterval);
+ heapMonitorInterval = null;
+ }
+ if (dbugger.isAttached()) {
+ dbugger.detach();
+ }
+ });
+
+ } catch (err) {
+ console.error('Failed to setup heap monitoring:', err);
+ }
+}
+
+async function stopAllocationProfiling(webContents: WebContents) {
+ try {
+ console.warn(`${new Date().toISOString()}: ⚠️ Trying to stop profiling...`);
+ const { profile } = await webContents.debugger.sendCommand('HeapProfiler.stopSampling');
+ console.warn(`${new Date().toISOString()}: ⚠️ Got profile...`);
+
+ // Save profile
+ const filename = path.join(app.getPath('userData'), `allocation-${Date.now()}.heapprofile`);
+
+ fs.writeFileSync(filename, JSON.stringify(profile, null, 2));
+ console.log(`Allocation profile saved to: ${filename}`);
+ } catch (err) {
+ console.error('Failed to stop profiling:', err);
+ }
+}
+
+async function takeHeapSnapshot(
+ dbugger: Electron.Debugger,
+ currentHeapMB: string
+): Promise<string> {
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
+ const filename = `heap-snapshot-${currentHeapMB}MB-${timestamp}.heapsnapshot`;
+ const filepath = path.join(app.getPath('userData'), filename);
+
+ console.log(`Saving heap snapshot to: ${filepath}`);
+
+ const writeStream = fs.createWriteStream(filepath);
+ let chunkCount = 0;
+
+ return new Promise((resolve, reject) => {
+ // Listen for heap snapshot chunks
+ const chunkHandler = (_event: any, method: string, params: any) => {
+ //console.log("chunkHandler:", method, params);
+ if (method === 'HeapProfiler.addHeapSnapshotChunk') {
+ writeStream.write(params.chunk);
+ chunkCount++;
+ } else if (method === 'HeapProfiler.reportHeapSnapshotProgress') {
+ if (params.finished) {
+ writeStream.end();
+ console.log(`✓ Heap snapshot saved: ${filepath} (${chunkCount} chunks)`);
+ dbugger.removeListener('message', chunkHandler);
+ resolve(filepath);
+ }
+ }
+ };
+
+ dbugger.on('message', chunkHandler);
+
+ // Start taking the snapshot
+ dbugger.sendCommand('HeapProfiler.takeHeapSnapshot', {
+ // reportProgress: true,
+ // captureNumericValue: true
+ }).catch((err) => {
+ console.error("failed to take heap snapshot", err);
+ dbugger.removeListener('message', chunkHandler);
+ writeStream.end();
+ reject(err);
+ });
+
+ writeStream.on('error', (err) => {
+ console.error("failed to write heap snapshot", err);
+ dbugger.removeListener('message', chunkHandler);
+ reject(err);
+ });
+ });
+}
+
const LocalConfigLocation = process.env.ELEMENT_DESKTOP_CONFIG_JSON ?? argv["config"];
const LocalConfigFilename = "config.json";
@@ -468,6 +675,12 @@ app.on("ready", async () => {
},
});
+ global.mainWindow.webContents.on('did-finish-load', () => {
+ if (global.mainWindow) {
+ setupHeapMonitoring(global.mainWindow);
+ }
+ });
+
global.mainWindow.setContentProtection(store.get("enableContentProtection"));
try {
@@ -592,6 +805,9 @@ app.on("ready", async () => {
});
app.on("window-all-closed", () => {
+ if (heapMonitorInterval) {
+ clearInterval(heapMonitorInterval);
+ }
app.quit();
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment