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 { 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 { 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 ?? {} }; }