From dac5cb42a5a7d47098e5165aa5a723f001590086 Mon Sep 17 00:00:00 2001 From: Myx Date: Fri, 12 Jun 2026 01:22:01 +0200 Subject: [PATCH] perf: diagnoistics improvements --- electron/app-metrics.ts | 16 +- .../diagnostics/diagnostics.flags.spec.ts | 15 +- electron/diagnostics/diagnostics.flags.ts | 10 +- electron/diagnostics/diagnostics.lifecycle.ts | 125 ++++++++++- electron/diagnostics/diagnostics.models.ts | 2 + electron/diagnostics/diagnostics.writer.ts | 8 +- .../high-memory-alert.rules.spec.ts | 27 +++ .../diagnostics/high-memory-alert.rules.ts | 11 + .../high-memory-alert.store.spec.ts | 64 ++++++ .../diagnostics/high-memory-alert.store.ts | 57 +++++ .../high-memory-snapshot.rules.spec.ts | 201 ++++++++++++++++++ .../diagnostics/high-memory-snapshot.rules.ts | 179 ++++++++++++++++ .../immediate-renderer-samples.collector.ts | 39 ++++ electron/diagnostics/index.ts | 12 ++ .../diagnostics/session-context.collector.ts | 91 ++++++++ electron/preload.ts | 11 + toju-app/public/i18n/catalog/app.json | 10 + toju-app/public/i18n/en.json | 10 + toju-app/src/app/app.html | 1 + toju-app/src/app/app.ts | 5 + .../platform/electron/electron-api.models.ts | 10 + .../electron-app-metrics.rules.spec.ts | 8 + .../electron/electron-app-metrics.rules.ts | 4 + .../desktop-high-memory-alert.service.ts | 82 +++++++ .../high-memory-alert-modal.component.html | 85 ++++++++ .../high-memory-alert-modal.component.ts | 47 ++++ .../diagnostics/diagnostics.bootstrap.ts | 22 ++ .../diagnostics/diagnostics.models.ts | 2 + tools/perf-diag-viewer.js | 42 +++- 29 files changed, 1168 insertions(+), 28 deletions(-) create mode 100644 electron/diagnostics/high-memory-alert.rules.spec.ts create mode 100644 electron/diagnostics/high-memory-alert.rules.ts create mode 100644 electron/diagnostics/high-memory-alert.store.spec.ts create mode 100644 electron/diagnostics/high-memory-alert.store.ts create mode 100644 electron/diagnostics/high-memory-snapshot.rules.spec.ts create mode 100644 electron/diagnostics/high-memory-snapshot.rules.ts create mode 100644 electron/diagnostics/immediate-renderer-samples.collector.ts create mode 100644 electron/diagnostics/session-context.collector.ts create mode 100644 toju-app/src/app/core/services/desktop-high-memory-alert.service.ts create mode 100644 toju-app/src/app/features/shell/high-memory-alert-modal/high-memory-alert-modal.component.html create mode 100644 toju-app/src/app/features/shell/high-memory-alert-modal/high-memory-alert-modal.component.ts diff --git a/electron/app-metrics.ts b/electron/app-metrics.ts index ef225fb..75b43a8 100644 --- a/electron/app-metrics.ts +++ b/electron/app-metrics.ts @@ -4,6 +4,10 @@ export interface AppMetricsProcessSnapshot { pid: number; type: string; workingSetKb: number | null; + peakWorkingSetKb: number | null; + privateBytesKb: number | null; + creationTime: number | null; + cpuPercent: number | null; } export interface AppMetricsSnapshot { @@ -17,7 +21,17 @@ export function collectAppMetricsSnapshot(): AppMetricsSnapshot { processes: app.getAppMetrics().map((metric) => ({ pid: metric.pid, type: metric.type, - workingSetKb: metric.memory?.workingSetSize ?? null + workingSetKb: metric.memory?.workingSetSize ?? null, + peakWorkingSetKb: readOptionalKilobytes(metric.memory?.peakWorkingSetSize), + privateBytesKb: readOptionalKilobytes(metric.memory?.privateBytes), + creationTime: metric.creationTime ?? null, + cpuPercent: typeof metric.cpu?.percentCPUUsage === 'number' + ? Math.round(metric.cpu.percentCPUUsage * 10) / 10 + : null })) }; } + +function readOptionalKilobytes(value: number | undefined): number | null { + return typeof value === 'number' && value >= 0 ? value : null; +} diff --git a/electron/diagnostics/diagnostics.flags.spec.ts b/electron/diagnostics/diagnostics.flags.spec.ts index a583c0c..3300822 100644 --- a/electron/diagnostics/diagnostics.flags.spec.ts +++ b/electron/diagnostics/diagnostics.flags.spec.ts @@ -8,7 +8,7 @@ import { isPerfDiagEnabled } from './diagnostics.flags'; describe('isPerfDiagEnabled', () => { it('returns false when the flag is unset', () => { expect(isPerfDiagEnabled({}, false)).toBe(false); - expect(isPerfDiagEnabled({}, true)).toBe(false); + expect(isPerfDiagEnabled({}, true)).toBe(true); }); it('returns true in development when METOYOU_PERF_DIAG is truthy', () => { @@ -17,11 +17,12 @@ describe('isPerfDiagEnabled', () => { expect(isPerfDiagEnabled({ METOYOU_PERF_DIAG: 'on' }, false)).toBe(true); }); - it('returns false in packaged builds unless force is set', () => { - expect(isPerfDiagEnabled({ METOYOU_PERF_DIAG: '1' }, true)).toBe(false); - expect(isPerfDiagEnabled({ - METOYOU_PERF_DIAG: '1', - METOYOU_PERF_DIAG_FORCE: '1' - }, true)).toBe(true); + it('returns true in packaged Electron builds without env flags', () => { + expect(isPerfDiagEnabled({}, true)).toBe(true); + expect(isPerfDiagEnabled({ METOYOU_PERF_DIAG: '0' }, true)).toBe(true); + }); + + it('returns false in development when the flag is unset', () => { + expect(isPerfDiagEnabled({}, false)).toBe(false); }); }); diff --git a/electron/diagnostics/diagnostics.flags.ts b/electron/diagnostics/diagnostics.flags.ts index 7ea1421..45acabd 100644 --- a/electron/diagnostics/diagnostics.flags.ts +++ b/electron/diagnostics/diagnostics.flags.ts @@ -17,13 +17,9 @@ export function isPerfDiagEnabled( env: NodeJS.ProcessEnv, isPackaged: boolean ): boolean { - if (!isTruthyFlag(env[PERF_DIAG_ENV])) { - return false; + if (isPackaged) { + return true; } - if (isPackaged && !isTruthyFlag(env[PERF_DIAG_FORCE_ENV])) { - return false; - } - - return true; + return isTruthyFlag(env[PERF_DIAG_ENV]); } diff --git a/electron/diagnostics/diagnostics.lifecycle.ts b/electron/diagnostics/diagnostics.lifecycle.ts index 529d114..8b65807 100644 --- a/electron/diagnostics/diagnostics.lifecycle.ts +++ b/electron/diagnostics/diagnostics.lifecycle.ts @@ -1,11 +1,23 @@ import { app, BrowserWindow, - ipcMain + ipcMain, + shell } from 'electron'; -import { collectAppMetricsSnapshot } from '../app-metrics'; +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'; @@ -15,6 +27,8 @@ 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; @@ -43,6 +57,37 @@ export function ensurePerfDiagIpcRegistered(): void { 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 { @@ -64,9 +109,13 @@ export function startPerfDiagnostics(): PerfDiagWriter | null { }); activeWriter = writer; + highMemoryAlertTriggeredThisSession = false; + sessionStartedAt = Date.now(); registerProcessCrashHandlers(writer); startProcessMetricsPolling(writer); + const userDataPath = app.getPath('userData'); + writer.append({ collectedAt: Date.now(), source: 'main', @@ -78,6 +127,18 @@ export function startPerfDiagnostics(): PerfDiagWriter | null { } }); + writer.append({ + collectedAt: Date.now(), + source: 'main', + type: 'environment', + payload: { + ...collectSessionContext({ + sessionStartedAt, + userDataPath + }) + } + }); + return writer; } @@ -195,6 +256,8 @@ function startProcessMetricsPolling(writer: PerfDiagWriter): void { processes: metrics.processes } }); + + void maybeTriggerHighMemoryAlert(writer, metrics, totalKb); } catch { // Collector failures must never affect the app. } @@ -204,6 +267,64 @@ function startProcessMetricsPolling(writer: PerfDiagWriter): void { 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(), diff --git a/electron/diagnostics/diagnostics.models.ts b/electron/diagnostics/diagnostics.models.ts index 92fa24e..2730495 100644 --- a/electron/diagnostics/diagnostics.models.ts +++ b/electron/diagnostics/diagnostics.models.ts @@ -2,10 +2,12 @@ export type PerfDiagSource = 'main' | 'renderer'; export type PerfDiagEntryType = | 'session' + | 'environment' | 'process' | 'store' | 'components' | 'heap' + | 'high-memory' | 'crash' | 'unresponsive'; diff --git a/electron/diagnostics/diagnostics.writer.ts b/electron/diagnostics/diagnostics.writer.ts index c800af7..2f3750e 100644 --- a/electron/diagnostics/diagnostics.writer.ts +++ b/electron/diagnostics/diagnostics.writer.ts @@ -7,7 +7,7 @@ import { resolveDiagnosticsFilePath } from './diagnostics.rules'; -const DEFAULT_RING_CAPACITY = 120; +const DEFAULT_RING_CAPACITY = 300; const FLUSH_DEBOUNCE_MS = 250; export interface PerfDiagWriterOptions { @@ -18,6 +18,7 @@ export interface PerfDiagWriterOptions { export class PerfDiagWriter { private readonly filePath: string; + private readonly sessionIdValue: string; private readonly ringCapacity: number; private readonly pendingLines: string[] = []; private ring: PerfDiagEntry[] = []; @@ -26,10 +27,15 @@ export class PerfDiagWriter { private disabled = false; constructor(options: PerfDiagWriterOptions) { + this.sessionIdValue = options.sessionId; this.filePath = resolveDiagnosticsFilePath(options.userDataPath, options.sessionId); this.ringCapacity = options.ringCapacity ?? DEFAULT_RING_CAPACITY; } + get sessionId(): string { + return this.sessionIdValue; + } + get snapshotFilePath(): string { return this.filePath; } diff --git a/electron/diagnostics/high-memory-alert.rules.spec.ts b/electron/diagnostics/high-memory-alert.rules.spec.ts new file mode 100644 index 0000000..168cf53 --- /dev/null +++ b/electron/diagnostics/high-memory-alert.rules.spec.ts @@ -0,0 +1,27 @@ +import { + describe, + expect, + it +} from 'vitest'; +import { + exceedsHighMemoryThreshold, + formatWorkingSetGb, + HIGH_MEMORY_THRESHOLD_KB +} from './high-memory-alert.rules'; + +describe('high-memory-alert.rules', () => { + it('uses a 2 GiB working-set threshold', () => { + expect(HIGH_MEMORY_THRESHOLD_KB).toBe(2 * 1024 * 1024); + }); + + it('detects totals at or above the threshold', () => { + expect(exceedsHighMemoryThreshold(HIGH_MEMORY_THRESHOLD_KB - 1)).toBe(false); + expect(exceedsHighMemoryThreshold(HIGH_MEMORY_THRESHOLD_KB)).toBe(true); + expect(exceedsHighMemoryThreshold(HIGH_MEMORY_THRESHOLD_KB + 1024)).toBe(true); + }); + + it('formats working set totals in gigabytes', () => { + expect(formatWorkingSetGb(1536 * 1024)).toBe('1.50'); + expect(formatWorkingSetGb(HIGH_MEMORY_THRESHOLD_KB)).toBe('2.00'); + }); +}); diff --git a/electron/diagnostics/high-memory-alert.rules.ts b/electron/diagnostics/high-memory-alert.rules.ts new file mode 100644 index 0000000..4504e9e --- /dev/null +++ b/electron/diagnostics/high-memory-alert.rules.ts @@ -0,0 +1,11 @@ +/** 2 GiB working-set threshold for writing a diagnostics snapshot. */ +export const HIGH_MEMORY_THRESHOLD_KB = 2 * 1024 * 1024; + +export function exceedsHighMemoryThreshold(totalWorkingSetKb: number | null | undefined): boolean { + return typeof totalWorkingSetKb === 'number' + && totalWorkingSetKb >= HIGH_MEMORY_THRESHOLD_KB; +} + +export function formatWorkingSetGb(totalWorkingSetKb: number): string { + return (totalWorkingSetKb / (1024 * 1024)).toFixed(2); +} diff --git a/electron/diagnostics/high-memory-alert.store.spec.ts b/electron/diagnostics/high-memory-alert.store.spec.ts new file mode 100644 index 0000000..ed27478 --- /dev/null +++ b/electron/diagnostics/high-memory-alert.store.spec.ts @@ -0,0 +1,64 @@ +import * as fsp from 'fs/promises'; +import * as os from 'os'; +import * as path from 'path'; +import { + afterEach, + describe, + expect, + it +} from 'vitest'; +import { + clearHighMemoryAlert, + readHighMemoryAlert, + resolveHighMemoryAlertPath, + writeHighMemoryAlert +} from './high-memory-alert.store'; + +describe('high-memory-alert.store', () => { + const tempDirs: string[] = []; + + afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => fsp.rm(dir, { + recursive: true, + force: true + }))); + }); + + it('writes and reads a pending startup alert record', async () => { + const userDataPath = await fsp.mkdtemp(path.join(os.tmpdir(), 'metoyou-high-memory-')); + + tempDirs.push(userDataPath); + + const record = { + logFilePath: path.join(userDataPath, 'diagnostics', 'perf-session.jsonl'), + detectedAt: 1_700_000_000_000, + peakWorkingSetKb: 2_200_000, + sessionId: 'session-1' + }; + + await writeHighMemoryAlert(userDataPath, record); + + expect(resolveHighMemoryAlertPath(userDataPath)).toBe( + path.join(userDataPath, 'diagnostics', 'high-memory-pending.json') + ); + + expect(await readHighMemoryAlert(userDataPath)).toEqual(record); + }); + + it('clears the pending startup alert record', async () => { + const userDataPath = await fsp.mkdtemp(path.join(os.tmpdir(), 'metoyou-high-memory-')); + + tempDirs.push(userDataPath); + + await writeHighMemoryAlert(userDataPath, { + logFilePath: '/tmp/perf.jsonl', + detectedAt: Date.now(), + peakWorkingSetKb: 2_100_000, + sessionId: 'session-2' + }); + + await clearHighMemoryAlert(userDataPath); + + expect(await readHighMemoryAlert(userDataPath)).toBeNull(); + }); +}); diff --git a/electron/diagnostics/high-memory-alert.store.ts b/electron/diagnostics/high-memory-alert.store.ts new file mode 100644 index 0000000..3f5cdfd --- /dev/null +++ b/electron/diagnostics/high-memory-alert.store.ts @@ -0,0 +1,57 @@ +import * as fsp from 'fs/promises'; +import * as path from 'path'; + +export interface HighMemoryAlertRecord { + logFilePath: string; + detectedAt: number; + peakWorkingSetKb: number; + sessionId: string; +} + +export function resolveHighMemoryAlertPath(userDataPath: string): string { + return path.join(userDataPath, 'diagnostics', 'high-memory-pending.json'); +} + +export async function readHighMemoryAlert(userDataPath: string): Promise { + try { + const raw = await fsp.readFile(resolveHighMemoryAlertPath(userDataPath), 'utf8'); + const parsed = JSON.parse(raw) as Partial; + + if ( + typeof parsed.logFilePath !== 'string' + || !parsed.logFilePath.trim() + || typeof parsed.detectedAt !== 'number' + || typeof parsed.peakWorkingSetKb !== 'number' + || typeof parsed.sessionId !== 'string' + ) { + return null; + } + + return { + logFilePath: parsed.logFilePath, + detectedAt: parsed.detectedAt, + peakWorkingSetKb: parsed.peakWorkingSetKb, + sessionId: parsed.sessionId + }; + } catch { + return null; + } +} + +export async function writeHighMemoryAlert( + userDataPath: string, + record: HighMemoryAlertRecord +): Promise { + const filePath = resolveHighMemoryAlertPath(userDataPath); + + await fsp.mkdir(path.dirname(filePath), { recursive: true }); + await fsp.writeFile(filePath, `${JSON.stringify(record, null, 2)}\n`, 'utf8'); +} + +export async function clearHighMemoryAlert(userDataPath: string): Promise { + try { + await fsp.unlink(resolveHighMemoryAlertPath(userDataPath)); + } catch { + // Missing pending alert is fine. + } +} diff --git a/electron/diagnostics/high-memory-snapshot.rules.spec.ts b/electron/diagnostics/high-memory-snapshot.rules.spec.ts new file mode 100644 index 0000000..ed2ff2b --- /dev/null +++ b/electron/diagnostics/high-memory-snapshot.rules.spec.ts @@ -0,0 +1,201 @@ +import { + describe, + expect, + it +} from 'vitest'; +import type { PerfDiagEntry } from './diagnostics.models'; +import { + buildHighMemoryDiagnosticPayload, + buildHighMemorySummary, + extractLatestRendererSamples, + extractProcessHistory, + formatMemoryUsageMb, + rankProcessesByWorkingSet, + summarizeRingBuffer +} from './high-memory-snapshot.rules'; + +function createProcess(overrides: Partial<{ + pid: number; + type: string; + workingSetKb: number | null; + peakWorkingSetKb: number | null; + privateBytesKb: number | null; + creationTime: number | null; + cpuPercent: number | null; +}> = {}) { + return { + pid: 1, + type: 'Tab', + workingSetKb: 1024, + peakWorkingSetKb: null, + privateBytesKb: null, + creationTime: null, + cpuPercent: null, + ...overrides + }; +} + +describe('high-memory-snapshot.rules', () => { + it('ranks processes by working set and computes share percentages', () => { + const tabProcess = createProcess({ pid: 1, type: 'Tab', workingSetKb: 512_000 }); + const gpuProcess = createProcess({ pid: 2, type: 'GPU', workingSetKb: 1_536_000 }); + const ranked = rankProcessesByWorkingSet([tabProcess, gpuProcess], 2_048_000); + + expect(ranked[0]?.type).toBe('GPU'); + expect(ranked[0]?.sharePercent).toBe(75); + expect(ranked[1]?.sharePercent).toBe(25); + }); + + it('extracts the latest renderer store, heap, and component samples', () => { + const entries: PerfDiagEntry[] = [ + { + collectedAt: 1, + source: 'renderer', + type: 'store', + payload: { domains: { chat: 100 } } + }, + { + collectedAt: 2, + source: 'renderer', + type: 'heap', + payload: { usedJsHeapMb: 120 } + }, + { + collectedAt: 3, + source: 'renderer', + type: 'components', + payload: { suspectedLeaks: [{ name: 'ChatMessageItem', count: 40, expected: 20 }] } + }, + { + collectedAt: 4, + source: 'renderer', + type: 'store', + payload: { domains: { chat: 500 } } + } + ]; + + expect(extractLatestRendererSamples(entries)).toEqual({ + store: { domains: { chat: 500 } }, + heap: { usedJsHeapMb: 120 }, + components: { suspectedLeaks: [{ name: 'ChatMessageItem', count: 40, expected: 20 }] } + }); + }); + + it('extracts recent process history from the ring buffer', () => { + const entries: PerfDiagEntry[] = [ + { + collectedAt: 1, + source: 'main', + type: 'process', + payload: { totalWorkingSetKb: 1000 } + }, + { + collectedAt: 2, + source: 'main', + type: 'session', + payload: { event: 'noop' } + }, + { + collectedAt: 3, + source: 'main', + type: 'process', + payload: { totalWorkingSetKb: 2000 } + } + ]; + + expect(extractProcessHistory(entries)).toEqual([{ collectedAt: 1, totalWorkingSetKb: 1000 }, { collectedAt: 3, totalWorkingSetKb: 2000 }]); + }); + + it('summarizes ring buffer entry counts', () => { + expect(summarizeRingBuffer([ + { collectedAt: 1, source: 'main', type: 'process', payload: {} }, + { collectedAt: 2, source: 'renderer', type: 'heap', payload: {} }, + { collectedAt: 3, source: 'main', type: 'process', payload: {} } + ])).toEqual({ + 'main:process': 2, + 'renderer:heap': 1 + }); + }); + + it('builds a high-memory summary with threshold context', () => { + const summary = buildHighMemorySummary( + 2_200_000, + [createProcess({ workingSetKb: 2_200_000 })], + 1_700_000_000_000 + ); + + expect(summary.totalWorkingSetGb).toBe('2.10'); + expect(summary.thresholdGb).toBe('2.00'); + expect(summary.topProcesses).toHaveLength(1); + }); + + it('builds a comprehensive high-memory diagnostic payload', () => { + const payload = buildHighMemoryDiagnosticPayload({ + detectedAt: 1_700_000_000_000, + totalWorkingSetKb: 2_200_000, + metrics: { + collectedAt: 1_700_000_000_000, + processes: [ + createProcess({ + workingSetKb: 2_200_000, + peakWorkingSetKb: 2_300_000, + privateBytesKb: 1_800_000, + creationTime: 1, + cpuPercent: 12 + }) + ] + }, + environment: { appVersion: '1.0.0' }, + mainProcessMemory: { + rss: 64 * 1024 * 1024, + heapTotal: 32 * 1024 * 1024, + heapUsed: 16 * 1024 * 1024, + external: 8 * 1024 * 1024, + arrayBuffers: 1024 + }, + ringEntries: [ + { + collectedAt: 1, + source: 'main', + type: 'process', + payload: { totalWorkingSetKb: 2_000_000 } + } + ], + immediateRendererEntries: [ + { + collectedAt: 2, + source: 'renderer', + type: 'heap', + payload: { usedJsHeapMb: 300, route: '/room/abc' } + } + ], + sessionId: 'session-1' + }); + + expect(payload.event).toBe('high-memory-threshold'); + expect(payload.summary).toMatchObject({ + totalWorkingSetKb: 2_200_000 + }); + + expect(payload.processHistory).toHaveLength(1); + expect(payload.recentRendererSamples).toEqual({ + store: null, + heap: { usedJsHeapMb: 300, route: '/room/abc' }, + components: null + }); + + expect(formatMemoryUsageMb({ + rss: 64 * 1024 * 1024, + heapTotal: 32 * 1024 * 1024, + heapUsed: 16 * 1024 * 1024, + external: 8 * 1024 * 1024, + arrayBuffers: 1024 + })).toEqual({ + rssMb: 64, + heapTotalMb: 32, + heapUsedMb: 16, + externalMb: 8, + arrayBuffersMb: 0 + }); + }); +}); diff --git a/electron/diagnostics/high-memory-snapshot.rules.ts b/electron/diagnostics/high-memory-snapshot.rules.ts new file mode 100644 index 0000000..0092b28 --- /dev/null +++ b/electron/diagnostics/high-memory-snapshot.rules.ts @@ -0,0 +1,179 @@ +import type { AppMetricsProcessSnapshot, AppMetricsSnapshot } from '../app-metrics'; +import type { PerfDiagEntry } from './diagnostics.models'; +import { formatWorkingSetGb, HIGH_MEMORY_THRESHOLD_KB } from './high-memory-alert.rules'; +import type { SessionContextSnapshot } from './session-context.collector'; + +export interface RankedProcessSnapshot extends AppMetricsProcessSnapshot { + sharePercent: number; +} + +export interface HighMemorySummary { + detectedAt: number; + thresholdKb: number; + thresholdGb: string; + totalWorkingSetKb: number; + totalWorkingSetGb: string; + topProcesses: RankedProcessSnapshot[]; +} + +export interface LatestRendererSamples { + store: Record | null; + heap: Record | null; + components: Record | null; +} + +export function rankProcessesByWorkingSet( + processes: readonly AppMetricsProcessSnapshot[], + totalWorkingSetKb: number | null +): RankedProcessSnapshot[] { + const total = totalWorkingSetKb ?? 0; + + return [...processes] + .filter((process) => process.workingSetKb != null && process.workingSetKb > 0) + .sort((left, right) => (right.workingSetKb ?? 0) - (left.workingSetKb ?? 0)) + .map((process) => ({ + ...process, + sharePercent: total > 0 + ? Math.round(((process.workingSetKb ?? 0) / total) * 1000) / 10 + : 0 + })); +} + +export function extractLatestRendererSamples(entries: readonly PerfDiagEntry[]): LatestRendererSamples { + let store: Record | null = null; + let heap: Record | null = null; + let components: Record | null = null; + + for (let index = entries.length - 1; index >= 0; index -= 1) { + const entry = entries[index]; + + if (entry.source !== 'renderer') { + continue; + } + + if (!store && entry.type === 'store') { + store = entry.payload; + } + + if (!heap && entry.type === 'heap') { + heap = entry.payload; + } + + if (!components && entry.type === 'components') { + components = entry.payload; + } + + if (store && heap && components) { + break; + } + } + + return { + store, + heap, + components + }; +} + +export function extractProcessHistory( + entries: readonly PerfDiagEntry[], + limit = 24 +): Record[] { + const history: Record[] = []; + + for (let index = entries.length - 1; index >= 0; index -= 1) { + const entry = entries[index]; + + if (entry.type !== 'process') { + continue; + } + + history.unshift({ + collectedAt: entry.collectedAt, + ...entry.payload + }); + + if (history.length >= limit) { + break; + } + } + + return history; +} + +export function summarizeRingBuffer(entries: readonly PerfDiagEntry[]): Record { + const counts: Record = {}; + + for (const entry of entries) { + const key = `${entry.source}:${entry.type}`; + + counts[key] = (counts[key] ?? 0) + 1; + } + + return counts; +} + +export function buildHighMemorySummary( + totalWorkingSetKb: number, + processes: readonly AppMetricsProcessSnapshot[], + detectedAt: number +): HighMemorySummary { + return { + detectedAt, + thresholdKb: HIGH_MEMORY_THRESHOLD_KB, + thresholdGb: formatWorkingSetGb(HIGH_MEMORY_THRESHOLD_KB), + totalWorkingSetKb, + totalWorkingSetGb: formatWorkingSetGb(totalWorkingSetKb), + topProcesses: rankProcessesByWorkingSet(processes, totalWorkingSetKb).slice(0, 12) + }; +} + +export function formatMemoryUsageMb(memoryUsage: NodeJS.MemoryUsage): Record { + return { + rssMb: roundMb(memoryUsage.rss), + heapTotalMb: roundMb(memoryUsage.heapTotal), + heapUsedMb: roundMb(memoryUsage.heapUsed), + externalMb: roundMb(memoryUsage.external), + arrayBuffersMb: roundMb(memoryUsage.arrayBuffers ?? 0) + }; +} + +export function buildHighMemoryDiagnosticPayload(input: { + detectedAt: number; + totalWorkingSetKb: number; + metrics: AppMetricsSnapshot; + environment: SessionContextSnapshot; + mainProcessMemory: NodeJS.MemoryUsage; + ringEntries: readonly PerfDiagEntry[]; + immediateRendererEntries: readonly PerfDiagEntry[]; + sessionId: string; +}): Record { + const mergedRingEntries = [...input.ringEntries, ...input.immediateRendererEntries]; + const recentRendererSamples = extractLatestRendererSamples(mergedRingEntries); + + return { + event: 'high-memory-threshold', + sessionId: input.sessionId, + summary: buildHighMemorySummary( + input.totalWorkingSetKb, + input.metrics.processes, + input.detectedAt + ), + environment: input.environment, + metrics: input.metrics, + mainProcessMemory: input.mainProcessMemory, + mainProcessMemoryMb: formatMemoryUsageMb(input.mainProcessMemory), + processHistory: extractProcessHistory(mergedRingEntries), + ringSummary: summarizeRingBuffer(mergedRingEntries), + recentRendererSamples, + immediateRendererSamples: input.immediateRendererEntries.map((entry) => ({ + collectedAt: entry.collectedAt, + type: entry.type, + payload: entry.payload + })) + }; +} + +function roundMb(bytes: number): number { + return Math.round((bytes / (1024 * 1024)) * 100) / 100; +} diff --git a/electron/diagnostics/immediate-renderer-samples.collector.ts b/electron/diagnostics/immediate-renderer-samples.collector.ts new file mode 100644 index 0000000..fcfc7c5 --- /dev/null +++ b/electron/diagnostics/immediate-renderer-samples.collector.ts @@ -0,0 +1,39 @@ +import type { BrowserWindow } from 'electron'; +import type { PerfDiagEntry } from './diagnostics.models'; + +export async function collectImmediateRendererSamples( + window: BrowserWindow | null | undefined +): Promise { + if (!window || window.isDestroyed()) { + return []; + } + + try { + const result = await window.webContents.executeJavaScript(` + (function () { + const collect = globalThis.__collectPerfDiagSample; + + return typeof collect === 'function' ? collect() : []; + })() + `, true); + + if (!Array.isArray(result)) { + return []; + } + + return result + .filter((entry) => entry && typeof entry === 'object') + .map((entry) => normalizeImmediateRendererEntry(entry as Partial)); + } catch { + return []; + } +} + +function normalizeImmediateRendererEntry(entry: Partial): PerfDiagEntry { + return { + collectedAt: Number(entry.collectedAt) || Date.now(), + source: 'renderer', + type: entry.type ?? 'session', + payload: entry.payload ?? {} + }; +} diff --git a/electron/diagnostics/index.ts b/electron/diagnostics/index.ts index 533f6f3..9c68d28 100644 --- a/electron/diagnostics/index.ts +++ b/electron/diagnostics/index.ts @@ -1,4 +1,16 @@ export { isPerfDiagEnabled, PERF_DIAG_ENV, PERF_DIAG_FORCE_ENV } from './diagnostics.flags'; +export { + clearHighMemoryAlert, + readHighMemoryAlert, + resolveHighMemoryAlertPath, + writeHighMemoryAlert +} from './high-memory-alert.store'; +export type { HighMemoryAlertRecord } from './high-memory-alert.store'; +export { + exceedsHighMemoryThreshold, + formatWorkingSetGb, + HIGH_MEMORY_THRESHOLD_KB +} from './high-memory-alert.rules'; export { attachRendererDiagnosticsHooks, ensurePerfDiagIpcRegistered, diff --git a/electron/diagnostics/session-context.collector.ts b/electron/diagnostics/session-context.collector.ts new file mode 100644 index 0000000..6088fb7 --- /dev/null +++ b/electron/diagnostics/session-context.collector.ts @@ -0,0 +1,91 @@ +import { app, BrowserWindow } from 'electron'; +import * as os from 'os'; + +export interface SessionWindowSnapshot { + id: number; + title: string; + url: string | null; + focused: boolean; + visible: boolean; + destroyed: boolean; +} + +export interface SessionContextSnapshot { + collectedAt: number; + sessionStartedAt: number; + uptimeMs: number; + appVersion: string; + electronVersion: string; + chromeVersion: string; + nodeVersion: string; + platform: NodeJS.Platform; + arch: string; + osType: string; + osRelease: string; + osVersion: string | null; + totalMemKb: number; + freeMemKb: number; + userDataPath: string; + appPath: string; + isPackaged: boolean; + locale: string; + windowCount: number; + windows: SessionWindowSnapshot[]; +} + +export function collectSessionContext(input: { + sessionStartedAt: number; + userDataPath: string; +}): SessionContextSnapshot { + const collectedAt = Date.now(); + + return { + collectedAt, + sessionStartedAt: input.sessionStartedAt, + uptimeMs: Math.max(0, collectedAt - input.sessionStartedAt), + appVersion: app.getVersion(), + electronVersion: process.versions.electron ?? 'unknown', + chromeVersion: process.versions.chrome ?? 'unknown', + nodeVersion: process.versions.node ?? 'unknown', + platform: process.platform, + arch: process.arch, + osType: os.type(), + osRelease: os.release(), + osVersion: readOsVersion(), + totalMemKb: Math.round(os.totalmem() / 1024), + freeMemKb: Math.round(os.freemem() / 1024), + userDataPath: input.userDataPath, + appPath: app.getAppPath(), + isPackaged: app.isPackaged, + locale: app.getLocale(), + windowCount: BrowserWindow.getAllWindows().length, + windows: BrowserWindow.getAllWindows().map(collectWindowSnapshot) + }; +} + +function collectWindowSnapshot(window: BrowserWindow): SessionWindowSnapshot { + let url: string | null = null; + + try { + url = window.webContents.getURL() || null; + } catch { + url = null; + } + + return { + id: window.id, + title: window.getTitle(), + url, + focused: window.isFocused(), + visible: window.isVisible(), + destroyed: window.isDestroyed() + }; +} + +function readOsVersion(): string | null { + try { + return os.version?.() ?? null; + } catch { + return null; + } +} diff --git a/electron/preload.ts b/electron/preload.ts index 4adfbaf..6259ced 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -259,6 +259,14 @@ export interface ElectronAPI { type: string; payload: Record; }) => Promise; + getPendingHighMemoryAlert: () => Promise<{ + logFilePath: string; + detectedAt: number; + peakWorkingSetKb: number; + sessionId: string; + } | null>; + acknowledgeHighMemoryAlert: () => Promise; + showLogFileInFolder: (filePath: string) => Promise<{ shown: boolean; reason?: string }>; getAppDataPath: () => Promise; openCurrentDataFolder: () => Promise; exportUserData: () => Promise; @@ -400,6 +408,9 @@ const electronAPI: ElectronAPI = { getAppMetrics: () => ipcRenderer.invoke('get-app-metrics'), isPerfDiagEnabled: () => ipcRenderer.invoke('perf-diag-is-enabled'), reportPerfDiagSample: (entry) => ipcRenderer.invoke('perf-diag-report', entry), + getPendingHighMemoryAlert: () => ipcRenderer.invoke('get-pending-high-memory-alert'), + acknowledgeHighMemoryAlert: () => ipcRenderer.invoke('acknowledge-high-memory-alert'), + showLogFileInFolder: (filePath) => ipcRenderer.invoke('show-log-file-in-folder', filePath), getAppDataPath: () => ipcRenderer.invoke('get-app-data-path'), openCurrentDataFolder: () => ipcRenderer.invoke('open-current-data-folder'), exportUserData: () => ipcRenderer.invoke('export-user-data'), diff --git a/toju-app/public/i18n/catalog/app.json b/toju-app/public/i18n/catalog/app.json index eee5549..63d5027 100644 --- a/toju-app/public/i18n/catalog/app.json +++ b/toju-app/public/i18n/catalog/app.json @@ -15,6 +15,16 @@ "downloadedMessage": "The update has already been downloaded. Restart the app when you're ready to finish applying it.", "updateSettings": "Update settings", "restartNow": "Restart now" + }, + "highMemoryAlert": { + "badge": "High memory usage", + "title": "The app used {{usageGb}} GB of RAM last session", + "message": "A diagnostics log was saved in your app data folder. You can open it now or copy the path if you want to share it with support.", + "openLog": "Open log file", + "showInFolder": "Show in folder", + "copyPath": "Copy path", + "dismiss": "Dismiss", + "dismissAriaLabel": "Dismiss high memory alert" } } } diff --git a/toju-app/public/i18n/en.json b/toju-app/public/i18n/en.json index b5235bd..597aa5d 100644 --- a/toju-app/public/i18n/en.json +++ b/toju-app/public/i18n/en.json @@ -15,6 +15,16 @@ "downloadedMessage": "The update has already been downloaded. Restart the app when you're ready to finish applying it.", "updateSettings": "Update settings", "restartNow": "Restart now" + }, + "highMemoryAlert": { + "badge": "High memory usage", + "title": "The app used {{usageGb}} GB of RAM last session", + "message": "A diagnostics log was saved in your app data folder. You can open it now or copy the path if you want to share it with support.", + "openLog": "Open log file", + "showInFolder": "Show in folder", + "copyPath": "Copy path", + "dismiss": "Dismiss", + "dismissAriaLabel": "Dismiss high memory alert" } }, "attachment": { diff --git a/toju-app/src/app/app.html b/toju-app/src/app/app.html index 5082a3d..f502347 100644 --- a/toju-app/src/app/app.html +++ b/toju-app/src/app/app.html @@ -167,6 +167,7 @@ + diff --git a/toju-app/src/app/app.ts b/toju-app/src/app/app.ts index 471b4bb..740efd9 100644 --- a/toju-app/src/app/app.ts +++ b/toju-app/src/app/app.ts @@ -26,6 +26,7 @@ import { loadLastViewedChatFromStorage } from './infrastructure/persistence'; import { DesktopAppUpdateService } from './core/services/desktop-app-update.service'; +import { DesktopHighMemoryAlertService } from './core/services/desktop-high-memory-alert.service'; import { ServerDirectoryFacade } from './domains/server-directory'; import { NotificationsFacade } from './domains/notifications'; import { TimeSyncService } from './core/services/time-sync.service'; @@ -53,6 +54,7 @@ import { SettingsModalComponent } from './features/settings/settings-modal/setti import { DebugConsoleComponent } from './shared/components/debug-console/debug-console.component'; import { ScreenShareSourcePickerComponent } from './shared/components/screen-share-source-picker/screen-share-source-picker.component'; import { NativeContextMenuComponent } from './features/shell/native-context-menu/native-context-menu.component'; +import { HighMemoryAlertModalComponent } from './features/shell/high-memory-alert-modal/high-memory-alert-modal.component'; import { UsersActions } from './store/users/users.actions'; import { RoomsActions } from './store/rooms/rooms.actions'; import { selectCurrentRoom } from './store/rooms/rooms.selectors'; @@ -81,6 +83,7 @@ import { AppI18nService, APP_TRANSLATE_IMPORTS } from './core/i18n'; DebugConsoleComponent, ScreenShareSourcePickerComponent, NativeContextMenuComponent, + HighMemoryAlertModalComponent, PrivateCallComponent, ThemeNodeDirective, ThemePickerOverlayComponent, @@ -103,6 +106,7 @@ export class App implements OnInit, OnDestroy { currentRoom = this.store.selectSignal(selectCurrentRoom); desktopUpdates = inject(DesktopAppUpdateService); desktopUpdateState = this.desktopUpdates.state; + desktopHighMemoryAlert = inject(DesktopHighMemoryAlertService); readonly databaseService = inject(DatabaseService); readonly router = inject(Router); readonly servers = inject(ServerDirectoryFacade); @@ -288,6 +292,7 @@ export class App implements OnInit, OnDestroy { // - desktop deep-link bridge (only relevant after first paint) // - background presence + game activity loops void this.desktopUpdates.initialize(); + void this.desktopHighMemoryAlert.initialize(); void this.kickOffBackgroundBootstrap(); // The only thing we genuinely must await before deciding which route diff --git a/toju-app/src/app/core/platform/electron/electron-api.models.ts b/toju-app/src/app/core/platform/electron/electron-api.models.ts index f7eeaf1..fad87a7 100644 --- a/toju-app/src/app/core/platform/electron/electron-api.models.ts +++ b/toju-app/src/app/core/platform/electron/electron-api.models.ts @@ -251,6 +251,13 @@ export interface ElectronPerfDiagEntry { payload: Record; } +export interface ElectronHighMemoryAlertRecord { + logFilePath: string; + detectedAt: number; + peakWorkingSetKb: number; + sessionId: string; +} + export interface ElectronApi { linuxDisplayServer: string; minimizeWindow: () => void; @@ -272,6 +279,9 @@ export interface ElectronApi { getAppMetrics: () => Promise; isPerfDiagEnabled?: () => Promise; reportPerfDiagSample?: (entry: ElectronPerfDiagEntry) => Promise; + getPendingHighMemoryAlert?: () => Promise; + acknowledgeHighMemoryAlert?: () => Promise; + showLogFileInFolder?: (filePath: string) => Promise<{ shown: boolean; reason?: string }>; getAppDataPath: () => Promise; openCurrentDataFolder: () => Promise; exportUserData: () => Promise; diff --git a/toju-app/src/app/core/platform/electron/electron-app-metrics.rules.spec.ts b/toju-app/src/app/core/platform/electron/electron-app-metrics.rules.spec.ts index 60a90c7..06c0863 100644 --- a/toju-app/src/app/core/platform/electron/electron-app-metrics.rules.spec.ts +++ b/toju-app/src/app/core/platform/electron/electron-app-metrics.rules.spec.ts @@ -6,6 +6,7 @@ import { import { formatAppRamLabel, + formatKilobytesAsGigabytes, formatKilobytesAsMegabytes, sumWorkingSetKb } from './electron-app-metrics.rules'; @@ -38,6 +39,13 @@ describe('sumWorkingSetKb', () => { }); }); +describe('formatKilobytesAsGigabytes', () => { + it('formats totals in gigabytes with two decimals', () => { + expect(formatKilobytesAsGigabytes(1536 * 1024)).toBe('1.50'); + expect(formatKilobytesAsGigabytes(2 * 1024 * 1024)).toBe('2.00'); + }); +}); + describe('formatKilobytesAsMegabytes', () => { it('rounds large values to whole megabytes', () => { expect(formatKilobytesAsMegabytes(412 * 1024)).toBe('412 MB'); diff --git a/toju-app/src/app/core/platform/electron/electron-app-metrics.rules.ts b/toju-app/src/app/core/platform/electron/electron-app-metrics.rules.ts index ae050cf..2570e2e 100644 --- a/toju-app/src/app/core/platform/electron/electron-app-metrics.rules.ts +++ b/toju-app/src/app/core/platform/electron/electron-app-metrics.rules.ts @@ -36,6 +36,10 @@ export function formatKilobytesAsMegabytes(kilobytes: number): string { return `${megabytes.toFixed(2)} MB`; } +export function formatKilobytesAsGigabytes(kilobytes: number): string { + return (kilobytes / (1024 * 1024)).toFixed(2); +} + export function formatAppRamLabel(snapshot: ElectronAppMetricsSnapshot): string | null { const totalKb = sumWorkingSetKb(snapshot.processes); diff --git a/toju-app/src/app/core/services/desktop-high-memory-alert.service.ts b/toju-app/src/app/core/services/desktop-high-memory-alert.service.ts new file mode 100644 index 0000000..aa2359a --- /dev/null +++ b/toju-app/src/app/core/services/desktop-high-memory-alert.service.ts @@ -0,0 +1,82 @@ +import { + Injectable, + computed, + inject, + signal +} from '@angular/core'; + +import { PlatformService } from '../platform'; +import type { ElectronHighMemoryAlertRecord } from '../platform/electron/electron-api.models'; +import { ElectronBridgeService } from '../platform/electron/electron-bridge.service'; +import { formatKilobytesAsGigabytes } from '../platform/electron/electron-app-metrics.rules'; + +@Injectable({ providedIn: 'root' }) +export class DesktopHighMemoryAlertService { + private readonly platform = inject(PlatformService); + private readonly electronBridge = inject(ElectronBridgeService); + + readonly pendingAlert = signal(null); + + readonly peakUsageGb = computed(() => { + const alert = this.pendingAlert(); + + return alert ? formatKilobytesAsGigabytes(alert.peakWorkingSetKb) : null; + }); + + async initialize(): Promise { + if (!this.platform.isElectron) { + return; + } + + const api = this.electronBridge.getApi(); + + if (!api?.getPendingHighMemoryAlert) { + return; + } + + const alert = await api.getPendingHighMemoryAlert(); + + if (alert) { + this.pendingAlert.set(alert); + } + } + + async dismiss(): Promise { + const api = this.electronBridge.getApi(); + + await api?.acknowledgeHighMemoryAlert?.(); + this.pendingAlert.set(null); + } + + async openLogFile(): Promise { + const alert = this.pendingAlert(); + const api = this.electronBridge.getApi(); + + if (!alert?.logFilePath || !api?.openFilePath) { + return; + } + + await api.openFilePath(alert.logFilePath); + } + + async showLogFileInFolder(): Promise { + const alert = this.pendingAlert(); + const api = this.electronBridge.getApi(); + + if (!alert?.logFilePath || !api?.showLogFileInFolder) { + return; + } + + await api.showLogFileInFolder(alert.logFilePath); + } + + async copyLogPath(): Promise { + const alert = this.pendingAlert(); + + if (!alert?.logFilePath) { + return; + } + + await navigator.clipboard.writeText(alert.logFilePath); + } +} diff --git a/toju-app/src/app/features/shell/high-memory-alert-modal/high-memory-alert-modal.component.html b/toju-app/src/app/features/shell/high-memory-alert-modal/high-memory-alert-modal.component.html new file mode 100644 index 0000000..a0a7cfe --- /dev/null +++ b/toju-app/src/app/features/shell/high-memory-alert-modal/high-memory-alert-modal.component.html @@ -0,0 +1,85 @@ +@if (alertService.pendingAlert(); as alert) { + + +
+
+ + +

+ {{ 'app.highMemoryAlert.badge' | translate }} +

+ +

+ {{ 'app.highMemoryAlert.title' | translate:{ usageGb: alertService.peakUsageGb() } }} +

+ +

+ {{ 'app.highMemoryAlert.message' | translate }} +

+ +

+ {{ alert.logFilePath }} +

+ +
+ + + + + + + +
+
+
+} diff --git a/toju-app/src/app/features/shell/high-memory-alert-modal/high-memory-alert-modal.component.ts b/toju-app/src/app/features/shell/high-memory-alert-modal/high-memory-alert-modal.component.ts new file mode 100644 index 0000000..e19d1ff --- /dev/null +++ b/toju-app/src/app/features/shell/high-memory-alert-modal/high-memory-alert-modal.component.ts @@ -0,0 +1,47 @@ +import { Component, inject } from '@angular/core'; +import { NgIcon, provideIcons } from '@ng-icons/core'; +import { lucideX } from '@ng-icons/lucide'; + +import { DesktopHighMemoryAlertService } from '../../../core/services/desktop-high-memory-alert.service'; +import { ThemeNodeDirective } from '../../../domains/theme'; +import { APP_TRANSLATE_IMPORTS } from '../../../core/i18n'; +import { ModalBackdropComponent } from '../../../shared/components/modal-backdrop/modal-backdrop.component'; + +@Component({ + selector: 'app-high-memory-alert-modal', + standalone: true, + imports: [ + NgIcon, + ThemeNodeDirective, + ModalBackdropComponent, + ...APP_TRANSLATE_IMPORTS + ], + viewProviders: [ + provideIcons({ + lucideX + }) + ], + templateUrl: './high-memory-alert-modal.component.html', + host: { + style: 'display: contents;' + } +}) +export class HighMemoryAlertModalComponent { + readonly alertService = inject(DesktopHighMemoryAlertService); + + async dismiss(): Promise { + await this.alertService.dismiss(); + } + + openLogFile(): void { + void this.alertService.openLogFile(); + } + + showLogFileInFolder(): void { + void this.alertService.showLogFileInFolder(); + } + + copyLogPath(): void { + void this.alertService.copyLogPath(); + } +} diff --git a/toju-app/src/app/infrastructure/diagnostics/diagnostics.bootstrap.ts b/toju-app/src/app/infrastructure/diagnostics/diagnostics.bootstrap.ts index c2f47a6..700c23f 100644 --- a/toju-app/src/app/infrastructure/diagnostics/diagnostics.bootstrap.ts +++ b/toju-app/src/app/infrastructure/diagnostics/diagnostics.bootstrap.ts @@ -7,6 +7,11 @@ import type { ElectronApi } from '../../core/platform/electron/electron-api.mode import { PerfDiagnosticsCollector, publishRendererDiagnosticsSample } from './diagnostics.collector'; import type { PerfDiagEntry, PerfDiagReporter } from './diagnostics.models'; +declare global { + // Registered for synchronous main-process sampling at high-memory threshold. + var __collectPerfDiagSample: (() => PerfDiagEntry[]) | undefined; +} + const SAMPLE_INTERVAL_MS = 10_000; let started = false; @@ -36,6 +41,22 @@ export async function bootstrapPerfDiagnostics( started = true; + let immediateSampleCollector: PerfDiagnosticsCollector | null = null; + + runInInjectionContext(injector, () => { + immediateSampleCollector = inject(PerfDiagnosticsCollector); + }); + + globalThis.__collectPerfDiagSample = () => { + if (!immediateSampleCollector) { + return []; + } + + const sample = immediateSampleCollector.collectSample(); + + return sample ? immediateSampleCollector.buildEntries(sample) : []; + }; + const reporter: PerfDiagReporter = { report: (entry: PerfDiagEntry) => reportSample(entry) }; @@ -92,5 +113,6 @@ function stopPerfDiagnosticsSampling(): void { sampleTimer = null; } + delete globalThis.__collectPerfDiagSample; started = false; } diff --git a/toju-app/src/app/infrastructure/diagnostics/diagnostics.models.ts b/toju-app/src/app/infrastructure/diagnostics/diagnostics.models.ts index b415e4a..b7991eb 100644 --- a/toju-app/src/app/infrastructure/diagnostics/diagnostics.models.ts +++ b/toju-app/src/app/infrastructure/diagnostics/diagnostics.models.ts @@ -2,10 +2,12 @@ export type PerfDiagSource = 'main' | 'renderer'; export type PerfDiagEntryType = | 'session' + | 'environment' | 'process' | 'store' | 'components' | 'heap' + | 'high-memory' | 'crash' | 'unresponsive'; diff --git a/tools/perf-diag-viewer.js b/tools/perf-diag-viewer.js index 6d0a7c9..828bbef 100644 --- a/tools/perf-diag-viewer.js +++ b/tools/perf-diag-viewer.js @@ -80,36 +80,58 @@ function formatKb(kb) { } function summarize(entries) { + const latestHighMemory = [...entries].reverse().find((entry) => entry.type === 'high-memory'); const latestProcess = [...entries].reverse().find((entry) => entry.type === 'process'); - const latestStore = [...entries].reverse().find((entry) => entry.type === 'store'); - const latestComponents = [...entries].reverse().find((entry) => entry.type === 'components'); - const latestHeap = [...entries].reverse().find((entry) => entry.type === 'heap'); + const latestStore = latestHighMemory?.payload?.recentRendererSamples?.store + ?? [...entries].reverse().find((entry) => entry.type === 'store')?.payload; + const latestComponents = latestHighMemory?.payload?.recentRendererSamples?.components + ?? [...entries].reverse().find((entry) => entry.type === 'components')?.payload; + const latestHeap = latestHighMemory?.payload?.recentRendererSamples?.heap + ?? [...entries].reverse().find((entry) => entry.type === 'heap')?.payload; - if (latestProcess) { + if (latestHighMemory?.payload?.summary) { + const summary = latestHighMemory.payload.summary; + + console.log(`High memory threshold crossed: ${summary.totalWorkingSetGb} GB (threshold ${summary.thresholdGb} GB)`); + + if (Array.isArray(summary.topProcesses) && summary.topProcesses.length > 0) { + console.log('Top processes:'); + + for (const process of summary.topProcesses.slice(0, 8)) { + console.log(` ${process.type} (pid ${process.pid}): ${formatKb(process.workingSetKb)} (${process.sharePercent}%)`); + } + } + } else if (latestProcess) { console.log(`Process RSS total: ${formatKb(latestProcess.payload.totalWorkingSetKb)}`); } if (latestHeap) { - console.log(`Renderer JS heap: ${latestHeap.payload.usedJsHeapMb ?? 'n/a'} MB`); + console.log(`Renderer JS heap: ${latestHeap.usedJsHeapMb ?? 'n/a'} MB`); } - if (latestStore?.payload?.domains) { + if (latestHighMemory?.payload?.mainProcessMemoryMb) { + const mainMemory = latestHighMemory.payload.mainProcessMemoryMb; + + console.log(`Main process heap used: ${mainMemory.heapUsedMb ?? 'n/a'} MB`); + } + + if (latestStore?.domains) { console.log('Store domains (estimated bytes):'); - for (const [domain, bytes] of Object.entries(latestStore.payload.domains).sort((a, b) => b[1] - a[1])) { + for (const [domain, bytes] of Object.entries(latestStore.domains).sort((a, b) => b[1] - a[1])) { console.log(` ${domain}: ${bytes}`); } } - if (latestComponents?.payload?.domains) { + if (latestComponents?.domains) { console.log('Live components by domain:'); - for (const [domain, count] of Object.entries(latestComponents.payload.domains).sort((a, b) => b[1] - a[1])) { + for (const [domain, count] of Object.entries(latestComponents.domains).sort((a, b) => b[1] - a[1])) { console.log(` ${domain}: ${count}`); } } - const leaks = latestComponents?.payload?.suspectedLeaks; + const leaks = latestComponents?.suspectedLeaks; if (Array.isArray(leaks) && leaks.length > 0) { console.log('Suspected component leaks:');