Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions core/main/diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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 })

Expand All @@ -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
Expand Down
71 changes: 71 additions & 0 deletions core/main/memWatch.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> }> } => {
const lines: Array<{ k: string; f: Record<string, unknown> }> = []
const log = { write: (_src: string, k: string, f?: Record<string, unknown>) => 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 } }])
})
})
74 changes: 74 additions & 0 deletions core/main/memWatch.ts
Original file line number Diff line number Diff line change
@@ -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<string, 'mainMb' | 'pageMb' | 'gpuMb'> = { 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) }
}
2 changes: 1 addition & 1 deletion core/package.json
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
2 changes: 1 addition & 1 deletion core/shared/termVersion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
2 changes: 2 additions & 0 deletions docs/diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`) |
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
3 changes: 2 additions & 1 deletion src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
1 change: 1 addition & 0 deletions tools/diag.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading