fix(memory): report the Windows number that predicts paging, not just resident pages (#16211) (#16589)

* fix(memory): report Windows commit charge, not just working set (#16211)

On Windows the per-process figure was working set — resident pages only.
An agent whose pages Windows has trimmed to the pagefile shrinks its
working set while still holding the commit that pushes the host into
paging, so Resource Manager and `orca diagnostics memory` understated an
owned tree by 10-40x (9 codex.exe: 1.4 GB working set, 13.4 GB private)
and could not warn before the host was already thrashing.

Add committed private bytes as a second, separately-labelled quantity
rather than redefining the existing one:

- CIM sweep gains one property (PageFileUsage, UInt32 KB); the typeperf
  fallback gains one counter (\Process(*)\Private Bytes). Both ride the
  sweep that already runs.
- MemorySnapshot gains optional `privateMemory` per app/worktree/session
  plus `processCommitMetric` and `totalPrivateMemory`. Rule 1 additive
  optional fields: old clients ignore them, and absence reads as "not
  measured", never as zero — Unix hosts and older hosts send nothing.
- `totalMemory` and `processMemoryMetric` keep their exact meaning, so
  the "shared pages may repeat" copy stays true; the working-set copy now
  also says paged-out memory is not counted.
- Resource Manager shows "Σ Private" beside "Σ WS", and tints the badge
  yellow/red once tracked commit passes 60/80% of physical RAM — the same
  thresholds `usageTextColorClass` already uses for host usage. Tint and
  tooltip only; no toast, and the badge number is unchanged.

The parsers move to windows-process-sample-parsing.ts and the Windows
sweep tests to their own file to stay under max-lines.

Not migrating the collector to windows-process-table.ts: the native
snapshot exposes no commit figure and no CPU times, and truncates
WorkingSetSize through a DWORD. Documented in the enumeration reference.

* fix(memory): derive the typeperf field cap from the counter list

The fallback parser's 8192-field cap was sized for three `\Process(*)`
counters. Adding `Private Bytes` cut the parsable process count from ~2730
to ~2047, and overrun is a blackout (`parseTypeperfCsvLine` returns `[]`, so
the whole sweep reports nothing) rather than a truncation. The counter list
now lives beside the decoder that reads those names back out of the PDH
header, and the cap is derived from it.

Also collapses the four spellings of "omit privateMemory when unmeasured"
in collector.ts onto one `commitField` helper, drops the unread parameter
and the never-rendered `columnLabel` from `getResourceCommitMetricCopy`,
folds `getCommitPressurePercent` into the only function that called it, and
reverts unrelated Prettier churn in the Windows enumeration doc.

The commit tint's doc comment no longer claims to predict host paging: it
measures Orca's own share of physical RAM. Host commit charge / commit
limit stays a follow-up (#16211).
This commit is contained in:
Neil
2026-08-26 15:43:02 -07:00
committed by GitHub
parent 015f904fca
commit 64c992cd56
19 changed files with 1436 additions and 660 deletions
@@ -190,8 +190,10 @@ function summarizeMemory(snapshot: MemorySnapshot): {
app: MemorySnapshot['app']
host: MemorySnapshot['host']
processMemoryMetric: MemorySnapshot['processMemoryMetric']
processCommitMetric: MemorySnapshot['processCommitMetric']
totalCpu: number
totalMemory: number
totalPrivateMemory: MemorySnapshot['totalPrivateMemory']
worktreeCount: number
sessionCount: number
worktreeMemory: number
@@ -208,8 +210,10 @@ function summarizeMemory(snapshot: MemorySnapshot): {
app: snapshot.app,
host: snapshot.host,
processMemoryMetric: snapshot.processMemoryMetric,
processCommitMetric: snapshot.processCommitMetric,
totalCpu: snapshot.totalCpu,
totalMemory: snapshot.totalMemory,
totalPrivateMemory: snapshot.totalPrivateMemory,
worktreeCount: snapshot.worktrees.length,
sessionCount: snapshot.worktrees.reduce(
(total, worktree) => total + worktree.sessions.length,
@@ -177,6 +177,13 @@ time to prove a PID has not been recycled — daemon identity, managed-hook
ownership, and CPU accounting in the memory collector — still reads it through
its own query. Those callers are not migrated.
Committed private bytes have no equivalent either, and the one memory value the
snapshot does carry is unusable for the sizes Orca now sees: `process.cc` stores
`pmc.WorkingSetSize` into a `DWORD`, so anything above 4 GB wraps. That is the
second reason `windows-process-resource-collector.ts` still runs its own
`Get-CimInstance` sweep — it needs `PageFileUsage` (commit) and the CPU-time
counters in the same pass. Migrating it to the native table would cost both.
Start time is a proxy for identity, not identity. The durable answer for the
process trees Orca itself spawns is an inherited handle: a job object names the
tree Orca created, so no start-time comparison is needed. Those readers should
+107
View File
@@ -113,5 +113,112 @@ describe('orca cli worktree awareness', () => {
expect(output).toContain('hostAvailable: 2.0 MB (free-memory)')
expect(output).toContain('app: 1.0 MB')
expect(output).toContain('- feature 1.0 MB 2.5% 1 session')
// Back-compat: a host that never heard of committed bytes prints none.
expect(output).not.toContain('totalPrivateMemory')
expect(output).not.toContain('processCommitMetric')
})
it('reports committed private bytes alongside the resident figure', async () => {
queueFixtures(
callMock,
okFixture('req_memory', {
app: {
cpu: 1.25,
memory: 1024 * 1024,
privateMemory: 2 * 1024 * 1024,
main: { cpu: 0.5, memory: 512 * 1024, privateMemory: 1024 * 1024 },
renderer: { cpu: 0.5, memory: 384 * 1024, privateMemory: 768 * 1024 },
other: { cpu: 0.25, memory: 128 * 1024, privateMemory: 256 * 1024 },
history: [1024 * 1024]
},
worktrees: [
{
worktreeId: 'repo::/tmp/repo/feature',
worktreeName: 'feature',
repoId: 'repo',
repoName: 'Orca',
cpu: 2.5,
memory: 1024 * 1024,
privateMemory: 14 * 1024 * 1024,
sessions: [
{
sessionId: 'pty-1',
paneKey: null,
pid: 123,
cpu: 2.5,
memory: 1024 * 1024,
privateMemory: 14 * 1024 * 1024
}
],
history: [1024 * 1024]
}
],
host: {
totalMemory: 8 * 1024 * 1024,
freeMemory: 2 * 1024 * 1024,
availableMemory: 2 * 1024 * 1024,
availableMemorySource: 'free-memory',
usedMemory: 6 * 1024 * 1024,
memoryUsagePercent: 75,
cpuCoreCount: 8,
loadAverage1m: 1.25
},
processMemoryMetric: 'working-set',
processCommitMetric: 'private-bytes',
totalCpu: 3.75,
totalMemory: 2 * 1024 * 1024,
totalPrivateMemory: 16 * 1024 * 1024,
collectedAt: 1000
})
)
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
await main(['diagnostics', 'memory'], '/tmp/repo')
const output = logSpy.mock.calls.flat().join('\n')
expect(output).toContain('totalMemory: 2.0 MB')
expect(output).toContain('processMemoryMetric: summed working set; shared pages may repeat')
expect(output).toContain('totalPrivateMemory: 16 MB')
expect(output).toContain(
'processCommitMetric: summed private bytes; committed memory, counted whether resident or paged out'
)
expect(output).toContain('- feature 1.0 MB 14 MB committed 2.5% 1 session')
})
it('passes committed bytes through --json untouched', async () => {
const snapshot = {
app: {
cpu: 0,
memory: 1024,
privateMemory: 4096,
main: { cpu: 0, memory: 1024, privateMemory: 4096 },
renderer: { cpu: 0, memory: 0, privateMemory: 0 },
other: { cpu: 0, memory: 0, privateMemory: 0 },
history: []
},
worktrees: [],
host: {
totalMemory: 16 * 1024,
freeMemory: 1024,
availableMemory: 1024,
availableMemorySource: 'free-memory',
usedMemory: 15 * 1024,
memoryUsagePercent: 93.75,
cpuCoreCount: 8,
loadAverage1m: 0
},
processMemoryMetric: 'working-set',
processCommitMetric: 'private-bytes',
totalCpu: 0,
totalMemory: 1024,
totalPrivateMemory: 4096,
collectedAt: 1000
}
queueFixtures(callMock, okFixture('req_memory', snapshot))
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
await main(['diagnostics', 'memory', '--json'], '/tmp/repo')
expect(JSON.parse(logSpy.mock.calls.flat().join('\n')).result).toEqual(snapshot)
})
})
+22
View File
@@ -16,6 +16,7 @@ export function formatMemorySnapshot(snapshot: MemorySnapshot): string {
`collectedAt: ${new Date(snapshot.collectedAt).toISOString()}`,
`totalMemory: ${formatByteCount(snapshot.totalMemory)}`,
`processMemoryMetric: ${formatProcessMemoryMetric(snapshot.processMemoryMetric)}`,
...formatCommitLines(snapshot),
`totalCpu: ${formatCpu(snapshot.totalCpu)}`,
[
`hostUsed: ${formatByteCount(snapshot.host.usedMemory)}`,
@@ -51,11 +52,32 @@ function formatWorktreeMemoryLine(worktree: WorktreeMemory): string {
return [
`- ${worktree.worktreeName}`,
`${formatByteCount(worktree.memory)}`,
...(worktree.privateMemory === undefined
? []
: [`${formatByteCount(worktree.privateMemory)} committed`]),
`${formatCpu(worktree.cpu)}`,
`${worktree.sessions.length} session${worktree.sessions.length === 1 ? '' : 's'}`
].join(' ')
}
// Why omitted rather than zeroed: a host that predates the field, or cannot read
// commit at all, must not be printed as agents committing nothing.
function formatCommitLines(snapshot: MemorySnapshot): string[] {
if (typeof snapshot.totalPrivateMemory !== 'number') {
return []
}
return [
`totalPrivateMemory: ${formatByteCount(snapshot.totalPrivateMemory)}`,
`processCommitMetric: ${formatProcessCommitMetric(snapshot.processCommitMetric)}`
]
}
function formatProcessCommitMetric(metric: MemorySnapshot['processCommitMetric']): string {
return metric === 'private-bytes'
? 'summed private bytes; committed memory, counted whether resident or paged out'
: `summed ${metric ?? 'unknown'}`
}
function formatCpu(cpu: number): string {
return `${cpu.toFixed(1)}%`
}
@@ -0,0 +1,620 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import os from 'node:os'
import type { MemorySnapshotStore } from './collector'
import { setAppEnvironment } from '../../shared/app-environment'
type AppMetricFixture = {
pid: number
type: string
cpu: { percentCPUUsage: number }
memory: { workingSetSize: number }
}
const { appMetricsMock, runProcessMock, execMock, listRegisteredPtysMock } = vi.hoisted(() => ({
appMetricsMock: vi.fn<() => AppMetricFixture[]>(() => []),
runProcessMock: vi.fn(),
execMock: vi.fn(),
listRegisteredPtysMock: vi.fn()
}))
vi.mock('child_process', () => ({
exec: (cmd: string, opts: unknown, cb: (err: Error | null, out: { stdout: string }) => void) =>
execMock(cmd, opts, cb)
}))
// Why mock the chokepoint for the Windows sweep: maxBuffer, timeout and the
// hidden console are its contract now, so the assertions below are about which
// query runs, not how a process is started.
vi.mock('../../shared/child-process/run-process', () => ({
runProcess: (spec: { program: string; args?: string[] }) => runProcessMock(spec)
}))
vi.mock('./pty-registry', () => ({
listRegisteredPtys: listRegisteredPtysMock
}))
function appEnvironment() {
return {
getPath: () => process.cwd(),
getAppPath: () => process.cwd(),
getVersion: () => '0.0.0-test',
isPackaged: () => false,
onWillQuit: () => {},
exit: () => {},
getAppMetrics: appMetricsMock
}
}
async function loadCollector() {
vi.resetModules()
const { setAppEnvironment: setResetAppEnvironment } = await import('../../shared/app-environment')
setResetAppEnvironment(appEnvironment())
return await import('./collector')
}
const emptyStore = {
getWorktreeMeta: () => undefined,
getRepo: () => undefined
} satisfies MemorySnapshotStore
describe('collectMemorySnapshot on Windows', () => {
beforeEach(() => {
setAppEnvironment(appEnvironment())
vi.restoreAllMocks()
appMetricsMock.mockReset()
appMetricsMock.mockReturnValue([])
runProcessMock.mockReset()
execMock.mockReset()
listRegisteredPtysMock.mockReset()
listRegisteredPtysMock.mockReturnValue([])
})
function mockPsResponse(stdout: string) {
execMock.mockImplementation((_cmd, _opts, cb) => cb(null, { stdout, stderr: '' }))
runProcessMock.mockImplementation((spec: { program: string }) =>
Promise.resolve({
code: 0,
signal: null,
stdout:
spec.program === 'typeperf.exe'
? psFixtureToTypeperfOutput(stdout)
: psFixtureToWindowsProcessOutput(stdout),
stderr: '',
timedOut: false
})
)
}
function psFixtureToWindowsProcessOutput(stdout: string): string {
return stdout
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const [pid, ppid, _cpu, rssKb] = line.split(/\s+/, 4)
const memory = Number.parseInt(rssKb ?? '', 10)
return [
pid ?? '',
ppid ?? '',
Number.isFinite(memory) && memory > 0 ? memory * 1024 : 0,
'0',
'0',
'1'
].join('\t')
})
.join('\r\n')
}
function psFixtureToTypeperfOutput(stdout: string): string {
const rows = stdout
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map((line, index) => {
const [pid, ppid, _cpu, rssKb] = line.split(/\s+/, 4)
const memoryKb = Number.parseInt(rssKb ?? '', 10)
return {
instance: `fixture${index}`,
pid: pid ?? '',
ppid: ppid ?? '',
memory: Number.isFinite(memoryKb) && memoryKb > 0 ? memoryKb * 1024 : 0
}
})
const counterColumns = (counter: string): string[] =>
rows.map((row) => `"\\\\HOST\\Process(${row.instance})\\${counter}"`)
const valueColumns = (field: 'pid' | 'ppid' | 'memory'): string[] =>
rows.map((row) => `"${row[field]}"`)
return [
[
'"(PDH-CSV 4.0)"',
...counterColumns('ID Process'),
...counterColumns('Creating Process ID'),
...counterColumns('Working Set')
].join(','),
['"time"', ...valueColumns('pid'), ...valueColumns('ppid'), ...valueColumns('memory')].join(
','
)
].join('\r\n')
}
it('uses one CIM process for Windows memory and CPU sampling', async () => {
vi.spyOn(os, 'platform').mockReturnValue('win32')
mockPsResponse('10 1 0 1024')
const { collectMemorySnapshot } = await loadCollector()
await collectMemorySnapshot(emptyStore)
expect(execMock).not.toHaveBeenCalled()
expect(runProcessMock).toHaveBeenCalledTimes(1)
const spec = runProcessMock.mock.calls[0][0]
expect(spec.program).toBe('powershell.exe')
expect(spec.args.join(' ')).toContain('Get-CimInstance Win32_Process')
expect(spec.args.join(' ')).toContain('KernelModeTime')
expect(spec.args.join(' ')).toContain('UserModeTime')
expect(spec.args.join(' ')).toContain('CreationDate')
expect(spec).toMatchObject({ maxOutputBytes: 10 * 1024 * 1024, timeoutMs: 5_000 })
})
it('attributes Windows process CPU from cumulative time deltas between sweeps', async () => {
vi.spyOn(os, 'platform').mockReturnValue('win32')
vi.spyOn(performance, 'now').mockReturnValueOnce(1_000).mockReturnValueOnce(3_000)
const cpuOutputs = [
'10\t1\t1048576\t10000000\t0\t638830000000000000',
'10\t1\t1048576\t30000000\t0\t638830000000000000'
]
runProcessMock.mockImplementation(() =>
Promise.resolve({
code: 0,
signal: null,
stdout: cpuOutputs.shift() ?? '',
stderr: '',
timedOut: false
})
)
listRegisteredPtysMock.mockReturnValue([
{
ptyId: 'windows-cpu-pty',
worktreeId: 'repo-1::C:\\repo',
sessionId: 'session-1',
paneKey: 'pane-1',
pid: 10
}
])
const { collectMemorySnapshot } = await loadCollector()
const first = await collectMemorySnapshot(emptyStore)
const second = await collectMemorySnapshot(emptyStore)
expect(first.worktrees[0].sessions[0].cpu).toBe(0)
expect(second.worktrees[0].sessions[0].cpu).toBe(100)
expect(runProcessMock.mock.calls.map(([spec]) => spec.program)).toEqual([
'powershell.exe',
'powershell.exe'
])
})
it('does not attribute prior CPU time after Windows reuses a process id', async () => {
vi.spyOn(os, 'platform').mockReturnValue('win32')
vi.spyOn(performance, 'now').mockReturnValueOnce(1_000).mockReturnValueOnce(3_000)
const cpuOutputs = [
'10\t1\t1048576\t10000000\t0\t638830000000000000',
'10\t1\t1048576\t30000000\t0\t638830000000000001'
]
runProcessMock.mockImplementation(() =>
Promise.resolve({
code: 0,
signal: null,
stdout: cpuOutputs.shift() ?? '',
stderr: '',
timedOut: false
})
)
listRegisteredPtysMock.mockReturnValue([
{
ptyId: 'reused-pid-pty',
worktreeId: 'repo-1::C:\\repo',
sessionId: 'session-1',
paneKey: 'pane-1',
pid: 10
}
])
const { collectMemorySnapshot } = await loadCollector()
await collectMemorySnapshot(emptyStore)
const second = await collectMemorySnapshot(emptyStore)
expect(second.worktrees[0].sessions[0].cpu).toBe(0)
})
it('supports cumulative CPU counters above JavaScript safe integers', async () => {
vi.spyOn(os, 'platform').mockReturnValue('win32')
vi.spyOn(performance, 'now').mockReturnValueOnce(1_000).mockReturnValueOnce(3_000)
const cpuOutputs = [
'10\t1\t1048576\t90071992547409920\t0\t638830000000000000',
'10\t1\t1048576\t90071992567409920\t0\t638830000000000000'
]
runProcessMock.mockImplementation(() =>
Promise.resolve({
code: 0,
signal: null,
stdout: cpuOutputs.shift() ?? '',
stderr: '',
timedOut: false
})
)
listRegisteredPtysMock.mockReturnValue([
{
ptyId: 'large-counter-pty',
worktreeId: 'repo-1::C:\\repo',
sessionId: 'session-1',
paneKey: 'pane-1',
pid: 10
}
])
const { collectMemorySnapshot } = await loadCollector()
await collectMemorySnapshot(emptyStore)
const second = await collectMemorySnapshot(emptyStore)
expect(second.worktrees[0].sessions[0].cpu).toBe(100)
})
it('keeps the older CPU baseline when forced snapshots are too close together', async () => {
vi.spyOn(os, 'platform').mockReturnValue('win32')
vi.spyOn(performance, 'now')
.mockReturnValueOnce(1_000)
.mockReturnValueOnce(1_100)
.mockReturnValueOnce(3_000)
const cpuOutputs = [
'10\t1\t1048576\t0\t0\t638830000000000000',
'10\t1\t1048576\t1000000\t0\t638830000000000000',
'10\t1\t1048576\t20000000\t0\t638830000000000000'
]
runProcessMock.mockImplementation(() =>
Promise.resolve({
code: 0,
signal: null,
stdout: cpuOutputs.shift() ?? '',
stderr: '',
timedOut: false
})
)
listRegisteredPtysMock.mockReturnValue([
{
ptyId: 'short-sample-pty',
worktreeId: 'repo-1::C:\\repo',
sessionId: 'session-1',
paneKey: 'pane-1',
pid: 10
}
])
const { collectMemorySnapshot } = await loadCollector()
await collectMemorySnapshot(emptyStore)
const tooSoon = await collectMemorySnapshot(emptyStore)
const normalPoll = await collectMemorySnapshot(emptyStore)
expect(tooSoon.worktrees[0].sessions[0].cpu).toBe(0)
expect(normalPoll.worktrees[0].sessions[0].cpu).toBe(100)
})
it('caps impossible Windows CPU deltas at the host core capacity', async () => {
vi.spyOn(os, 'platform').mockReturnValue('win32')
vi.spyOn(os, 'cpus').mockReturnValue([{}, {}] as ReturnType<typeof os.cpus>)
vi.spyOn(performance, 'now').mockReturnValueOnce(1_000).mockReturnValueOnce(3_000)
const cpuOutputs = [
'10\t1\t1048576\t0\t0\t638830000000000000',
'10\t1\t1048576\t1000000000\t0\t638830000000000000'
]
runProcessMock.mockImplementation(() =>
Promise.resolve({
code: 0,
signal: null,
stdout: cpuOutputs.shift() ?? '',
stderr: '',
timedOut: false
})
)
listRegisteredPtysMock.mockReturnValue([
{
ptyId: 'impossible-cpu-pty',
worktreeId: 'repo-1::C:\\repo',
sessionId: 'session-1',
paneKey: 'pane-1',
pid: 10
}
])
const { collectMemorySnapshot } = await loadCollector()
await collectMemorySnapshot(emptyStore)
const capped = await collectMemorySnapshot(emptyStore)
expect(capped.worktrees[0].sessions[0].cpu).toBe(200)
})
it('warms CPU sampling again after Resource Manager was closed', async () => {
vi.spyOn(os, 'platform').mockReturnValue('win32')
vi.spyOn(performance, 'now').mockReturnValueOnce(1_000).mockReturnValueOnce(12_000)
const cpuOutputs = [
'10\t1\t1048576\t0\t0\t638830000000000000',
'10\t1\t1048576\t100000000\t0\t638830000000000000'
]
runProcessMock.mockImplementation(() =>
Promise.resolve({
code: 0,
signal: null,
stdout: cpuOutputs.shift() ?? '',
stderr: '',
timedOut: false
})
)
listRegisteredPtysMock.mockReturnValue([
{
ptyId: 'stale-counter-pty',
worktreeId: 'repo-1::C:\\repo',
sessionId: 'session-1',
paneKey: 'pane-1',
pid: 10
}
])
const { collectMemorySnapshot } = await loadCollector()
await collectMemorySnapshot(emptyStore)
const reopened = await collectMemorySnapshot(emptyStore)
expect(reopened.worktrees[0].sessions[0].cpu).toBe(0)
})
it('preserves Windows process memory when CPU counters are unavailable', async () => {
vi.spyOn(os, 'platform').mockReturnValue('win32')
runProcessMock.mockImplementation(() =>
Promise.resolve({
code: 0,
signal: null,
stdout: '10\t1\t1048576\t\t\t638830000000000000',
stderr: '',
timedOut: false
})
)
listRegisteredPtysMock.mockReturnValue([
{
ptyId: 'cpu-failure-pty',
worktreeId: 'repo-1::C:\\repo',
sessionId: 'session-1',
paneKey: 'pane-1',
pid: 10
}
])
const { collectMemorySnapshot } = await loadCollector()
const snapshot = await collectMemorySnapshot(emptyStore)
expect(snapshot.worktrees[0].sessions[0]).toMatchObject({ cpu: 0, memory: 1024 * 1024 })
})
it('uses Typeperf during the CIM retry cooldown', async () => {
vi.spyOn(os, 'platform').mockReturnValue('win32')
runProcessMock.mockImplementation((spec: { program: string }) =>
Promise.resolve(
spec.program === 'powershell.exe'
? { code: 1, signal: null, stdout: '', stderr: 'CIM unavailable', timedOut: false }
: {
code: 0,
signal: null,
stdout: psFixtureToTypeperfOutput('10 1 0 1024'),
stderr: '',
timedOut: false
}
)
)
listRegisteredPtysMock.mockReturnValue([
{
ptyId: 'cim-pty',
worktreeId: null,
sessionId: null,
paneKey: null,
pid: 10
}
])
const { collectMemorySnapshot } = await loadCollector()
const first = await collectMemorySnapshot(emptyStore)
const second = await collectMemorySnapshot(emptyStore)
expect(runProcessMock).toHaveBeenCalledTimes(3)
expect(runProcessMock.mock.calls.map(([spec]) => spec.program)).toEqual([
'powershell.exe',
'typeperf.exe',
'typeperf.exe'
])
expect(runProcessMock.mock.calls[1][0]).toMatchObject({ timeoutMs: 5_000 })
expect(first.worktrees[0].memory).toBe(1048576)
expect(second.worktrees[0].memory).toBe(1048576)
})
it('retries CIM after fallback and warms CPU sampling before restoring deltas', async () => {
vi.spyOn(os, 'platform').mockReturnValue('win32')
vi.spyOn(performance, 'now')
.mockReturnValueOnce(1_000)
.mockReturnValueOnce(2_000)
.mockReturnValueOnce(31_001)
.mockReturnValueOnce(32_000)
.mockReturnValueOnce(34_000)
const cimOutputs = [
'10\t1\t1048576\t10000000\t0\t638830000000000000',
'10\t1\t1048576\t30000000\t0\t638830000000000000'
]
let cimCalls = 0
runProcessMock.mockImplementation((spec: { program: string }) => {
if (spec.program === 'typeperf.exe') {
return Promise.resolve({
code: 0,
signal: null,
stdout: psFixtureToTypeperfOutput('10 1 0 1024'),
stderr: '',
timedOut: false
})
}
cimCalls += 1
return Promise.resolve(
cimCalls === 1
? { code: 1, signal: null, stdout: '', stderr: 'transient CIM failure', timedOut: false }
: {
code: 0,
signal: null,
stdout: cimOutputs.shift() ?? '',
stderr: '',
timedOut: false
}
)
})
listRegisteredPtysMock.mockReturnValue([
{
ptyId: 'recovering-cim-pty',
worktreeId: 'repo-1::C:\\repo',
sessionId: 'session-1',
paneKey: 'pane-1',
pid: 10
}
])
const { collectMemorySnapshot } = await loadCollector()
await collectMemorySnapshot(emptyStore)
await collectMemorySnapshot(emptyStore)
const warming = await collectMemorySnapshot(emptyStore)
const recovered = await collectMemorySnapshot(emptyStore)
expect(runProcessMock.mock.calls.map(([spec]) => spec.program)).toEqual([
'powershell.exe',
'typeperf.exe',
'typeperf.exe',
'powershell.exe',
'powershell.exe'
])
expect(warming.worktrees[0].sessions[0].cpu).toBe(0)
expect(recovered.worktrees[0].sessions[0].cpu).toBe(100)
})
it('sums committed private bytes across the whole PTY subtree on Windows', async () => {
vi.spyOn(os, 'platform').mockReturnValue('win32')
// Working set stays small while commit is 10-40x larger — the reported shape.
const rows = [
'10\t1\t52428800\t0\t0\t638830000000000000\t1048576',
'11\t10\t104857600\t0\t0\t638830000000000000\t2097152',
'12\t11\t52428800\t0\t0\t638830000000000000\t524288',
'900\t1\t20971520\t0\t0\t638830000000000000\t262144'
].join('\r\n')
runProcessMock.mockResolvedValue({
code: 0,
signal: null,
stdout: rows,
stderr: '',
timedOut: false
})
appMetricsMock.mockReturnValue([
{ pid: 900, type: 'Browser', cpu: { percentCPUUsage: 0 }, memory: { workingSetSize: 0 } }
])
listRegisteredPtysMock.mockReturnValue([
{
ptyId: 'pty-1',
worktreeId: 'repo-1::C:\\repo',
sessionId: 'session-1',
paneKey: 'pane-1',
pid: 10
}
])
const { collectMemorySnapshot } = await loadCollector()
const snapshot = await collectMemorySnapshot(emptyStore)
const committedKb = 1048576 + 2097152 + 524288
expect(snapshot.worktrees[0].sessions[0].privateMemory).toBe(committedKb * 1024)
expect(snapshot.worktrees[0].privateMemory).toBe(committedKb * 1024)
expect(snapshot.app.privateMemory).toBe(262144 * 1024)
expect(snapshot.totalPrivateMemory).toBe((committedKb + 262144) * 1024)
expect(snapshot.processCommitMetric).toBe('private-bytes')
// The resident figure keeps its old meaning rather than being redefined.
expect(snapshot.processMemoryMetric).toBe('working-set')
expect(snapshot.worktrees[0].memory).toBe(52428800 + 104857600 + 52428800)
})
it('omits the commit metric entirely when the Windows sweep cannot report it', async () => {
vi.spyOn(os, 'platform').mockReturnValue('win32')
runProcessMock.mockResolvedValue({
code: 0,
signal: null,
stdout: '10\t1\t52428800\t0\t0\t638830000000000000',
stderr: '',
timedOut: false
})
listRegisteredPtysMock.mockReturnValue([
{
ptyId: 'pty-1',
worktreeId: 'repo-1::C:\\repo',
sessionId: 'session-1',
paneKey: 'pane-1',
pid: 10
}
])
const { collectMemorySnapshot } = await loadCollector()
const snapshot = await collectMemorySnapshot(emptyStore)
// Why not zero: a host that cannot measure commit must be distinguishable
// from agents that hold none.
expect(snapshot.processCommitMetric).toBeUndefined()
expect(snapshot.totalPrivateMemory).toBeUndefined()
expect(snapshot.worktrees[0].privateMemory).toBeUndefined()
expect(snapshot.worktrees[0].sessions[0].privateMemory).toBeUndefined()
expect(snapshot.app.privateMemory).toBeUndefined()
expect(snapshot.totalMemory).toBe(52428800)
})
it('carries no commit metric on Unix, where ps has no committed-bytes column', async () => {
vi.spyOn(os, 'platform').mockReturnValue('darwin')
mockPsResponse('10 1 0 1024')
listRegisteredPtysMock.mockReturnValue([
{
ptyId: 'pty-1',
worktreeId: 'repo-1::/repo',
sessionId: 'session-1',
paneKey: 'pane-1',
pid: 10
}
])
const { collectMemorySnapshot } = await loadCollector()
const snapshot = await collectMemorySnapshot(emptyStore)
expect(snapshot.processMemoryMetric).toBe('rss')
expect(snapshot.processCommitMetric).toBeUndefined()
expect(snapshot.totalPrivateMemory).toBeUndefined()
expect(snapshot.worktrees[0].sessions[0].privateMemory).toBeUndefined()
})
it("attributes a shared ancestor's commit to one PTY only", async () => {
vi.spyOn(os, 'platform').mockReturnValue('win32')
runProcessMock.mockResolvedValue({
code: 0,
signal: null,
stdout: [
'10\t1\t1024\t0\t0\t638830000000000000\t1024',
'11\t10\t1024\t0\t0\t638830000000000000\t2048'
].join('\r\n'),
stderr: '',
timedOut: false
})
listRegisteredPtysMock.mockReturnValue([
{ ptyId: 'a', worktreeId: 'repo::C:\\a', sessionId: 's-a', paneKey: null, pid: 10 },
{ ptyId: 'b', worktreeId: 'repo::C:\\b', sessionId: 's-b', paneKey: null, pid: 11 }
])
const { collectMemorySnapshot } = await loadCollector()
const snapshot = await collectMemorySnapshot(emptyStore)
expect(snapshot.worktrees[0].sessions[0].privateMemory).toBe((1024 + 2048) * 1024)
expect(snapshot.worktrees[1].sessions[0].privateMemory).toBe(0)
expect(snapshot.totalPrivateMemory).toBe((1024 + 2048) * 1024)
})
})
+3 -427
View File
@@ -52,11 +52,6 @@ async function loadCollector() {
return await import('./collector')
}
async function loadWindowsProcessResourceCollector() {
vi.resetModules()
return await import('./windows-process-resource-collector')
}
const emptyStore = {
getWorktreeMeta: () => undefined,
getRepo: () => undefined
@@ -125,68 +120,6 @@ describe('parsePsOutput', () => {
})
})
describe('parseWindowsProcessOutput', () => {
it('parses tab-delimited CIM process rows', async () => {
const { parseWindowsProcessOutput } = await loadWindowsProcessResourceCollector()
expect(parseWindowsProcessOutput('100\t1\t2048\r\n200\t100\t1024')).toEqual([
{ pid: 100, ppid: 1, cpu: 0, memory: 2048 },
{ pid: 200, ppid: 100, cpu: 0, memory: 1024 }
])
})
it('skips malformed rows and clamps invalid memory to zero', async () => {
const { parseWindowsProcessOutput } = await loadWindowsProcessResourceCollector()
expect(
parseWindowsProcessOutput(
[
'garbage',
'abc\t1\t100',
'10\txyz\t100',
'0\t0\t100',
'-5\t0\t100',
'30\t-1\t100',
'20\t1\t-50'
].join('\n')
)
).toEqual([{ pid: 20, ppid: 1, cpu: 0, memory: 0 }])
})
it('preserves empty CIM field positions instead of shifting CPU ticks into memory', async () => {
const { parseWindowsProcessOutput } = await loadWindowsProcessResourceCollector()
expect(parseWindowsProcessOutput('100\t1\t\t200\t300\t638830000000000000')).toEqual([
{ pid: 100, ppid: 1, cpu: 0, memory: 0 }
])
})
})
describe('parseTypeperfProcessOutput', () => {
it('joins PID, parent PID, and working-set counters by process instance', async () => {
const { parseTypeperfProcessOutput } = await loadWindowsProcessResourceCollector()
const stdout = [
'"(PDH-CSV 4.0)","\\\\HOST\\Process(node)\\ID Process","\\\\HOST\\Process(node#1)\\ID Process","\\\\HOST\\Process(node)\\Creating Process ID","\\\\HOST\\Process(node#1)\\Creating Process ID","\\\\HOST\\Process(node)\\Working Set","\\\\HOST\\Process(node#1)\\Working Set"',
'"07/15/2026 01:44:54.514","100.000000","200.000000","1.000000","100.000000","2048.000000","4096.000000"'
].join('\r\n')
expect(parseTypeperfProcessOutput(stdout)).toEqual([
{ pid: 100, ppid: 1, cpu: 0, memory: 2048 },
{ pid: 200, ppid: 100, cpu: 0, memory: 4096 }
])
})
it('ignores aggregate and incomplete rows and clamps invalid memory', async () => {
const { parseTypeperfProcessOutput } = await loadWindowsProcessResourceCollector()
const stdout = [
'"(PDH-CSV 4.0)","\\\\HOST\\Process(_Total)\\ID Process","\\\\HOST\\Process(cmd)\\ID Process","\\\\HOST\\Process(orphan)\\ID Process","\\\\HOST\\Process(_Total)\\Creating Process ID","\\\\HOST\\Process(cmd)\\Creating Process ID","\\\\HOST\\Process(_Total)\\Working Set","\\\\HOST\\Process(cmd)\\Working Set"',
'"time","0.000000","100.000000","200.000000","0.000000","1.000000","999999.000000","-1.000000"'
].join('\r\n')
expect(parseTypeperfProcessOutput(stdout)).toEqual([{ pid: 100, ppid: 1, cpu: 0, memory: 0 }])
})
})
describe('collectSubtree', () => {
function makeIndex(rows: { pid: number; ppid: number }[]) {
const byPid = new Map<number, { pid: number; ppid: number; cpu: number; memory: number }>()
@@ -200,7 +133,7 @@ describe('collectSubtree', () => {
childrenOf.set(r.ppid, [r.pid])
}
}
return { byPid, childrenOf }
return { byPid, childrenOf, hasPrivateMemory: false }
}
it('walks every descendant of the root inclusive', async () => {
@@ -240,7 +173,8 @@ describe('collectSubtree', () => {
// those as "walked" but do not fabricate a row for them.
const index = {
byPid: new Map([[1, { pid: 1, ppid: 0, cpu: 0, memory: 0 }]]),
childrenOf: new Map([[1, [2]]])
childrenOf: new Map([[1, [2]]]),
hasPrivateMemory: false
}
expect(collectSubtree(index, 1)).toEqual([1])
@@ -336,364 +270,6 @@ describe('collectMemorySnapshot', () => {
expect(execMock).toHaveBeenCalledTimes(count)
}
it('uses one CIM process for Windows memory and CPU sampling', async () => {
vi.spyOn(os, 'platform').mockReturnValue('win32')
mockPsResponse('10 1 0 1024')
const { collectMemorySnapshot } = await loadCollector()
await collectMemorySnapshot(emptyStore)
expect(execMock).not.toHaveBeenCalled()
expect(runProcessMock).toHaveBeenCalledTimes(1)
const spec = runProcessMock.mock.calls[0][0]
expect(spec.program).toBe('powershell.exe')
expect(spec.args.join(' ')).toContain('Get-CimInstance Win32_Process')
expect(spec.args.join(' ')).toContain('KernelModeTime')
expect(spec.args.join(' ')).toContain('UserModeTime')
expect(spec.args.join(' ')).toContain('CreationDate')
expect(spec).toMatchObject({ maxOutputBytes: 10 * 1024 * 1024, timeoutMs: 5_000 })
})
it('attributes Windows process CPU from cumulative time deltas between sweeps', async () => {
vi.spyOn(os, 'platform').mockReturnValue('win32')
vi.spyOn(performance, 'now').mockReturnValueOnce(1_000).mockReturnValueOnce(3_000)
const cpuOutputs = [
'10\t1\t1048576\t10000000\t0\t638830000000000000',
'10\t1\t1048576\t30000000\t0\t638830000000000000'
]
runProcessMock.mockImplementation(() =>
Promise.resolve({
code: 0,
signal: null,
stdout: cpuOutputs.shift() ?? '',
stderr: '',
timedOut: false
})
)
listRegisteredPtysMock.mockReturnValue([
{
ptyId: 'windows-cpu-pty',
worktreeId: 'repo-1::C:\\repo',
sessionId: 'session-1',
paneKey: 'pane-1',
pid: 10
}
])
const { collectMemorySnapshot } = await loadCollector()
const first = await collectMemorySnapshot(emptyStore)
const second = await collectMemorySnapshot(emptyStore)
expect(first.worktrees[0].sessions[0].cpu).toBe(0)
expect(second.worktrees[0].sessions[0].cpu).toBe(100)
expect(runProcessMock.mock.calls.map(([spec]) => spec.program)).toEqual([
'powershell.exe',
'powershell.exe'
])
})
it('does not attribute prior CPU time after Windows reuses a process id', async () => {
vi.spyOn(os, 'platform').mockReturnValue('win32')
vi.spyOn(performance, 'now').mockReturnValueOnce(1_000).mockReturnValueOnce(3_000)
const cpuOutputs = [
'10\t1\t1048576\t10000000\t0\t638830000000000000',
'10\t1\t1048576\t30000000\t0\t638830000000000001'
]
runProcessMock.mockImplementation(() =>
Promise.resolve({
code: 0,
signal: null,
stdout: cpuOutputs.shift() ?? '',
stderr: '',
timedOut: false
})
)
listRegisteredPtysMock.mockReturnValue([
{
ptyId: 'reused-pid-pty',
worktreeId: 'repo-1::C:\\repo',
sessionId: 'session-1',
paneKey: 'pane-1',
pid: 10
}
])
const { collectMemorySnapshot } = await loadCollector()
await collectMemorySnapshot(emptyStore)
const second = await collectMemorySnapshot(emptyStore)
expect(second.worktrees[0].sessions[0].cpu).toBe(0)
})
it('supports cumulative CPU counters above JavaScript safe integers', async () => {
vi.spyOn(os, 'platform').mockReturnValue('win32')
vi.spyOn(performance, 'now').mockReturnValueOnce(1_000).mockReturnValueOnce(3_000)
const cpuOutputs = [
'10\t1\t1048576\t90071992547409920\t0\t638830000000000000',
'10\t1\t1048576\t90071992567409920\t0\t638830000000000000'
]
runProcessMock.mockImplementation(() =>
Promise.resolve({
code: 0,
signal: null,
stdout: cpuOutputs.shift() ?? '',
stderr: '',
timedOut: false
})
)
listRegisteredPtysMock.mockReturnValue([
{
ptyId: 'large-counter-pty',
worktreeId: 'repo-1::C:\\repo',
sessionId: 'session-1',
paneKey: 'pane-1',
pid: 10
}
])
const { collectMemorySnapshot } = await loadCollector()
await collectMemorySnapshot(emptyStore)
const second = await collectMemorySnapshot(emptyStore)
expect(second.worktrees[0].sessions[0].cpu).toBe(100)
})
it('keeps the older CPU baseline when forced snapshots are too close together', async () => {
vi.spyOn(os, 'platform').mockReturnValue('win32')
vi.spyOn(performance, 'now')
.mockReturnValueOnce(1_000)
.mockReturnValueOnce(1_100)
.mockReturnValueOnce(3_000)
const cpuOutputs = [
'10\t1\t1048576\t0\t0\t638830000000000000',
'10\t1\t1048576\t1000000\t0\t638830000000000000',
'10\t1\t1048576\t20000000\t0\t638830000000000000'
]
runProcessMock.mockImplementation(() =>
Promise.resolve({
code: 0,
signal: null,
stdout: cpuOutputs.shift() ?? '',
stderr: '',
timedOut: false
})
)
listRegisteredPtysMock.mockReturnValue([
{
ptyId: 'short-sample-pty',
worktreeId: 'repo-1::C:\\repo',
sessionId: 'session-1',
paneKey: 'pane-1',
pid: 10
}
])
const { collectMemorySnapshot } = await loadCollector()
await collectMemorySnapshot(emptyStore)
const tooSoon = await collectMemorySnapshot(emptyStore)
const normalPoll = await collectMemorySnapshot(emptyStore)
expect(tooSoon.worktrees[0].sessions[0].cpu).toBe(0)
expect(normalPoll.worktrees[0].sessions[0].cpu).toBe(100)
})
it('caps impossible Windows CPU deltas at the host core capacity', async () => {
vi.spyOn(os, 'platform').mockReturnValue('win32')
vi.spyOn(os, 'cpus').mockReturnValue([{}, {}] as ReturnType<typeof os.cpus>)
vi.spyOn(performance, 'now').mockReturnValueOnce(1_000).mockReturnValueOnce(3_000)
const cpuOutputs = [
'10\t1\t1048576\t0\t0\t638830000000000000',
'10\t1\t1048576\t1000000000\t0\t638830000000000000'
]
runProcessMock.mockImplementation(() =>
Promise.resolve({
code: 0,
signal: null,
stdout: cpuOutputs.shift() ?? '',
stderr: '',
timedOut: false
})
)
listRegisteredPtysMock.mockReturnValue([
{
ptyId: 'impossible-cpu-pty',
worktreeId: 'repo-1::C:\\repo',
sessionId: 'session-1',
paneKey: 'pane-1',
pid: 10
}
])
const { collectMemorySnapshot } = await loadCollector()
await collectMemorySnapshot(emptyStore)
const capped = await collectMemorySnapshot(emptyStore)
expect(capped.worktrees[0].sessions[0].cpu).toBe(200)
})
it('warms CPU sampling again after Resource Manager was closed', async () => {
vi.spyOn(os, 'platform').mockReturnValue('win32')
vi.spyOn(performance, 'now').mockReturnValueOnce(1_000).mockReturnValueOnce(12_000)
const cpuOutputs = [
'10\t1\t1048576\t0\t0\t638830000000000000',
'10\t1\t1048576\t100000000\t0\t638830000000000000'
]
runProcessMock.mockImplementation(() =>
Promise.resolve({
code: 0,
signal: null,
stdout: cpuOutputs.shift() ?? '',
stderr: '',
timedOut: false
})
)
listRegisteredPtysMock.mockReturnValue([
{
ptyId: 'stale-counter-pty',
worktreeId: 'repo-1::C:\\repo',
sessionId: 'session-1',
paneKey: 'pane-1',
pid: 10
}
])
const { collectMemorySnapshot } = await loadCollector()
await collectMemorySnapshot(emptyStore)
const reopened = await collectMemorySnapshot(emptyStore)
expect(reopened.worktrees[0].sessions[0].cpu).toBe(0)
})
it('preserves Windows process memory when CPU counters are unavailable', async () => {
vi.spyOn(os, 'platform').mockReturnValue('win32')
runProcessMock.mockImplementation(() =>
Promise.resolve({
code: 0,
signal: null,
stdout: '10\t1\t1048576\t\t\t638830000000000000',
stderr: '',
timedOut: false
})
)
listRegisteredPtysMock.mockReturnValue([
{
ptyId: 'cpu-failure-pty',
worktreeId: 'repo-1::C:\\repo',
sessionId: 'session-1',
paneKey: 'pane-1',
pid: 10
}
])
const { collectMemorySnapshot } = await loadCollector()
const snapshot = await collectMemorySnapshot(emptyStore)
expect(snapshot.worktrees[0].sessions[0]).toMatchObject({ cpu: 0, memory: 1024 * 1024 })
})
it('uses Typeperf during the CIM retry cooldown', async () => {
vi.spyOn(os, 'platform').mockReturnValue('win32')
runProcessMock.mockImplementation((spec: { program: string }) =>
Promise.resolve(
spec.program === 'powershell.exe'
? { code: 1, signal: null, stdout: '', stderr: 'CIM unavailable', timedOut: false }
: {
code: 0,
signal: null,
stdout: psFixtureToTypeperfOutput('10 1 0 1024'),
stderr: '',
timedOut: false
}
)
)
listRegisteredPtysMock.mockReturnValue([
{
ptyId: 'cim-pty',
worktreeId: null,
sessionId: null,
paneKey: null,
pid: 10
}
])
const { collectMemorySnapshot } = await loadCollector()
const first = await collectMemorySnapshot(emptyStore)
const second = await collectMemorySnapshot(emptyStore)
expect(runProcessMock).toHaveBeenCalledTimes(3)
expect(runProcessMock.mock.calls.map(([spec]) => spec.program)).toEqual([
'powershell.exe',
'typeperf.exe',
'typeperf.exe'
])
expect(runProcessMock.mock.calls[1][0]).toMatchObject({ timeoutMs: 5_000 })
expect(first.worktrees[0].memory).toBe(1048576)
expect(second.worktrees[0].memory).toBe(1048576)
})
it('retries CIM after fallback and warms CPU sampling before restoring deltas', async () => {
vi.spyOn(os, 'platform').mockReturnValue('win32')
vi.spyOn(performance, 'now')
.mockReturnValueOnce(1_000)
.mockReturnValueOnce(2_000)
.mockReturnValueOnce(31_001)
.mockReturnValueOnce(32_000)
.mockReturnValueOnce(34_000)
const cimOutputs = [
'10\t1\t1048576\t10000000\t0\t638830000000000000',
'10\t1\t1048576\t30000000\t0\t638830000000000000'
]
let cimCalls = 0
runProcessMock.mockImplementation((spec: { program: string }) => {
if (spec.program === 'typeperf.exe') {
return Promise.resolve({
code: 0,
signal: null,
stdout: psFixtureToTypeperfOutput('10 1 0 1024'),
stderr: '',
timedOut: false
})
}
cimCalls += 1
return Promise.resolve(
cimCalls === 1
? { code: 1, signal: null, stdout: '', stderr: 'transient CIM failure', timedOut: false }
: {
code: 0,
signal: null,
stdout: cimOutputs.shift() ?? '',
stderr: '',
timedOut: false
}
)
})
listRegisteredPtysMock.mockReturnValue([
{
ptyId: 'recovering-cim-pty',
worktreeId: 'repo-1::C:\\repo',
sessionId: 'session-1',
paneKey: 'pane-1',
pid: 10
}
])
const { collectMemorySnapshot } = await loadCollector()
await collectMemorySnapshot(emptyStore)
await collectMemorySnapshot(emptyStore)
const warming = await collectMemorySnapshot(emptyStore)
const recovered = await collectMemorySnapshot(emptyStore)
expect(runProcessMock.mock.calls.map(([spec]) => spec.program)).toEqual([
'powershell.exe',
'typeperf.exe',
'typeperf.exe',
'powershell.exe',
'powershell.exe'
])
expect(warming.worktrees[0].sessions[0].cpu).toBe(0)
expect(recovered.worktrees[0].sessions[0].cpu).toBe(100)
})
it('coalesces concurrent callers onto a single in-flight sweep', async () => {
// Why: the collector exists in part to prevent a burst of renderer
// polls from spawning overlapping `ps` children. If a regression ever
+83 -12
View File
@@ -30,7 +30,9 @@ import { getAppEnvironment, type AppEnvironment } from '../../shared/app-environ
import type {
AppMemory,
MemorySnapshot,
ProcessCommitMetric,
SessionMemory,
UsageValues,
WorktreeMemory
} from '../../shared/process-stats-types'
import type { Store } from '../persistence'
@@ -81,12 +83,43 @@ type ProcRow = {
cpu: number
/** Resident memory in bytes. */
memory: number
/** Committed bytes, resident or paged out. Absent when the host cannot report it. */
privateMemory?: number
}
/** Indexed view of a single host process sweep. */
type ProcIndex = {
byPid: Map<number, ProcRow>
childrenOf: Map<number, number[]>
/**
* Whether this sweep reported committed bytes at all. Data-driven rather than
* platform-driven: the Windows typeperf fallback can be missing the counter,
* and reporting a 0 sum then would read as "agents commit nothing".
*/
hasPrivateMemory: boolean
}
const PROCESS_COMMIT_METRIC: ProcessCommitMetric = 'private-bytes'
/**
* The one rule for every committed-bytes key: present only when the sweep could
* measure it, because a 0 would read as "these processes commit nothing".
*/
function commitField(hasPrivateMemory: boolean, privateMemory: number): { privateMemory?: number } {
return hasPrivateMemory ? { privateMemory: clampNumber(privateMemory) } : {}
}
/** The snapshot-level pair, which names the unit alongside the total. */
function snapshotCommitFields(
hasPrivateMemory: boolean,
totalPrivateMemory: number
): Pick<MemorySnapshot, 'processCommitMetric' | 'totalPrivateMemory'> {
return hasPrivateMemory
? {
processCommitMetric: PROCESS_COMMIT_METRIC,
totalPrivateMemory: clampNumber(totalPrivateMemory)
}
: {}
}
function clampNumber(value: unknown): number {
@@ -155,9 +188,11 @@ async function enumerateProcesses(): Promise<ProcIndex> {
const byPid = new Map<number, ProcRow>()
const childrenOf = new Map<number, number[]>()
let hasPrivateMemory = false
for (const row of rows) {
byPid.set(row.pid, row)
hasPrivateMemory ||= row.privateMemory !== undefined
const siblings = childrenOf.get(row.ppid)
if (siblings) {
siblings.push(row.pid)
@@ -166,7 +201,7 @@ async function enumerateProcesses(): Promise<ProcIndex> {
}
}
return { byPid, childrenOf }
return { byPid, childrenOf, hasPrivateMemory }
}
async function enumerateUnix(): Promise<ProcRow[]> {
@@ -261,13 +296,16 @@ function electronMetricMemoryBytes(
}
function bucketElectronMetrics(processIndex: ProcIndex): AppBucketsRaw {
const main = { cpu: 0, memory: 0 }
const renderer = { cpu: 0, memory: 0 }
const other = { cpu: 0, memory: 0 }
const main = { cpu: 0, memory: 0, privateMemory: 0 }
const renderer = { cpu: 0, memory: 0, privateMemory: 0 }
const other = { cpu: 0, memory: 0, privateMemory: 0 }
for (const proc of getAppEnvironment().getAppMetrics()) {
const cpu = clampNumber(proc.cpu?.percentCPUUsage)
const memoryBytes = electronMetricMemoryBytes(proc, processIndex)
// Why the host row rather than Electron's own metric: getAppMetrics has no
// commit figure for helper processes, and the sweep already indexed them.
const privateBytes = clampNumber(processIndex.byPid.get(proc.pid)?.privateMemory)
// Why: lowercase once so future Electron versions emitting different
// casing ('browser' vs 'Browser') still bucket correctly.
@@ -281,14 +319,24 @@ function bucketElectronMetrics(processIndex: ProcIndex): AppBucketsRaw {
target.cpu += cpu
target.memory += memoryBytes
target.privateMemory += privateBytes
}
const usage = (bucket: typeof main): UsageValues => ({
cpu: bucket.cpu,
memory: bucket.memory,
...commitField(processIndex.hasPrivateMemory, bucket.privateMemory)
})
return {
main,
renderer,
other,
cpu: main.cpu + renderer.cpu + other.cpu,
memory: main.memory + renderer.memory + other.memory
main: usage(main),
renderer: usage(renderer),
other: usage(other),
...usage({
cpu: main.cpu + renderer.cpu + other.cpu,
memory: main.memory + renderer.memory + other.memory,
privateMemory: main.privateMemory + renderer.privateMemory + other.privateMemory
})
}
}
@@ -301,6 +349,7 @@ type WorktreeBucket = {
repoName: string
cpu: number
memory: number
privateMemory: number
sessions: SessionMemory[]
}
@@ -334,7 +383,16 @@ function makeEmptyBucket(
repoId: string,
repoName: string
): WorktreeBucket {
return { worktreeId, worktreeName, repoId, repoName, cpu: 0, memory: 0, sessions: [] }
return {
worktreeId,
worktreeName,
repoId,
repoName,
cpu: 0,
memory: 0,
privateMemory: 0,
sessions: []
}
}
// ─── Main collection path ───────────────────────────────────────────
@@ -361,6 +419,7 @@ async function runSnapshot(store: MemorySnapshotStore): Promise<MemorySnapshot>
for (const pty of ptys) {
let sessionCpu = 0
let sessionMemory = 0
let sessionPrivateMemory = 0
if (pty.pid != null) {
for (const pid of collectSubtree(processIndex, pty.pid)) {
@@ -374,6 +433,9 @@ async function runSnapshot(store: MemorySnapshotStore): Promise<MemorySnapshot>
claimed.add(pid)
sessionCpu += row.cpu
sessionMemory += row.memory
// Why the whole subtree: an agent's committed bytes live in the
// children it spawned (codex.exe, MCP servers), not in the shell.
sessionPrivateMemory += clampNumber(row.privateMemory)
}
}
@@ -382,7 +444,8 @@ async function runSnapshot(store: MemorySnapshotStore): Promise<MemorySnapshot>
paneKey: pty.paneKey,
pid: pty.pid ?? 0,
cpu: clampNumber(sessionCpu),
memory: clampNumber(sessionMemory)
memory: clampNumber(sessionMemory),
...commitField(processIndex.hasPrivateMemory, sessionPrivateMemory)
}
let bucket: WorktreeBucket
@@ -401,6 +464,7 @@ async function runSnapshot(store: MemorySnapshotStore): Promise<MemorySnapshot>
bucket.cpu += session.cpu
bucket.memory += session.memory
bucket.privateMemory += clampNumber(session.privateMemory)
bucket.sessions.push(session)
}
@@ -419,16 +483,19 @@ async function runSnapshot(store: MemorySnapshotStore): Promise<MemorySnapshot>
}
sweepStaleHistory(now)
const worktrees: WorktreeMemory[] = bucketList.map((b) => ({
const worktrees: WorktreeMemory[] = bucketList.map(({ privateMemory, ...b }) => ({
...b,
...commitField(processIndex.hasPrivateMemory, privateMemory),
history: readHistory(b.worktreeId)
}))
let sessionCpuTotal = 0
let sessionMemoryTotal = 0
let sessionPrivateTotal = 0
for (const wt of worktrees) {
sessionCpuTotal += wt.cpu
sessionMemoryTotal += wt.memory
sessionPrivateTotal += clampNumber(wt.privateMemory)
}
return {
@@ -436,6 +503,10 @@ async function runSnapshot(store: MemorySnapshotStore): Promise<MemorySnapshot>
worktrees,
host,
processMemoryMetric: getProcessMemoryMetric(),
...snapshotCommitFields(
processIndex.hasPrivateMemory,
clampNumber(appBuckets.privateMemory) + sessionPrivateTotal
),
totalCpu: appBuckets.cpu + sessionCpuTotal,
totalMemory: appBuckets.memory + sessionMemoryTotal,
collectedAt: now
@@ -2,50 +2,24 @@ import { runProcess } from '../../shared/child-process/run-process'
import os from 'node:os'
import { performance } from 'node:perf_hooks'
import {
iterateProcessOutputLines,
PROCESS_OUTPUT_FIELD_SCAN_MAX_CHARS
} from '../../shared/process-output-field-scanner'
parseTypeperfProcessOutput,
parseWindowsProcessSample,
TYPEPERF_COUNTERS,
type ParsedWindowsProcessSample,
type WindowsProcessResourceRow
} from './windows-process-sample-parsing'
export type { WindowsProcessResourceRow } from './windows-process-sample-parsing'
const PROCESS_QUERY_TIMEOUT_MS = 5_000
const PROCESS_QUERY_MAX_BUFFER = 10 * 1024 * 1024
const TYPEPERF_COUNTERS = [
'\\Process(*)\\ID Process',
'\\Process(*)\\Creating Process ID',
'\\Process(*)\\Working Set'
] as const
const TYPEPERF_MAX_FIELDS = 8_192
const TYPEPERF_MAX_LINE_CHARS = 1024 * 1024
const CPU_MIN_SAMPLE_MS = 250
const CPU_STALE_AFTER_MS = 10_000
const HUNDRED_NS_TICKS_PER_MS = 10_000
const CIM_RETRY_AFTER_MS = 30_000
export type WindowsProcessResourceRow = {
pid: number
ppid: number
/** Percent of one core (may exceed 100 on multi-core). */
cpu: number
/** Resident memory in bytes. */
memory: number
}
type WindowsCpuTimes = {
cpuTicks: bigint
startTimeId: string
}
type WindowsProcessSample = {
type WindowsProcessSample = ParsedWindowsProcessSample & {
sampledAtMs: number
rows: WindowsProcessResourceRow[]
cpuByPid: Map<number, WindowsCpuTimes>
}
type ParsedWindowsProcessSample = Omit<WindowsProcessSample, 'sampledAtMs'>
type TypeperfProcessFields = {
pid?: number
ppid?: number
memory?: number
}
let processBackend: 'cim' | 'typeperf' = 'cim'
@@ -114,14 +88,16 @@ function applyWindowsCpuSample(sample: WindowsProcessSample): WindowsProcessReso
}
async function enumerateWindowsWithCim(): Promise<WindowsProcessSample | null> {
// PageFileUsage rides along on the sweep that already runs: it is the commit
// charge the working set stops showing once Windows starts trimming pages.
const args = [
'-NoLogo',
'-NoProfile',
'-NonInteractive',
'-Command',
"$ErrorActionPreference = 'Stop'; $ProgressPreference = 'SilentlyContinue'; " +
'Get-CimInstance Win32_Process -Property ProcessId,ParentProcessId,WorkingSetSize,KernelModeTime,UserModeTime,CreationDate | ' +
'ForEach-Object { try { [string]::Join([char]9, @($_.ProcessId, $_.ParentProcessId, $_.WorkingSetSize, [string]$_.KernelModeTime, [string]$_.UserModeTime, $_.CreationDate.ToUniversalTime().Ticks)) } catch {} }'
'Get-CimInstance Win32_Process -Property ProcessId,ParentProcessId,WorkingSetSize,KernelModeTime,UserModeTime,CreationDate,PageFileUsage | ' +
'ForEach-Object { try { [string]::Join([char]9, @($_.ProcessId, $_.ParentProcessId, $_.WorkingSetSize, [string]$_.KernelModeTime, [string]$_.UserModeTime, $_.CreationDate.ToUniversalTime().Ticks, $_.PageFileUsage)) } catch {} }'
]
try {
const stdout = await execFileText('powershell.exe', args)
@@ -164,169 +140,6 @@ async function execFileText(file: string, args: string[]): Promise<string> {
return result.stdout
}
function parseWindowsProcessSample(stdout: string): ParsedWindowsProcessSample {
const rows: WindowsProcessResourceRow[] = []
const cpuByPid = new Map<number, WindowsCpuTimes>()
for (const line of iterateProcessOutputLines(stdout)) {
const fields = parseCimTabFields(line)
if (fields.length < 3) {
continue
}
const pid = Number.parseInt(fields[0], 10)
const ppid = Number.parseInt(fields[1], 10)
const memory = Number.parseInt(fields[2], 10)
if (!Number.isSafeInteger(pid) || pid <= 0 || !Number.isSafeInteger(ppid) || ppid < 0) {
continue
}
rows.push({
pid,
ppid,
cpu: 0,
memory: Number.isFinite(memory) && memory > 0 ? memory : 0
})
const kernelTicks = parseUnsignedBigInt(fields[3])
const userTicks = parseUnsignedBigInt(fields[4])
const startTimeId = fields[5] ?? ''
if (
kernelTicks !== null &&
userTicks !== null &&
/^\d+$/.test(startTimeId) &&
!/^0+$/.test(startTimeId)
) {
cpuByPid.set(pid, { cpuTicks: kernelTicks + userTicks, startTimeId })
}
}
return { rows, cpuByPid }
}
function parseCimTabFields(line: string): string[] {
// Why: CIM serializes null properties as empty tab fields; collapsing
// whitespace would shift CPU counters into the working-set column.
if (line.length > PROCESS_OUTPUT_FIELD_SCAN_MAX_CHARS) {
return []
}
return line.split('\t', 6).map((field) => field.trim())
}
/** Parse tab-delimited PowerShell CIM process rows without deriving CPU deltas. */
export function parseWindowsProcessOutput(stdout: string): WindowsProcessResourceRow[] {
return parseWindowsProcessSample(stdout).rows
}
/** Parse one CSV sample from Windows Typeperf. */
export function parseTypeperfProcessOutput(stdout: string): WindowsProcessResourceRow[] {
let headers: string[] | null = null
let values: string[] | null = null
for (const line of iterateProcessOutputLines(stdout)) {
if (!line || line.length > TYPEPERF_MAX_LINE_CHARS) {
continue
}
const fields = parseTypeperfCsvLine(line)
if (!headers && fields[0]?.startsWith('(PDH-CSV')) {
headers = fields
continue
}
if (headers && fields.length === headers.length) {
values = fields
break
}
}
if (!headers || !values) {
return []
}
const byInstance = new Map<string, TypeperfProcessFields>()
for (let index = 1; index < headers.length; index += 1) {
const path = parseTypeperfCounterPath(headers[index])
if (!path || path.instance === '_Total') {
continue
}
const value = Number.parseFloat(values[index])
if (!Number.isFinite(value)) {
continue
}
const row = byInstance.get(path.instance) ?? {}
if (path.counter === 'ID Process') {
row.pid = Math.trunc(value)
} else if (path.counter === 'Creating Process ID') {
row.ppid = Math.trunc(value)
} else if (path.counter === 'Working Set') {
row.memory = value
}
byInstance.set(path.instance, row)
}
const rows: WindowsProcessResourceRow[] = []
for (const row of byInstance.values()) {
if (row.pid === undefined || row.pid <= 0 || row.ppid === undefined || row.ppid < 0) {
continue
}
rows.push({
pid: row.pid,
ppid: row.ppid,
cpu: 0,
memory: row.memory !== undefined && row.memory > 0 ? row.memory : 0
})
}
return rows
}
function parseTypeperfCounterPath(path: string): { instance: string; counter: string } | null {
const processStart = path.lastIndexOf('\\Process(')
const counterStart = path.lastIndexOf(')\\')
if (processStart === -1 || counterStart <= processStart + 9) {
return null
}
return {
instance: path.slice(processStart + 9, counterStart),
counter: path.slice(counterStart + 2)
}
}
function parseTypeperfCsvLine(line: string): string[] {
const fields: string[] = []
let value = ''
let quoted = false
for (let index = 0; index < line.length; index += 1) {
const char = line[index]
if (char === '"') {
if (quoted && line[index + 1] === '"') {
value += '"'
index += 1
} else {
quoted = !quoted
}
continue
}
if (char === ',' && !quoted) {
fields.push(value)
value = ''
if (fields.length >= TYPEPERF_MAX_FIELDS) {
return []
}
continue
}
value += char
}
fields.push(value)
return fields
}
function parseUnsignedBigInt(value: string | undefined): bigint | null {
if (!value || !/^\d+$/.test(value)) {
return null
}
try {
return BigInt(value)
} catch {
return null
}
}
function nonNegativeNumber(value: unknown): number {
return typeof value === 'number' && Number.isFinite(value) ? Math.max(0, value) : 0
}
@@ -0,0 +1,140 @@
import { describe, expect, it, vi } from 'vitest'
async function loadWindowsProcessSampleParsing() {
vi.resetModules()
return await import('./windows-process-sample-parsing')
}
describe('parseWindowsProcessOutput', () => {
it('parses tab-delimited CIM process rows', async () => {
const { parseWindowsProcessOutput } = await loadWindowsProcessSampleParsing()
expect(parseWindowsProcessOutput('100\t1\t2048\r\n200\t100\t1024')).toEqual([
{ pid: 100, ppid: 1, cpu: 0, memory: 2048 },
{ pid: 200, ppid: 100, cpu: 0, memory: 1024 }
])
})
it('skips malformed rows and clamps invalid memory to zero', async () => {
const { parseWindowsProcessOutput } = await loadWindowsProcessSampleParsing()
expect(
parseWindowsProcessOutput(
[
'garbage',
'abc\t1\t100',
'10\txyz\t100',
'0\t0\t100',
'-5\t0\t100',
'30\t-1\t100',
'20\t1\t-50'
].join('\n')
)
).toEqual([{ pid: 20, ppid: 1, cpu: 0, memory: 0 }])
})
it('reads PageFileUsage kilobytes into committed private bytes', async () => {
const { parseWindowsProcessOutput } = await loadWindowsProcessSampleParsing()
// 5,600,000 KB of commit behind a 96 MB working set is the reported shape.
expect(
parseWindowsProcessOutput('100\t1\t100663296\t0\t0\t638830000000000000\t5600000')
).toEqual([{ pid: 100, ppid: 1, cpu: 0, memory: 100663296, privateMemory: 5600000 * 1024 }])
})
it('leaves committed bytes absent when the host omits PageFileUsage', async () => {
const { parseWindowsProcessOutput } = await loadWindowsProcessSampleParsing()
const [row] = parseWindowsProcessOutput('100\t1\t2048\t0\t0\t638830000000000000')
expect(row.privateMemory).toBeUndefined()
expect(parseWindowsProcessOutput('100\t1\t2048\t0\t0\t1\t')[0].privateMemory).toBeUndefined()
})
it('keeps a zero PageFileUsage distinct from an unreported one', async () => {
const { parseWindowsProcessOutput } = await loadWindowsProcessSampleParsing()
expect(parseWindowsProcessOutput('4\t0\t2048\t0\t0\t1\t0')[0].privateMemory).toBe(0)
})
it('preserves empty CIM field positions instead of shifting CPU ticks into memory', async () => {
const { parseWindowsProcessOutput } = await loadWindowsProcessSampleParsing()
expect(parseWindowsProcessOutput('100\t1\t\t200\t300\t638830000000000000')).toEqual([
{ pid: 100, ppid: 1, cpu: 0, memory: 0 }
])
})
})
describe('parseTypeperfProcessOutput', () => {
it('joins PID, parent PID, and working-set counters by process instance', async () => {
const { parseTypeperfProcessOutput } = await loadWindowsProcessSampleParsing()
const stdout = [
'"(PDH-CSV 4.0)","\\\\HOST\\Process(node)\\ID Process","\\\\HOST\\Process(node#1)\\ID Process","\\\\HOST\\Process(node)\\Creating Process ID","\\\\HOST\\Process(node#1)\\Creating Process ID","\\\\HOST\\Process(node)\\Working Set","\\\\HOST\\Process(node#1)\\Working Set"',
'"07/15/2026 01:44:54.514","100.000000","200.000000","1.000000","100.000000","2048.000000","4096.000000"'
].join('\r\n')
expect(parseTypeperfProcessOutput(stdout)).toEqual([
{ pid: 100, ppid: 1, cpu: 0, memory: 2048 },
{ pid: 200, ppid: 100, cpu: 0, memory: 4096 }
])
})
it('joins the Private Bytes counter onto the same process instance', async () => {
const { parseTypeperfProcessOutput } = await loadWindowsProcessSampleParsing()
const stdout = [
'"(PDH-CSV 4.0)","\\\\HOST\\Process(codex)\\ID Process","\\\\HOST\\Process(codex)\\Creating Process ID","\\\\HOST\\Process(codex)\\Working Set","\\\\HOST\\Process(codex)\\Private Bytes"',
'"07/15/2026 01:44:54.514","100.000000","1.000000","100663296.000000","5734400000.000000"'
].join('\r\n')
expect(parseTypeperfProcessOutput(stdout)).toEqual([
{ pid: 100, ppid: 1, cpu: 0, memory: 100663296, privateMemory: 5734400000 }
])
})
it('leaves committed bytes absent when the Private Bytes counter is missing', async () => {
const { parseTypeperfProcessOutput } = await loadWindowsProcessSampleParsing()
const stdout = [
'"(PDH-CSV 4.0)","\\\\HOST\\Process(codex)\\ID Process","\\\\HOST\\Process(codex)\\Creating Process ID","\\\\HOST\\Process(codex)\\Working Set"',
'"time","100.000000","1.000000","2048.000000"'
].join('\r\n')
expect(parseTypeperfProcessOutput(stdout)[0].privateMemory).toBeUndefined()
})
it('still parses a busy host once a fourth counter widens every sample line', async () => {
const { parseTypeperfProcessOutput } = await loadWindowsProcessSampleParsing()
// 2100 processes x 4 counters overruns a fixed 8192-field cap; the reported
// MCP fan-out host runs well past 2048 processes.
const instanceCount = 2100
const headers = ['"(PDH-CSV 4.0)"']
const values = ['"time"']
for (let index = 0; index < instanceCount; index += 1) {
for (const counter of ['ID Process', 'Creating Process ID', 'Working Set', 'Private Bytes']) {
headers.push(`"\\\\HOST\\Process(node#${index})\\${counter}"`)
}
values.push(`"${1000 + index}"`, '"1"', '"2048"', '"4096"')
}
const stdout = [headers.join(','), values.join(',')].join('\r\n')
const rows = parseTypeperfProcessOutput(stdout)
expect(rows).toHaveLength(instanceCount)
expect(rows[instanceCount - 1]).toEqual({
pid: 1000 + instanceCount - 1,
ppid: 1,
cpu: 0,
memory: 2048,
privateMemory: 4096
})
})
it('ignores aggregate and incomplete rows and clamps invalid memory', async () => {
const { parseTypeperfProcessOutput } = await loadWindowsProcessSampleParsing()
const stdout = [
'"(PDH-CSV 4.0)","\\\\HOST\\Process(_Total)\\ID Process","\\\\HOST\\Process(cmd)\\ID Process","\\\\HOST\\Process(orphan)\\ID Process","\\\\HOST\\Process(_Total)\\Creating Process ID","\\\\HOST\\Process(cmd)\\Creating Process ID","\\\\HOST\\Process(_Total)\\Working Set","\\\\HOST\\Process(cmd)\\Working Set"',
'"time","0.000000","100.000000","200.000000","0.000000","1.000000","999999.000000","-1.000000"'
].join('\r\n')
expect(parseTypeperfProcessOutput(stdout)).toEqual([{ pid: 100, ppid: 1, cpu: 0, memory: 0 }])
})
})
@@ -0,0 +1,240 @@
/**
* Text parsers for the two Windows process-table formats the memory collector
* reads: tab-delimited `Get-CimInstance Win32_Process` rows and one Typeperf
* CSV sample. Kept apart from the backend/CPU-delta orchestration so each side
* stays readable on its own.
*/
import {
iterateProcessOutputLines,
PROCESS_OUTPUT_FIELD_SCAN_MAX_CHARS
} from '../../shared/process-output-field-scanner'
/**
* Counter paths typeperf is asked for, kept beside the decoder that reads their
* names back out of the PDH header.
*/
export const TYPEPERF_COUNTERS = [
'\\Process(*)\\ID Process',
'\\Process(*)\\Creating Process ID',
'\\Process(*)\\Working Set',
'\\Process(*)\\Private Bytes'
] as const
const TYPEPERF_MAX_INSTANCES = 4_096
// Why derived: PDH emits one field per counter per instance plus a timestamp, so
// a fixed cap silently shrinks the parsable process count each time a counter is
// added. The 1 MB line cap bounds memory independently.
const TYPEPERF_MAX_FIELDS = 1 + TYPEPERF_COUNTERS.length * TYPEPERF_MAX_INSTANCES
const TYPEPERF_MAX_LINE_CHARS = 1024 * 1024
export type WindowsProcessResourceRow = {
pid: number
ppid: number
/** Percent of one core (may exceed 100 on multi-core). */
cpu: number
/** Resident memory in bytes. */
memory: number
/** Committed private bytes, resident or paged out. Absent when the host did not report it. */
privateMemory?: number
}
export type WindowsCpuTimes = {
cpuTicks: bigint
startTimeId: string
}
export type ParsedWindowsProcessSample = {
rows: WindowsProcessResourceRow[]
cpuByPid: Map<number, WindowsCpuTimes>
}
type TypeperfProcessFields = {
pid?: number
ppid?: number
memory?: number
privateMemory?: number
}
export function parseWindowsProcessSample(stdout: string): ParsedWindowsProcessSample {
const rows: WindowsProcessResourceRow[] = []
const cpuByPid = new Map<number, WindowsCpuTimes>()
for (const line of iterateProcessOutputLines(stdout)) {
const fields = parseCimTabFields(line)
if (fields.length < 3) {
continue
}
const pid = Number.parseInt(fields[0], 10)
const ppid = Number.parseInt(fields[1], 10)
const memory = Number.parseInt(fields[2], 10)
if (!Number.isSafeInteger(pid) || pid <= 0 || !Number.isSafeInteger(ppid) || ppid < 0) {
continue
}
const privateMemory = parseCimPageFileBytes(fields[6])
rows.push({
pid,
ppid,
cpu: 0,
memory: Number.isFinite(memory) && memory > 0 ? memory : 0,
...(privateMemory === null ? {} : { privateMemory })
})
const kernelTicks = parseUnsignedBigInt(fields[3])
const userTicks = parseUnsignedBigInt(fields[4])
const startTimeId = fields[5] ?? ''
if (
kernelTicks !== null &&
userTicks !== null &&
/^\d+$/.test(startTimeId) &&
!/^0+$/.test(startTimeId)
) {
cpuByPid.set(pid, { cpuTicks: kernelTicks + userTicks, startTimeId })
}
}
return { rows, cpuByPid }
}
function parseCimTabFields(line: string): string[] {
// Why: CIM serializes null properties as empty tab fields; collapsing
// whitespace would shift CPU counters into the working-set column.
if (line.length > PROCESS_OUTPUT_FIELD_SCAN_MAX_CHARS) {
return []
}
return line.split('\t', 7).map((field) => field.trim())
}
/**
* Win32_Process.PageFileUsage is a UInt32 of KILOBYTES. null (not 0) when the
* property is missing, because a host that cannot report commit must not be
* indistinguishable from a process holding none.
*/
function parseCimPageFileBytes(field: string | undefined): number | null {
if (!field) {
return null
}
const kb = Number.parseInt(field, 10)
return Number.isSafeInteger(kb) && kb >= 0 ? kb * 1024 : null
}
/** Parse tab-delimited PowerShell CIM process rows without deriving CPU deltas. */
export function parseWindowsProcessOutput(stdout: string): WindowsProcessResourceRow[] {
return parseWindowsProcessSample(stdout).rows
}
/** Parse one CSV sample from Windows Typeperf. */
export function parseTypeperfProcessOutput(stdout: string): WindowsProcessResourceRow[] {
let headers: string[] | null = null
let values: string[] | null = null
for (const line of iterateProcessOutputLines(stdout)) {
if (!line || line.length > TYPEPERF_MAX_LINE_CHARS) {
continue
}
const fields = parseTypeperfCsvLine(line)
if (!headers && fields[0]?.startsWith('(PDH-CSV')) {
headers = fields
continue
}
if (headers && fields.length === headers.length) {
values = fields
break
}
}
if (!headers || !values) {
return []
}
const byInstance = new Map<string, TypeperfProcessFields>()
for (let index = 1; index < headers.length; index += 1) {
const path = parseTypeperfCounterPath(headers[index])
if (!path || path.instance === '_Total') {
continue
}
const value = Number.parseFloat(values[index])
if (!Number.isFinite(value)) {
continue
}
const row = byInstance.get(path.instance) ?? {}
if (path.counter === 'ID Process') {
row.pid = Math.trunc(value)
} else if (path.counter === 'Creating Process ID') {
row.ppid = Math.trunc(value)
} else if (path.counter === 'Working Set') {
row.memory = value
} else if (path.counter === 'Private Bytes') {
row.privateMemory = value
}
byInstance.set(path.instance, row)
}
const rows: WindowsProcessResourceRow[] = []
for (const row of byInstance.values()) {
if (row.pid === undefined || row.pid <= 0 || row.ppid === undefined || row.ppid < 0) {
continue
}
rows.push({
pid: row.pid,
ppid: row.ppid,
cpu: 0,
memory: row.memory !== undefined && row.memory > 0 ? row.memory : 0,
...(row.privateMemory !== undefined && row.privateMemory >= 0
? { privateMemory: row.privateMemory }
: {})
})
}
return rows
}
function parseTypeperfCounterPath(path: string): { instance: string; counter: string } | null {
const processStart = path.lastIndexOf('\\Process(')
const counterStart = path.lastIndexOf(')\\')
if (processStart === -1 || counterStart <= processStart + 9) {
return null
}
return {
instance: path.slice(processStart + 9, counterStart),
counter: path.slice(counterStart + 2)
}
}
function parseTypeperfCsvLine(line: string): string[] {
const fields: string[] = []
let value = ''
let quoted = false
for (let index = 0; index < line.length; index += 1) {
const char = line[index]
if (char === '"') {
if (quoted && line[index + 1] === '"') {
value += '"'
index += 1
} else {
quoted = !quoted
}
continue
}
if (char === ',' && !quoted) {
fields.push(value)
value = ''
if (fields.length >= TYPEPERF_MAX_FIELDS) {
return []
}
continue
}
value += char
}
fields.push(value)
return fields
}
function parseUnsignedBigInt(value: string | undefined): bigint | null {
if (!value || !/^\d+$/.test(value)) {
return null
}
try {
return BigInt(value)
} catch {
return null
}
}
@@ -67,7 +67,11 @@ import {
getResourceManagerAriaLabel,
getResourceManagerTooltipLines
} from './resource-manager-terminal-copy'
import { getResourceMemoryMetricCopy } from './resource-memory-metric-copy'
import {
getCommitPressureToneClass,
getResourceCommitMetricCopy,
getResourceMemoryMetricCopy
} from './resource-memory-metric-copy'
import { requiresKillConfirmation } from './resource-session-kill-confirmation'
import { resolveResourceManagerWorktreeTarget } from './resource-manager-worktree-target'
import {
@@ -960,15 +964,29 @@ export function ResourceUsageStatusSegment({
const memoryMetricCopy = getResourceMemoryMetricCopy(
resourceSnapshot?.processMemoryMetric ?? 'rss'
)
const { totalMemory, totalCpu, memBadgeLabel } = useMemo(() => {
const memory = resourceSnapshot?.totalMemory ?? 0
const cpu = resourceSnapshot?.totalCpu ?? 0
return {
totalMemory: memory,
totalCpu: cpu,
memBadgeLabel: resourceSnapshot ? formatMemory(memory) : '—'
}
}, [resourceSnapshot])
// Why null-not-zero: a host that cannot read commit (every Unix host, and any
// host older than the field) must render nothing here, never "0 B committed".
const commitMetricCopy = resourceSnapshot?.processCommitMetric
? getResourceCommitMetricCopy()
: null
const { totalMemory, totalCpu, memBadgeLabel, totalPrivateMemory, commitToneClass } =
useMemo(() => {
const memory = resourceSnapshot?.totalMemory ?? 0
const cpu = resourceSnapshot?.totalCpu ?? 0
const privateMemory = resourceSnapshot?.totalPrivateMemory
return {
totalMemory: memory,
totalCpu: cpu,
memBadgeLabel: resourceSnapshot ? formatMemory(memory) : '—',
totalPrivateMemory: privateMemory,
commitToneClass: getCommitPressureToneClass({
privateMemory,
hostTotalMemory: resourceSnapshot?.host.totalMemory ?? 0
})
}
}, [resourceSnapshot])
const commitBadgeLabel =
commitMetricCopy && totalPrivateMemory !== undefined ? formatMemory(totalPrivateMemory) : null
// Why: memorySnapshotError null means "succeeded" OR "never fetched"; a sessions failure before any snapshot still counts as daemon-unreachable.
const daemonUnreachable = sessionsError && (memorySnapshotError !== null || snapshot === null)
@@ -976,7 +994,14 @@ export function ResourceUsageStatusSegment({
const sessionsOnlyError = sessionsError && memorySnapshotError === null
const resourceManagerTooltipLines = getResourceManagerTooltipLines({
memoryLabel: resourceSnapshot
? `${memBadgeLabel} · ${memoryMetricCopy.summaryLabel}`
? [
`${memBadgeLabel} · ${memoryMetricCopy.summaryLabel}`,
commitBadgeLabel && commitMetricCopy
? `${commitBadgeLabel} ${commitMetricCopy.summaryLabel}`
: null
]
.filter(Boolean)
.join(' · ')
: memBadgeLabel,
sessionCount: triggerSessionCount,
spaceScanReady
@@ -1162,7 +1187,14 @@ export function ResourceUsageStatusSegment({
<MemoryStick className="size-3 text-muted-foreground" />
{!iconOnly && (
<>
<span className="text-[11px] font-medium tabular-nums text-muted-foreground">
{/* Tint only: the number stays the resident sum it has always been,
and the tooltip names the commit figure that raised the tone. */}
<span
className={cn(
'text-[11px] font-medium tabular-nums',
commitToneClass ?? 'text-muted-foreground'
)}
>
{memBadgeLabel}
</span>
<span className="text-muted-foreground/50">·</span>
@@ -1353,6 +1385,30 @@ export function ResourceUsageStatusSegment({
{memoryMetricCopy.description}
</TooltipContent>
</Tooltip>
{commitBadgeLabel && commitMetricCopy && (
<>
<span className="text-muted-foreground/50">·</span>
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<span
tabIndex={0}
className={cn(
'font-medium focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:rounded',
commitToneClass ?? 'text-foreground'
)}
>
{commitBadgeLabel}{' '}
<span className="font-normal text-muted-foreground">
{commitMetricCopy.summaryLabel}
</span>
</span>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6} className="z-[70] max-w-xs">
{commitMetricCopy.description}
</TooltipContent>
</Tooltip>
</>
)}
</div>
{orphanCount > 0 && (
<span className="shrink-0 text-yellow-500" aria-live="polite">
@@ -4,7 +4,11 @@ vi.mock('@/i18n/i18n', () => ({
translate: (_key: string, fallback: string) => fallback
}))
import { getResourceMemoryMetricCopy } from './resource-memory-metric-copy'
import {
getCommitPressureToneClass,
getResourceCommitMetricCopy,
getResourceMemoryMetricCopy
} from './resource-memory-metric-copy'
describe('resource memory metric copy', () => {
it('discloses that Unix RSS sums can repeat shared and aliased pages', () => {
@@ -16,11 +20,54 @@ describe('resource memory metric copy', () => {
})
})
it('uses working-set terminology for Windows snapshots', () => {
it('says working set counts only resident pages, so paged-out memory is missing', () => {
expect(getResourceMemoryMetricCopy('working-set')).toEqual({
columnLabel: 'WS',
summaryLabel: 'Σ WS',
description: 'Summed working set (WS). Shared pages can appear in more than one process.'
description:
'Summed working set (WS): pages resident in RAM right now. Shared pages can appear in more than one process, and memory Windows has paged out is not counted here.'
})
})
it('labels committed bytes as a separate quantity, not a corrected working set', () => {
expect(getResourceCommitMetricCopy()).toEqual({
summaryLabel: 'Σ Private',
description:
'Summed private bytes: memory these processes have committed, counted whether it is resident or paged out. This is what the host charges against its commit limit, so it keeps rising while the working set above shrinks under paging.'
})
})
})
describe('commit pressure tone', () => {
const hostTotalMemory = 16 * 1024 ** 3
it('stays silent while tracked commit is a modest share of RAM', () => {
expect(getCommitPressureToneClass({ privateMemory: 4 * 1024 ** 3, hostTotalMemory })).toBeNull()
})
it('warns at the same 60/80 thresholds the host usage bars already use', () => {
expect(getCommitPressureToneClass({ privateMemory: 10 * 1024 ** 3, hostTotalMemory })).toBe(
'text-yellow-500'
)
// The reported host: 13.4 GB committed by agents on 16 GB of RAM.
expect(getCommitPressureToneClass({ privateMemory: 13.4 * 1024 ** 3, hostTotalMemory })).toBe(
'text-red-500'
)
})
it('stays silent for a snapshot that carries no commit figure at all', () => {
expect(getCommitPressureToneClass({ privateMemory: undefined, hostTotalMemory })).toBeNull()
})
it('stays silent when the host total is unknown, rather than dividing by zero', () => {
expect(
getCommitPressureToneClass({ privateMemory: 8 * 1024 ** 3, hostTotalMemory: 0 })
).toBeNull()
})
it('keeps warning above 100% of RAM rather than capping the share', () => {
expect(getCommitPressureToneClass({ privateMemory: 32 * 1024 ** 3, hostTotalMemory })).toBe(
'text-red-500'
)
})
})
@@ -1,5 +1,6 @@
import type { ProcessMemoryMetric } from '../../../../shared/process-stats-types'
import { translate } from '@/i18n/i18n'
import { usageTextColorClass } from './usage-roster-formatting'
export type ResourceMemoryMetricCopy = {
columnLabel: string
@@ -14,7 +15,7 @@ export function getResourceMemoryMetricCopy(metric: ProcessMemoryMetric): Resour
summaryLabel: 'Σ WS',
description: translate(
'auto.components.status.bar.resource.memory.metric.workingSetDescription',
'Summed working set (WS). Shared pages can appear in more than one process.'
'Summed working set (WS): pages resident in RAM right now. Shared pages can appear in more than one process, and memory Windows has paged out is not counted here.'
)
}
}
@@ -27,3 +28,39 @@ export function getResourceMemoryMetricCopy(metric: ProcessMemoryMetric): Resour
)
}
}
/** No column of its own yet, so no `columnLabel`: the commit figure is a summary + tooltip. */
export function getResourceCommitMetricCopy(): Omit<ResourceMemoryMetricCopy, 'columnLabel'> {
return {
summaryLabel: 'Σ Private',
description: translate(
'auto.components.status.bar.resource.memory.metric.privateBytesDescription',
'Summed private bytes: memory these processes have committed, counted whether it is resident or paged out. This is what the host charges against its commit limit, so it keeps rising while the working set above shrinks under paging.'
)
}
}
/**
* Warning tint once *Orca's own* tracked commit grows large against physical
* RAM, on the same 60/80 bands as the host usage bars. Deliberately not a
* host-wide paging predictor: that needs the host's commit charge and commit
* limit, which this snapshot does not carry (#16211).
*
* Null both when the share is unremarkable and when the snapshot has no commit
* figure at all — silence is the honest answer for an unmeasured host.
*/
export function getCommitPressureToneClass(args: {
privateMemory: number | undefined
hostTotalMemory: number
}): string | null {
const { privateMemory, hostTotalMemory } = args
if (typeof privateMemory !== 'number' || !Number.isFinite(privateMemory)) {
return null
}
if (!Number.isFinite(hostTotalMemory) || hostTotalMemory <= 0) {
return null
}
// Uncapped on purpose: commit past 100% of RAM is the loudest case, not an error.
const tone = usageTextColorClass((privateMemory / hostTotalMemory) * 100)
return tone === 'text-foreground' ? null : tone
}
+2 -1
View File
@@ -3842,7 +3842,8 @@
"resource": {
"memory": {
"metric": {
"workingSetDescription": "Summed working set (WS). Shared pages can appear in more than one process.",
"workingSetDescription": "Summed working set (WS): pages resident in RAM right now. Shared pages can appear in more than one process, and memory Windows has paged out is not counted here.",
"privateBytesDescription": "Summed private bytes: memory these processes have committed, counted whether it is resident or paged out. This is what the host charges against its commit limit, so it keeps rising while the working set above shrinks under paging.",
"rssDescription": "Summed resident set size (RSS). Shared or aliased pages can appear in more than one process."
}
},
+2 -1
View File
@@ -3501,7 +3501,8 @@
"resource": {
"memory": {
"metric": {
"workingSetDescription": "Suma del conjunto de trabajo (WS). Las páginas compartidas pueden aparecer en más de un proceso.",
"workingSetDescription": "Suma del conjunto de trabajo (WS): las páginas residentes en RAM en este momento. Las páginas compartidas pueden aparecer en más de un proceso, y la memoria que Windows ha paginado a disco no se cuenta aquí.",
"privateBytesDescription": "Suma de bytes privados: la memoria que estos procesos han confirmado, contada tanto si está residente como si está paginada a disco. Es lo que el host imputa a su límite de confirmación, así que sigue subiendo mientras el conjunto de trabajo de arriba se reduce por la paginación.",
"rssDescription": "Suma del tamaño del conjunto residente (RSS). Las páginas compartidas o con alias pueden aparecer en más de un proceso."
}
},
+2 -1
View File
@@ -3501,7 +3501,8 @@
"resource": {
"memory": {
"metric": {
"workingSetDescription": "合計ワーキングセット(WS)。共有ページが複数のプロセスに表示されることがあります。",
"workingSetDescription": "合計ワーキングセット(WS): 現在 RAM に常駐しているページです。共有ページが複数のプロセスに表示されることがあり、Windows がページアウトしたメモリはここには含まれません。",
"privateBytesDescription": "合計プライベートバイト: これらのプロセスがコミットしたメモリで、常駐中かページアウト済みかを問わず計上されます。ホストがコミット制限に対して計上する値であるため、ページングによって上のワーキングセットが縮小しても増え続けます。",
"rssDescription": "合計レジデントセットサイズ(RSS)。共有またはエイリアスされたページが複数のプロセスに表示されることがあります。"
}
},
+2 -1
View File
@@ -3506,7 +3506,8 @@
"resource": {
"memory": {
"metric": {
"workingSetDescription": "합산된 워킹 세트(WS). 공유 페이지가 둘 이상의 프로세스에 나타날 수 있습니다.",
"workingSetDescription": "합산된 워킹 세트(WS): 지금 RAM에 상주 중인 페이지입니다. 공유 페이지가 둘 이상의 프로세스에 나타날 수 있으며, Windows가 페이지 아웃한 메모리는 여기에 포함되지 않습니다.",
"privateBytesDescription": "합산된 프라이빗 바이트: 이 프로세스들이 커밋한 메모리로, 상주 여부와 관계없이 계산됩니다. 호스트가 커밋 한도에 반영하는 값이므로, 페이징으로 위의 워킹 세트가 줄어드는 동안에도 계속 늘어납니다.",
"rssDescription": "합산된 레지던트 세트 크기(RSS). 공유 또는 별칭된 페이지가 둘 이상의 프로세스에 나타날 수 있습니다."
}
},
+2 -1
View File
@@ -3516,7 +3516,8 @@
"resource": {
"memory": {
"metric": {
"workingSetDescription": "工作集 (WS) 的总和。共享页可能会出现在多个进程中。",
"workingSetDescription": "工作集 (WS) 的总和:当前驻留在 RAM 中的页。共享页可能会出现在多个进程中,被 Windows 换出到页面文件的内存不计入其中。",
"privateBytesDescription": "专用字节的总和:这些进程已提交的内存,无论驻留还是已换出都会计入。这是主机计入提交限制的数值,因此在分页导致上方工作集缩小时它仍会继续上升。",
"rssDescription": "驻留集大小 (RSS) 的总和。共享页或别名映射页可能会出现在多个进程中。"
}
},
+31
View File
@@ -14,10 +14,29 @@ export type StatsSummary = {
export type UsageValues = {
cpu: number
memory: number
/**
* Committed bytes (see `ProcessCommitMetric`), a second quantity alongside
* `memory` — never a substitute for it. Absent means the host cannot report
* it; absent must never be read as zero.
*/
privateMemory?: number
}
export type ProcessMemoryMetric = 'rss' | 'working-set'
/**
* Unit of every `privateMemory` field in a snapshot.
*
* `private-bytes` is the Windows private commit charge
* (`Win32_Process.PageFileUsage` / `\Process(*)\Private Bytes`): memory a
* process has committed whether or not it is currently resident. Working set
* counts only resident pages, so an agent whose pages have been trimmed to the
* pagefile shrinks its working set while still holding the commit that pushes
* the host into paging. Unix has no equivalent, so snapshots from those hosts
* carry no commit metric at all.
*/
export type ProcessCommitMetric = 'private-bytes'
export type HostAvailableMemorySource = 'memory-pressure' | 'proc-meminfo' | 'free-memory'
/** The top-level cpu/memory are the sum of main + renderer + other. */
@@ -66,9 +85,21 @@ export type MemorySnapshot = {
host: HostMemory
/** Per-process byte metric used by app, session, worktree, history, and totalMemory values. */
processMemoryMetric: ProcessMemoryMetric
/**
* Names the unit of every `privateMemory` field below. Absent when this sweep
* produced none — an older host, or any host whose process table cannot
* report committed bytes. Readers must treat absence as unknown, not zero.
*/
processCommitMetric?: ProcessCommitMetric
/** Sum of app + all tracked worktree sessions. Percent of a single core, so may exceed 100 on multi-core machines. */
totalCpu: number
/** Sum of per-process samples. Shared pages may repeat, so this can exceed host.totalMemory. */
totalMemory: number
/**
* Sum of app + all tracked worktree `privateMemory`. Present exactly when
* `processCommitMetric` is. Committed bytes are not bounded by physical RAM,
* so exceeding host.totalMemory is the signal, not a bug.
*/
totalPrivateMemory?: number
collectedAt: number
}