310 lines
9.5 KiB
TypeScript
310 lines
9.5 KiB
TypeScript
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<import("v8").HeapSpaceInfo>;
|
|
heapSnapshotFile?: string;
|
|
custom?: Record<string, unknown>;
|
|
};
|
|
// (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<MemoryUsageStats> {
|
|
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;
|