import { app, BrowserWindow, ipcMain } from 'electron'; import { collectAppMetricsSnapshot } from '../app-metrics'; import { sumWorkingSetKb } from './process-metrics.rules'; import { isPerfDiagEnabled } from './diagnostics.flags'; 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; 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; } }); } 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; registerProcessCrashHandlers(writer); startProcessMetricsPolling(writer); writer.append({ collectedAt: Date.now(), source: 'main', type: 'session', payload: { event: 'started', sessionId, filePath: writer.snapshotFilePath } }); 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 { 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 } }); } catch { // Collector failures must never affect the app. } }; sample(); processPollTimer = setInterval(sample, PROCESS_POLL_INTERVAL_MS); } function normalizeRendererEntry(entry: PerfDiagEntry): PerfDiagEntry { return { collectedAt: Number(entry.collectedAt) || Date.now(), source: 'renderer', type: entry.type, payload: entry.payload ?? {} }; }