diff --git a/.gitignore b/.gitignore index d8e4f224baa..90e66fac4b6 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/AGENTS.md b/AGENTS.md index ba14ab6a967..292dcc64ed3 100644 --- a/AGENTS.md +++ b/AGENTS.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. diff --git a/config/packaged-runtime-node-modules.cjs b/config/packaged-runtime-node-modules.cjs index 71ca780d1b8..488b1bec409 100644 --- a/config/packaged-runtime-node-modules.cjs +++ b/config/packaged-runtime-node-modules.cjs @@ -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-', diff --git a/config/patches/@vscode__windows-process-tree@0.8.0.patch b/config/patches/@vscode__windows-process-tree@0.8.0.patch new file mode 100644 index 00000000000..cf68eea1985 --- /dev/null +++ b/config/patches/@vscode__windows-process-tree@0.8.0.patch @@ -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& 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); diff --git a/config/scripts/ensure-native-runtime.mjs b/config/scripts/ensure-native-runtime.mjs index 44261e968a9..e8258d69149 100644 --- a/config/scripts/ensure-native-runtime.mjs +++ b/config/scripts/ensure-native-runtime.mjs @@ -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. diff --git a/config/scripts/package-electron-runtime-contract.test.mjs b/config/scripts/package-electron-runtime-contract.test.mjs index a539dc2b717..ef9745e476c 100644 --- a/config/scripts/package-electron-runtime-contract.test.mjs +++ b/config/scripts/package-electron-runtime-contract.test.mjs @@ -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 = [ diff --git a/config/scripts/rebuild-native-deps.mjs b/config/scripts/rebuild-native-deps.mjs index c96e2587c4f..6d776abd1ca 100644 --- a/config/scripts/rebuild-native-deps.mjs +++ b/config/scripts/rebuild-native-deps.mjs @@ -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 = diff --git a/docs/reference/windows-process-enumeration.md b/docs/reference/windows-process-enumeration.md new file mode 100644 index 00000000000..7e6b7831de5 --- /dev/null +++ b/docs/reference/windows-process-enumeration.md @@ -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. diff --git a/package.json b/package.json index 3fd332f5fe1..ee488485b7f 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1c0d6b0d31b..cbd1d443102 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: diff --git a/src/main/ports/local-workspace-port-scanner.ts b/src/main/ports/local-workspace-port-scanner.ts index ce2b7b7683c..66373db6929 100644 --- a/src/main/ports/local-workspace-port-scanner.ts +++ b/src/main/ports/local-workspace-port-scanner.ts @@ -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 }) } } diff --git a/src/main/providers/agent-foreground-process-pi.test.ts b/src/main/providers/agent-foreground-process-pi.test.ts index af63db24d42..2b850298518 100644 --- a/src/main/providers/agent-foreground-process-pi.test.ts +++ b/src/main/providers/agent-foreground-process-pi.test.ts @@ -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( diff --git a/src/main/providers/agent-foreground-process.test.ts b/src/main/providers/agent-foreground-process.test.ts index c9f32dd049f..e3bf726f030 100644 --- a/src/main/providers/agent-foreground-process.test.ts +++ b/src/main/providers/agent-foreground-process.test.ts @@ -8,15 +8,17 @@ vi.mock('child_process', () => ({ execFile: execFileMock })) +const getAllProcessesMock = vi.fn() + import { resetProcessTableSnapshotForTests } from '../../shared/process-table-snapshot' +import { __setWindowsProcessTreeLoaderForTests } from '../windows/windows-process-table' import { resolveAgentForegroundProcess, resolveAgentForegroundProcessWithAvailability } from './agent-foreground-process' -import { resetWindowsProcessRowsSnapshotForTests } from './windows-foreground-process-rows' -// Why: the module wraps execFile with promisify, so the mock must honor the -// Node callback contract — invoke the last arg with (err, { stdout, stderr }). +// Why: the POSIX reader wraps execFile with promisify, so the mock must honor +// the Node callback contract — invoke the last arg with (err, { stdout, stderr }). function mockPs(stdout: string): void { execFileMock.mockImplementation((_cmd: string, _args: string[], _opts: unknown, cb: unknown) => { const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void @@ -24,51 +26,39 @@ function mockPs(stdout: string): void { }) } -function windowsProcessJsonRows( - rows: { - CommandLine: string | null - Name: string - ParentProcessId: number - ProcessId: number - ExecutablePath?: string | null - }[] = [ - { - CommandLine: 'powershell.exe', - Name: 'powershell.exe', - ParentProcessId: 99, - ProcessId: 100 - }, - { - CommandLine: 'node C:\\Users\\dev\\AppData\\Roaming\\npm\\codex.cmd', - Name: 'node.exe', - ParentProcessId: 100, - ProcessId: 101 - } - ] -): string { - return JSON.stringify( - rows.map((row) => ({ - ExecutablePath: row.ExecutablePath ?? null, - ...row - })) - ) +type NativeProcessRow = { + pid: number + ppid: number + name: string + commandLine?: string } -function windowsProcessValueRows(): string { - return [ - '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', - '' - ].join('\r\n') +const DEFAULT_WINDOWS_ROWS: NativeProcessRow[] = [ + { + 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' + } +] + +function mockWindowsRows(rows: NativeProcessRow[] = DEFAULT_WINDOWS_ROWS): void { + getAllProcessesMock.mockImplementation((cb: (snapshot: NativeProcessRow[]) => void) => { + cb(rows) + }) +} + +/** `undefined` rows are how the native snapshot reports a table it cannot read. */ +function mockUnreadableWindowsTable(): void { + getAllProcessesMock.mockImplementation((cb: (snapshot: undefined) => void) => { + cb(undefined) + }) } describe('resolveAgentForegroundProcess', () => { @@ -76,15 +66,20 @@ describe('resolveAgentForegroundProcess', () => { beforeEach(() => { execFileMock.mockReset() + getAllProcessesMock.mockReset() resetProcessTableSnapshotForTests() // Why: the Windows rows reader caches across calls (500ms TTL), so each - // case's execFile mock must not be answered by the previous case's rows. - resetWindowsProcessRowsSnapshotForTests() + // case's rows must not be answered by the previous case's snapshot. + __setWindowsProcessTreeLoaderForTests(() => ({ + ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2 }, + getAllProcesses: getAllProcessesMock + })) platform = Object.getOwnPropertyDescriptor(process, 'platform') - Object.defineProperty(process, 'platform', { value: 'darwin' }) + Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) }) afterEach(() => { + __setWindowsProcessTreeLoaderForTests() if (platform) { Object.defineProperty(process, 'platform', platform) } @@ -132,68 +127,58 @@ describe('resolveAgentForegroundProcess', () => { it('reports the outer omp wrapper on Windows', async () => { Object.defineProperty(process, 'platform', { value: 'win32' }) - execFileMock.mockImplementation( - (_cmd: string, _args: string[], _opts: unknown, cb: unknown) => { - const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void - callback(null, { - stdout: windowsProcessJsonRows([ - { - CommandLine: 'powershell.exe', - Name: 'powershell.exe', - ParentProcessId: 99, - ProcessId: 100 - }, - { - CommandLine: 'omp.exe', - Name: 'omp.exe', - ParentProcessId: 100, - ProcessId: 101 - }, - { - CommandLine: 'pi.exe', - Name: 'pi.exe', - ParentProcessId: 101, - ProcessId: 102 - } - ]), - stderr: '' - }) + mockWindowsRows([ + { + 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(resolveAgentForegroundProcess(100, 'pi.exe')).resolves.toBe('omp') }) it('keeps the Windows omp ancestor when context selects one of multiple pi descendants', async () => { Object.defineProperty(process, 'platform', { value: 'win32' }) - mockPs( - windowsProcessJsonRows([ - { - CommandLine: 'powershell.exe', - Name: 'powershell.exe', - ParentProcessId: 99, - ProcessId: 100 - }, - { - CommandLine: 'omp.exe', - Name: 'omp.exe', - ParentProcessId: 100, - ProcessId: 101 - }, - { - CommandLine: 'pi.exe --cwd C:\\repo\\orca', - Name: 'pi.exe', - ParentProcessId: 101, - ProcessId: 102 - }, - { - CommandLine: 'pi.exe --cwd C:\\repo\\other', - Name: 'pi.exe', - ParentProcessId: 100, - ProcessId: 103 - } - ]) - ) + mockWindowsRows([ + { + 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 --cwd C:\\repo\\orca' + }, + { + pid: 103, + ppid: 100, + name: 'pi.exe', + commandLine: 'pi.exe --cwd C:\\repo\\other' + } + ]) await expect( resolveAgentForegroundProcess(100, 'pi.exe', { contextPaths: ['C:\\repo\\orca'] }) @@ -261,214 +246,115 @@ describe('resolveAgentForegroundProcess', () => { it('recognizes Windows wrapper-launched agents from descendant command lines', async () => { Object.defineProperty(process, 'platform', { value: 'win32' }) - execFileMock.mockImplementation( - (_cmd: string, _args: string[], _opts: unknown, cb: unknown) => { - const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void - callback(null, { stdout: windowsProcessJsonRows(), stderr: '' }) - } - ) + mockWindowsRows() await expect(resolveAgentForegroundProcess(100, 'node.exe')).resolves.toBe('codex') - expect(execFileMock).toHaveBeenCalledWith( - 'powershell.exe', - expect.any(Array), - expect.objectContaining({ timeout: 3000 }), - expect.any(Function) - ) }) it('recognizes Windows shell-rooted agent launches from descendant command lines', async () => { Object.defineProperty(process, 'platform', { value: 'win32' }) - execFileMock.mockImplementation( - (_cmd: string, _args: string[], _opts: unknown, cb: unknown) => { - const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void - callback(null, { stdout: windowsProcessJsonRows(), stderr: '' }) - } - ) + mockWindowsRows() await expect(resolveAgentForegroundProcess(100, 'powershell.exe')).resolves.toBe('codex') }) it('recognizes the native Windows Cursor launcher process tree', async () => { Object.defineProperty(process, 'platform', { value: 'win32' }) - mockPs( - windowsProcessJsonRows([ - { - CommandLine: 'powershell.exe', - Name: 'powershell.exe', - ParentProcessId: 99, - ProcessId: 100 - }, - { - CommandLine: 'cmd.exe /c cursor-agent.cmd', - Name: 'cmd.exe', - ParentProcessId: 100, - ProcessId: 101 - }, - { - CommandLine: - 'powershell.exe -File C:\\Users\\dev\\AppData\\Local\\cursor-agent\\cursor-agent.ps1', - Name: 'powershell.exe', - ParentProcessId: 101, - ProcessId: 102 - }, - { - CommandLine: - 'node.exe C:\\Users\\dev\\AppData\\Local\\cursor-agent\\versions\\2026.07.09-a3815c0\\index.js', - Name: 'node.exe', - ParentProcessId: 102, - ProcessId: 103 - }, - { - CommandLine: - 'node.exe C:\\Users\\dev\\AppData\\Local\\cursor-agent\\versions\\2026.07.09-a3815c0\\index.js worker-server', - Name: 'node.exe', - ParentProcessId: 103, - ProcessId: 104 - }, - { - CommandLine: 'C:\\Users\\dev\\.grok\\bin\\agent.exe', - Name: 'agent.exe', - ParentProcessId: 100, - ProcessId: 105 - } - ]) - ) + mockWindowsRows([ + { + pid: 100, + ppid: 99, + name: 'powershell.exe', + commandLine: 'powershell.exe' + }, + { + pid: 101, + ppid: 100, + name: 'cmd.exe', + commandLine: 'cmd.exe /c cursor-agent.cmd' + }, + { + pid: 102, + ppid: 101, + name: 'powershell.exe', + commandLine: + 'powershell.exe -File C:\\Users\\dev\\AppData\\Local\\cursor-agent\\cursor-agent.ps1' + }, + { + pid: 103, + ppid: 102, + name: 'node.exe', + commandLine: + 'node.exe C:\\Users\\dev\\AppData\\Local\\cursor-agent\\versions\\2026.07.09-a3815c0\\index.js' + }, + { + pid: 104, + ppid: 103, + name: 'node.exe', + commandLine: + 'node.exe C:\\Users\\dev\\AppData\\Local\\cursor-agent\\versions\\2026.07.09-a3815c0\\index.js worker-server' + }, + { + pid: 105, + ppid: 100, + name: 'agent.exe', + commandLine: 'C:\\Users\\dev\\.grok\\bin\\agent.exe' + } + ]) await expect(resolveAgentForegroundProcess(100, 'powershell.exe')).resolves.toBe('cursor-agent') }) it('recognizes Windows Git Bash shell-rooted agent launches', async () => { Object.defineProperty(process, 'platform', { value: 'win32' }) - execFileMock.mockImplementation( - (_cmd: string, _args: string[], _opts: unknown, cb: unknown) => { - const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void - callback(null, { - stdout: windowsProcessJsonRows([ - { - CommandLine: 'C:\\Program Files\\Git\\bin\\bash.exe --login -i', - Name: 'bash.exe', - ParentProcessId: 99, - ProcessId: 100 - }, - { - CommandLine: 'node C:\\Users\\dev\\AppData\\Roaming\\npm\\codex.cmd', - Name: 'node.exe', - ParentProcessId: 100, - ProcessId: 101 - } - ]), - stderr: '' - }) + mockWindowsRows([ + { + pid: 100, + ppid: 99, + name: 'bash.exe', + commandLine: 'C:\\Program Files\\Git\\bin\\bash.exe --login -i' + }, + { + pid: 101, + ppid: 100, + name: 'node.exe', + commandLine: 'node C:\\Users\\dev\\AppData\\Roaming\\npm\\codex.cmd' } - ) + ]) await expect(resolveAgentForegroundProcess(100, 'bash.exe')).resolves.toBe('codex') }) - it('keeps multiline Windows command lines inside the parsed process row', async () => { + it('keeps a multiline Windows command line inside its own row', async () => { + // The prompt text impersonates another row; recognition must read the row + // it belongs to rather than the process it names. Object.defineProperty(process, 'platform', { value: 'win32' }) - execFileMock.mockImplementation( - (_cmd: string, _args: string[], _opts: unknown, cb: unknown) => { - const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void - callback(null, { - stdout: windowsProcessJsonRows([ - { - CommandLine: 'powershell.exe', - Name: 'powershell.exe', - ParentProcessId: 99, - ProcessId: 100 - }, - { - CommandLine: [ - 'node', - 'C:\\Users\\dev\\AppData\\Roaming\\npm\\node_modules\\@openai\\codex\\bin\\codex.js', - '--prompt', - '"line one\r\nName=gemini.exe\r\nProcessId=999"' - ].join(' '), - Name: 'node.exe', - ParentProcessId: 100, - ProcessId: 101 - } - ]), - stderr: '' - }) + mockWindowsRows([ + { + 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\\node_modules\\@openai\\codex\\bin\\codex.js', + '--prompt', + '"line one\r\nName=gemini.exe\r\nProcessId=999"' + ].join(' ') } - ) + ]) await expect(resolveAgentForegroundProcess(100, 'powershell.exe')).resolves.toBe('codex') }) - it('falls back to WMIC when Windows PowerShell process enumeration fails', async () => { - Object.defineProperty(process, 'platform', { value: 'win32' }) - execFileMock.mockImplementation((cmd: string, _args: string[], _opts: unknown, cb: unknown) => { - const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void - if (cmd === 'powershell.exe') { - callback(new Error('powershell unavailable'), { stdout: '', stderr: '' }) - return - } - callback(null, { stdout: windowsProcessValueRows(), stderr: '' }) - }) - - await expect(resolveAgentForegroundProcess(100, 'node.exe')).resolves.toBe('codex') - expect(execFileMock).toHaveBeenCalledWith( - 'wmic', - expect.any(Array), - expect.objectContaining({ timeout: 3000 }), - expect.any(Function) - ) - }) - - it('falls back to WMIC when Windows PowerShell returns no process rows', async () => { - Object.defineProperty(process, 'platform', { value: 'win32' }) - execFileMock.mockImplementation((cmd: string, _args: string[], _opts: unknown, cb: unknown) => { - const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void - if (cmd === 'powershell.exe') { - callback(null, { stdout: ' \r\n', stderr: '' }) - return - } - callback(null, { stdout: windowsProcessValueRows(), stderr: '' }) - }) - - await expect(resolveAgentForegroundProcess(100, 'node.exe')).resolves.toBe('codex') - expect(execFileMock).toHaveBeenCalledWith( - 'wmic', - expect.any(Array), - expect.objectContaining({ timeout: 3000 }), - expect.any(Function) - ) - }) - it('distinguishes unavailable Windows enumeration from a confirmed shell', async () => { Object.defineProperty(process, 'platform', { value: 'win32' }) - execFileMock.mockImplementation( - (_cmd: string, _args: string[], _opts: unknown, cb: unknown) => { - const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void - callback(new Error('enumeration unavailable'), { stdout: '', stderr: '' }) - } - ) - - await expect( - resolveAgentForegroundProcessWithAvailability(100, 'powershell.exe') - ).resolves.toEqual({ available: false, processName: 'powershell.exe' }) - await expect(resolveAgentForegroundProcess(100, 'powershell.exe')).resolves.toBe( - 'powershell.exe' - ) - }) - - it.each([ - ['blank', ' \r\n'], - ['unparseable', 'wmic returned no structured process values'] - ])('treats successful but %s WMIC output as unavailable', async (_label, wmicOutput) => { - Object.defineProperty(process, 'platform', { value: 'win32' }) - execFileMock.mockImplementation((cmd: string, _args: string[], _opts: unknown, cb: unknown) => { - const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void - if (cmd === 'powershell.exe') { - callback(new Error('powershell unavailable'), { stdout: '', stderr: '' }) - return - } - callback(null, { stdout: wmicOutput, stderr: '' }) - }) + mockUnreadableWindowsTable() await expect( resolveAgentForegroundProcessWithAvailability(100, 'powershell.exe') @@ -480,22 +366,14 @@ describe('resolveAgentForegroundProcess', () => { it('treats an observed Windows shell with no children as authoritative', async () => { Object.defineProperty(process, 'platform', { value: 'win32' }) - execFileMock.mockImplementation( - (_cmd: string, _args: string[], _opts: unknown, cb: unknown) => { - const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void - callback(null, { - stdout: windowsProcessJsonRows([ - { - CommandLine: 'powershell.exe', - Name: 'powershell.exe', - ParentProcessId: 99, - ProcessId: 100 - } - ]), - stderr: '' - }) + mockWindowsRows([ + { + pid: 100, + ppid: 99, + name: 'powershell.exe', + commandLine: 'powershell.exe' } - ) + ]) await expect( resolveAgentForegroundProcessWithAvailability(100, 'powershell.exe') @@ -504,16 +382,14 @@ describe('resolveAgentForegroundProcess', () => { it('does not restore a recognized fallback that disappeared before confirmation', async () => { Object.defineProperty(process, 'platform', { value: 'win32' }) - mockPs( - windowsProcessJsonRows([ - { - CommandLine: 'powershell.exe', - Name: 'powershell.exe', - ParentProcessId: 99, - ProcessId: 100 - } - ]) - ) + mockWindowsRows([ + { + pid: 100, + ppid: 99, + name: 'powershell.exe', + commandLine: 'powershell.exe' + } + ]) await expect( resolveAgentForegroundProcessWithAvailability(100, 'droid', { @@ -525,22 +401,14 @@ describe('resolveAgentForegroundProcess', () => { it('treats a Windows snapshot missing the requested shell as unavailable', async () => { Object.defineProperty(process, 'platform', { value: 'win32' }) - execFileMock.mockImplementation( - (_cmd: string, _args: string[], _opts: unknown, cb: unknown) => { - const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void - callback(null, { - stdout: windowsProcessJsonRows([ - { - CommandLine: 'unrelated.exe', - Name: 'unrelated.exe', - ParentProcessId: 99, - ProcessId: 200 - } - ]), - stderr: '' - }) + mockWindowsRows([ + { + pid: 200, + ppid: 99, + name: 'unrelated.exe', + commandLine: 'unrelated.exe' } - ) + ]) await expect( resolveAgentForegroundProcessWithAvailability(100, 'powershell.exe') @@ -552,62 +420,54 @@ describe('resolveAgentForegroundProcess', () => { it('does not use unrelated Windows agent descendants for wrapper fallbacks', async () => { Object.defineProperty(process, 'platform', { value: 'win32' }) - execFileMock.mockImplementation( - (_cmd: string, _args: string[], _opts: unknown, cb: unknown) => { - const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void - callback(null, { - stdout: [ - 'CommandLine=powershell.exe', - 'Name=powershell.exe', - 'ParentProcessId=99', - 'ProcessId=100', - '', - 'CommandLine=node C:\\repo\\server.js', - 'Name=node.exe', - 'ParentProcessId=100', - 'ProcessId=101', - '', - 'CommandLine=codex', - 'Name=codex.exe', - 'ParentProcessId=100', - 'ProcessId=102', - '' - ].join('\r\n'), - stderr: '' - }) + mockWindowsRows([ + { + pid: 100, + ppid: 99, + name: 'powershell.exe', + commandLine: 'powershell.exe' + }, + { + pid: 101, + ppid: 100, + name: 'node.exe', + commandLine: 'node C:\\repo\\server.js' + }, + { + pid: 102, + ppid: 100, + name: 'codex.exe', + commandLine: 'codex' } - ) + ]) await expect(resolveAgentForegroundProcess(100, 'node.exe')).resolves.toBe('node.exe') }) it('fails closed for ambiguous Windows shell-rooted agent descendants', async () => { Object.defineProperty(process, 'platform', { value: 'win32' }) - execFileMock.mockImplementation( - (_cmd: string, _args: string[], _opts: unknown, cb: unknown) => { - const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void - callback(null, { - stdout: [ - 'CommandLine=powershell.exe', - 'Name=powershell.exe', - 'ParentProcessId=99', - 'ProcessId=100', - '', - 'CommandLine=node C:\\Users\\dev\\AppData\\Roaming\\npm\\node_modules\\@openai\\codex\\bin\\codex.js', - 'Name=node.exe', - 'ParentProcessId=100', - 'ProcessId=101', - '', - 'CommandLine=node C:\\Users\\dev\\AppData\\Roaming\\npm\\node_modules\\@google\\gemini-cli\\bundle\\gemini.mjs', - 'Name=node.exe', - 'ParentProcessId=100', - 'ProcessId=102', - '' - ].join('\r\n'), - stderr: '' - }) + mockWindowsRows([ + { + 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\\node_modules\\@openai\\codex\\bin\\codex.js' + }, + { + pid: 102, + ppid: 100, + name: 'node.exe', + commandLine: + 'node C:\\Users\\dev\\AppData\\Roaming\\npm\\node_modules\\@google\\gemini-cli\\bundle\\gemini.mjs' } - ) + ]) await expect(resolveAgentForegroundProcess(100, 'powershell.exe')).resolves.toBe( 'powershell.exe' @@ -616,28 +476,26 @@ describe('resolveAgentForegroundProcess', () => { it('filters detached agents before resolving an otherwise ambiguous ConPTY tree', async () => { Object.defineProperty(process, 'platform', { value: 'win32' }) - mockPs( - windowsProcessJsonRows([ - { - CommandLine: 'powershell.exe', - Name: 'powershell.exe', - ParentProcessId: 99, - ProcessId: 100 - }, - { - CommandLine: 'droid', - Name: 'droid.exe', - ParentProcessId: 100, - ProcessId: 101 - }, - { - CommandLine: 'agy', - Name: 'agy.exe', - ParentProcessId: 100, - ProcessId: 102 - } - ]) - ) + mockWindowsRows([ + { + pid: 100, + ppid: 99, + name: 'powershell.exe', + commandLine: 'powershell.exe' + }, + { + pid: 101, + ppid: 100, + name: 'droid.exe', + commandLine: 'droid' + }, + { + pid: 102, + ppid: 100, + name: 'agy.exe', + commandLine: 'agy' + } + ]) await expect( resolveAgentForegroundProcessWithAvailability(100, 'powershell.exe', { @@ -649,37 +507,28 @@ describe('resolveAgentForegroundProcess', () => { it('recognizes a Windows shell-rooted agent when only one candidate matches the worktree path', async () => { Object.defineProperty(process, 'platform', { value: 'win32' }) - execFileMock.mockImplementation( - (_cmd: string, _args: string[], _opts: unknown, cb: unknown) => { - const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void - callback(null, { - stdout: [ - 'CommandLine=powershell.exe', - 'CreationDate=20260616110000.000000-000', - 'ExecutablePath=C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe', - 'Name=powershell.exe', - 'ParentProcessId=99', - 'ProcessId=100', - '', - 'CommandLine=node C:\\Users\\dev\\AppData\\Roaming\\npm\\node_modules\\@openai\\codex\\bin\\codex.js --cwd C:\\repo\\orca', - 'CreationDate=20260616110100.000000-000', - 'ExecutablePath=C:\\Program Files\\nodejs\\node.exe', - 'Name=node.exe', - 'ParentProcessId=100', - 'ProcessId=101', - '', - 'CommandLine=node C:\\Users\\dev\\AppData\\Roaming\\npm\\node_modules\\@google\\gemini-cli\\bundle\\gemini.mjs --cwd C:\\repo\\other', - 'CreationDate=20260616110200.000000-000', - 'ExecutablePath=C:\\Program Files\\nodejs\\node.exe', - 'Name=node.exe', - 'ParentProcessId=100', - 'ProcessId=102', - '' - ].join('\r\n'), - stderr: '' - }) + mockWindowsRows([ + { + 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\\node_modules\\@openai\\codex\\bin\\codex.js --cwd C:\\repo\\orca' + }, + { + pid: 102, + ppid: 100, + name: 'node.exe', + commandLine: + 'node C:\\Users\\dev\\AppData\\Roaming\\npm\\node_modules\\@google\\gemini-cli\\bundle\\gemini.mjs --cwd C:\\repo\\other' } - ) + ]) await expect( resolveAgentForegroundProcess(100, 'powershell.exe', { @@ -690,37 +539,26 @@ describe('resolveAgentForegroundProcess', () => { it('recognizes the deepest Windows shell-rooted agent when candidates share one lineage', async () => { Object.defineProperty(process, 'platform', { value: 'win32' }) - execFileMock.mockImplementation( - (_cmd: string, _args: string[], _opts: unknown, cb: unknown) => { - const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void - callback(null, { - stdout: [ - 'CommandLine=powershell.exe', - 'CreationDate=20260616110000.000000-000', - 'ExecutablePath=C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe', - 'Name=powershell.exe', - 'ParentProcessId=99', - 'ProcessId=100', - '', - 'CommandLine=codex --cwd C:\\repo\\orca', - 'CreationDate=20260616110100.000000-000', - 'ExecutablePath=C:\\Users\\dev\\AppData\\Roaming\\npm\\codex.cmd', - 'Name=codex.exe', - 'ParentProcessId=100', - 'ProcessId=101', - '', - 'CommandLine=gemini --cwd C:\\repo\\orca', - 'CreationDate=20260616110200.000000-000', - 'ExecutablePath=C:\\Users\\dev\\AppData\\Roaming\\npm\\gemini.cmd', - 'Name=gemini.exe', - 'ParentProcessId=101', - 'ProcessId=102', - '' - ].join('\r\n'), - stderr: '' - }) + mockWindowsRows([ + { + pid: 100, + ppid: 99, + name: 'powershell.exe', + commandLine: 'powershell.exe' + }, + { + pid: 101, + ppid: 100, + name: 'codex.exe', + commandLine: 'codex --cwd C:\\repo\\orca' + }, + { + pid: 102, + ppid: 101, + name: 'gemini.exe', + commandLine: 'gemini --cwd C:\\repo\\orca' } - ) + ]) await expect( resolveAgentForegroundProcess(100, 'powershell.exe', { @@ -731,37 +569,26 @@ describe('resolveAgentForegroundProcess', () => { it('fails closed for sibling Windows agents that both match the same worktree path', async () => { Object.defineProperty(process, 'platform', { value: 'win32' }) - execFileMock.mockImplementation( - (_cmd: string, _args: string[], _opts: unknown, cb: unknown) => { - const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void - callback(null, { - stdout: [ - 'CommandLine=powershell.exe', - 'CreationDate=20260616110000.000000-000', - 'ExecutablePath=C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe', - 'Name=powershell.exe', - 'ParentProcessId=99', - 'ProcessId=100', - '', - 'CommandLine=codex --cwd C:\\repo\\orca', - 'CreationDate=20260616110100.000000-000', - 'ExecutablePath=C:\\Users\\dev\\AppData\\Roaming\\npm\\codex.cmd', - 'Name=codex.exe', - 'ParentProcessId=100', - 'ProcessId=101', - '', - 'CommandLine=gemini --cwd C:\\repo\\orca', - 'CreationDate=20260616110200.000000-000', - 'ExecutablePath=C:\\Users\\dev\\AppData\\Roaming\\npm\\gemini.cmd', - 'Name=gemini.exe', - 'ParentProcessId=100', - 'ProcessId=102', - '' - ].join('\r\n'), - stderr: '' - }) + mockWindowsRows([ + { + pid: 100, + ppid: 99, + name: 'powershell.exe', + commandLine: 'powershell.exe' + }, + { + pid: 101, + ppid: 100, + name: 'codex.exe', + commandLine: 'codex --cwd C:\\repo\\orca' + }, + { + pid: 102, + ppid: 100, + name: 'gemini.exe', + commandLine: 'gemini --cwd C:\\repo\\orca' } - ) + ]) await expect( resolveAgentForegroundProcess(100, 'powershell.exe', { @@ -772,31 +599,27 @@ describe('resolveAgentForegroundProcess', () => { it('fails closed when Windows has multiple matching wrapper descendants', async () => { Object.defineProperty(process, 'platform', { value: 'win32' }) - execFileMock.mockImplementation( - (_cmd: string, _args: string[], _opts: unknown, cb: unknown) => { - const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void - callback(null, { - stdout: [ - 'CommandLine=powershell.exe', - 'Name=powershell.exe', - 'ParentProcessId=99', - 'ProcessId=100', - '', - 'CommandLine=node C:\\repo\\server.js', - 'Name=node.exe', - 'ParentProcessId=100', - 'ProcessId=101', - '', - 'CommandLine=node C:\\Users\\dev\\AppData\\Roaming\\npm\\node_modules\\@openai\\codex\\bin\\codex.js', - 'Name=node.exe', - 'ParentProcessId=100', - 'ProcessId=102', - '' - ].join('\r\n'), - stderr: '' - }) + mockWindowsRows([ + { + pid: 100, + ppid: 99, + name: 'powershell.exe', + commandLine: 'powershell.exe' + }, + { + pid: 101, + ppid: 100, + name: 'node.exe', + commandLine: 'node C:\\repo\\server.js' + }, + { + pid: 102, + ppid: 100, + name: 'node.exe', + commandLine: + 'node C:\\Users\\dev\\AppData\\Roaming\\npm\\node_modules\\@openai\\codex\\bin\\codex.js' } - ) + ]) await expect(resolveAgentForegroundProcess(100, 'node.exe')).resolves.toBe('node.exe') }) @@ -805,27 +628,25 @@ describe('resolveAgentForegroundProcess', () => { Object.defineProperty(process, 'platform', { value: 'win32' }) await expect(resolveAgentForegroundProcess(100, 'vim.exe')).resolves.toBe('vim.exe') - expect(execFileMock).not.toHaveBeenCalled() + expect(getAllProcessesMock).not.toHaveBeenCalled() }) it('authorizes a fresh Windows agent only when it still belongs to the ConPTY', async () => { Object.defineProperty(process, 'platform', { value: 'win32' }) - mockPs( - windowsProcessJsonRows([ - { - CommandLine: 'powershell.exe', - Name: 'powershell.exe', - ParentProcessId: 99, - ProcessId: 100 - }, - { - CommandLine: 'droid', - Name: 'droid.exe', - ParentProcessId: 100, - ProcessId: 101 - } - ]) - ) + mockWindowsRows([ + { + pid: 100, + ppid: 99, + name: 'powershell.exe', + commandLine: 'powershell.exe' + }, + { + pid: 101, + ppid: 100, + name: 'droid.exe', + commandLine: 'droid' + } + ]) const readWindowsConptyProcessIds = vi.fn(async () => new Set([100, 101, 999])) await expect( @@ -839,22 +660,20 @@ describe('resolveAgentForegroundProcess', () => { it('excludes a detached Windows Droid descendant from byte authority', async () => { Object.defineProperty(process, 'platform', { value: 'win32' }) - mockPs( - windowsProcessJsonRows([ - { - CommandLine: 'powershell.exe', - Name: 'powershell.exe', - ParentProcessId: 99, - ProcessId: 100 - }, - { - CommandLine: 'droid', - Name: 'droid.exe', - ParentProcessId: 100, - ProcessId: 101 - } - ]) - ) + mockWindowsRows([ + { + pid: 100, + ppid: 99, + name: 'powershell.exe', + commandLine: 'powershell.exe' + }, + { + pid: 101, + ppid: 100, + name: 'droid.exe', + commandLine: 'droid' + } + ]) await expect( resolveAgentForegroundProcessWithAvailability(100, 'powershell.exe', { @@ -866,16 +685,14 @@ describe('resolveAgentForegroundProcess', () => { it('does not fork the ConPTY membership helper when no Windows agent is inferred', async () => { Object.defineProperty(process, 'platform', { value: 'win32' }) - mockPs( - windowsProcessJsonRows([ - { - CommandLine: 'powershell.exe', - Name: 'powershell.exe', - ParentProcessId: 99, - ProcessId: 100 - } - ]) - ) + mockWindowsRows([ + { + pid: 100, + ppid: 99, + name: 'powershell.exe', + commandLine: 'powershell.exe' + } + ]) const readWindowsConptyProcessIds = vi.fn(async () => new Set([100, 999])) await expect( diff --git a/src/main/providers/windows-agent-foreground-process-scan-volume.test.ts b/src/main/providers/windows-agent-foreground-process-scan-volume.test.ts index 28a97aa5437..d3717ba89e4 100644 --- a/src/main/providers/windows-agent-foreground-process-scan-volume.test.ts +++ b/src/main/providers/windows-agent-foreground-process-scan-volume.test.ts @@ -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) }) }) diff --git a/src/main/providers/windows-foreground-process-rows.test.ts b/src/main/providers/windows-foreground-process-rows.test.ts index 7c2b8a9b1b6..caaf08f3da0 100644 --- a/src/main/providers/windows-foreground-process-rows.test.ts +++ b/src/main/providers/windows-foreground-process-rows.test.ts @@ -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, 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 | 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) }) }) diff --git a/src/main/providers/windows-foreground-process-rows.ts b/src/main/providers/windows-foreground-process-rows.ts index d5e8f8185cd..83b60eecace 100644 --- a/src/main/providers/windows-foreground-process-rows.ts +++ b/src/main/providers/windows-foreground-process-rows.ts @@ -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 { - 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({ - 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 { - return windowsProcessRowsReader.getFreshSnapshot() +export async function queryWindowsProcessRowsFresh(): Promise { + return (await readWindowsProcessTableFresh()).map(toProcessRow) } export async function queryWindowsProcessDescendants( @@ -63,10 +64,11 @@ export async function queryWindowsProcessDescendants( ): Promise { 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( @@ -218,56 +107,3 @@ function collectDescendants( } return descendants } - -/** Runs the PowerShell/CIM whole-process-table scan; returns null when unavailable. */ -async function queryWindowsProcessesWithPowerShell(): Promise { - 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 { - 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 - } -} diff --git a/src/main/windows-pty-root-identity.test.ts b/src/main/windows-pty-root-identity.test.ts index c4be64e27fd..b3bf6319140 100644 --- a/src/main/windows-pty-root-identity.test.ts +++ b/src/main/windows-pty-root-identity.test.ts @@ -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'])) }) }) diff --git a/src/main/windows/windows-process-table.test.ts b/src/main/windows/windows-process-table.test.ts new file mode 100644 index 00000000000..32ef183ed02 --- /dev/null +++ b/src/main/windows/windows-process-table.test.ts @@ -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) + }) +}) diff --git a/src/main/windows/windows-process-table.ts b/src/main/windows/windows-process-table.ts new file mode 100644 index 00000000000..1a222838312 --- /dev/null +++ b/src/main/windows/windows-process-table.ts @@ -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 { + 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({ + runPs: readNativeRows, + now: () => Date.now() +}) + +/** Cached snapshot, refreshed on the shared TTL. */ +export function readWindowsProcessTable(): Promise { + 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 { + 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 +} diff --git a/src/relay/pty-shell-utils.test.ts b/src/relay/pty-shell-utils.test.ts index d4f69f4dd4b..ac913e689f0 100644 --- a/src/relay/pty-shell-utils.test.ts +++ b/src/relay/pty-shell-utils.test.ts @@ -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( platform: NodeJS.Platform, run: () => T | Promise @@ -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') }) diff --git a/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt b/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt index c01838dfa4f..22a1b165060 100644 --- a/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt +++ b/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt @@ -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