perf(windows): read the process table natively instead of forking PowerShell (#15749)

* perf(windows): read the process table natively instead of forking PowerShell

Seven independent readers each forked powershell.exe to run
Get-CimInstance Win32_Process, with a wmic fallback that Windows 11 24H2
has removed. On a domain-joined host with PowerShell Transcription
enabled by policy, one of them running every ~2s recorded ~289GB across
1.4 million files (#15209). The same scan cost ~700ms and ran per pane
(#15036), and a Group Policy or AV block turned it into 'unavailable',
which callers read as 'no evidence' -- which is how a PTY tree survives
its own teardown (#9045, #10475).

A Toolhelp32 snapshot answers the same question with no child process.
Measured on Windows 11 with 1050 processes, p50/p95:

  pid+ppid+name          15.9 / 17.5 ms
  +memory +command line  30.6 / 33.7 ms
  Get-CimInstance         706 / 723  ms

Two upstream defects needed patching, both found by running it on real
hardware. The binding requires Spectre-mitigated libraries our agents do
not carry (node-pty is patched the same way). And enumeration stopped
after 1024 processes: on a host with 1051 the module returned exactly
1024, and the querying process was itself among the 27 missing -- a
truncated snapshot silently hides the descendants teardown is looking
for, which is the failure this whole change exists to remove.

Migrated: the foreground/descendant reader (the #15209 scraper and the
teardown identity gate) and the port scanner's PID attribution. NOT
migrated: the memory collector and three identity probes, which need
Win32_Process.CreationDate and have no native equivalent. Start time is
a proxy for identity anyway; an inherited job handle is the real answer,
so those belong with the job-object work rather than here.

Packaging follows the windows-native-registry contract exactly:
optional, absent from onlyBuiltDependencies so macOS/Linux never run
node-gyp, win32-only in the packaged runtime. Asserted by the existing
contract test, which also stops pinning a whole source literal that only
tested its own formatting.

* chore(process): ratchet the child_process allowlist down

windows-foreground-process-rows.ts no longer spawns anything, so its
allowlist line is stale. The guard fails on a stale entry as well as a
new one, precisely so a migrated file cannot keep a slot open and hide
the next regression in the same path.

* fix(ports): import the process-table reader the scanner uses

Missing import: the migration replaced the PowerShell call but the new
symbol was never imported, so tsc failed. Vitest transpiles without
typechecking, which is why the port-scanner suite stayed green.

* fix(deps): sync this branch's lockfile with its patch set

Same class as the fix on the tip branch: pnpm records a hash per patched
dependency, and this branch introduces the windows-process-tree patch
without its lockfile entry matching. Every job here failed at install
with ERR_PNPM_LOCKFILE_CONFIG_MISMATCH.

Verified with --frozen-lockfile, which is what CI runs and what my local
runs were not.

* test(relay): drive the relay's Windows fixtures from the native snapshot

Two relay cases fed a PowerShell CIM payload through a mocked execFile.
That reader is gone, so both failed -- deterministically, on every PR
run for this branch and the one above it.

I did not catch it because my own verification sweep was
'src/main src/shared config/scripts' and never included src/relay. The
relay is a first-class consumer of the process table; leaving it out of
the sweep is how a deterministic failure survived six review rounds.
This commit is contained in:
Neil
2026-08-21 21:54:57 -07:00
committed by GitHub
parent 42a42281bd
commit 057fbfcffc
21 changed files with 1044 additions and 1049 deletions
+1
View File
@@ -103,6 +103,7 @@ docs/**
!docs/reference/headless-linux-server.md
!docs/reference/linux-glibc-compatibility.md
!docs/reference/relay-grace-time-reconfiguration.md
!docs/reference/windows-process-enumeration.md
!docs/reference/remote-wire-compatibility.md
!docs/reference/renderer-agent-status-performance.md
!docs/reference/ssh-execution-boundary.md
+2
View File
@@ -34,6 +34,8 @@ Orca targets macOS, Linux, and Windows. Keep all platform-dependent behavior beh
- **Shortcut labels in UI**: Display `⌘` / `⇧` on Mac and `Ctrl+` / `Shift+` on other platforms.
- **File paths**: Use `path.join` or Electron/Node path utilities — never assume `/` or `\`.
- **Windows setup scripts**: the setup/issue-command runner is a `.cmd` batch file unless the script starts with a `#!` line — never derive that from the user's terminal-shell preference, and never launch a `.cmd` runner with a bare `cmd.exe /c` from a Git Bash pane (MSYS rewrites the `/c`). See [`docs/reference/windows-setup-shell.md`](./docs/reference/windows-setup-shell.md).
- **Windows child processes**: start them through `runProcess`/`spawnProcess` in `src/shared/child-process/` — never `child_process` directly. It pins `windowsHide`, refuses `shell: true`, and encodes `.cmd`/`.bat` arguments so neither `CommandLineToArgvW` nor `cmd.exe` mangles them. A ratchet test fails on any new direct import.
- **Windows process enumeration**: read the table through `src/main/windows/windows-process-table.ts`, never by forking `powershell.exe`. See [`docs/reference/windows-process-enumeration.md`](./docs/reference/windows-process-enumeration.md).
- **WSL commands**: build argv with `buildWslExecArgs` (always `--exec` — under `--`, `wsl.exe` expands `$name` in every argument and silently rewrites the script), and fence anything whose stdout you parse with `buildWslCapturedLoginShellCommand`, because the interactive login shell prints the distro banner to stdout. See [`docs/reference/wsl-command-execution.md`](./docs/reference/wsl-command-execution.md).
- **Linux native modules**: keep the glibc floor at Ubuntu 20.04 / glibc 2.31. A module compiled from source on a newer runner can reference symbol versions absent on the floor and crash the app on startup. See [`docs/reference/linux-glibc-compatibility.md`](./docs/reference/linux-glibc-compatibility.md); packaging fails if a bundled native binary needs newer glibc.
+4 -1
View File
@@ -31,7 +31,10 @@ const PACKAGED_RUNTIME_PACKAGE_ROOTS = [
'yaml',
'zod'
]
const WINDOWS_PACKAGED_RUNTIME_PACKAGE_ROOTS = ['windows-native-registry']
const WINDOWS_PACKAGED_RUNTIME_PACKAGE_ROOTS = [
'@vscode/windows-process-tree',
'windows-native-registry'
]
const NODE_PTY_PREBUILD_PREFIX_BY_PLATFORM = {
darwin: 'darwin-',
@@ -0,0 +1,27 @@
diff --git a/binding.gyp b/binding.gyp
index 855bd4b86f0a3c18c7594212c0e42b6e35bc4001..5f15551cb1520af996f216500a83ac68b57f5104 100644
--- a/binding.gyp
+++ b/binding.gyp
@@ -16,9 +16,6 @@
],
"include_dirs": [],
"libraries": [ 'psapi.lib' ],
- "msvs_configuration_attributes": {
- "SpectreMitigation": "Spectre"
- },
"msvs_settings": {
"VCCLCompilerTool": {
"AdditionalOptions": [
diff --git a/src/process.cc b/src/process.cc
index 3eea92077c4d1d433119361d5c432881859131e9..1998f4addd4d7e9aba946ea6f7f7a4a5d13291bc 100644
--- a/src/process.cc
+++ b/src/process.cc
@@ -37,7 +37,7 @@ uint32_t GetRawProcessList(std::vector<ProcessInfo>& process_info,
process_info.push_back(std::move(pinfo));
process_count++;
}
- } while (process_count < 1024 && Process32Next(snapshot_handle, &process_entry));
+ } while (Process32Next(snapshot_handle, &process_entry));
}
CloseHandle(snapshot_handle);
+10 -1
View File
@@ -13,7 +13,9 @@ const runtime = readRuntimeArg()
const NATIVE_MODULES = [
'node-pty',
...(process.platform === 'win32' ? ['windows-native-registry'] : [])
...(process.platform === 'win32'
? ['windows-native-registry', '@vscode/windows-process-tree']
: [])
]
const NODE_PTY_CONPTY_RUNTIME_FILES = ['conpty.dll', 'OpenConsole.exe']
const CHILD_CHECK_FLAG = '--check-only'
@@ -244,6 +246,13 @@ function collectNativeModuleFailures() {
}
function loadNativeModule(moduleName) {
if (moduleName === '@vscode/windows-process-tree') {
// Why call through: the package defers its .node addon until first use, so
// a bare require would not catch an ABI mismatch or a missing build.
const processTree = require(moduleName)
processTree.getAllProcesses(() => {}, processTree.ProcessDataFlag.None)
return
}
if (moduleName === 'windows-native-registry') {
const registry = require(moduleName)
// Why: the package defers loading its .node addon until the first registry call.
@@ -42,12 +42,12 @@ describe('Electron runtime package contract', () => {
// Why: pnpm installs optional target architectures on every host; the root
// Windows-only rebuild owns this addon so macOS/Linux never run node-gyp for it.
expect(packageJson.pnpm.onlyBuiltDependencies).not.toContain('windows-native-registry')
expect(rebuildScript).toContain(
"rebuildPlatform === 'win32' ? ['windows-native-registry'] : []"
)
expect(ensureScript).toContain(
"process.platform === 'win32' ? ['windows-native-registry'] : []"
)
// Why assert the guard and the member separately: the list now carries more
// than one addon, so pinning the whole literal only tested its formatting.
expect(rebuildScript).toContain("rebuildPlatform === 'win32'")
expect(rebuildScript).toContain("'windows-native-registry'")
expect(ensureScript).toContain("process.platform === 'win32'")
expect(ensureScript).toContain("'windows-native-registry'")
const packageTargets = {
win32: createPackagedRuntimeNodeModuleResources('win32'),
darwin: createPackagedRuntimeNodeModuleResources('darwin'),
@@ -68,6 +68,47 @@ describe('Electron runtime package contract', () => {
}
})
it('keeps the native Windows process-table addon optional and platform-gated', () => {
const rebuildScript = readFileSync(
join(projectDir, 'config/scripts/rebuild-native-deps.mjs'),
'utf8'
)
const ensureScript = readFileSync(
join(projectDir, 'config/scripts/ensure-native-runtime.mjs'),
'utf8'
)
expect(packageJson.optionalDependencies['@vscode/windows-process-tree']).toBe('0.8.0')
// Why: same rule as the registry addon -- pnpm installs optional deps on
// every host, so macOS/Linux must never run node-gyp for a Windows addon.
expect(packageJson.pnpm.onlyBuiltDependencies).not.toContain('@vscode/windows-process-tree')
expect(rebuildScript).toContain("'@vscode/windows-process-tree'")
expect(ensureScript).toContain("'@vscode/windows-process-tree'")
// Why pin the patch: the upstream binding.gyp requires Spectre-mitigated
// libraries our build agents do not carry, and the enumeration stops after
// 1024 processes -- on a busy host that silently hides the very descendants
// teardown is looking for.
expect(packageJson.pnpm.patchedDependencies['@vscode/windows-process-tree@0.8.0']).toBe(
'config/patches/@vscode__windows-process-tree@0.8.0.patch'
)
const packageTargets = {
win32: createPackagedRuntimeNodeModuleResources('win32'),
darwin: createPackagedRuntimeNodeModuleResources('darwin'),
linux: createPackagedRuntimeNodeModuleResources('linux')
}
expect(packageTargets.win32).toEqual(
expect.arrayContaining([
expect.objectContaining({ to: join('node_modules', '@vscode', 'windows-process-tree') })
])
)
for (const platform of ['darwin', 'linux']) {
expect(packageTargets[platform]).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ to: join('node_modules', '@vscode', 'windows-process-tree') })
])
)
}
})
it('guards package scripts that launch Electron tooling', () => {
const scripts = packageJson.scripts
const guardedScripts = [
+3 -1
View File
@@ -62,7 +62,9 @@ if (ignoreModules.length > 0) {
const NATIVE_MODULES = [
'node-pty',
'cpu-features',
...(rebuildPlatform === 'win32' ? ['windows-native-registry'] : [])
...(rebuildPlatform === 'win32'
? ['windows-native-registry', '@vscode/windows-process-tree']
: [])
]
const onlyModules = NATIVE_MODULES.filter((m) => !ignoreModules.includes(m))
const forceRebuild =
@@ -0,0 +1,84 @@
# Reading the Windows process table
Orca needs three things from the Windows process table: who a PID's parent is
(descendant walks and teardown identity), what a process is running (agent
recognition), and how much memory/CPU it uses (Resource Manager).
Node cannot answer the first one without native code. That is why seven
independent readers existed, each forking `powershell.exe` to run
`Get-CimInstance Win32_Process`, with a `wmic` fallback that Windows 11 24H2 has
since removed.
## Use the native snapshot
`src/main/windows/windows-process-table.ts` is the only module that may read the
table. It wraps a Toolhelp32 snapshot from `@vscode/windows-process-tree`.
```ts
import { readWindowsProcessTable, readWindowsProcessTableFresh } from '../windows/windows-process-table'
```
- `readWindowsProcessTable()` — shared TTL cache. Use for anything periodic.
- `readWindowsProcessTableFresh()` — a snapshot that starts after the call. Use
for teardown identity, where a cached row can predate the exit it is being
asked about.
Both **reject** when the table cannot be read. Do not convert that into an empty
array. An empty table is a claim that nothing is running, and callers act on
that claim by declaring a tree dead or a shell childless. "Unavailable" has to
stay distinguishable from "empty" — collapsing the two is how a PTY tree
survived its own teardown (#9045).
Measured on Windows 11 with 1050 processes (p50 / p95):
| | p50 | p95 |
| --- | --- | --- |
| pid + ppid + name | 15.9 ms | 17.5 ms |
| + memory + command line | 30.6 ms | 33.7 ms |
| `Get-CimInstance` via PowerShell | 706 ms | 723 ms |
## Why the package is patched
`config/patches/@vscode__windows-process-tree@0.8.0.patch` carries two hunks.
1. **Spectre mitigation.** The upstream `binding.gyp` requires Spectre-mitigated
libraries, which Orca's Windows build agents do not install. `node-pty` is
patched the same way for the same reason.
2. **The 1024-process cap.** `GetRawProcessList` stopped after 1024 entries.
Measured on a real host with 1051 processes, the module returned exactly
1024 and the querying process was itself among the 27 missing. A truncated
snapshot silently hides the descendants a teardown is trying to reap — the
exact failure the native path exists to remove.
The typings claim `commandLine` is truncated at 512 characters. Measured, it is
not: the longest observed on a real host was 26,059.
## Packaging
The addon is Windows-only, so it follows the same contract as
`windows-native-registry` (asserted by
`config/scripts/package-electron-runtime-contract.test.mjs`):
- an `optionalDependency`, so a macOS/Linux install tolerates its absence;
- **not** in `pnpm.onlyBuiltDependencies` — pnpm installs optional dependencies
on every host, and macOS/Linux must never run `node-gyp` for it;
- listed in the win32 branch of `rebuild-native-deps.mjs` and
`ensure-native-runtime.mjs`;
- copied into the packaged `node_modules` for win32 only.
## What the snapshot does not provide
`CreationDate` (process start time) has no equivalent. Anything using a start
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.
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
be resolved that way rather than by adding a start time to this module.
Do not adopt `getProcessCpuUsage()` from the package. It takes both CPU samples
inside one call with a blocking `Sleep(1000)` in the middle, which would hold a
libuv threadpool slot for a full second out of the Resource Manager's two-second
poll.
+3 -1
View File
@@ -263,6 +263,7 @@
"zustand": "^5.0.14"
},
"optionalDependencies": {
"@vscode/windows-process-tree": "0.8.0",
"sherpa-onnx-darwin-arm64": "1.12.37",
"sherpa-onnx-darwin-x64": "1.12.37",
"sherpa-onnx-linux-arm64": "1.12.37",
@@ -314,7 +315,8 @@
"@xterm/addon-webgl@0.20.0-beta.286": "config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch",
"@xterm/addon-serialize@0.15.0-beta.287": "config/patches/@xterm__addon-serialize@0.15.0-beta.287.patch",
"@xterm/xterm@6.1.0-beta.287": "config/patches/@xterm__xterm@6.1.0-beta.287.patch",
"lint-staged@16.4.0": "config/patches/lint-staged@16.4.0.patch"
"lint-staged@16.4.0": "config/patches/lint-staged@16.4.0.patch",
"@vscode/windows-process-tree@0.8.0": "config/patches/@vscode__windows-process-tree@0.8.0.patch"
}
},
"reactDoctor": {
+21
View File
@@ -8,6 +8,9 @@ overrides:
monaco-editor>dompurify: 3.4.13
patchedDependencies:
'@vscode/windows-process-tree@0.8.0':
hash: 7c08bebce9b36829be6035218db2383f1a889c21ec907ea7e85c36fc54795a05
path: config/patches/@vscode__windows-process-tree@0.8.0.patch
'@xterm/addon-ligatures@0.11.0-beta.287':
hash: 47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920
path: config/patches/@xterm__addon-ligatures@0.11.0-beta.287.patch
@@ -402,6 +405,9 @@ importers:
specifier: ^5.0.14
version: 5.0.14(@types/react@19.2.17)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7))
optionalDependencies:
'@vscode/windows-process-tree':
specifier: 0.8.0
version: 0.8.0(patch_hash=7c08bebce9b36829be6035218db2383f1a889c21ec907ea7e85c36fc54795a05)
sherpa-onnx-darwin-arm64:
specifier: 1.12.37
version: 1.12.37
@@ -3378,6 +3384,9 @@ packages:
'@vitest/utils@4.1.5':
resolution: {integrity: sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==}
'@vscode/windows-process-tree@0.8.0':
resolution: {integrity: sha512-TI+h2GRwX+igD/YYJMQQVAFcsCNSg7Te2yYxQpKMzwto5RsJ8d2KKgOeur/p/6sAOQwZvopiTDOClyTHEn9MhQ==}
'@xmldom/xmldom@0.8.13':
resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==}
engines: {node: '>=10.0.0'}
@@ -5426,6 +5435,10 @@ packages:
node-addon-api@4.3.0:
resolution: {integrity: sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==}
node-addon-api@7.1.0:
resolution: {integrity: sha512-mNcltoe1R8o7STTegSOHdnJNN7s5EUvhoS7ShnTHDyOSd+8H+UdWODq6qSv67PjC8Zc5JRT8+oLAMCr0SIXw7g==}
engines: {node: ^16 || ^18 || >= 20}
node-addon-api@7.1.1:
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
@@ -9592,6 +9605,11 @@ snapshots:
convert-source-map: 2.0.0
tinyrainbow: 3.1.0
'@vscode/windows-process-tree@0.8.0(patch_hash=7c08bebce9b36829be6035218db2383f1a889c21ec907ea7e85c36fc54795a05)':
dependencies:
node-addon-api: 7.1.0
optional: true
'@xmldom/xmldom@0.8.13': {}
'@xterm/addon-fit@0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))':
@@ -12004,6 +12022,9 @@ snapshots:
node-addon-api@4.3.0:
optional: true
node-addon-api@7.1.0:
optional: true
node-addon-api@7.1.1: {}
node-api-version@0.2.1:
+10 -17
View File
@@ -9,6 +9,7 @@ import type {
WorkspacePortScanResult
} from '../../shared/workspace-ports'
import { getProcessOutputFields } from '../../shared/process-output-field-scanner'
import { readWindowsProcessTable } from '../windows/windows-process-table'
import { advertisedUrlWatcher, type AdvertisedUrlWatcher } from './advertised-url-watcher'
import { isPortScanWorkerUnavailableError, runPortScanCommand } from './port-scan-command-client'
import { PortScanCommandTimeoutError } from './port-scan-command-protocol'
@@ -491,23 +492,15 @@ async function loadWindowsProcessMetadata(
return result
}
try {
const pidFilter = Array.from(pids)
.filter(Number.isFinite)
.map((pid) => `ProcessId=${pid}`)
.join(' OR ')
const { stdout } = await runPortScanCommand('powershell.exe', [
'-NoProfile',
'-Command',
`Get-CimInstance Win32_Process -Filter "${pidFilter}" | Select-Object ProcessId,Name,CommandLine | ConvertTo-Json -Compress`
])
const parsed = JSON.parse(stdout) as
| { ProcessId: number; Name?: string; CommandLine?: string }
| { ProcessId: number; Name?: string; CommandLine?: string }[]
for (const row of Array.isArray(parsed) ? parsed : [parsed]) {
if (pids.has(row.ProcessId)) {
result.set(row.ProcessId, {
processName: row.Name,
commandLine: row.CommandLine
// Why the native snapshot: attributing ports used to fork a powershell.exe
// per scan just to turn PIDs into names. That is a ~700ms cold start, a
// conhost window, and one more thing a Group Policy can block -- for data
// the panel treats as optional anyway.
for (const row of await readWindowsProcessTable()) {
if (pids.has(row.pid)) {
result.set(row.pid, {
processName: row.name,
commandLine: row.command || undefined
})
}
}
@@ -1,57 +1,49 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn() }))
const getAllProcessesMock = vi.fn()
vi.mock('child_process', () => ({ execFile: execFileMock }))
import { resetProcessTableSnapshotForTests } from '../../shared/process-table-snapshot'
import { __setWindowsProcessTreeLoaderForTests } from '../windows/windows-process-table'
import { resolveAgentForegroundProcessWithAvailability } from './agent-foreground-process'
import { resetWindowsProcessRowsSnapshotForTests } from './windows-foreground-process-rows'
describe('Pi Windows foreground recognition', () => {
let platform: PropertyDescriptor | undefined
beforeEach(() => {
execFileMock.mockReset()
resetProcessTableSnapshotForTests()
resetWindowsProcessRowsSnapshotForTests()
getAllProcessesMock.mockReset()
platform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { value: 'win32' })
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
__setWindowsProcessTreeLoaderForTests(() => ({
ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2 },
getAllProcesses: getAllProcessesMock
}))
})
afterEach(() => {
__setWindowsProcessTreeLoaderForTests()
if (platform) {
Object.defineProperty(process, 'platform', platform)
}
})
it('recognizes the npm entrypoint within the active ConPTY', async () => {
const rows = JSON.stringify([
const rows = [
{
CommandLine: 'C:\\Program Files\\Git\\usr\\bin\\bash.exe',
ExecutablePath: 'C:\\Program Files\\Git\\usr\\bin\\bash.exe',
Name: 'bash.exe',
ParentProcessId: 99,
ProcessId: 100
pid: 100,
ppid: 99,
name: 'bash.exe',
commandLine: '"C:\\Program Files\\Git\\usr\\bin\\bash.exe"'
},
{
CommandLine:
'node.exe C:\\Users\\dev\\AppData\\Roaming\\npm\\node_modules\\@earendil-works\\pi-coding-agent\\dist\\cli.js',
ExecutablePath: 'C:\\Program Files\\nodejs\\node.exe',
Name: 'node.exe',
ParentProcessId: 100,
ProcessId: 101
pid: 101,
ppid: 100,
name: 'node.exe',
commandLine:
'node.exe C:\\Users\\dev\\AppData\\Roaming\\npm\\node_modules\\@earendil-works\\pi-coding-agent\\dist\\cli.js'
}
])
execFileMock.mockImplementation(
(_cmd: string, _args: string[], _options: unknown, callback: unknown) => {
const done = callback as (error: null, result: { stdout: string; stderr: string }) => void
done(null, {
stdout: rows,
stderr: ''
})
}
)
]
getAllProcessesMock.mockImplementation((cb: (snapshot: unknown) => void) => {
cb(rows)
})
const readWindowsConptyProcessIds = vi.fn(async () => new Set([100, 101]))
await expect(
File diff suppressed because it is too large Load Diff
@@ -1,26 +1,19 @@
// Regression guard: bound the volume of full-process-table PowerShell/CIM scans
// driven by Windows agent foreground-process inspection — the Windows analogue of
// issue #6288 (POSIX `ps`).
// Regression guard: bound the volume of full-process-table scans driven by
// Windows agent foreground-process inspection — the Windows analogue of issue
// #6288 (POSIX `ps`).
//
// Drives queryWindowsProcessDescendants across several concurrently-inspecting
// agent panes on the agent-completion cadence (ACTIVE_POLL_INTERVAL_MS = 750ms)
// and counts how many powershell.exe process-table scans actually spawn. Pre-fix
// the call site forked one powershell.exe per pane per tick; with the shared
// snapshot cache the scans collapse to ~one per tick regardless of pane count,
// while each pane still resolves the same descendant set.
// and counts how many Toolhelp32 snapshots actually run. Pre-fix the call site
// scanned once per pane per tick; with the shared snapshot cache the scans
// collapse to ~one per tick regardless of pane count, while each pane still
// resolves the same descendant set.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { execFileMock, powershellScanCount } = vi.hoisted(() => ({
execFileMock: vi.fn(),
powershellScanCount: { value: 0 }
}))
const getAllProcessesMock = vi.fn()
vi.mock('child_process', () => ({ execFile: execFileMock }))
import {
queryWindowsProcessDescendants,
resetWindowsProcessRowsSnapshotForTests
} from './windows-foreground-process-rows'
import { __setWindowsProcessTreeLoaderForTests } from '../windows/windows-process-table'
import { queryWindowsProcessDescendants } from './windows-foreground-process-rows'
const ACTIVE_POLL_INTERVAL_MS = 750
const PANE_COUNT = 6
@@ -29,64 +22,56 @@ const TICKS = Math.floor((WINDOW_SECONDS * 1000) / ACTIVE_POLL_INTERVAL_MS)
const shellPid = (pane: number): number => 100 + pane * 1000
// A real CIM query returns the whole system, so one shared snapshot must contain
// every pane's shell + foreground node/codex child. Each pane resolves its own
// A snapshot returns the whole system, so one shared scan must contain every
// pane's shell + foreground node/codex child. Each pane resolves its own
// descendant from the single scan.
const PROCESS_TABLE_JSON = JSON.stringify(
Array.from({ length: PANE_COUNT }, (_, pane) => {
const shell = shellPid(pane)
return [
{
ProcessId: shell,
ParentProcessId: 99,
Name: 'cmd.exe',
CommandLine: 'cmd.exe',
ExecutablePath: 'C:/Windows/System32/cmd.exe'
},
{
ProcessId: shell + 1,
ParentProcessId: shell,
Name: 'node.exe',
CommandLine: 'node C:/Users/dev/AppData/codex/bin/codex.js',
ExecutablePath: 'C:/Program Files/nodejs/node.exe'
}
]
}).flat()
)
function installCountingPowerShellMock(): void {
execFileMock.mockImplementation((cmd: string, _args: unknown, _opts: unknown, cb: unknown) => {
const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void
if (cmd === 'powershell.exe') {
powershellScanCount.value += 1
const NATIVE_ROWS = Array.from({ length: PANE_COUNT }, (_, pane) => {
const shell = shellPid(pane)
return [
{
pid: shell,
ppid: 99,
name: 'cmd.exe',
commandLine: 'cmd.exe'
},
{
pid: shell + 1,
ppid: shell,
name: 'node.exe',
commandLine: 'node C:/Users/dev/AppData/codex/bin/codex.js'
}
callback(null, { stdout: PROCESS_TABLE_JSON, stderr: '' })
})
}
]
}).flat()
describe('windows agent foreground inspection powershell-scan volume', () => {
describe('windows agent foreground inspection process-table scan volume', () => {
let platform: PropertyDescriptor | undefined
beforeEach(() => {
execFileMock.mockReset()
resetWindowsProcessRowsSnapshotForTests()
powershellScanCount.value = 0
getAllProcessesMock.mockReset()
getAllProcessesMock.mockImplementation((cb: (snapshot: unknown) => void) => {
cb(NATIVE_ROWS)
})
platform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
__setWindowsProcessTreeLoaderForTests(() => ({
ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2 },
getAllProcesses: getAllProcessesMock
}))
vi.useFakeTimers({ toFake: ['Date'] })
vi.setSystemTime(0)
})
afterEach(() => {
vi.useRealTimers()
__setWindowsProcessTreeLoaderForTests()
if (platform) {
Object.defineProperty(process, 'platform', platform)
}
})
it('bounds powershell scans by poll ticks, not by pane count, while resolving every pane', async () => {
installCountingPowerShellMock()
const scanCount = (): number => getAllProcessesMock.mock.calls.length
it('bounds process-table scans by poll ticks, not by pane count, while resolving every pane', async () => {
for (let tick = 0; tick < TICKS; tick++) {
vi.setSystemTime(tick * ACTIVE_POLL_INTERVAL_MS)
// All panes inspect concurrently within the tick (worst case).
@@ -106,22 +91,20 @@ describe('windows agent foreground inspection powershell-scan volume', () => {
}
const totalInspections = PANE_COUNT * TICKS
// Pre-fix this equals totalInspections (one powershell.exe per inspection).
// With the shared cache, concurrent panes within a tick share one scan and
// the 500ms TTL forces a fresh scan each new 750ms tick -> ~one per tick.
expect(powershellScanCount.value).toBeLessThanOrEqual(TICKS + 1)
expect(powershellScanCount.value).toBeLessThan(totalInspections / 2)
// Pre-fix this equals totalInspections (one scan per inspection). With the
// shared cache, concurrent panes within a tick share one scan and the 500ms
// TTL forces a fresh scan each new 750ms tick -> ~one per tick.
expect(scanCount()).toBeLessThanOrEqual(TICKS + 1)
expect(scanCount()).toBeLessThan(totalInspections / 2)
})
it('collapses a burst of concurrent panes into a single scan', async () => {
installCountingPowerShellMock()
await Promise.all(
Array.from({ length: PANE_COUNT }, (_, pane) =>
queryWindowsProcessDescendants(shellPid(pane))
)
)
expect(powershellScanCount.value).toBe(1)
expect(scanCount()).toBe(1)
})
})
@@ -1,142 +1,111 @@
// Regression guard: the Windows agent foreground-process scan re-forks
// powershell.exe (or the wmic fallback) on a ~1s/pane cadence. Electron's main
// process has no console, so a spawn without windowsHide pops a fresh conhost
// window per scan that flashes and steals keyboard focus from the foreground app
// (including Orca's own terminal). Both probes MUST pass windowsHide: true.
/**
* The scan that gates PTY teardown used to fork `powershell.exe` (with a `wmic`
* fallback) on a ~1s/pane cadence. Two of the cases this suite used to carry —
* "the powershell probe passes windowsHide" and "the wmic fallback passes
* windowsHide" — are gone because there is no child process to hide any more.
*
* What survives is the contract that does not depend on the mechanism: a
* teardown-time read must be fresh, and a 32-wide worktree delete must still
* collapse into one scan.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn() }))
vi.mock('child_process', () => ({ execFile: execFileMock }))
const getAllProcessesMock = vi.fn()
import { __setWindowsProcessTreeLoaderForTests } from '../windows/windows-process-table'
import {
queryWindowsProcessDescendants,
queryWindowsProcessRowsFresh,
resetWindowsProcessRowsSnapshotForTests
} from './windows-foreground-process-rows'
type ExecFileCallback = (err: unknown, result: { stdout: string; stderr: string }) => void
type ExecFileCall = [string, string[], Record<string, unknown>, ExecFileCallback]
const POWERSHELL_ROWS_JSON = JSON.stringify([
const NATIVE_ROWS = [
{
ProcessId: 100,
ParentProcessId: 50,
Name: 'powershell.exe',
CommandLine: 'powershell.exe',
ExecutablePath: 'C:/Windows/System32/WindowsPowerShell/v1.0/powershell.exe'
pid: 100,
ppid: 50,
name: 'powershell.exe',
commandLine: '"C:/Windows/System32/WindowsPowerShell/v1.0/powershell.exe" -NoProfile'
},
{
ProcessId: 200,
ParentProcessId: 100,
Name: 'node.exe',
CommandLine: 'node C:/Users/dev/AppData/codex/bin/codex.js',
ExecutablePath: 'C:/Program Files/nodejs/node.exe'
pid: 200,
ppid: 100,
name: 'node.exe',
commandLine: 'node C:/Users/dev/AppData/codex/bin/codex.js'
}
])
]
const WMIC_ROWS_VALUE =
'CommandLine=powershell.exe\n' +
'ExecutablePath=C:/Windows/System32/WindowsPowerShell/v1.0/powershell.exe\n' +
'Name=powershell.exe\n' +
'ParentProcessId=50\n' +
'ProcessId=100\n\n' +
'CommandLine=node C:/Users/dev/AppData/codex/bin/codex.js\n' +
'ExecutablePath=C:/Program Files/nodejs/node.exe\n' +
'Name=node.exe\n' +
'ParentProcessId=100\n' +
'ProcessId=200\n'
/** Returns the options object passed to the mocked execFile for a given command. */
function optionsForCommand(command: string): Record<string, unknown> | undefined {
const call = execFileMock.mock.calls.find((args) => (args as ExecFileCall)[0] === command) as
| ExecFileCall
| undefined
return call?.[2]
}
describe('windows foreground process rows spawn options', () => {
describe('windows process rows', () => {
let platform: PropertyDescriptor | undefined
beforeEach(() => {
execFileMock.mockReset()
resetWindowsProcessRowsSnapshotForTests()
getAllProcessesMock.mockReset()
getAllProcessesMock.mockImplementation((cb: (rows: unknown) => void) => {
cb(NATIVE_ROWS)
})
platform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
__setWindowsProcessTreeLoaderForTests(() => ({
ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2 },
getAllProcesses: getAllProcessesMock
}))
})
afterEach(() => {
__setWindowsProcessTreeLoaderForTests()
if (platform) {
Object.defineProperty(process, 'platform', platform)
}
})
it('hides the console window for the powershell process-table scan', async () => {
execFileMock.mockImplementation((_cmd: string, _args, _opts, cb: ExecFileCallback) => {
cb(null, { stdout: POWERSHELL_ROWS_JSON, stderr: '' })
})
const scanCount = (): number => getAllProcessesMock.mock.calls.length
it('walks descendants from the native snapshot', async () => {
const candidates = await queryWindowsProcessDescendants(100)
expect(candidates?.[0]?.pid).toBe(200)
expect(optionsForCommand('powershell.exe')).toMatchObject({ windowsHide: true })
})
it('hides the console window for the wmic fallback scan', async () => {
execFileMock.mockImplementation((cmd: string, _args, _opts, cb: ExecFileCallback) => {
// Force the powershell probe to miss so the wmic fallback runs.
if (cmd === 'powershell.exe') {
cb(new Error('powershell unavailable'), { stdout: '', stderr: '' })
return
}
cb(null, { stdout: WMIC_ROWS_VALUE, stderr: '' })
})
const candidates = await queryWindowsProcessDescendants(100)
expect(candidates?.[0]?.pid).toBe(200)
expect(optionsForCommand('wmic')).toMatchObject({ windowsHide: true })
it('recovers the image path from a quoted command line', async () => {
// Why keep this at all: agent matching used to get ExecutablePath as its own
// CIM column. The command line already starts with the same path, so the
// column was a cost with no extra information.
const rows = await queryWindowsProcessRowsFresh()
expect(rows.find((row) => row.pid === 100)?.executablePath).toBe(
'C:/Windows/System32/WindowsPowerShell/v1.0/powershell.exe'
)
expect(rows.find((row) => row.pid === 200)?.executablePath).toBe('node')
})
})
// Regression guard: the PID-identity probe that gates `taskkill /T /F` needs rows
// from a scan started after it asked, but worktree delete tears down PTYs 32-wide.
// Reading the table uncached would fork 32 powershell cold-starts per delete.
describe('queryWindowsProcessRowsFresh', () => {
let platform: PropertyDescriptor | undefined
beforeEach(() => {
execFileMock.mockReset()
it('reports an unreadable table as unavailable, not as an empty machine', async () => {
// An empty table is a claim that nothing is running, and callers act on it
// by declaring a tree dead. Unavailable has to stay distinguishable.
getAllProcessesMock.mockImplementation((cb: (rows: unknown) => void) => {
cb(undefined)
})
resetWindowsProcessRowsSnapshotForTests()
platform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
execFileMock.mockImplementation((_cmd: string, _args, _opts, cb: ExecFileCallback) => {
cb(null, { stdout: POWERSHELL_ROWS_JSON, stderr: '' })
})
await expect(queryWindowsProcessRowsFresh()).rejects.toThrow()
expect(await queryWindowsProcessDescendants(100)).toBeNull()
})
afterEach(() => {
if (platform) {
Object.defineProperty(process, 'platform', platform)
}
it('returns null when the root is absent from the snapshot', async () => {
// Only an observed root can authoritatively have no descendants.
expect(await queryWindowsProcessDescendants(999)).toBeNull()
})
const powershellScanCount = (): number =>
execFileMock.mock.calls.filter((call) => call[0] === 'powershell.exe').length
it('collapses a burst of concurrent identity probes into one scan', async () => {
// A worktree delete tears down PTYs 32-wide.
const rows = await Promise.all(Array.from({ length: 32 }, () => queryWindowsProcessRowsFresh()))
expect(powershellScanCount()).toBe(1)
expect(scanCount()).toBe(1)
expect(rows[31]?.map((row) => row.pid)).toEqual([100, 200])
})
it('never answers from the TTL cache, which can predate the recycle it detects', async () => {
await queryWindowsProcessDescendants(100)
expect(powershellScanCount()).toBe(1)
expect(scanCount()).toBe(1)
await queryWindowsProcessRowsFresh()
expect(powershellScanCount()).toBe(2)
expect(scanCount()).toBe(2)
})
})
@@ -1,17 +1,9 @@
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import { createProcessTableSnapshotReader } from '../../shared/process-table-snapshot'
const execFileAsync = promisify(execFile)
const WINDOWS_PROCESS_QUERY_TIMEOUT_MS = 3_000
// Why: CommandLine can contain CR/LF text. JSON keeps process fields structured
// so an argument cannot masquerade as another `Name=` / `ProcessId=` row.
const POWERSHELL_PROCESS_QUERY =
'[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; ' +
'Get-CimInstance -ClassName Win32_Process ' +
'-Property CommandLine,ExecutablePath,Name,ParentProcessId,ProcessId | ' +
'Select-Object CommandLine,ExecutablePath,Name,ParentProcessId,ProcessId | ' +
'ConvertTo-Json -Compress'
import {
readWindowsProcessTable,
readWindowsProcessTableFresh,
resetWindowsProcessTableForTests,
type WindowsProcessRow as NativeWindowsProcessRow
} from '../windows/windows-process-table'
export type WindowsProcessRow = {
pid: number
@@ -23,38 +15,47 @@ export type WindowsProcessRow = {
export type WindowsProcessCandidate = WindowsProcessRow & { depth: number }
// Why: agent foreground inspection forks a whole-process-table PowerShell/CIM
// scan per pane on the same 750ms/2000ms cadence as the POSIX `ps` path. Without
// dedup, K concurrent agent panes fork K powershell.exe cold-starts, each ~10-40x
// heavier than `ps` — the Windows analogue of the idle-CPU churn #6288/#6667 fixed
// for POSIX. Reuse the same TTL + single-in-flight reader, caching parsed rows so
// a burst of panes collapses to ~2 scans/sec; every caller runs its own descendant
// walk over the shared snapshot.
async function runWindowsProcessRows(): Promise<WindowsProcessRow[]> {
const rows =
(await queryWindowsProcessesWithPowerShell()) ?? (await queryWindowsProcessesWithWmic())
if (!rows) {
// Reject so the reader does not cache the miss; callers fall through to
// node-pty's process name (the prior null-return contract is preserved by
// queryWindowsProcessDescendants catching this).
throw new Error('windows process enumeration unavailable')
/**
* Recover the image path from the command line.
*
* Why derive rather than query: a separate `ExecutablePath` column cost a
* `Get-CimInstance` scan, and the command line already begins with the same
* path — quoted when it contains spaces. Agent matching only uses this as extra
* text alongside `command`, so a best-effort first token preserves it.
*/
function executablePathFromCommand(command: string): string {
if (!command) {
return ''
}
return rows
if (command.startsWith('"')) {
const end = command.indexOf('"', 1)
return end === -1 ? '' : command.slice(1, end)
}
const space = command.indexOf(' ')
return space === -1 ? command : command.slice(0, space)
}
const windowsProcessRowsReader = createProcessTableSnapshotReader<WindowsProcessRow[]>({
runPs: runWindowsProcessRows,
now: () => Date.now()
})
function toProcessRow(row: NativeWindowsProcessRow): WindowsProcessRow {
return {
pid: row.pid,
ppid: row.ppid,
name: row.name,
// Why fall back to the image name: a process that denied a query handle has
// no command line, and callers match on `command` first.
command: row.command || row.name,
executablePath: executablePathFromCommand(row.command)
}
}
/**
* Rows from a scan that starts after this call. PID-identity checks in teardown
* must not reuse a cached row — it can predate the very recycle it detects — but
* they must still dedupe: a worktree delete tears down PTYs 32-wide, so a bypass
* would fork that many powershell cold-starts. Rejects when both probes fail.
* Rows from a scan that starts after this call.
*
* PID-identity checks in teardown must not reuse a cached row — it can predate
* the very recycle it is meant to detect. Rejects when the table is unreadable,
* so "unavailable" stays distinguishable from "nothing is running".
*/
export function queryWindowsProcessRowsFresh(): Promise<WindowsProcessRow[]> {
return windowsProcessRowsReader.getFreshSnapshot()
export async function queryWindowsProcessRowsFresh(): Promise<WindowsProcessRow[]> {
return (await readWindowsProcessTableFresh()).map(toProcessRow)
}
export async function queryWindowsProcessDescendants(
@@ -63,10 +64,11 @@ export async function queryWindowsProcessDescendants(
): Promise<WindowsProcessCandidate[] | null> {
let rows: WindowsProcessRow[]
try {
rows =
const native =
options.fresh === true
? await windowsProcessRowsReader.getFreshSnapshot()
: await windowsProcessRowsReader.getSnapshot()
? await readWindowsProcessTableFresh()
: await readWindowsProcessTable()
rows = native.map(toProcessRow)
} catch {
return null
}
@@ -78,122 +80,9 @@ export async function queryWindowsProcessDescendants(
return collectDescendants(rows, rootPid).sort((a, b) => b.depth - a.depth)
}
/**
* Test-only: clear the shared Windows process-table snapshot so suites that mock
* execFile between cases don't get one case's rows served to the next within TTL.
*/
/** Test-only: clear the shared snapshot so one case's rows never serve the next. */
export function resetWindowsProcessRowsSnapshotForTests(): void {
windowsProcessRowsReader.reset()
}
function parseWindowsProcessValueRows(stdout: string): WindowsProcessRow[] {
const rows: WindowsProcessRow[] = []
let command = ''
let executablePath = ''
let name = ''
let pid = Number.NaN
let ppid = Number.NaN
const flush = (): void => {
if (Number.isFinite(pid) && Number.isFinite(ppid)) {
rows.push({ pid, ppid, name, command: command || name, executablePath })
}
command = ''
executablePath = ''
name = ''
pid = Number.NaN
ppid = Number.NaN
}
for (const raw of stdout.split(/\r?\n/)) {
const line = raw.trim()
if (!line) {
flush()
continue
}
const eq = line.indexOf('=')
if (eq === -1) {
continue
}
const key = line.slice(0, eq)
const value = line.slice(eq + 1)
if (key === 'CommandLine') {
command = value
} else if (key === 'ExecutablePath') {
executablePath = value
} else if (key === 'Name') {
name = value
} else if (key === 'ParentProcessId') {
ppid = Number.parseInt(value, 10)
} else if (key === 'ProcessId') {
pid = Number.parseInt(value, 10)
}
}
flush()
return rows
}
type WindowsProcessJsonRow = {
CommandLine?: unknown
ExecutablePath?: unknown
Name?: unknown
ParentProcessId?: unknown
ProcessId?: unknown
}
function parseWindowsProcessJsonRows(stdout: string): WindowsProcessRow[] | null {
const trimmed = stdout.trim()
if (!trimmed) {
return []
}
try {
const parsed = JSON.parse(trimmed) as unknown
const items = Array.isArray(parsed) ? parsed : [parsed]
return items.flatMap((item) => {
if (!item || typeof item !== 'object') {
return []
}
const row = item as WindowsProcessJsonRow
const pid = numberFromWindowsProcessField(row.ProcessId)
const ppid = numberFromWindowsProcessField(row.ParentProcessId)
if (!Number.isFinite(pid) || !Number.isFinite(ppid)) {
return []
}
const name = stringFromWindowsProcessField(row.Name)
const command = stringFromWindowsProcessField(row.CommandLine) || name
return [
{
pid,
ppid,
name,
command,
executablePath: stringFromWindowsProcessField(row.ExecutablePath)
}
]
})
} catch {
return null
}
}
function stringFromWindowsProcessField(value: unknown): string {
if (typeof value === 'string') {
return value
}
if (value === null || value === undefined) {
return ''
}
return String(value)
}
function numberFromWindowsProcessField(value: unknown): number {
if (typeof value === 'number') {
return value
}
if (typeof value === 'string') {
return Number.parseInt(value, 10)
}
return Number.NaN
resetWindowsProcessTableForTests()
}
function collectDescendants<Row extends { pid: number; ppid: number }>(
@@ -218,56 +107,3 @@ function collectDescendants<Row extends { pid: number; ppid: number }>(
}
return descendants
}
/** Runs the PowerShell/CIM whole-process-table scan; returns null when unavailable. */
async function queryWindowsProcessesWithPowerShell(): Promise<WindowsProcessRow[] | null> {
try {
const { stdout } = await execFileAsync(
'powershell.exe',
['-NoProfile', '-NonInteractive', '-Command', POWERSHELL_PROCESS_QUERY],
{
encoding: 'utf8',
timeout: WINDOWS_PROCESS_QUERY_TIMEOUT_MS,
maxBuffer: 8 * 1024 * 1024,
// Why: this scan re-forks on a ~1s/pane cadence. Electron's main has no
// console, so without windowsHide each fork pops a fresh conhost window
// that flashes and steals keyboard focus from the foreground app
// (including Orca's own terminal).
windowsHide: true
}
)
const rows = parseWindowsProcessJsonRows(stdout)
return rows && rows.length > 0 ? rows : null
} catch {
return null
}
}
/** Fallback whole-process-table scan via wmic when PowerShell is unavailable. */
async function queryWindowsProcessesWithWmic(): Promise<WindowsProcessRow[] | null> {
try {
const { stdout } = await execFileAsync(
'wmic',
[
'process',
'get',
'CommandLine,ExecutablePath,Name,ParentProcessId,ProcessId',
'/format:value'
],
{
encoding: 'utf8',
timeout: WINDOWS_PROCESS_QUERY_TIMEOUT_MS,
maxBuffer: 8 * 1024 * 1024,
// Why: same focus-stealing hazard as the powershell probe — hide the
// wmic fallback's console window too.
windowsHide: true
}
)
const rows = parseWindowsProcessValueRows(stdout)
return rows.length > 0 ? rows : null
} catch {
// Best-effort: Windows process enumeration may be disabled, so callers
// still fall back to node-pty's process name when both probes fail.
return null
}
}
+18 -25
View File
@@ -1,13 +1,8 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { execFileMock, powershellScanCount } = vi.hoisted(() => ({
execFileMock: vi.fn(),
powershellScanCount: { value: 0 }
}))
const getAllProcessesMock = vi.fn()
vi.mock('child_process', () => ({ execFile: execFileMock }))
import { resetWindowsProcessRowsSnapshotForTests } from './providers/windows-foreground-process-rows'
import { __setWindowsProcessTreeLoaderForTests } from './windows/windows-process-table'
import {
classifyWindowsTreeKillTarget,
verifyWindowsTreeKillTarget,
@@ -108,7 +103,7 @@ describe('verifyWindowsTreeKillTarget', () => {
).resolves.toBe('foreign')
})
it('returns unknown when both Windows process probes are unavailable', async () => {
it('returns unknown when the Windows process table is unavailable', async () => {
const readRows = vi.fn().mockResolvedValue(null)
await expect(
verifyWindowsTreeKillTarget(4242, { readRows, ownerPid: ORCA_PID, platform: 'win32' })
@@ -116,7 +111,7 @@ describe('verifyWindowsTreeKillTarget', () => {
})
it('returns unknown when the process query rejects', async () => {
const readRows = vi.fn().mockRejectedValue(new Error('powershell missing'))
const readRows = vi.fn().mockRejectedValue(new Error('process table unavailable'))
await expect(
verifyWindowsTreeKillTarget(4242, { readRows, ownerPid: ORCA_PID, platform: 'win32' })
).resolves.toBe('unknown')
@@ -158,29 +153,27 @@ describe('verifyWindowsTreeKillTarget', () => {
// Regression guard on the DEFAULT reader, which the cases above bypass by
// injecting readRows: worktree delete tears down PTYs 32-wide, so a probe that
// reads the table uncached forks 32 powershell cold-starts per delete — the
// reads the table uncached takes 32 full process-table scans per delete — the
// churn #6288/#6667 fixed for POSIX. Exercises the real wiring, not a fake.
describe('verifyWindowsTreeKillTarget scan volume', () => {
const ROWS_JSON = JSON.stringify([
{ ProcessId: ORCA_PID, ParentProcessId: 900, Name: 'orca.exe', CommandLine: 'orca.exe' },
{ ProcessId: 4242, ParentProcessId: ORCA_PID, Name: 'pwsh.exe', CommandLine: 'pwsh.exe' }
])
const NATIVE_ROWS = [
{ pid: ORCA_PID, ppid: 900, name: 'orca.exe', commandLine: 'orca.exe' },
{ pid: 4242, ppid: ORCA_PID, name: 'pwsh.exe', commandLine: 'pwsh.exe' }
]
beforeEach(() => {
execFileMock.mockReset()
powershellScanCount.value = 0
resetWindowsProcessRowsSnapshotForTests()
execFileMock.mockImplementation((cmd: string, ..._rest: unknown[]) => {
const cb = _rest.at(-1) as (e: unknown, r: { stdout: string; stderr: string }) => void
if (cmd === 'powershell.exe') {
powershellScanCount.value += 1
}
cb(null, { stdout: ROWS_JSON, stderr: '' })
getAllProcessesMock.mockReset()
getAllProcessesMock.mockImplementation((cb: (snapshot: unknown) => void) => {
cb(NATIVE_ROWS)
})
__setWindowsProcessTreeLoaderForTests(() => ({
ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2 },
getAllProcesses: getAllProcessesMock
}))
})
afterEach(() => {
resetWindowsProcessRowsSnapshotForTests()
__setWindowsProcessTreeLoaderForTests()
})
it('collapses a 32-wide teardown burst into a single process-table scan', async () => {
@@ -190,7 +183,7 @@ describe('verifyWindowsTreeKillTarget scan volume', () => {
)
)
expect(powershellScanCount.value).toBe(1)
expect(getAllProcessesMock.mock.calls.length).toBe(1)
expect(new Set(verdicts)).toEqual(new Set(['own']))
})
})
@@ -0,0 +1,82 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
__setWindowsProcessTreeLoaderForTests,
isWindowsProcessTableAvailable,
readWindowsProcessTable,
readWindowsProcessTableFresh,
resetWindowsProcessTableForTests
} from './windows-process-table'
const getAllProcesses = vi.fn()
const NATIVE = [
{ pid: 4, ppid: 0, name: 'System' },
{ pid: 100, ppid: 4, name: 'orca.exe', commandLine: '"C:/a b/orca.exe" --x', memory: 4096 }
]
describe('windows process table', () => {
let platform: PropertyDescriptor | undefined
beforeEach(() => {
getAllProcesses.mockReset()
getAllProcesses.mockImplementation((cb: (rows: unknown) => void) => cb(NATIVE))
platform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
__setWindowsProcessTreeLoaderForTests(() => ({
ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2 },
getAllProcesses
}))
})
afterEach(() => {
__setWindowsProcessTreeLoaderForTests()
if (platform) {
Object.defineProperty(process, 'platform', platform)
}
})
it('maps native rows, defaulting an unreadable command line to empty', async () => {
const rows = await readWindowsProcessTableFresh()
expect(rows).toEqual([
{ pid: 4, ppid: 0, name: 'System', command: '', memoryBytes: undefined },
{
pid: 100,
ppid: 4,
name: 'orca.exe',
command: '"C:/a b/orca.exe" --x',
memoryBytes: 4096
}
])
})
it('requests memory and command line together', async () => {
await readWindowsProcessTableFresh()
expect(getAllProcesses.mock.calls[0]?.[1]).toBe(3)
})
it('serves repeat reads from the shared snapshot', async () => {
await readWindowsProcessTable()
await readWindowsProcessTable()
expect(getAllProcesses).toHaveBeenCalledTimes(1)
})
it('rejects rather than reporting an empty machine when the module is absent', async () => {
// A caller that reads "no processes" acts on it -- by declaring a tree dead,
// or by concluding a shell has no children. Absence must not look like that.
__setWindowsProcessTreeLoaderForTests(() => null)
await expect(readWindowsProcessTableFresh()).rejects.toThrow(/unavailable/)
expect(isWindowsProcessTableAvailable()).toBe(false)
})
it('rejects when the snapshot itself fails', async () => {
getAllProcesses.mockImplementation((cb: (rows: unknown) => void) => cb(undefined))
resetWindowsProcessTableForTests()
await expect(readWindowsProcessTableFresh()).rejects.toThrow()
})
it('is unavailable off Windows without attempting a require', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' })
__setWindowsProcessTreeLoaderForTests()
expect(isWindowsProcessTableAvailable()).toBe(false)
})
})
+160
View File
@@ -0,0 +1,160 @@
import { createRequire } from 'node:module'
import { createProcessTableSnapshotReader } from '../../shared/process-table-snapshot'
/**
* The only place Orca reads the Windows process table.
*
* Every previous reader forked `powershell.exe` to run a `Get-CimInstance
* Win32_Process` scan (with a `wmic` fallback that Windows 11 24H2 has
* removed). Seven of them existed, on independent cadences. That is why:
*
* - a PowerShell Transcription policy recorded ~289 GB across 1.4 million
* files, because a scan ran every ~2 seconds (#15209);
* - a Group Policy or AV block turned a process query into "unavailable",
* which callers read as "no evidence", which is how a PTY tree survived its
* own teardown (#9045, #10475);
* - the scan cost ~700 ms and ran per pane, so panes multiplied it (#15036).
*
* A Toolhelp32 snapshot answers the same question in ~16 ms with no child
* process at all, so none of those failure modes have anywhere to live.
*
* Measured on Windows 11 (1050 processes), p50 / p95:
* pid+ppid+name 15.9 / 17.5 ms
* +memory +commandLine 30.6 / 33.7 ms
* PowerShell CIM 706 / 723 ms
*/
export type WindowsProcessRow = {
pid: number
ppid: number
name: string
/** Full command line. Empty when the process denied a query handle. */
command: string
/** Working set in bytes, or undefined when not requested/queryable. */
memoryBytes?: number
}
type NativeProcessInfo = {
pid: number
ppid: number
name: string
memory?: number
commandLine?: string
}
type WindowsProcessTreeModule = {
ProcessDataFlag: { None: number; Memory: number; CommandLine: number }
getAllProcesses: (
callback: (processes: NativeProcessInfo[] | undefined) => void,
flags?: number
) => void
}
const requireFromMain = createRequire(__filename)
let cachedModule: WindowsProcessTreeModule | null | undefined
let moduleLoader: () => WindowsProcessTreeModule | null = loadWindowsProcessTree
/**
* Resolve the native module, or null where it cannot be used.
*
* Why tolerate absence: it is an optional, Windows-only dependency, so a
* macOS/Linux install legitimately has no binary. Callers must treat null the
* same way they treat any other unavailable evidence.
*/
function loadWindowsProcessTree(): WindowsProcessTreeModule | null {
if (cachedModule !== undefined) {
return cachedModule
}
if (process.platform !== 'win32') {
cachedModule = null
return cachedModule
}
try {
cachedModule = requireFromMain('@vscode/windows-process-tree') as WindowsProcessTreeModule
} catch {
cachedModule = null
}
return cachedModule
}
function readNativeRows(): Promise<WindowsProcessRow[]> {
const native = moduleLoader()
if (!native) {
// Reject rather than resolve empty: an empty table is a claim that nothing
// is running, and callers act on that by force-killing or by declaring a
// tree dead. "Unavailable" has to stay distinguishable from "empty".
return Promise.reject(new Error('windows process table unavailable'))
}
const flags = native.ProcessDataFlag.Memory | native.ProcessDataFlag.CommandLine
return new Promise((resolve, reject) => {
try {
native.getAllProcesses((processes) => {
if (!processes) {
reject(new Error('windows process table returned no snapshot'))
return
}
resolve(
processes.map((row) => ({
pid: row.pid,
ppid: row.ppid,
name: row.name,
command: row.commandLine ?? '',
memoryBytes: row.memory
}))
)
}, flags)
} catch (error) {
reject(error instanceof Error ? error : new Error(String(error)))
}
})
}
// Why still cache: the snapshot is cheap but not free, and a worktree delete
// tears down PTYs 32-wide. The shared TTL + single-in-flight reader collapses
// that burst into one scan, exactly as the PowerShell path had to.
const snapshotReader = createProcessTableSnapshotReader<WindowsProcessRow[]>({
runPs: readNativeRows,
now: () => Date.now()
})
/** Cached snapshot, refreshed on the shared TTL. */
export function readWindowsProcessTable(): Promise<WindowsProcessRow[]> {
return snapshotReader.getSnapshot()
}
/**
* A snapshot taken after this call returns.
*
* Identity checks during teardown must not reuse a cached row — it can predate
* the very process exit it is being asked about.
*/
export function readWindowsProcessTableFresh(): Promise<WindowsProcessRow[]> {
return snapshotReader.getFreshSnapshot()
}
/** Whether the native table can be read at all on this host. */
export function isWindowsProcessTableAvailable(): boolean {
return moduleLoader() !== null
}
/**
* Test-only: substitute the native module.
*
* Why an injector and not `vi.mock`: the module is resolved through
* `createRequire` so a macOS/Linux install can legitimately not have it, and
* `createRequire` bypasses the module mocker.
*/
export function __setWindowsProcessTreeLoaderForTests(
loader?: () => WindowsProcessTreeModule | null
): void {
moduleLoader = loader ?? loadWindowsProcessTree
cachedModule = undefined
snapshotReader.reset()
}
/** Test-only: drop the shared snapshot so suites cannot serve each other's rows. */
export function resetWindowsProcessTableForTests(): void {
snapshotReader.reset()
cachedModule = undefined
}
+32 -53
View File
@@ -1,7 +1,8 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { execFileMock, execFileSyncMock } = vi.hoisted(() => ({
const { execFileMock, execFileSyncMock, getAllProcessesMock } = vi.hoisted(() => ({
execFileMock: vi.fn(),
getAllProcessesMock: vi.fn(),
execFileSyncMock: vi.fn()
}))
@@ -11,6 +12,7 @@ vi.mock('child_process', () => ({
}))
import { resetWindowsProcessRowsSnapshotForTests } from '../main/providers/windows-foreground-process-rows'
import { __setWindowsProcessTreeLoaderForTests } from '../main/windows/windows-process-table'
import { resetProcessTableSnapshotForTests } from '../shared/process-table-snapshot'
import {
getForegroundProcessName,
@@ -35,6 +37,21 @@ function mockExecFile(
)
}
/**
* Feed the native Windows snapshot. A real snapshot always contains the
* querying process, and the reader rejects a table without it.
*/
function mockWindowsProcessTable(
rows: { pid: number; ppid: number; name: string; commandLine?: string }[]
): void {
__setWindowsProcessTreeLoaderForTests(() => ({
ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2 },
getAllProcesses: (cb: (value: typeof rows | undefined) => void) =>
cb([{ pid: process.pid, ppid: 0, name: 'vitest.exe', commandLine: 'vitest' }, ...rows])
}))
getAllProcessesMock.mockClear()
}
async function withProcessPlatform<T>(
platform: NodeJS.Platform,
run: () => T | Promise<T>
@@ -56,6 +73,7 @@ beforeEach(() => {
execFileSyncMock.mockReset()
resetProcessTableSnapshotForTests()
resetWindowsProcessRowsSnapshotForTests()
__setWindowsProcessTreeLoaderForTests()
})
describe('isProcessAlive', () => {
@@ -306,29 +324,15 @@ describe('getForegroundProcessName', () => {
it('recognizes Windows SSH relay shell-rooted agent descendants', async () => {
await withProcessPlatform('win32', async () => {
mockExecFile((command) => {
if (command === 'powershell.exe') {
return {
stdout: JSON.stringify([
{
CommandLine: 'powershell.exe',
ExecutablePath: 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe',
Name: 'powershell.exe',
ParentProcessId: 99,
ProcessId: 100
},
{
CommandLine: 'node C:\\Users\\dev\\AppData\\Roaming\\npm\\codex.cmd',
ExecutablePath: 'C:\\Program Files\\nodejs\\node.exe',
Name: 'node.exe',
ParentProcessId: 100,
ProcessId: 101
}
])
}
mockWindowsProcessTable([
{ pid: 100, ppid: 99, name: 'powershell.exe', commandLine: 'powershell.exe' },
{
pid: 101,
ppid: 100,
name: 'node.exe',
commandLine: 'node C:\\Users\\dev\\AppData\\Roaming\\npm\\codex.cmd'
}
return new Error('unexpected command')
})
])
await expect(getForegroundProcessName(100, 'powershell.exe')).resolves.toBe('codex')
})
@@ -430,36 +434,11 @@ describe('getForegroundProcessName', () => {
it('rescans a Windows pi fallback for its outer omp wrapper', async () => {
await withProcessPlatform('win32', async () => {
mockExecFile((command) => {
if (command === 'powershell.exe') {
return {
stdout: JSON.stringify([
{
CommandLine: 'powershell.exe',
ExecutablePath: 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe',
Name: 'powershell.exe',
ParentProcessId: 99,
ProcessId: 100
},
{
CommandLine: 'omp.exe',
ExecutablePath: 'C:\\Tools\\omp.exe',
Name: 'omp.exe',
ParentProcessId: 100,
ProcessId: 101
},
{
CommandLine: 'pi.exe',
ExecutablePath: 'C:\\Tools\\pi.exe',
Name: 'pi.exe',
ParentProcessId: 101,
ProcessId: 102
}
])
}
}
return new Error('unexpected command')
})
mockWindowsProcessTable([
{ pid: 100, ppid: 99, name: 'powershell.exe', commandLine: 'powershell.exe' },
{ pid: 101, ppid: 100, name: 'omp.exe', commandLine: 'omp.exe' },
{ pid: 102, ppid: 101, name: 'pi.exe', commandLine: 'pi.exe' }
])
await expect(getForegroundProcessName(100, 'pi')).resolves.toBe('omp')
})
@@ -108,7 +108,6 @@ src/main/ports/port-scan-command-execution.ts
src/main/providers/macos-login-session-pty-probe.ts
src/main/providers/process-cwd.ts
src/main/providers/windows-conpty-process-membership.ts
src/main/providers/windows-foreground-process-rows.ts
src/main/pty-descendant-termination.ts
src/main/pty/posix-pty-foreground-group.ts
src/main/pty/posix-pty-process-groups.ts