diff --git a/core/main/diagnostics.ts b/core/main/diagnostics.ts index 3440547..e9590ab 100644 --- a/core/main/diagnostics.ts +++ b/core/main/diagnostics.ts @@ -7,6 +7,7 @@ import { createDiagLog, NULL_DIAG_LOG, setDiagMain, type DiagLog } from './diagL import type { IpcMainLike } from './ipc' import { timeIpcMain, type IpcMainPatchable } from './ipcTiming' import { startStallWatch, type StallWatch } from './stallWatch' +import { startMemWatch, type MemWatch, type ProcessMetricLike } from './memWatch' /** * THE DIAGNOSTICS LOG, WIRED IN ONE CALL (#140). A host calls this in main @@ -39,6 +40,9 @@ export interface DiagnosticsDeps { * app is ready, so it is hooked in `watchWindow`. Its `suspend` and * `resume` keep a sleep from being logged as a lag. */ powerMonitor?: () => EmitterLike + /** Electron's `app.getAppMetrics`: each process's memory for the `memory` + * crumb and `mem-high` (2026-10-16). Absent: no memory readings. */ + appMetrics?: () => ProcessMetricLike[] } /** The slice of a BrowserWindow the watch needs. */ @@ -93,6 +97,8 @@ export function startDiagnostics(deps: DiagnosticsDeps): Diagnostics { return typeof stack === 'string' ? stack : null } }) + const metrics = deps.appMetrics + const mem: MemWatch | null = metrics ? startMemWatch({ log, metrics }) : null const unhook = hookCrashes({ log, process: deps.process, app: deps.app }) registerDiagIpc({ ipcMain: deps.ipcMain, log, watch, openFolder: deps.openFolder }) @@ -117,6 +123,7 @@ export function startDiagnostics(deps: DiagnosticsDeps): Diagnostics { }, stop: () => { watch.stop() + mem?.stop() power?.removeListener('suspend', onSuspend) power?.removeListener('resume', onResume) power = null diff --git a/core/main/memWatch.test.ts b/core/main/memWatch.test.ts new file mode 100644 index 0000000..39f1120 --- /dev/null +++ b/core/main/memWatch.test.ts @@ -0,0 +1,71 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { DiagLog } from './diagLog' +import { startMemWatch, type ProcessMetricLike } from './memWatch' + +const fakeLog = (): { log: DiagLog; lines: Array<{ k: string; f: Record }> } => { + const lines: Array<{ k: string; f: Record }> = [] + const log = { write: (_src: string, k: string, f?: Record) => lines.push({ k, f: f ?? {} }) } as unknown as DiagLog + return { log, lines } +} +const proc = (type: string, mb: number): ProcessMetricLike => ({ type, memory: { workingSetSize: mb * 512, privateBytes: mb * 1024 } }) + +describe('the memory watch (2026-10-16: a 15.9 GB page nobody saw growing)', () => { + afterEach(() => vi.useRealTimers()) + + it('a crumb with each kind of process, in MB of private memory', () => { + const { log, lines } = fakeLog() + const w = startMemWatch({ log, metrics: () => [proc('Browser', 240), proc('Tab', 300), proc('GPU', 350), proc('Utility', 13)] }) + w.sample() + w.stop() + expect(lines).toEqual([{ k: 'crumb', f: { a: 'memory', mainMb: 240, pageMb: 300, gpuMb: 350, otherMb: 13 } }]) + }) + + it('every five minutes, by itself', async () => { + vi.useFakeTimers() + const { log, lines } = fakeLog() + const w = startMemWatch({ log, metrics: () => [proc('Tab', 100)] }) + await vi.advanceTimersByTimeAsync(15 * 60 * 1000) + w.stop() + expect(lines.filter((l) => l.k === 'crumb')).toHaveLength(3) + }) + + it('mem-high when the page passes 2 GB, then only at each doubling', () => { + const { log, lines } = fakeLog() + let page = 1500 + const w = startMemWatch({ log, metrics: () => [proc('Tab', page)] }) + const highs = (): number[] => lines.filter((l) => l.k === 'mem-high').map((l) => l.f.overMb as number) + w.sample() + expect(highs()).toEqual([]) + page = 2100 + w.sample() + page = 3000 + w.sample() + expect(highs()).toEqual([2048]) + page = 9800 // past 4 and 8 GB at once: one line, the next at 16 GB + w.sample() + page = 15900 + w.sample() + expect(highs()).toEqual([2048, 4096]) + page = 16500 + w.sample() + expect(highs()).toEqual([2048, 4096, 16384]) + w.stop() + }) + + it('the working set where there are no private bytes; a metrics call that throws is skipped', () => { + const { log, lines } = fakeLog() + let fail = false + const w = startMemWatch({ + log, + metrics: () => { + if (fail) throw new Error('gone') + return [{ type: 'Tab', memory: { workingSetSize: 200 * 1024 } }] + } + }) + w.sample() + fail = true + w.sample() + w.stop() + expect(lines).toEqual([{ k: 'crumb', f: { a: 'memory', mainMb: 0, pageMb: 200, gpuMb: 0, otherMb: 0 } }]) + }) +}) diff --git a/core/main/memWatch.ts b/core/main/memWatch.ts new file mode 100644 index 0000000..c73691f --- /dev/null +++ b/core/main/memWatch.ts @@ -0,0 +1,74 @@ +import type { DiagLog } from './diagLog' + +/** + * HOW BIG EACH PROCESS IS, EVERY FEW MINUTES (owner, 2026-10-16, after every + * Claude tab in the Stable copy stopped taking keys: the page process had + * grown to 15.9 GB overnight, 2.5 MB a second, and nothing in the log said + * so; it took a live look at the process list to see it). A `memory` crumb + * every five minutes with each kind of process's private memory, so the next + * leak shows as a rising line long before it hurts, and the crumbs before a + * stall say how big the page was. When the page passes 2 GB a `mem-high` + * problem is written, and again at every doubling (4, 8, 16 GB), never more + * often: a page that big is a leak, not a busy terminal (the page is a few + * hundred MB with eight Claude tabs, MEASURED the same night after a restart: + * 74 MB at start). + */ + +/** The slice of Electron's `app.getAppMetrics()` this reads (sizes in KB). */ +export interface ProcessMetricLike { + type: string + memory: { workingSetSize: number; privateBytes?: number } +} + +export interface MemWatchDeps { + log: DiagLog + metrics(): ProcessMetricLike[] + everyMs?: number + /** The page size, in MB, of the first `mem-high`. */ + highMb?: number +} + +export interface MemWatch { + /** One reading now (the timer's, and the tests'). */ + sample(): void + stop(): void +} + +export const MEM_EVERY_MS = 5 * 60 * 1000 +export const MEM_HIGH_MB = 2048 + +/** Electron's process types, as the crumb names them. */ +const KIND: Record = { Browser: 'mainMb', Tab: 'pageMb', GPU: 'gpuMb' } + +export function startMemWatch(deps: MemWatchDeps): MemWatch { + const highMb = deps.highMb ?? MEM_HIGH_MB + let nextHigh = highMb + const sample = (): void => { + let list: ProcessMetricLike[] + try { + list = deps.metrics() + } catch { + return + } + const mb = { mainMb: 0, pageMb: 0, gpuMb: 0, otherMb: 0 } + for (const p of list) { + // Private bytes is what a leak grows; the working set where Windows has none. + const kb = p.memory.privateBytes ?? p.memory.workingSetSize + mb[KIND[p.type] ?? 'otherMb'] += kb / 1024 + } + const out = { + mainMb: Math.round(mb.mainMb), + pageMb: Math.round(mb.pageMb), + gpuMb: Math.round(mb.gpuMb), + otherMb: Math.round(mb.otherMb) + } + deps.log.write('main', 'crumb', { a: 'memory', ...out }) + if (out.pageMb >= nextHigh) { + deps.log.write('main', 'mem-high', { ...out, overMb: nextHigh }) + while (nextHigh <= out.pageMb) nextHigh *= 2 + } + } + const timer = setInterval(sample, deps.everyMs ?? MEM_EVERY_MS) + timer.unref?.() + return { sample, stop: () => clearInterval(timer) } +} diff --git a/core/package.json b/core/package.json index 0abc432..bd312cf 100644 --- a/core/package.json +++ b/core/package.json @@ -1,6 +1,6 @@ { "name": "prism-term-core", - "version": "0.40.9", + "version": "0.40.10", "description": "What Prism Terminal and Prism share: the terminal (pty, shells, agent detection and indicator, themes, links, the panel, dictation) and the update chip with its window. TypeScript source, compiled by the host.", "license": "MIT", "private": true, diff --git a/core/shared/termVersion.ts b/core/shared/termVersion.ts index 4304db6..0f299cb 100644 --- a/core/shared/termVersion.ts +++ b/core/shared/termVersion.ts @@ -7,5 +7,5 @@ * so a core release that bumps `core/package.json` (or xterm) without this * fails `npm test` (core/README.md, "Releasing the core"). */ -export const TERM_CORE_VERSION = '0.40.9' +export const TERM_CORE_VERSION = '0.40.10' export const XTERM_VERSION = '6.0.0' diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 78e72a7..83e4ce8 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -90,6 +90,7 @@ Quiet level, always on: | `session-end` | main | Windows is shutting down or logging off (no `quit` follows) | | | `main-lag` | main | main's 50 ms tick came 100 ms or more late (never for a sleep: `powerMonitor`) | `ms`, `inflight` (`ch`, `ms`, `done`) | | `fs-slow` | main | a `stat` of userData (every 5 s) took 500 ms or more | `ms` | +| `mem-high` | main | the page process passed 2 GB of private memory, then again at each doubling (2026-10-16: a leak took the page to 15.9 GB unseen) | `mainMb`, `pageMb`, `gpuMb`, `otherMb`, `overMb` | | `ipc-slow` | main | a `handle` settled after 500 ms, or a sync `on` body ran 100 ms; never a call that waits on the user or a download (the folder picker, an update install, a dictation download: `LONG_WAIT` plus the host's `longWaitChannels`), which is also left out of `inflight` | `ch`, `ms`, `args`, `ok`, `sync` | | `ipc-error` | main | a handler threw or rejected (rethrown unchanged) | `ch`, `ms`, `err`, `stack` | | `page-stall` | page | a long animation frame of 200 ms or more | `ms`, `blocking`, `scripts` (`src`, `fn`, `invoker`, `ms`), `crumbs` (`a`, `ago`) | @@ -166,6 +167,7 @@ Prism Terminal and the core say these today: | `shell-exit` | main (core) | `id`, `pid`, `exitCode` | | `shell-spawn-failed` | main (core) | `id`, `shell`, `cwd`, `err` | | `update-check` | main | `offered`, `fetchMs`, `offerMs`, `heapMb`, `rssMb` (every 4 h; see below) | +| `memory` | main | `mainMb`, `pageMb`, `gpuMb`, `otherMb`: each kind of process's private memory, every 5 min (`core/main/memWatch.ts`; a host passes `app.getAppMetrics`). A rising `pageMb` is a page leak: `npm run diag -- --all --kinds crumb` shows the line | Adding one: `crumb('name', { fields })` from `core/renderer/lib/diag` in a page, or `diagMain().write('main', 'crumb', { a: 'name', ... })` in main. A crumb that can fire many times a diff --git a/package-lock.json b/package-lock.json index b0ba6cd..00c18de 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "prism-terminal", - "version": "0.53.2", + "version": "0.53.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "prism-terminal", - "version": "0.53.2", + "version": "0.53.3", "license": "MIT", "dependencies": { "@xterm/addon-fit": "^0.11.0", diff --git a/package.json b/package.json index de5b828..923e948 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "prism-terminal", "productName": "Prism Terminal", - "version": "0.53.2", + "version": "0.53.3", "description": "A tabbed Windows terminal for AI CLIs.", "main": "./out/main/index.js", "author": "Max", diff --git a/src/main/index.ts b/src/main/index.ts index 2c29764..f735536 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -765,7 +765,8 @@ if (!app.requestSingleInstanceLock()) { openFolder: (dir) => pathOpeners.openPath(dir), // These wait on the user (the picker) or a download: never a stall. longWaitChannels: [APP_CH.dialogPickFolder, APP_CH.updateInstall], - powerMonitor: () => powerMonitor + powerMonitor: () => powerMonitor, + appMetrics: () => app.getAppMetrics() }) app.on('second-instance', (_e, argv, workingDirectory) => { // Resolved against the folder the second launch was typed in (#16). diff --git a/tools/diag.mjs b/tools/diag.mjs index 9deef26..8be7a3f 100644 --- a/tools/diag.mjs +++ b/tools/diag.mjs @@ -24,6 +24,7 @@ const APPS = { pt: 'PrismTerminal', stable: 'PrismTerminalStable', prism: 'Prism const PROBLEMS = new Set([ 'main-lag', 'fs-slow', + 'mem-high', 'ipc-slow', 'ipc-error', 'page-stall',