336 lines
8.0 KiB
TypeScript
336 lines
8.0 KiB
TypeScript
import {
|
|
app,
|
|
BrowserWindow,
|
|
ipcMain,
|
|
shell
|
|
} from 'electron';
|
|
import { collectAppMetricsSnapshot, type AppMetricsSnapshot } from '../app-metrics';
|
|
import { getMainWindow } from '../window/create-window';
|
|
import { resolveReadablePath } from '../path-jail';
|
|
import { sumWorkingSetKb } from './process-metrics.rules';
|
|
import { isPerfDiagEnabled } from './diagnostics.flags';
|
|
import { exceedsHighMemoryThreshold } from './high-memory-alert.rules';
|
|
import { buildHighMemoryDiagnosticPayload } from './high-memory-snapshot.rules';
|
|
import { collectImmediateRendererSamples } from './immediate-renderer-samples.collector';
|
|
import { collectSessionContext } from './session-context.collector';
|
|
import {
|
|
clearHighMemoryAlert,
|
|
readHighMemoryAlert,
|
|
writeHighMemoryAlert
|
|
} from './high-memory-alert.store';
|
|
import type { PerfDiagEntry } from './diagnostics.models';
|
|
import { PerfDiagWriter } from './diagnostics.writer';
|
|
|
|
const PROCESS_POLL_INTERVAL_MS = 5_000;
|
|
|
|
let activeWriter: PerfDiagWriter | null = null;
|
|
let processPollTimer: NodeJS.Timeout | null = null;
|
|
let diagnosticsEnabled = false;
|
|
let ipcRegistered = false;
|
|
let highMemoryAlertTriggeredThisSession = false;
|
|
let sessionStartedAt = 0;
|
|
|
|
export function isPerfDiagActive(): boolean {
|
|
return diagnosticsEnabled;
|
|
}
|
|
|
|
export function ensurePerfDiagIpcRegistered(): void {
|
|
if (ipcRegistered) {
|
|
return;
|
|
}
|
|
|
|
ipcRegistered = true;
|
|
|
|
ipcMain.handle('perf-diag-is-enabled', () => diagnosticsEnabled);
|
|
|
|
ipcMain.handle('perf-diag-report', (_event, entry: PerfDiagEntry) => {
|
|
const writer = activeWriter;
|
|
|
|
if (!diagnosticsEnabled || !writer) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
writer.append(normalizeRendererEntry(entry));
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
});
|
|
|
|
ipcMain.handle('get-pending-high-memory-alert', async () => {
|
|
return readHighMemoryAlert(app.getPath('userData'));
|
|
});
|
|
|
|
ipcMain.handle('acknowledge-high-memory-alert', async () => {
|
|
await clearHighMemoryAlert(app.getPath('userData'));
|
|
return true;
|
|
});
|
|
|
|
ipcMain.handle('show-log-file-in-folder', async (_event, filePath: string) => {
|
|
if (typeof filePath !== 'string' || !filePath.trim()) {
|
|
return {
|
|
shown: false,
|
|
reason: 'missing-path'
|
|
};
|
|
}
|
|
|
|
const scopedPath = await resolveReadablePath(filePath);
|
|
|
|
if (!scopedPath) {
|
|
return {
|
|
shown: false,
|
|
reason: 'outside-app-data'
|
|
};
|
|
}
|
|
|
|
shell.showItemInFolder(scopedPath);
|
|
|
|
return { shown: true };
|
|
});
|
|
}
|
|
|
|
export function getActivePerfDiagWriter(): PerfDiagWriter | null {
|
|
return activeWriter;
|
|
}
|
|
|
|
export function startPerfDiagnostics(): PerfDiagWriter | null {
|
|
ensurePerfDiagIpcRegistered();
|
|
diagnosticsEnabled = isPerfDiagEnabled(process.env, app.isPackaged);
|
|
|
|
if (!diagnosticsEnabled) {
|
|
return null;
|
|
}
|
|
|
|
const sessionId = `${Date.now().toString(36)}-${process.pid}`;
|
|
const writer = new PerfDiagWriter({
|
|
userDataPath: app.getPath('userData'),
|
|
sessionId
|
|
});
|
|
|
|
activeWriter = writer;
|
|
highMemoryAlertTriggeredThisSession = false;
|
|
sessionStartedAt = Date.now();
|
|
registerProcessCrashHandlers(writer);
|
|
startProcessMetricsPolling(writer);
|
|
|
|
const userDataPath = app.getPath('userData');
|
|
|
|
writer.append({
|
|
collectedAt: Date.now(),
|
|
source: 'main',
|
|
type: 'session',
|
|
payload: {
|
|
event: 'started',
|
|
sessionId,
|
|
filePath: writer.snapshotFilePath
|
|
}
|
|
});
|
|
|
|
writer.append({
|
|
collectedAt: Date.now(),
|
|
source: 'main',
|
|
type: 'environment',
|
|
payload: {
|
|
...collectSessionContext({
|
|
sessionStartedAt,
|
|
userDataPath
|
|
})
|
|
}
|
|
});
|
|
|
|
return writer;
|
|
}
|
|
|
|
export function attachRendererDiagnosticsHooks(window: BrowserWindow): void {
|
|
const writer = activeWriter;
|
|
|
|
if (!writer) {
|
|
return;
|
|
}
|
|
|
|
window.webContents.on('render-process-gone', (_event, details) => {
|
|
writer.append({
|
|
collectedAt: Date.now(),
|
|
source: 'main',
|
|
type: 'crash',
|
|
payload: {
|
|
reason: details.reason,
|
|
exitCode: details.exitCode
|
|
}
|
|
});
|
|
|
|
void writer.flushSnapshot('render-process-gone');
|
|
});
|
|
|
|
window.webContents.on('unresponsive', () => {
|
|
writer.append({
|
|
collectedAt: Date.now(),
|
|
source: 'main',
|
|
type: 'unresponsive',
|
|
payload: {}
|
|
});
|
|
});
|
|
|
|
window.webContents.on('responsive', () => {
|
|
writer.append({
|
|
collectedAt: Date.now(),
|
|
source: 'main',
|
|
type: 'session',
|
|
payload: { event: 'renderer-responsive' }
|
|
});
|
|
});
|
|
}
|
|
|
|
export async function shutdownPerfDiagnostics(): Promise<void> {
|
|
if (!activeWriter) {
|
|
return;
|
|
}
|
|
|
|
await activeWriter.flushSnapshot('shutdown');
|
|
|
|
if (processPollTimer) {
|
|
clearInterval(processPollTimer);
|
|
processPollTimer = null;
|
|
}
|
|
|
|
activeWriter = null;
|
|
diagnosticsEnabled = false;
|
|
}
|
|
|
|
function registerProcessCrashHandlers(writer: PerfDiagWriter): void {
|
|
app.on('child-process-gone', (_event, details) => {
|
|
writer.append({
|
|
collectedAt: Date.now(),
|
|
source: 'main',
|
|
type: 'crash',
|
|
payload: {
|
|
type: details.type,
|
|
reason: details.reason,
|
|
exitCode: details.exitCode,
|
|
serviceName: details.serviceName ?? null,
|
|
name: details.name ?? null
|
|
}
|
|
});
|
|
});
|
|
|
|
process.on('uncaughtException', (error) => {
|
|
writer.append({
|
|
collectedAt: Date.now(),
|
|
source: 'main',
|
|
type: 'crash',
|
|
payload: {
|
|
scope: 'main-uncaughtException',
|
|
message: error.message
|
|
}
|
|
});
|
|
|
|
void writer.flushSnapshot('uncaughtException');
|
|
});
|
|
|
|
process.on('unhandledRejection', (reason) => {
|
|
writer.append({
|
|
collectedAt: Date.now(),
|
|
source: 'main',
|
|
type: 'crash',
|
|
payload: {
|
|
scope: 'main-unhandledRejection',
|
|
reason: String(reason)
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function startProcessMetricsPolling(writer: PerfDiagWriter): void {
|
|
const sample = (): void => {
|
|
try {
|
|
const metrics = collectAppMetricsSnapshot();
|
|
const totalKb = sumWorkingSetKb(metrics.processes);
|
|
|
|
writer.append({
|
|
collectedAt: metrics.collectedAt,
|
|
source: 'main',
|
|
type: 'process',
|
|
payload: {
|
|
totalWorkingSetKb: totalKb,
|
|
processes: metrics.processes
|
|
}
|
|
});
|
|
|
|
void maybeTriggerHighMemoryAlert(writer, metrics, totalKb);
|
|
} catch {
|
|
// Collector failures must never affect the app.
|
|
}
|
|
};
|
|
|
|
sample();
|
|
processPollTimer = setInterval(sample, PROCESS_POLL_INTERVAL_MS);
|
|
}
|
|
|
|
async function maybeTriggerHighMemoryAlert(
|
|
writer: PerfDiagWriter,
|
|
metrics: AppMetricsSnapshot,
|
|
totalWorkingSetKb: number | null
|
|
): Promise<void> {
|
|
if (highMemoryAlertTriggeredThisSession || !exceedsHighMemoryThreshold(totalWorkingSetKb)) {
|
|
return;
|
|
}
|
|
|
|
highMemoryAlertTriggeredThisSession = true;
|
|
|
|
const detectedAt = Date.now();
|
|
const userDataPath = app.getPath('userData');
|
|
const immediateRendererEntries = await collectImmediateRendererSamples(getMainWindow());
|
|
const environment = collectSessionContext({
|
|
sessionStartedAt,
|
|
userDataPath
|
|
});
|
|
|
|
for (const entry of immediateRendererEntries) {
|
|
writer.append(entry);
|
|
}
|
|
|
|
writer.append({
|
|
collectedAt: detectedAt,
|
|
source: 'main',
|
|
type: 'environment',
|
|
payload: {
|
|
...environment
|
|
}
|
|
});
|
|
|
|
writer.append({
|
|
collectedAt: detectedAt,
|
|
source: 'main',
|
|
type: 'high-memory',
|
|
payload: buildHighMemoryDiagnosticPayload({
|
|
detectedAt,
|
|
totalWorkingSetKb: totalWorkingSetKb ?? 0,
|
|
metrics,
|
|
environment,
|
|
mainProcessMemory: process.memoryUsage(),
|
|
ringEntries: writer.bufferedEntries,
|
|
immediateRendererEntries,
|
|
sessionId: writer.sessionId
|
|
})
|
|
});
|
|
|
|
await writer.flushSnapshot('high-memory-threshold');
|
|
|
|
await writeHighMemoryAlert(userDataPath, {
|
|
logFilePath: writer.snapshotFilePath,
|
|
detectedAt,
|
|
peakWorkingSetKb: totalWorkingSetKb ?? 0,
|
|
sessionId: writer.sessionId
|
|
});
|
|
}
|
|
|
|
function normalizeRendererEntry(entry: PerfDiagEntry): PerfDiagEntry {
|
|
return {
|
|
collectedAt: Number(entry.collectedAt) || Date.now(),
|
|
source: 'renderer',
|
|
type: entry.type,
|
|
payload: entry.payload ?? {}
|
|
};
|
|
}
|