perf: diagnoistics improvements
This commit is contained in:
@@ -4,6 +4,10 @@ export interface AppMetricsProcessSnapshot {
|
|||||||
pid: number;
|
pid: number;
|
||||||
type: string;
|
type: string;
|
||||||
workingSetKb: number | null;
|
workingSetKb: number | null;
|
||||||
|
peakWorkingSetKb: number | null;
|
||||||
|
privateBytesKb: number | null;
|
||||||
|
creationTime: number | null;
|
||||||
|
cpuPercent: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppMetricsSnapshot {
|
export interface AppMetricsSnapshot {
|
||||||
@@ -17,7 +21,17 @@ export function collectAppMetricsSnapshot(): AppMetricsSnapshot {
|
|||||||
processes: app.getAppMetrics().map((metric) => ({
|
processes: app.getAppMetrics().map((metric) => ({
|
||||||
pid: metric.pid,
|
pid: metric.pid,
|
||||||
type: metric.type,
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { isPerfDiagEnabled } from './diagnostics.flags';
|
|||||||
describe('isPerfDiagEnabled', () => {
|
describe('isPerfDiagEnabled', () => {
|
||||||
it('returns false when the flag is unset', () => {
|
it('returns false when the flag is unset', () => {
|
||||||
expect(isPerfDiagEnabled({}, false)).toBe(false);
|
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', () => {
|
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);
|
expect(isPerfDiagEnabled({ METOYOU_PERF_DIAG: 'on' }, false)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns false in packaged builds unless force is set', () => {
|
it('returns true in packaged Electron builds without env flags', () => {
|
||||||
expect(isPerfDiagEnabled({ METOYOU_PERF_DIAG: '1' }, true)).toBe(false);
|
expect(isPerfDiagEnabled({}, true)).toBe(true);
|
||||||
expect(isPerfDiagEnabled({
|
expect(isPerfDiagEnabled({ METOYOU_PERF_DIAG: '0' }, true)).toBe(true);
|
||||||
METOYOU_PERF_DIAG: '1',
|
});
|
||||||
METOYOU_PERF_DIAG_FORCE: '1'
|
|
||||||
}, true)).toBe(true);
|
it('returns false in development when the flag is unset', () => {
|
||||||
|
expect(isPerfDiagEnabled({}, false)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,13 +17,9 @@ export function isPerfDiagEnabled(
|
|||||||
env: NodeJS.ProcessEnv,
|
env: NodeJS.ProcessEnv,
|
||||||
isPackaged: boolean
|
isPackaged: boolean
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!isTruthyFlag(env[PERF_DIAG_ENV])) {
|
if (isPackaged) {
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isPackaged && !isTruthyFlag(env[PERF_DIAG_FORCE_ENV])) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isTruthyFlag(env[PERF_DIAG_ENV]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,23 @@
|
|||||||
import {
|
import {
|
||||||
app,
|
app,
|
||||||
BrowserWindow,
|
BrowserWindow,
|
||||||
ipcMain
|
ipcMain,
|
||||||
|
shell
|
||||||
} from 'electron';
|
} 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 { sumWorkingSetKb } from './process-metrics.rules';
|
||||||
import { isPerfDiagEnabled } from './diagnostics.flags';
|
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 type { PerfDiagEntry } from './diagnostics.models';
|
||||||
import { PerfDiagWriter } from './diagnostics.writer';
|
import { PerfDiagWriter } from './diagnostics.writer';
|
||||||
|
|
||||||
@@ -15,6 +27,8 @@ let activeWriter: PerfDiagWriter | null = null;
|
|||||||
let processPollTimer: NodeJS.Timeout | null = null;
|
let processPollTimer: NodeJS.Timeout | null = null;
|
||||||
let diagnosticsEnabled = false;
|
let diagnosticsEnabled = false;
|
||||||
let ipcRegistered = false;
|
let ipcRegistered = false;
|
||||||
|
let highMemoryAlertTriggeredThisSession = false;
|
||||||
|
let sessionStartedAt = 0;
|
||||||
|
|
||||||
export function isPerfDiagActive(): boolean {
|
export function isPerfDiagActive(): boolean {
|
||||||
return diagnosticsEnabled;
|
return diagnosticsEnabled;
|
||||||
@@ -43,6 +57,37 @@ export function ensurePerfDiagIpcRegistered(): void {
|
|||||||
return false;
|
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 {
|
export function getActivePerfDiagWriter(): PerfDiagWriter | null {
|
||||||
@@ -64,9 +109,13 @@ export function startPerfDiagnostics(): PerfDiagWriter | null {
|
|||||||
});
|
});
|
||||||
|
|
||||||
activeWriter = writer;
|
activeWriter = writer;
|
||||||
|
highMemoryAlertTriggeredThisSession = false;
|
||||||
|
sessionStartedAt = Date.now();
|
||||||
registerProcessCrashHandlers(writer);
|
registerProcessCrashHandlers(writer);
|
||||||
startProcessMetricsPolling(writer);
|
startProcessMetricsPolling(writer);
|
||||||
|
|
||||||
|
const userDataPath = app.getPath('userData');
|
||||||
|
|
||||||
writer.append({
|
writer.append({
|
||||||
collectedAt: Date.now(),
|
collectedAt: Date.now(),
|
||||||
source: 'main',
|
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;
|
return writer;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,6 +256,8 @@ function startProcessMetricsPolling(writer: PerfDiagWriter): void {
|
|||||||
processes: metrics.processes
|
processes: metrics.processes
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void maybeTriggerHighMemoryAlert(writer, metrics, totalKb);
|
||||||
} catch {
|
} catch {
|
||||||
// Collector failures must never affect the app.
|
// Collector failures must never affect the app.
|
||||||
}
|
}
|
||||||
@@ -204,6 +267,64 @@ function startProcessMetricsPolling(writer: PerfDiagWriter): void {
|
|||||||
processPollTimer = setInterval(sample, PROCESS_POLL_INTERVAL_MS);
|
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 {
|
function normalizeRendererEntry(entry: PerfDiagEntry): PerfDiagEntry {
|
||||||
return {
|
return {
|
||||||
collectedAt: Number(entry.collectedAt) || Date.now(),
|
collectedAt: Number(entry.collectedAt) || Date.now(),
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ export type PerfDiagSource = 'main' | 'renderer';
|
|||||||
|
|
||||||
export type PerfDiagEntryType =
|
export type PerfDiagEntryType =
|
||||||
| 'session'
|
| 'session'
|
||||||
|
| 'environment'
|
||||||
| 'process'
|
| 'process'
|
||||||
| 'store'
|
| 'store'
|
||||||
| 'components'
|
| 'components'
|
||||||
| 'heap'
|
| 'heap'
|
||||||
|
| 'high-memory'
|
||||||
| 'crash'
|
| 'crash'
|
||||||
| 'unresponsive';
|
| 'unresponsive';
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
resolveDiagnosticsFilePath
|
resolveDiagnosticsFilePath
|
||||||
} from './diagnostics.rules';
|
} from './diagnostics.rules';
|
||||||
|
|
||||||
const DEFAULT_RING_CAPACITY = 120;
|
const DEFAULT_RING_CAPACITY = 300;
|
||||||
const FLUSH_DEBOUNCE_MS = 250;
|
const FLUSH_DEBOUNCE_MS = 250;
|
||||||
|
|
||||||
export interface PerfDiagWriterOptions {
|
export interface PerfDiagWriterOptions {
|
||||||
@@ -18,6 +18,7 @@ export interface PerfDiagWriterOptions {
|
|||||||
|
|
||||||
export class PerfDiagWriter {
|
export class PerfDiagWriter {
|
||||||
private readonly filePath: string;
|
private readonly filePath: string;
|
||||||
|
private readonly sessionIdValue: string;
|
||||||
private readonly ringCapacity: number;
|
private readonly ringCapacity: number;
|
||||||
private readonly pendingLines: string[] = [];
|
private readonly pendingLines: string[] = [];
|
||||||
private ring: PerfDiagEntry[] = [];
|
private ring: PerfDiagEntry[] = [];
|
||||||
@@ -26,10 +27,15 @@ export class PerfDiagWriter {
|
|||||||
private disabled = false;
|
private disabled = false;
|
||||||
|
|
||||||
constructor(options: PerfDiagWriterOptions) {
|
constructor(options: PerfDiagWriterOptions) {
|
||||||
|
this.sessionIdValue = options.sessionId;
|
||||||
this.filePath = resolveDiagnosticsFilePath(options.userDataPath, options.sessionId);
|
this.filePath = resolveDiagnosticsFilePath(options.userDataPath, options.sessionId);
|
||||||
this.ringCapacity = options.ringCapacity ?? DEFAULT_RING_CAPACITY;
|
this.ringCapacity = options.ringCapacity ?? DEFAULT_RING_CAPACITY;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get sessionId(): string {
|
||||||
|
return this.sessionIdValue;
|
||||||
|
}
|
||||||
|
|
||||||
get snapshotFilePath(): string {
|
get snapshotFilePath(): string {
|
||||||
return this.filePath;
|
return this.filePath;
|
||||||
}
|
}
|
||||||
|
|||||||
27
electron/diagnostics/high-memory-alert.rules.spec.ts
Normal file
27
electron/diagnostics/high-memory-alert.rules.spec.ts
Normal file
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
11
electron/diagnostics/high-memory-alert.rules.ts
Normal file
11
electron/diagnostics/high-memory-alert.rules.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
64
electron/diagnostics/high-memory-alert.store.spec.ts
Normal file
64
electron/diagnostics/high-memory-alert.store.spec.ts
Normal file
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
57
electron/diagnostics/high-memory-alert.store.ts
Normal file
57
electron/diagnostics/high-memory-alert.store.ts
Normal file
@@ -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<HighMemoryAlertRecord | null> {
|
||||||
|
try {
|
||||||
|
const raw = await fsp.readFile(resolveHighMemoryAlertPath(userDataPath), 'utf8');
|
||||||
|
const parsed = JSON.parse(raw) as Partial<HighMemoryAlertRecord>;
|
||||||
|
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
try {
|
||||||
|
await fsp.unlink(resolveHighMemoryAlertPath(userDataPath));
|
||||||
|
} catch {
|
||||||
|
// Missing pending alert is fine.
|
||||||
|
}
|
||||||
|
}
|
||||||
201
electron/diagnostics/high-memory-snapshot.rules.spec.ts
Normal file
201
electron/diagnostics/high-memory-snapshot.rules.spec.ts
Normal file
@@ -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
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
179
electron/diagnostics/high-memory-snapshot.rules.ts
Normal file
179
electron/diagnostics/high-memory-snapshot.rules.ts
Normal file
@@ -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<string, unknown> | null;
|
||||||
|
heap: Record<string, unknown> | null;
|
||||||
|
components: Record<string, unknown> | 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<string, unknown> | null = null;
|
||||||
|
let heap: Record<string, unknown> | null = null;
|
||||||
|
let components: Record<string, unknown> | 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<string, unknown>[] {
|
||||||
|
const history: Record<string, unknown>[] = [];
|
||||||
|
|
||||||
|
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<string, number> {
|
||||||
|
const counts: Record<string, number> = {};
|
||||||
|
|
||||||
|
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<string, number> {
|
||||||
|
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<string, unknown> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
39
electron/diagnostics/immediate-renderer-samples.collector.ts
Normal file
39
electron/diagnostics/immediate-renderer-samples.collector.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import type { BrowserWindow } from 'electron';
|
||||||
|
import type { PerfDiagEntry } from './diagnostics.models';
|
||||||
|
|
||||||
|
export async function collectImmediateRendererSamples(
|
||||||
|
window: BrowserWindow | null | undefined
|
||||||
|
): Promise<PerfDiagEntry[]> {
|
||||||
|
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<PerfDiagEntry>));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeImmediateRendererEntry(entry: Partial<PerfDiagEntry>): PerfDiagEntry {
|
||||||
|
return {
|
||||||
|
collectedAt: Number(entry.collectedAt) || Date.now(),
|
||||||
|
source: 'renderer',
|
||||||
|
type: entry.type ?? 'session',
|
||||||
|
payload: entry.payload ?? {}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,4 +1,16 @@
|
|||||||
export { isPerfDiagEnabled, PERF_DIAG_ENV, PERF_DIAG_FORCE_ENV } from './diagnostics.flags';
|
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 {
|
export {
|
||||||
attachRendererDiagnosticsHooks,
|
attachRendererDiagnosticsHooks,
|
||||||
ensurePerfDiagIpcRegistered,
|
ensurePerfDiagIpcRegistered,
|
||||||
|
|||||||
91
electron/diagnostics/session-context.collector.ts
Normal file
91
electron/diagnostics/session-context.collector.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -259,6 +259,14 @@ export interface ElectronAPI {
|
|||||||
type: string;
|
type: string;
|
||||||
payload: Record<string, unknown>;
|
payload: Record<string, unknown>;
|
||||||
}) => Promise<boolean>;
|
}) => Promise<boolean>;
|
||||||
|
getPendingHighMemoryAlert: () => Promise<{
|
||||||
|
logFilePath: string;
|
||||||
|
detectedAt: number;
|
||||||
|
peakWorkingSetKb: number;
|
||||||
|
sessionId: string;
|
||||||
|
} | null>;
|
||||||
|
acknowledgeHighMemoryAlert: () => Promise<boolean>;
|
||||||
|
showLogFileInFolder: (filePath: string) => Promise<{ shown: boolean; reason?: string }>;
|
||||||
getAppDataPath: () => Promise<string>;
|
getAppDataPath: () => Promise<string>;
|
||||||
openCurrentDataFolder: () => Promise<boolean>;
|
openCurrentDataFolder: () => Promise<boolean>;
|
||||||
exportUserData: () => Promise<ExportUserDataResult>;
|
exportUserData: () => Promise<ExportUserDataResult>;
|
||||||
@@ -400,6 +408,9 @@ const electronAPI: ElectronAPI = {
|
|||||||
getAppMetrics: () => ipcRenderer.invoke('get-app-metrics'),
|
getAppMetrics: () => ipcRenderer.invoke('get-app-metrics'),
|
||||||
isPerfDiagEnabled: () => ipcRenderer.invoke('perf-diag-is-enabled'),
|
isPerfDiagEnabled: () => ipcRenderer.invoke('perf-diag-is-enabled'),
|
||||||
reportPerfDiagSample: (entry) => ipcRenderer.invoke('perf-diag-report', entry),
|
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'),
|
getAppDataPath: () => ipcRenderer.invoke('get-app-data-path'),
|
||||||
openCurrentDataFolder: () => ipcRenderer.invoke('open-current-data-folder'),
|
openCurrentDataFolder: () => ipcRenderer.invoke('open-current-data-folder'),
|
||||||
exportUserData: () => ipcRenderer.invoke('export-user-data'),
|
exportUserData: () => ipcRenderer.invoke('export-user-data'),
|
||||||
|
|||||||
@@ -15,6 +15,16 @@
|
|||||||
"downloadedMessage": "The update has already been downloaded. Restart the app when you're ready to finish applying it.",
|
"downloadedMessage": "The update has already been downloaded. Restart the app when you're ready to finish applying it.",
|
||||||
"updateSettings": "Update settings",
|
"updateSettings": "Update settings",
|
||||||
"restartNow": "Restart now"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,16 @@
|
|||||||
"downloadedMessage": "The update has already been downloaded. Restart the app when you're ready to finish applying it.",
|
"downloadedMessage": "The update has already been downloaded. Restart the app when you're ready to finish applying it.",
|
||||||
"updateSettings": "Update settings",
|
"updateSettings": "Update settings",
|
||||||
"restartNow": "Restart now"
|
"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": {
|
"attachment": {
|
||||||
|
|||||||
@@ -167,6 +167,7 @@
|
|||||||
<app-incoming-call-modal />
|
<app-incoming-call-modal />
|
||||||
<app-screen-share-source-picker />
|
<app-screen-share-source-picker />
|
||||||
<app-native-context-menu />
|
<app-native-context-menu />
|
||||||
|
<app-high-memory-alert-modal />
|
||||||
<app-debug-console [showLauncher]="false" />
|
<app-debug-console [showLauncher]="false" />
|
||||||
<app-theme-picker-overlay />
|
<app-theme-picker-overlay />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
loadLastViewedChatFromStorage
|
loadLastViewedChatFromStorage
|
||||||
} from './infrastructure/persistence';
|
} from './infrastructure/persistence';
|
||||||
import { DesktopAppUpdateService } from './core/services/desktop-app-update.service';
|
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 { ServerDirectoryFacade } from './domains/server-directory';
|
||||||
import { NotificationsFacade } from './domains/notifications';
|
import { NotificationsFacade } from './domains/notifications';
|
||||||
import { TimeSyncService } from './core/services/time-sync.service';
|
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 { DebugConsoleComponent } from './shared/components/debug-console/debug-console.component';
|
||||||
import { ScreenShareSourcePickerComponent } from './shared/components/screen-share-source-picker/screen-share-source-picker.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 { 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 { UsersActions } from './store/users/users.actions';
|
||||||
import { RoomsActions } from './store/rooms/rooms.actions';
|
import { RoomsActions } from './store/rooms/rooms.actions';
|
||||||
import { selectCurrentRoom } from './store/rooms/rooms.selectors';
|
import { selectCurrentRoom } from './store/rooms/rooms.selectors';
|
||||||
@@ -81,6 +83,7 @@ import { AppI18nService, APP_TRANSLATE_IMPORTS } from './core/i18n';
|
|||||||
DebugConsoleComponent,
|
DebugConsoleComponent,
|
||||||
ScreenShareSourcePickerComponent,
|
ScreenShareSourcePickerComponent,
|
||||||
NativeContextMenuComponent,
|
NativeContextMenuComponent,
|
||||||
|
HighMemoryAlertModalComponent,
|
||||||
PrivateCallComponent,
|
PrivateCallComponent,
|
||||||
ThemeNodeDirective,
|
ThemeNodeDirective,
|
||||||
ThemePickerOverlayComponent,
|
ThemePickerOverlayComponent,
|
||||||
@@ -103,6 +106,7 @@ export class App implements OnInit, OnDestroy {
|
|||||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||||
desktopUpdates = inject(DesktopAppUpdateService);
|
desktopUpdates = inject(DesktopAppUpdateService);
|
||||||
desktopUpdateState = this.desktopUpdates.state;
|
desktopUpdateState = this.desktopUpdates.state;
|
||||||
|
desktopHighMemoryAlert = inject(DesktopHighMemoryAlertService);
|
||||||
readonly databaseService = inject(DatabaseService);
|
readonly databaseService = inject(DatabaseService);
|
||||||
readonly router = inject(Router);
|
readonly router = inject(Router);
|
||||||
readonly servers = inject(ServerDirectoryFacade);
|
readonly servers = inject(ServerDirectoryFacade);
|
||||||
@@ -288,6 +292,7 @@ export class App implements OnInit, OnDestroy {
|
|||||||
// - desktop deep-link bridge (only relevant after first paint)
|
// - desktop deep-link bridge (only relevant after first paint)
|
||||||
// - background presence + game activity loops
|
// - background presence + game activity loops
|
||||||
void this.desktopUpdates.initialize();
|
void this.desktopUpdates.initialize();
|
||||||
|
void this.desktopHighMemoryAlert.initialize();
|
||||||
void this.kickOffBackgroundBootstrap();
|
void this.kickOffBackgroundBootstrap();
|
||||||
|
|
||||||
// The only thing we genuinely must await before deciding which route
|
// The only thing we genuinely must await before deciding which route
|
||||||
|
|||||||
@@ -251,6 +251,13 @@ export interface ElectronPerfDiagEntry {
|
|||||||
payload: Record<string, unknown>;
|
payload: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ElectronHighMemoryAlertRecord {
|
||||||
|
logFilePath: string;
|
||||||
|
detectedAt: number;
|
||||||
|
peakWorkingSetKb: number;
|
||||||
|
sessionId: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ElectronApi {
|
export interface ElectronApi {
|
||||||
linuxDisplayServer: string;
|
linuxDisplayServer: string;
|
||||||
minimizeWindow: () => void;
|
minimizeWindow: () => void;
|
||||||
@@ -272,6 +279,9 @@ export interface ElectronApi {
|
|||||||
getAppMetrics: () => Promise<ElectronAppMetricsSnapshot>;
|
getAppMetrics: () => Promise<ElectronAppMetricsSnapshot>;
|
||||||
isPerfDiagEnabled?: () => Promise<boolean>;
|
isPerfDiagEnabled?: () => Promise<boolean>;
|
||||||
reportPerfDiagSample?: (entry: ElectronPerfDiagEntry) => Promise<boolean>;
|
reportPerfDiagSample?: (entry: ElectronPerfDiagEntry) => Promise<boolean>;
|
||||||
|
getPendingHighMemoryAlert?: () => Promise<ElectronHighMemoryAlertRecord | null>;
|
||||||
|
acknowledgeHighMemoryAlert?: () => Promise<boolean>;
|
||||||
|
showLogFileInFolder?: (filePath: string) => Promise<{ shown: boolean; reason?: string }>;
|
||||||
getAppDataPath: () => Promise<string>;
|
getAppDataPath: () => Promise<string>;
|
||||||
openCurrentDataFolder: () => Promise<boolean>;
|
openCurrentDataFolder: () => Promise<boolean>;
|
||||||
exportUserData: () => Promise<ExportUserDataResult>;
|
exportUserData: () => Promise<ExportUserDataResult>;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
formatAppRamLabel,
|
formatAppRamLabel,
|
||||||
|
formatKilobytesAsGigabytes,
|
||||||
formatKilobytesAsMegabytes,
|
formatKilobytesAsMegabytes,
|
||||||
sumWorkingSetKb
|
sumWorkingSetKb
|
||||||
} from './electron-app-metrics.rules';
|
} 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', () => {
|
describe('formatKilobytesAsMegabytes', () => {
|
||||||
it('rounds large values to whole megabytes', () => {
|
it('rounds large values to whole megabytes', () => {
|
||||||
expect(formatKilobytesAsMegabytes(412 * 1024)).toBe('412 MB');
|
expect(formatKilobytesAsMegabytes(412 * 1024)).toBe('412 MB');
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ export function formatKilobytesAsMegabytes(kilobytes: number): string {
|
|||||||
return `${megabytes.toFixed(2)} MB`;
|
return `${megabytes.toFixed(2)} MB`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatKilobytesAsGigabytes(kilobytes: number): string {
|
||||||
|
return (kilobytes / (1024 * 1024)).toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
export function formatAppRamLabel(snapshot: ElectronAppMetricsSnapshot): string | null {
|
export function formatAppRamLabel(snapshot: ElectronAppMetricsSnapshot): string | null {
|
||||||
const totalKb = sumWorkingSetKb(snapshot.processes);
|
const totalKb = sumWorkingSetKb(snapshot.processes);
|
||||||
|
|
||||||
|
|||||||
@@ -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<ElectronHighMemoryAlertRecord | null>(null);
|
||||||
|
|
||||||
|
readonly peakUsageGb = computed(() => {
|
||||||
|
const alert = this.pendingAlert();
|
||||||
|
|
||||||
|
return alert ? formatKilobytesAsGigabytes(alert.peakWorkingSetKb) : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
async initialize(): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
const api = this.electronBridge.getApi();
|
||||||
|
|
||||||
|
await api?.acknowledgeHighMemoryAlert?.();
|
||||||
|
this.pendingAlert.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async openLogFile(): Promise<void> {
|
||||||
|
const alert = this.pendingAlert();
|
||||||
|
const api = this.electronBridge.getApi();
|
||||||
|
|
||||||
|
if (!alert?.logFilePath || !api?.openFilePath) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await api.openFilePath(alert.logFilePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
async showLogFileInFolder(): Promise<void> {
|
||||||
|
const alert = this.pendingAlert();
|
||||||
|
const api = this.electronBridge.getApi();
|
||||||
|
|
||||||
|
if (!alert?.logFilePath || !api?.showLogFileInFolder) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await api.showLogFileInFolder(alert.logFilePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
async copyLogPath(): Promise<void> {
|
||||||
|
const alert = this.pendingAlert();
|
||||||
|
|
||||||
|
if (!alert?.logFilePath) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await navigator.clipboard.writeText(alert.logFilePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
@if (alertService.pendingAlert(); as alert) {
|
||||||
|
<app-modal-backdrop
|
||||||
|
[zIndex]="120"
|
||||||
|
[ariaLabel]="'app.highMemoryAlert.dismissAriaLabel' | translate"
|
||||||
|
(dismissed)="dismiss()"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
appThemeNode="highMemoryAlertDialog"
|
||||||
|
class="fixed inset-0 z-[121] flex items-center justify-center px-4"
|
||||||
|
role="alertdialog"
|
||||||
|
[attr.aria-labelledby]="'high-memory-alert-title'"
|
||||||
|
[attr.aria-describedby]="'high-memory-alert-description'"
|
||||||
|
>
|
||||||
|
<div class="relative w-full max-w-lg rounded-xl border border-border bg-card p-4 shadow-lg">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="dismiss()"
|
||||||
|
class="absolute right-2 top-2 grid h-8 w-8 place-items-center rounded-lg text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
|
||||||
|
[attr.aria-label]="'app.highMemoryAlert.dismissAriaLabel' | translate"
|
||||||
|
>
|
||||||
|
<ng-icon
|
||||||
|
name="lucideX"
|
||||||
|
class="h-4 w-4"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-destructive">
|
||||||
|
{{ 'app.highMemoryAlert.badge' | translate }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2
|
||||||
|
id="high-memory-alert-title"
|
||||||
|
class="mt-1 pr-10 text-base font-semibold text-foreground"
|
||||||
|
>
|
||||||
|
{{ 'app.highMemoryAlert.title' | translate:{ usageGb: alertService.peakUsageGb() } }}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p
|
||||||
|
id="high-memory-alert-description"
|
||||||
|
class="mt-2 pr-2 text-sm leading-6 text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{ 'app.highMemoryAlert.message' | translate }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class="mt-3 break-all rounded-lg border border-border/70 bg-secondary/40 px-3 py-2 font-mono text-[11px] leading-5 text-muted-foreground">
|
||||||
|
{{ alert.logFilePath }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-4 flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="openLogFile()"
|
||||||
|
class="inline-flex items-center rounded-lg bg-primary px-3 py-2 text-xs font-semibold text-primary-foreground transition-colors hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
{{ 'app.highMemoryAlert.openLog' | translate }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="showLogFileInFolder()"
|
||||||
|
class="inline-flex items-center rounded-lg border border-border bg-secondary px-3 py-2 text-xs font-medium text-foreground transition-colors hover:bg-secondary/80"
|
||||||
|
>
|
||||||
|
{{ 'app.highMemoryAlert.showInFolder' | translate }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="copyLogPath()"
|
||||||
|
class="inline-flex items-center rounded-lg border border-border bg-secondary px-3 py-2 text-xs font-medium text-foreground transition-colors hover:bg-secondary/80"
|
||||||
|
>
|
||||||
|
{{ 'app.highMemoryAlert.copyPath' | translate }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="dismiss()"
|
||||||
|
class="inline-flex items-center rounded-lg border border-border px-3 py-2 text-xs font-medium text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
|
||||||
|
>
|
||||||
|
{{ 'app.highMemoryAlert.dismiss' | translate }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -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<void> {
|
||||||
|
await this.alertService.dismiss();
|
||||||
|
}
|
||||||
|
|
||||||
|
openLogFile(): void {
|
||||||
|
void this.alertService.openLogFile();
|
||||||
|
}
|
||||||
|
|
||||||
|
showLogFileInFolder(): void {
|
||||||
|
void this.alertService.showLogFileInFolder();
|
||||||
|
}
|
||||||
|
|
||||||
|
copyLogPath(): void {
|
||||||
|
void this.alertService.copyLogPath();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,11 @@ import type { ElectronApi } from '../../core/platform/electron/electron-api.mode
|
|||||||
import { PerfDiagnosticsCollector, publishRendererDiagnosticsSample } from './diagnostics.collector';
|
import { PerfDiagnosticsCollector, publishRendererDiagnosticsSample } from './diagnostics.collector';
|
||||||
import type { PerfDiagEntry, PerfDiagReporter } from './diagnostics.models';
|
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;
|
const SAMPLE_INTERVAL_MS = 10_000;
|
||||||
|
|
||||||
let started = false;
|
let started = false;
|
||||||
@@ -36,6 +41,22 @@ export async function bootstrapPerfDiagnostics(
|
|||||||
|
|
||||||
started = true;
|
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 = {
|
const reporter: PerfDiagReporter = {
|
||||||
report: (entry: PerfDiagEntry) => reportSample(entry)
|
report: (entry: PerfDiagEntry) => reportSample(entry)
|
||||||
};
|
};
|
||||||
@@ -92,5 +113,6 @@ function stopPerfDiagnosticsSampling(): void {
|
|||||||
sampleTimer = null;
|
sampleTimer = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
delete globalThis.__collectPerfDiagSample;
|
||||||
started = false;
|
started = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ export type PerfDiagSource = 'main' | 'renderer';
|
|||||||
|
|
||||||
export type PerfDiagEntryType =
|
export type PerfDiagEntryType =
|
||||||
| 'session'
|
| 'session'
|
||||||
|
| 'environment'
|
||||||
| 'process'
|
| 'process'
|
||||||
| 'store'
|
| 'store'
|
||||||
| 'components'
|
| 'components'
|
||||||
| 'heap'
|
| 'heap'
|
||||||
|
| 'high-memory'
|
||||||
| 'crash'
|
| 'crash'
|
||||||
| 'unresponsive';
|
| 'unresponsive';
|
||||||
|
|
||||||
|
|||||||
@@ -80,36 +80,58 @@ function formatKb(kb) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function summarize(entries) {
|
function summarize(entries) {
|
||||||
|
const latestHighMemory = [...entries].reverse().find((entry) => entry.type === 'high-memory');
|
||||||
const latestProcess = [...entries].reverse().find((entry) => entry.type === 'process');
|
const latestProcess = [...entries].reverse().find((entry) => entry.type === 'process');
|
||||||
const latestStore = [...entries].reverse().find((entry) => entry.type === 'store');
|
const latestStore = latestHighMemory?.payload?.recentRendererSamples?.store
|
||||||
const latestComponents = [...entries].reverse().find((entry) => entry.type === 'components');
|
?? [...entries].reverse().find((entry) => entry.type === 'store')?.payload;
|
||||||
const latestHeap = [...entries].reverse().find((entry) => entry.type === 'heap');
|
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)}`);
|
console.log(`Process RSS total: ${formatKb(latestProcess.payload.totalWorkingSetKb)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (latestHeap) {
|
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):');
|
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}`);
|
console.log(` ${domain}: ${bytes}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (latestComponents?.payload?.domains) {
|
if (latestComponents?.domains) {
|
||||||
console.log('Live components by domain:');
|
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}`);
|
console.log(` ${domain}: ${count}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const leaks = latestComponents?.payload?.suspectedLeaks;
|
const leaks = latestComponents?.suspectedLeaks;
|
||||||
|
|
||||||
if (Array.isArray(leaks) && leaks.length > 0) {
|
if (Array.isArray(leaks) && leaks.length > 0) {
|
||||||
console.log('Suspected component leaks:');
|
console.log('Suspected component leaks:');
|
||||||
|
|||||||
Reference in New Issue
Block a user