| 1 | import type { ProcessMetric } from "electron"; |
| 2 | |
| 3 | interface ProcessSample { |
| 4 | pid: number; type: string; creationTime: number; |
| 5 | cpuPercent: number | null; workingSetMb: number | null; privateMb: number | null; |
| 6 | } |
| 7 | interface Sample { |
| 8 | atMs: number; intervalMs: number | null; truncated: boolean; processes: ProcessSample[]; |
| 9 | } |
| 10 | export interface MemoryGrowth { |
| 11 | pid: number; type: string; metric: "private" | "working-set"; |
| 12 | baselineMb: number; currentMb: number; durationMs: number; |
| 13 | } |
| 14 | |
| 15 | export function detectMemoryGrowth(samples: readonly Sample[]): MemoryGrowth[] { |
| 16 | if (samples.length < 5) return []; |
| 17 | const last = samples[samples.length - 1]; |
| 18 | if (last.atMs - samples[0].atMs < 120_000) return []; |
| 19 | const growth: MemoryGrowth[] = []; |
| 20 | for (const process of last.processes) { |
| 21 | if (!Number.isFinite(process.creationTime)) continue; |
| 22 | const rows = samples.map((s) => s.processes.find((p) => p.pid === process.pid && p.creationTime === process.creationTime)); |
| 23 | // Require one continuous process identity and a settled two-reading baseline. |
| 24 | if (rows.some((row) => !row)) continue; |
| 25 | const metric = rows.every((row) => row!.privateMb !== null) ? "private" : "working-set"; |
| 26 | const values = rows.map((row) => metric === "private" ? row!.privateMb : row!.workingSetMb); |
| 27 | if (values.some((v) => v === null)) continue; |
| 28 | const baselineMb = Math.max(values[0]!, values[1]!); |
| 29 | const threshold = baselineMb + Math.max(256, baselineMb * 0.5); |
| 30 | if (!values.slice(-3).every((value) => value! >= threshold)) continue; |
| 31 | growth.push({ pid: process.pid, type: process.type, metric, baselineMb, currentMb: values[values.length - 1]!, durationMs: last.atMs - samples[0].atMs }); |
| 32 | } |
| 33 | return growth.sort((a, b) => (b.currentMb - b.baselineMb) - (a.currentMb - a.baselineMb)).slice(0, 3); |
| 34 | } |
| 35 | |
| 36 | // Numeric process metrics only. This does not include the Go service and must |
| 37 | // never be described as a leak detector or the app's exclusive physical memory. |
| 38 | export class ProcessDiagnostics { |
| 39 | private samples: Sample[] = []; |
| 40 | private previousAt: number | undefined; |
| 41 | private previousProcesses = new Set<string>(); |
| 42 | constructor( |
| 43 | private readonly read: () => ProcessMetric[], |
| 44 | private readonly now = () => performance.now(), |
| 45 | private readonly foreground = () => true, |
| 46 | ) {} |
| 47 | |
| 48 | sample(): void { |
| 49 | const atMs = this.now(); |
| 50 | const interval = this.foreground() ? 30_000 : 60_000; |
| 51 | if (this.previousAt !== undefined && atMs - this.previousAt < interval) return; |
| 52 | try { |
| 53 | const metrics = this.read(); |
| 54 | const intervalMs = this.previousAt === undefined ? null : atMs - this.previousAt; |
| 55 | const finite = (n: number | undefined) => typeof n === "number" && Number.isFinite(n) && n >= 0 ? n : null; |
| 56 | const mb = (n: number | undefined) => { const value = finite(n); return value === null ? null : value / 1024; }; |
| 57 | const types = new Set(["Browser", "Tab", "GPU", "Utility", "Zygote", "Sandbox helper", "Pepper Plugin", "Pepper Plugin Broker"]); |
| 58 | const processes = metrics.slice(0, 128).map((metric) => ({ |
| 59 | pid: metric.pid, |
| 60 | type: types.has(metric.type) ? metric.type : "Other", |
| 61 | creationTime: metric.creationTime, |
| 62 | cpuPercent: intervalMs === null || !this.previousProcesses.has(`${metric.pid}:${metric.creationTime}`) ? null : finite(metric.cpu?.percentCPUUsage), |
| 63 | workingSetMb: mb(metric.memory?.workingSetSize), |
| 64 | privateMb: mb(metric.memory?.privateBytes), |
| 65 | })); |
| 66 | this.previousAt = atMs; |
| 67 | this.previousProcesses = new Set(processes.map((p) => `${p.pid}:${p.creationTime}`)); |
| 68 | this.samples = [...this.samples.filter((s) => atMs - s.atMs <= 300_000), { atMs, intervalMs, truncated: metrics.length > 128, processes }].slice(-12); |
| 69 | } catch { /* A failed diagnostic sample must never affect the app. */ } |
| 70 | } |
| 71 | |
| 72 | snapshot() { |
| 73 | this.sample(); |
| 74 | const now = this.now(); |
| 75 | const samples = this.samples.filter((s) => now - s.atMs <= 300_000); |
| 76 | return { |
| 77 | scope: "electron" as const, |
| 78 | growth: detectMemoryGrowth(samples), |
| 79 | samples: samples.map(({ atMs, ...sample }) => ({ ageMs: now - atMs, ...sample })), |
| 80 | }; |
| 81 | } |
| 82 | } |
| 83 |