mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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
@@ -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": {
|
||||
|
||||
Generated
+21
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user