Files
orca/config/scripts/idle-cpu-process-sampling.mjs
T
983532a5cb fix(bench): make bench:idle-cpu build, sample CPU and parse the process table on Windows (#27622)
* fix(bench): make the idle-CPU benchmark run and report CPU on Windows

Three independent Windows defects in the idle-CPU benchmark (its report is
what Windows performance work would cite):

- `buildAppIfNeeded` spawned `npx`, which is `npx.cmd` on Windows, so
  execFileSync failed with ENOENT before any sampling. Build through
  `config/scripts/run-electron-vite-build.mjs` instead — the repo's own
  Windows-safe wrapper, which also runs the boot-graph verification.
- `readWindowsProcesses` returned `cpuTimeSeconds: null`, so the sampler's
  per-process CPU delta (idle-cpu-process-sampling.mjs:198) fell back to
  the hardcoded `percentCpu: 0` and every CPU KPI in the report was zero.
  Read `UserModeTime`/`KernelModeTime` from `Win32_Process` (100 ns units)
  and keep `null` when a host does not return them.

Measured on Windows, same options before and after the CPU fix:

| class | mean CPU before | mean CPU after |
| --- | ---: | ---: |
| renderer | 0 | 2.20 |
| gpu | 0 | 2.08 |
| utility | 0 | 0.11 |
| agent-or-node | 0 | 0.06 |
| total | 0 | 4.51 (p95 8.40) |

RSS is unchanged (934 MiB -> 937 MiB), so only the CPU reporting changed.

Refs #25235

* fix(bench): reject a missing Win32_Process time instead of reading it as zero

Review follow-up (CodeRabbit). `Number(null)` is `0`, which is also a real
counter value, so an absent `UserModeTime`/`KernelModeTime` passed the
finite check and the sampler would report a kernel-only delta instead of
taking the fallback. Require an actual number from the CIM payload.

Verified on Windows that the real payload still reports CPU once the check
is tight: total mean 4.40%, p95 14.48%, renderer 3.51%.

* test(bench): cover the Windows CIM CPU time mapping

The mapping was only reachable through Get-CimInstance, so no test ran it.
Lift it into cimCpuSeconds and assert the 100ns-to-seconds sum plus the
missing / string / non-finite cases that must stay null.

* fix(bench): read the Windows process table as UTF-8 so JSON.parse survives

PowerShell writes to a redirected stdout in the OEM code page (437 here),
which best-fits characters such as U+2022 and U+2192 in any running
process's command line to raw 0x07 / 0x1A bytes. ConvertTo-Json had left
them unescaped because they are not control characters in UTF-16, so the
sampler's JSON.parse failed with "Bad control character in string literal"
whenever such a process happened to be alive -- hence intermittent.

Set [Console]::OutputEncoding to UTF-8, as the app's own CIM scan does,
and cover it with a Windows-only test that fails without the fix.

Fixes #25236

* fix(bench): pass the local runtime to fetchRepos in the idle-CPU fixture

fetchRepos now requires a runtimeEnvironmentId, so the fixture's bare call
threw "Cannot read properties of undefined (reading 'runtimeEnvironmentId')"
before sampling on every platform. null selects this app's own catalog, as
the e2e helpers already do.

* ci(bench): run the Windows process-table test in the Windows lane

The UTF-8 regression test self-skips off win32, so it must be registered in
both Windows lists or it runs on no machine.

---------

Co-authored-by: Paolo <paovalletta@hotmail.it>
Co-authored-by: Orca Worker <orca-worker@localhost>
2026-10-11 20:27:15 -07:00

341 lines
10 KiB
JavaScript

import { execFileSync, spawnSync } from 'node:child_process'
function parseCpuTimeSeconds(value) {
const trimmed = String(value || '').trim()
if (!trimmed) {
return null
}
const [dayOrTime, maybeTime] = trimmed.includes('-') ? trimmed.split('-', 2) : [null, trimmed]
const days = dayOrTime === null ? 0 : Number(dayOrTime)
const parts = maybeTime.split(':').map(Number)
if (!Number.isFinite(days) || parts.some((part) => !Number.isFinite(part))) {
return null
}
if (parts.length === 3) {
return days * 86400 + parts[0] * 3600 + parts[1] * 60 + parts[2]
}
if (parts.length === 2) {
return days * 86400 + parts[0] * 60 + parts[1]
}
if (parts.length === 1) {
return days * 86400 + parts[0]
}
return null
}
function parseUnixProcesses(stdout) {
const rows = []
for (const raw of stdout.split('\n')) {
const line = raw.trim()
if (!line) {
continue
}
const match = line.match(/^(\d+)\s+(\d+)\s+([\d.]+)\s+(\d+)\s+(\S+)\s+(.+)$/)
if (!match) {
continue
}
rows.push({
pid: Number(match[1]),
ppid: Number(match[2]),
percentCpu: Number(match[3]),
rssBytes: Number(match[4]) * 1024,
cpuTimeSeconds: parseCpuTimeSeconds(match[5]),
command: match[6]
})
}
return rows
}
function readUnixProcesses() {
const stdout = execFileSync('ps', ['-axo', 'pid=,ppid=,pcpu=,rss=,cputime=,command='], {
encoding: 'utf8',
env: { ...process.env, LC_ALL: 'C', LANG: 'C' },
maxBuffer: 20 * 1024 * 1024
})
return parseUnixProcesses(stdout)
}
// `Win32_Process` times are 100 ns counts. Not `Number(value)`: that turns a missing
// field into a real-looking 0, so the CPU delta would silently go kernel-only.
function cimTimeSeconds(value) {
return typeof value === 'number' && Number.isFinite(value) ? value / 10_000_000 : null
}
export function cimCpuSeconds(entry) {
const userModeSeconds = cimTimeSeconds(entry.UserModeTime)
const kernelModeSeconds = cimTimeSeconds(entry.KernelModeTime)
return userModeSeconds !== null && kernelModeSeconds !== null
? userModeSeconds + kernelModeSeconds
: null
}
function readWindowsProcesses() {
// Why UTF-8: the default OEM code page best-fits characters such as `•` or `→` in any
// process's command line to raw control bytes (0x07, 0x1A), which JSON.parse rejects.
const script =
'[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; ' +
'Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,WorkingSetSize,UserModeTime,KernelModeTime,CommandLine | ConvertTo-Json -Compress'
const result = spawnSync('powershell.exe', ['-NoProfile', '-Command', script], {
encoding: 'utf8',
maxBuffer: 20 * 1024 * 1024
})
if (result.status !== 0) {
throw new Error(result.stderr || 'PowerShell process enumeration failed')
}
const parsed = JSON.parse(result.stdout || '[]')
const entries = Array.isArray(parsed) ? parsed : [parsed]
return entries.map((entry) => ({
pid: Number(entry.ProcessId),
ppid: Number(entry.ParentProcessId),
percentCpu: 0,
cpuTimeSeconds: cimCpuSeconds(entry),
rssBytes: Number(entry.WorkingSetSize) || 0,
command: String(entry.CommandLine || '')
}))
}
export function readProcessRows() {
return process.platform === 'win32' ? readWindowsProcesses() : readUnixProcesses()
}
export function descendantsOf(rows, rootPid) {
const children = new Map()
for (const row of rows) {
const list = children.get(row.ppid) ?? []
list.push(row)
children.set(row.ppid, list)
}
const result = []
const stack = [rootPid]
const seen = new Set()
while (stack.length > 0) {
const pid = stack.pop()
if (seen.has(pid)) {
continue
}
seen.add(pid)
const row = rows.find((candidate) => candidate.pid === pid)
if (row) {
result.push(row)
}
for (const child of children.get(pid) ?? []) {
stack.push(child.pid)
}
}
return result
}
export function classify(row, rootPid) {
const command = row.command.toLowerCase()
if (row.pid === rootPid) {
return 'main'
}
if (command.includes('daemon-entry')) {
return 'daemon'
}
if (command.includes('--type=gpu-process')) {
return 'gpu'
}
if (command.includes('--type=renderer')) {
return 'renderer'
}
if (command.includes('--type=utility')) {
return 'utility'
}
if (command.includes('--type=')) {
return 'electron-other'
}
if (command.includes('node') || command.includes('/pi') || command.endsWith(' pi')) {
return 'agent-or-node'
}
return 'other-descendant'
}
const DEFAULT_WORKLOAD_OVERRUN_LIMIT_MS = 120_000
export async function sampleProcessTreeUntilWorkloadsComplete({
rootPid,
requestedDurationMs,
intervalMs,
workloadPromise,
maxWorkloadOverrunMs = DEFAULT_WORKLOAD_OVERRUN_LIMIT_MS,
readRows = readProcessRows,
now = Date.now,
wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
}) {
const samplingStartedAt = now()
const requestedDeadline = samplingStartedAt + requestedDurationMs
const hardDeadline = requestedDeadline + maxWorkloadOverrunMs
let workloadSettled = false
let workloadResult
let workloadError
let workloadSettledAt = null
void workloadPromise.then(
(result) => {
workloadResult = result
workloadSettled = true
workloadSettledAt = now()
},
(error) => {
workloadError = error
workloadSettled = true
workloadSettledAt = now()
}
)
const samples = []
let previousSnapshot = null
const needsFinalWorkloadSample = () =>
workloadSettledAt !== null && (previousSnapshot?.at ?? -Infinity) < workloadSettledAt
while (
now() <= requestedDeadline ||
samples.length === 0 ||
!workloadSettled ||
needsFinalWorkloadSample()
) {
const sampledAt = now()
if (workloadError) {
throw workloadError
}
if (
(!workloadSettled && sampledAt >= hardDeadline) ||
(workloadSettledAt !== null && workloadSettledAt > hardDeadline)
) {
throw new Error(
`Benchmark workload exceeded the ${maxWorkloadOverrunMs}ms sampling overrun limit`
)
}
const processRows = descendantsOf(readRows(), rootPid)
const rawProcesses = processRows.map((row) => ({ ...row, kind: classify(row, rootPid) }))
if (previousSnapshot) {
const elapsedSeconds = Math.max(0.001, (sampledAt - previousSnapshot.at) / 1000)
const previousByPid = new Map(previousSnapshot.processes.map((proc) => [proc.pid, proc]))
const processes = rawProcesses.map((row) => {
const previous = previousByPid.get(row.pid)
const canComputeDelta =
typeof row.cpuTimeSeconds === 'number' && typeof previous?.cpuTimeSeconds === 'number'
const cpu = canComputeDelta
? Math.max(0, ((row.cpuTimeSeconds - previous.cpuTimeSeconds) / elapsedSeconds) * 100)
: row.percentCpu
return { ...row, cpu }
})
samples.push({
at: sampledAt,
elapsedMs: sampledAt - previousSnapshot.at,
totalCpuPercent: processes.reduce((sum, proc) => sum + proc.cpu, 0),
totalRssBytes: processes.reduce((sum, proc) => sum + proc.rssBytes, 0),
processes
})
}
previousSnapshot = { at: sampledAt, processes: rawProcesses }
await wait(intervalMs)
}
if (workloadError) {
throw workloadError
}
const measuredDurationMs = Math.max(
0,
(previousSnapshot?.at ?? samplingStartedAt) - samplingStartedAt
)
return {
samples,
workloadResult,
samplingWindow: {
requestedDurationMs,
measuredDurationMs,
maxWorkloadOverrunMs,
extendedForWorkload: workloadSettledAt !== null && workloadSettledAt > requestedDeadline,
workloadSettledElapsedMs:
workloadSettledAt === null ? null : Math.max(0, workloadSettledAt - samplingStartedAt),
workloadSettledBeforeStop: workloadSettled
}
}
}
function mean(values) {
return values.length === 0 ? 0 : values.reduce((sum, value) => sum + value, 0) / values.length
}
function percentile(sorted, fraction) {
if (sorted.length === 0) {
return 0
}
const index = Math.min(sorted.length - 1, Math.ceil(sorted.length * fraction) - 1)
return sorted[index]
}
export function summarizeSamples(samples) {
const byKind = new Map()
for (const sample of samples) {
for (const proc of sample.processes) {
const bucket = byKind.get(proc.kind) ?? { cpuValues: [], rssValues: [], maxProcessCount: 0 }
bucket.cpuValues.push(proc.cpu)
bucket.rssValues.push(proc.rssBytes)
byKind.set(proc.kind, bucket)
}
const counts = new Map()
for (const proc of sample.processes) {
counts.set(proc.kind, (counts.get(proc.kind) ?? 0) + 1)
}
for (const [kind, count] of counts) {
byKind.get(kind).maxProcessCount = Math.max(byKind.get(kind).maxProcessCount, count)
}
}
const summary = {}
for (const [kind, values] of byKind) {
const cpuSorted = [...values.cpuValues].sort((a, b) => a - b)
const rssSumBySample = samples.map((sample) =>
sample.processes
.filter((proc) => proc.kind === kind)
.reduce((sum, proc) => sum + proc.rssBytes, 0)
)
summary[kind] = {
meanCpuPercent: mean(values.cpuValues),
p95CpuPercent: percentile(cpuSorted, 0.95),
maxCpuPercent: Math.max(0, ...values.cpuValues),
meanRssBytes: mean(rssSumBySample),
maxProcessCount: values.maxProcessCount
}
}
summary.total = {
meanCpuPercent: mean(samples.map((sample) => sample.totalCpuPercent)),
p95CpuPercent: percentile(
samples.map((sample) => sample.totalCpuPercent).sort((a, b) => a - b),
0.95
),
meanRssBytes: mean(samples.map((sample) => sample.totalRssBytes))
}
return summary
}
export function summarizeProcessInventory(samples) {
const inventory = {}
for (const sample of samples) {
const counts = new Map()
for (const proc of sample.processes) {
counts.set(proc.kind, (counts.get(proc.kind) ?? 0) + 1)
const entry = inventory[proc.kind] ?? {
maxProcessCount: 0,
maxCpuPercent: 0,
commandSamples: []
}
entry.maxCpuPercent = Math.max(entry.maxCpuPercent, proc.cpu)
if (!entry.commandSamples.includes(proc.command) && entry.commandSamples.length < 6) {
entry.commandSamples.push(proc.command)
}
inventory[proc.kind] = entry
}
for (const [kind, count] of counts) {
inventory[kind].maxProcessCount = Math.max(inventory[kind].maxProcessCount, count)
}
}
return inventory
}
export function terminateProcesses(processes) {
for (const proc of processes) {
try {
process.kill(proc.pid)
} catch {}
}
}