Files
Toju/electron/diagnostics/diagnostics.lifecycle.ts
2026-06-12 01:22:01 +02:00

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