import { BrowserWindow } from "electron"; import log from "electron-log/main"; import fs from "fs"; import os from "os"; import path from "path"; import Store from "../main/store/store"; /** * Human-readable memory/cpu/resource snapshot. */ export type MemoryUsageStats = { timestamp: string; label?: string; uptimeSeconds: number; pid: number; memory: { rss: number; heapTotal: number; heapUsed: number; external: number; arrayBuffers?: number; }; memoryPretty: { rss: string; heapTotal: string; heapUsed: string; external: string; arrayBuffers?: string; }; os: { totalMem: number; freeMem: number; freeMemPercent: number; }; cpuUsage?: NodeJS.CpuUsage; resourceUsage?: NodeJS.ResourceUsage; heapSpaces?: Array; heapSnapshotFile?: string; custom?: Record; }; // (merged into top import) /** * Options for dumpMemoryStats. */ export type DumpOptions = { /** * Call global.gc() before sampling if available (requires node run with --expose-gc). */ runGc?: boolean; /** * Optional label to include in the returned snapshot. */ label?: string; includeHeapSpaces?: boolean; writeHeapSnapshot?: boolean; heapSnapshotDir?: string; }; /** * Convert bytes to a compact human readable string. */ function formatBytes(bytes: number): string { if (!isFinite(bytes)) return String(bytes); const units = ["B", "KB", "MB", "GB", "TB"]; let i = 0; let val = bytes; while (val >= 1024 && i < units.length - 1) { val /= 1024; i++; } return `${val.toFixed(2)} ${units[i]}`; } /** * Asynchronously produce a memory / cpu / os snapshot. * * Example: * const stats = await dumpMemoryStats({ runGc: true, label: 'before-heavy-task' }); */ export async function dumpMemoryStats( options: DumpOptions = {}, ): Promise { const { runGc = false, label, includeHeapSpaces = true, writeHeapSnapshot = true, heapSnapshotDir, } = options; // Allow GC if requested and available to get a cleaner snapshot if (runGc && typeof (global as any).gc === "function") { try { (global as any).gc(); } catch { // ignore GC errors } } // Let the event loop settle a tick so GC can complete if run await new Promise((resolve) => setImmediate(resolve)); const mem = process.memoryUsage(); const totalMem = os.totalmem(); const freeMem = os.freemem(); const stats: MemoryUsageStats = { timestamp: new Date().toISOString(), label, uptimeSeconds: Math.floor(process.uptime()), pid: process.pid, memory: { rss: mem.rss, heapTotal: mem.heapTotal, heapUsed: mem.heapUsed, external: mem.external, arrayBuffers: mem.arrayBuffers, }, memoryPretty: { rss: formatBytes(mem.rss), heapTotal: formatBytes(mem.heapTotal), heapUsed: formatBytes(mem.heapUsed), external: formatBytes(mem.external), arrayBuffers: mem.arrayBuffers !== undefined ? formatBytes(mem.arrayBuffers) : undefined, }, os: { totalMem, freeMem, freeMemPercent: Math.round((freeMem / totalMem) * 10000) / 100, }, cpuUsage: process.cpuUsage ? process.cpuUsage() : undefined, resourceUsage: typeof process.resourceUsage === "function" ? process.resourceUsage() : undefined, custom: { numBrowserWindows: BrowserWindow.getAllWindows().length, }, }; if (includeHeapSpaces) { try { // eslint-disable-next-line @typescript-eslint/no-var-requires const v8: typeof import("v8") = require("v8"); if (typeof v8.getHeapSpaceStatistics === "function") { stats.heapSpaces = v8.getHeapSpaceStatistics(); } } catch (err) { log.warn("Failed to get heap space stats", err); } } if (writeHeapSnapshot) { try { if (!runGc && typeof (global as any).gc === "function") { try { (global as any).gc(); } catch { /* ignore */ } } // eslint-disable-next-line @typescript-eslint/no-var-requires const v8: typeof import("v8") = require("v8"); if (typeof v8.writeHeapSnapshot === "function") { const baseDir = heapSnapshotDir || path.dirname(log.transports.file.getFile().path); const dir = path.join(baseDir, "heap-snapshots"); fs.mkdirSync(dir, { recursive: true }); const fileName = `heap-${Date.now()}-${process.pid}.heapsnapshot`; const filePath = path.join(dir, fileName); const snapshotPath = v8.writeHeapSnapshot(filePath); stats.heapSnapshotFile = snapshotPath; } else { log.warn("v8.writeHeapSnapshot not available"); } } catch (err) { log.warn("Failed to write heap snapshot", err); } } return stats; } const memLogger = log.create({ logId: "mem-stat" }); memLogger.transports.file.resolvePathFn = () => { const filePath = path.join( path.dirname(log.transports.file.getFile().path), "memory-stats.log", ); return filePath; }; // Configure memory logger format to include process ID memLogger.transports.file.format = "[{y}-{m}-{d} {h}:{i}:{s}.{ms}] [{level}] [PID:{processId}] {text}"; memLogger.transports.console.format = "[{y}-{m}-{d} {h}:{i}:{s}.{ms}] [{level}] [PID:{processId}] {text}"; export async function dumpMemoryStatsToFile() { try { const stats = await dumpMemoryStats({ includeHeapSpaces: false }); memLogger.debug("[MemStat]:", stats); } catch (error) { log.warn("Unexpected error while writing memory stats log", error); } } function ongoingMemoryDump() { console.log( `Memory logging set to ${Store.get("settings.enableMemDebug")}. Log file at ${memLogger.transports.file.getFile().path}`, ); setInterval( async () => { // Also write each snapshot to a dedicated memory stats log file as JSON lines. try { const loggingEnabled = Store.get("settings.enableMemDebug"); log.debug( "Checking if memory stats logging is enabled.", loggingEnabled, ); if (loggingEnabled) { // Enforce heap snapshot folder size limit (< 1GB) before writing a new snapshot. const MAX_DIR_BYTES = 5 * 1024 * 1024 * 1024; // 5GB const TARGET_REDUCED_BYTES = Math.floor(MAX_DIR_BYTES * 0.9); // prune down to 90% const baseDir = path.dirname(log.transports.file.getFile().path); const heapDir = path.join(baseDir, "heap-snapshots"); try { fs.mkdirSync(heapDir, { recursive: true }); const files = fs .readdirSync(heapDir) .filter((f) => f.endsWith(".heapsnapshot")); let totalSize = 0; const fileStats: Array<{ file: string; size: number; mtimeMs: number; }> = []; for (const file of files) { try { const stat = fs.statSync(path.join(heapDir, file)); if (stat.isFile()) { totalSize += stat.size; fileStats.push({ file, size: stat.size, mtimeMs: stat.mtimeMs, }); } } catch (e) { log.warn("Failed to stat heap snapshot file", file, e); } } if (totalSize > MAX_DIR_BYTES) { // Sort oldest first and delete until below TARGET_REDUCED_BYTES. fileStats.sort((a, b) => a.mtimeMs - b.mtimeMs); let bytesAfter = totalSize; for (const info of fileStats) { if (bytesAfter <= TARGET_REDUCED_BYTES) break; try { fs.unlinkSync(path.join(heapDir, info.file)); bytesAfter -= info.size; log.warn( `Pruned heap snapshot '${info.file}' (${formatBytes(info.size)}) to reduce directory size. New size: ${formatBytes(bytesAfter)}.`, ); } catch (errDel) { log.warn( "Failed to delete heap snapshot file", info.file, errDel, ); } } if (bytesAfter > MAX_DIR_BYTES) { // Still above hard cap; skip writing a new snapshot this cycle. log.warn( `Heap snapshot directory still above hard cap (${formatBytes(bytesAfter)} > ${formatBytes(MAX_DIR_BYTES)}). Skipping new heap snapshot this cycle.`, ); const stats = await dumpMemoryStats({ includeHeapSpaces: false, writeHeapSnapshot: false, }); memLogger.debug("[MemStat]:", stats); return; // skip remainder; we already logged stats without snapshot. } } } catch (dirErr) { log.warn( "Unexpected error while enforcing heap snapshot directory size limit", dirErr, ); // Continue; failure to enforce limit should not stop memory stats. } // Directory is within allowed bounds (or pruning succeeded); proceed normally. const stats = await dumpMemoryStats({ includeHeapSpaces: false }); memLogger.debug("[MemStat]:", stats); } } catch (err) { log.warn("Unexpected error while writing memory stats log", err); } }, 15 * 60 * 1000, ); // every 15 minutes } export default ongoingMemoryDump;