Files
orca/src/main/memory/windows-process-sample-parsing.ts
T
Neil 64c992cd56 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).
2026-08-26 15:43:02 -07:00

241 lines
7.0 KiB
TypeScript

/**
* 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
}
}