diff --git a/docs/reference/windows-edr-posture.md b/docs/reference/windows-edr-posture.md index ff3e6d49cc6..24854890fc7 100644 --- a/docs/reference/windows-edr-posture.md +++ b/docs/reference/windows-edr-posture.md @@ -84,10 +84,12 @@ embedded name for the old disk name to contradict. ### Every process gets a handle, on a timer -`src/main/windows/windows-process-table.ts` takes a Toolhelp32 snapshot under -**one** flag set, `CommandLine | CreationTime`, shared by every caller. pid, ppid -and name come out of the snapshot itself and open nothing. `CommandLine` is what -opens a handle: the addon calls `GetProcessCommandLine` per process, which opens +`src/main/windows/windows-process-table.ts` takes a Toolhelp32 snapshot under one +of **two** flag sets: identity (`None | CreationTime`) for callers that read only +pid, ppid and name, and detailed (`+ CommandLine`) for callers that match on a +command line. pid, ppid and name come out of the snapshot itself and open +nothing, so an identity scan opens nothing at all. `CommandLine` is what opens a +handle: the addon calls `GetProcessCommandLine` per process, which opens `PROCESS_QUERY_LIMITED_INFORMATION` — the same right Task Manager takes — and asks the kernel for the string. Upstream it opened `PROCESS_QUERY_INFORMATION | PROCESS_VM_READ` and walked the PEB with three @@ -113,15 +115,15 @@ panes multiplied it (#15036). The native snapshot answers the same question in See [`windows-process-enumeration.md`](./windows-process-enumeration.md). -Asking for fewer fields is cheaper, and the module now asks for the smallest set -that still answers every caller. There is **no** per-flag-set cache split: one -TTL-cached snapshot serves everyone, deliberately, because a split would restore -the per-pane fan-out the cache exists to remove — a 32-wide teardown has to -collapse into one scan. So the cheap identity-only read is not something any -caller can select; every read pays for `CommandLine`. An earlier revision of this -file described a two-cache design with 6.3 ms / 12.3 ms p50 figures at 492 -processes. That design is not in the tree and those numbers describe no code -path here; the figures that do apply are the module's own, in +Asking for fewer fields is cheaper, and each caller now asks for the smallest set +that answers it. There are exactly **two** TTL-cached snapshots, one per flag +set, never one per caller: the fan-out the cache exists to remove is one scan per +_caller_, and each reader still serves every caller wanting its flag set, so a +32-wide teardown still collapses into one scan of each. Teardown identity and the +owner probe select the identity set and therefore open no handles; the per-pane +foreground tracker genuinely needs a command line and still pays for one. A third +cache would need a third flag set, not a third caller. Measured at 492 processes, +p50: identity 6.3 ms, detailed 12.3 ms — see [`windows-process-enumeration.md`](./windows-process-enumeration.md). **How an EDR read it:** a cross-process handle plus a remote memory read against @@ -150,11 +152,12 @@ unpatched source, so "it required cleanly" is not evidence. What to declare to administrators is now one `PROCESS_QUERY_LIMITED_INFORMATION` handle per process on a detailed snapshot and -no remote memory access at all. What this does not narrow is _which_ processes -are asked — a detailed scan still queries every pid, including `lsass.exe`. -Restricting the command-line pass to Orca's own subtree needs job-object -membership as its source of truth (a ppid-derived allowlist would miss the -detached, reparented descendants of #9045 and #10475), and remains unclaimed work. +no remote memory access at all; an identity snapshot opens nothing. What this +does not narrow is _which_ processes are asked — a detailed scan still queries +every pid, including `lsass.exe`. Restricting the command-line pass to Orca's own +subtree needs job-object membership as its source of truth (a ppid-derived +allowlist would miss the detached, reparented descendants of #9045 and #10475), +and remains unclaimed work. ### Encoded, policy-bypassing PowerShell diff --git a/docs/reference/windows-process-enumeration.md b/docs/reference/windows-process-enumeration.md index fb58030be6e..34afb56c8e6 100644 --- a/docs/reference/windows-process-enumeration.md +++ b/docs/reference/windows-process-enumeration.md @@ -16,38 +16,193 @@ table. It wraps a Toolhelp32 snapshot from `@vscode/windows-process-tree`. ```ts import { + readWindowsProcessIdentityTable, + readWindowsProcessIdentityTableFresh, 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. +Each pair is a shared TTL cache plus a `Fresh` variant that starts its scan +after the call. Use `Fresh` for teardown identity, where a cached row can +predate the exit it is being asked about, and the cached one for anything +periodic. -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 +All four **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): +## Two flag sets: ask for a command line only if you read one + +Neither flag is a wider column on the same query. Each is a separate +per-process syscall sequence, and they are not equally expensive to the EDR +watching: + +- `CommandLine` (`process_commandline.cc`) — + `OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION)`, then + `NtQueryInformationProcess(ProcessCommandLineInformation)` twice: once to size + the buffer, once to fill it. The kernel builds the string, so no address space + is opened or read. It used to walk the target's PEB with three chained + `ReadProcessMemory` calls; the patched addon no longer contains that primitive. +- `Memory` (`process.cc`) — retired. It took a **second** `OpenProcess`, and that + one carried `PROCESS_VM_READ`, which it acquired and never used. + +Measured here (541 processes, 405 openable), per detailed scan, before → after +dropping `Memory`: `OpenProcess` 1082 → 541. That halving is all the `Memory` +drop bought on its own — both handles carried `PROCESS_VM_READ` at the time, so +it moved the PEB traffic not at all. Replacing the PEB walk with the kernel +query is what took `PROCESS_VM_READ` and `ReadProcessMemory` out of the addon +altogether; the two changes compose, and neither substitutes for the other. + +So be precise about what these two flag sets buy now. A detailed scan is one +`OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION)` per process and no memory +access at all. What the split buys on top of that is the handle itself: an +identity scan opens nothing. + +So the module exposes two snapshots, and the row types differ so a cheap caller +cannot read what its flag set did not pay for: + +| reader | row type | flags | per-process handles | +| ------------------------------------------ | ---------------------------- | --------------------------- | ------------------- | +| `readWindowsProcessIdentityTable[Fresh]()` | `WindowsProcessIdentityRow` | `None \| CreationTime` | none | +| `readWindowsProcessTable[Fresh]()` | `WindowsProcessRow` | `+ CommandLine` | one `OpenProcess` | + +`Memory` is requested by neither. Nothing reads a working set off this table — +`windows-process-resource-collector.ts` runs its own sweep because it needs +commit and CPU counters in the same pass, and the addon stores `WorkingSetSize` +into a `DWORD` so anything above 4 GB wraps anyway. + +Measured on Windows 11 with 492 processes (p50 / p95): | | p50 | p95 | | -------------------------------- | ------- | ------- | -| pid + ppid + name | 15.9 ms | 17.5 ms | -| + memory + command line | 30.6 ms | 33.7 ms | +| identity (pid + ppid + name) | 6.3 ms | 7.0 ms | +| detailed (+ command line) | 12.3 ms | 13.4 ms | +| _retired_ (+ memory) | 13.1 ms | 14.1 ms | | `Get-CimInstance` via PowerShell | 706 ms | 723 ms | -Those are the module's published figures. The flag set this module actually -requests is `CommandLine | CreationTime` — **not** `Memory`, which cost a second -`OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ)` plus -`GetProcessMemoryInfo` per process (`src/process.cc:47-63`) for a value nothing -read. Dropping it halves the handles a snapshot opens. The remaining set sits -between the two rows above and has not been measured separately; on a real -Windows host, `Get-Counter '\Process(Orca)\Handle Count'` sampled across a -snapshot cadence is the check. +There are exactly **two** caches, never one per caller. The fan-out this module +exists to prevent is one scan per _caller_, and each reader still serves every +caller wanting its flag set, so a 32-wide teardown still collapses into one scan +of each. A third cache would need a third flag set, not a third caller. + +### Only one native read may be in flight, ever + +This is the price of having two flag sets, and it is not optional. + +The npm wrapper **coalesces rather than queues**. `getRawProcessList` pushes the +callback onto one list and calls the addon only when no request is in progress, +so a second concurrent caller's `flags` are **discarded** and it is handed the +first caller's rows. Measured against the real addon: issue identity first, both +callers get the same array, 0 of 541 rows carry a command line. A detailed read +that overlaps an identity read therefore returns a table with **every command +line empty**, and agent recognition reads that as "no agent" — silently, and +only under concurrency. + +Nothing else in this module prevents that. Each snapshot cache single-flights +only within itself (`inFlight` is a closure per reader), and the wedge set +latches only *after* a read misses its 3 s deadline, so through the healthy +~12 ms of a scan neither excludes the other. Overlap is the normal state rather +than an edge case: other panes keep polling detailed at 750 ms while a teardown +takes identity snapshots, and `codex-structured-turn-processes.ts` issues fresh +detailed scans on turn stop. + +`nativeReadGate` serializes every native read across both flag sets. It is also +what makes the relay's bare addon safe: `adaptAddon` has no queue at all, and +two simultaneous `CreateToolhelp32Snapshot` calls are the crash the vendor's +queue exists to prevent. Every link settles — a wedged read still rejects on its +deadline — so a waiter is never stranded; it re-checks the wedge and rejects. + +Because only one native call is ever outstanding, the wedge gate and the 3 s +deadline stay **shared** and retention stays bounded at exactly one callback, +not one per reader. Read ids are module-global and monotonic, so a late callback +can only clear its own wedge. + +`resetNativeReaderState` **chains** onto the gate rather than replacing it. A +replacement would let a waiter still holding the old chain run beside a read +queued on the new one; every link settles within the deadline, so chaining costs +a bounded wait and keeps the exclusion whole. That path is test-only, which is +exactly why it matters — it would otherwise hand a suite two concurrent calls +into its own mock, the condition these tests exist to detect. + +### Testing this module: assert a positive property, on the right mock + +Three defects have now shipped in this file's tests, all the same shape — a case +that passed for a reason other than the one it claimed to check: + +1. A loader that built a **fresh mock per call**, so the coalescing it was meant + to reproduce could never happen. +2. An identity-side assertion of only `!('command' in row)`, which a correctly + flagged read and a coalesced one satisfy equally, so the test would go green + on the very regression it guards. +3. A concurrency assertion placed on the **coalescing** mock, whose own + `requestInProgress` latch means it can never report more than one call in + flight — so it held whether or not this module excluded anything, and passed + against a read gate that had genuinely lost exclusion. + +The third arrived in the fix for the first two, which is the point: this is not a +mistake you make once. + +So: assert what each flag set **did** get, not only what it lacks, and put those +assertions in the helper both orderings run through, or the reverse order keeps +the blind spot. The identity set is checked on `creationTimeMs` because that is +the field it exists to carry. Keep both that check and the flags-array check — +they catch **different** failures and neither is redundant. The flags array +catches a read served another flag set's rows (the coalescing bug); the +positional `creationTimeMs` check catches field shaping — identity dropping +`CreationTime` from its flags, or `toIdentityRow` failing to forward it — which +no flags assertion would notice. + +And pick the mock to match the claim. The coalescing mock models the npm +wrapper's queue semantics and is the only place to assert those. Concurrency has +to be measured against the bare-addon mock, which has no queue and so makes +re-entry observable. + +With no native binding there is only one scan to run and it is the 1.4 s +PowerShell one, so the identity view rides the detailed snapshot — projected +through `toIdentityRow`, so an identity row carries no command line on any host. + +### Which callers need which + +| caller | reads | flag set | +| --------------------------------------------- | ------------------ | -------- | +| `windows-agent-foreground-process.ts` | `command` (agent recognition) | detailed | +| `local-workspace-platform-port-scanner.ts` | `command` (port attribution) | detailed | +| `codex-structured-turn-processes.ts` | `command` (turn-process identity) | detailed | +| `structured-tui-process-identity.ts` | `command` (child match) | detailed | +| `windows-pty-root-identity.ts` | `pid` / `ppid` only | identity | +| `agent-session-process-identity-probe.ts` | `creationTimeMs` only | identity | +| `relay/windows-port-scan.ts` | `name` (port owner label) | detailed | + +`windows-port-scan.ts` is the one mismatch in the table: it reads only `pid` and +`name`, which the identity set answers, but it calls the detailed reader. On a +host with a live pane that costs nothing extra — the detailed snapshot is +already cached — and on a headless relay it pays for a command line no caller +reads. Left as-is deliberately, because moving it to identity would trade that +for a second scan whenever a pane is polling; revisit if the relay ever scans +ports without one. + +The per-pane foreground tracker is the hot one (750 ms / 2 s cadence) and it +genuinely needs the command line, so the repeating per-process `OpenProcess` is +not something the split removes. What the split removes is that handle from +teardown identity and from the owner probe, which now open nothing. + +### `creationTimeMs` does not exist on any shipped build + +Nothing in the repo supplies a `CreationTime` flag. The package enum is +`None`/`Memory`/`CommandLine`, `process_worker.cc` emits no `creationTimeMs`, +the vendored patch adds none, and `adaptAddon`'s `PROCESS_DATA_FLAG` lacks the +bit. So `creationTimeMs` is always `undefined` in production and +`isWindowsProcessStartTimeAvailable()` is always `false` — a latent product gap +that predates the split and needs its own owner. + +Two consequences. `IDENTITY_PROJECTION.flags` evaluates to `0` today, so the +identity reader really does open zero handles. And +`agent-session-process-identity-probe.ts` early-returns on +`isWindowsProcessStartTimeAvailable()` rather than scanning the whole table to +produce `null`. Do not build anything on Windows start time working. Those CIM numbers are from a 1050-process host. The scan scales with process count: on a 1486-process Windows SSH host it measured **1.36 s** and produced @@ -345,9 +500,10 @@ ownership, and CPU accounting in the memory collector — still reads it through its own query. Those callers are not migrated. Committed private bytes have no equivalent either, and the one memory value the -snapshot _can_ carry is unusable for the sizes Orca now sees: `process.cc` stores -`pmc.WorkingSetSize` into a `DWORD`, so anything above 4 GB wraps. That is the -second reason `windows-process-resource-collector.ts` still runs its own +addon can produce is unusable for the sizes Orca now sees: `process.cc` stores +`pmc.WorkingSetSize` into a `DWORD`, so anything above 4 GB wraps — which is why +neither flag set asks for it. That is the second reason +`windows-process-resource-collector.ts` still runs its own `Get-CimInstance` sweep — it needs `PageFileUsage` (commit) and the CPU-time counters in the same pass. Migrating it to the native table would cost both, and it is why this module no longer sets the `Memory` flag at all: the field had no diff --git a/src/main/providers/windows-foreground-process-rows.test.ts b/src/main/providers/windows-foreground-process-rows.test.ts index 924c81789ce..42330dd6fe8 100644 --- a/src/main/providers/windows-foreground-process-rows.test.ts +++ b/src/main/providers/windows-foreground-process-rows.test.ts @@ -12,9 +12,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const getAllProcessesMock = vi.fn() -import { __setWindowsProcessTreeLoaderForTests } from '../windows/windows-process-table' +import { + __setWindowsProcessTreeLoaderForTests, + readWindowsProcessIdentityTable +} from '../windows/windows-process-table' import { queryWindowsProcessDescendants, + queryWindowsProcessLinksFresh, queryWindowsProcessRowsFresh, resetWindowsProcessRowsSnapshotForTests } from './windows-foreground-process-rows' @@ -117,4 +121,25 @@ describe('windows process rows', () => { expect(scanCount()).toBe(2) }) + + it('never answers the ancestry links from the identity TTL cache either', async () => { + // The identity table is a second reader with its own TTL, so the freshness + // the ancestry walk depends on has to be pinned on its own. + await readWindowsProcessIdentityTable() + getAllProcessesMock.mockImplementation((cb: (rows: unknown) => void) => { + cb(withSelf([{ pid: 300, ppid: 100, name: 'node.exe' }])) + }) + // Proves the cache the fresh read below ignores is live, not merely expired. + expect((await readWindowsProcessIdentityTable()).map((row) => row.pid)).toEqual([ + process.pid, + 100, + 200 + ]) + expect(scanCount()).toBe(1) + + const links = await queryWindowsProcessLinksFresh() + + expect(scanCount()).toBe(2) + expect(links.map((row) => row.pid)).toEqual([process.pid, 300]) + }) }) diff --git a/src/main/providers/windows-foreground-process-rows.ts b/src/main/providers/windows-foreground-process-rows.ts index e8320a6d00a..16f01d5fbe7 100644 --- a/src/main/providers/windows-foreground-process-rows.ts +++ b/src/main/providers/windows-foreground-process-rows.ts @@ -1,8 +1,10 @@ import { collectDescendantsFromIndex, getProcessTableIndex } from '../../shared/process-table-index' import { + readWindowsProcessIdentityTableFresh, readWindowsProcessTable, readWindowsProcessTableFresh, resetWindowsProcessTableForTests, + type WindowsProcessIdentityRow, type WindowsProcessRow as NativeWindowsProcessRow } from '../windows/windows-process-table' @@ -62,6 +64,16 @@ export async function queryWindowsProcessRowsFresh(): Promise { + return readWindowsProcessIdentityTableFresh() +} + export async function queryWindowsProcessDescendants( rootPid: number, options: { fresh?: boolean } = {} diff --git a/src/main/runtime/agent-session-process-identity-probe.ts b/src/main/runtime/agent-session-process-identity-probe.ts index 51577048d31..5d441782ca8 100644 --- a/src/main/runtime/agent-session-process-identity-probe.ts +++ b/src/main/runtime/agent-session-process-identity-probe.ts @@ -15,7 +15,10 @@ import type { } from '../../shared/agent-session-lease-adjudication' import type { AgentSessionProcessIdentity } from '../../shared/agent-session-record' import { runProcess } from '../../shared/child-process/run-process' -import { readWindowsProcessTableFresh } from '../windows/windows-process-table' +import { + isWindowsProcessStartTimeAvailable, + readWindowsProcessIdentityTableFresh +} from '../windows/windows-process-table' /** Start times drift by scheduler granularity and clock reads; compare with a tolerance. */ export const PROCESS_START_TIME_TOLERANCE_MS = 2_000 @@ -111,8 +114,17 @@ async function readDarwinProcessStartTimesMs( } async function readWindowsProcessStartTimeMs(pid: number): Promise { + // No shipped addon build exposes the creation-time flag, so without this the + // whole table gets scanned to produce `null` every time. + if (!isWindowsProcessStartTimeAvailable()) { + return null + } try { - const row = (await readWindowsProcessTableFresh()).find((candidate) => candidate.pid === pid) + // Identity flag set: only the creation time is read, so no command line is + // worth an `OpenProcess` per process here. + const row = (await readWindowsProcessIdentityTableFresh()).find( + (candidate) => candidate.pid === pid + ) return row?.creationTimeMs ?? null } catch { return null diff --git a/src/main/windows-pty-root-identity.ts b/src/main/windows-pty-root-identity.ts index c99224cb72a..28c632682d8 100644 --- a/src/main/windows-pty-root-identity.ts +++ b/src/main/windows-pty-root-identity.ts @@ -1,4 +1,4 @@ -import { queryWindowsProcessRowsFresh } from './providers/windows-foreground-process-rows' +import { queryWindowsProcessLinksFresh } from './providers/windows-foreground-process-rows' import { readOrcaChromiumProcessPids } from './orca-chromium-process-pids' /** @@ -138,7 +138,7 @@ export async function verifyWindowsTreeKillTarget( return 'unknown' } const rows = await readLinksBeforeDeadline( - deps.readRows ?? queryWindowsProcessRowsFresh, + deps.readRows ?? queryWindowsProcessLinksFresh, deps.timeoutMs ?? WINDOWS_ROOT_IDENTITY_TIMEOUT_MS ) if (!rows) { diff --git a/src/main/windows/windows-process-table-cim-scan.ts b/src/main/windows/windows-process-table-cim-scan.ts index 213f157f63b..b8d654ce238 100644 --- a/src/main/windows/windows-process-table-cim-scan.ts +++ b/src/main/windows/windows-process-table-cim-scan.ts @@ -75,6 +75,8 @@ export function parseWindowsCimProcessRows(stdout: string): WindowsProcessRow[] return [] } const name = fieldAsString(row.Name) + // No working set: Win32_Process reports one, but nothing reads memory off + // this table and asking widens an already costly scan. return [{ pid, ppid, name, command: fieldAsString(row.CommandLine) || name }] }) } diff --git a/src/main/windows/windows-process-table.test.ts b/src/main/windows/windows-process-table.test.ts index 96da6fcb4ef..bb5eda24385 100644 --- a/src/main/windows/windows-process-table.test.ts +++ b/src/main/windows/windows-process-table.test.ts @@ -8,12 +8,20 @@ import { __setWindowsProcessTreeRequireForTests, isWindowsProcessTableAvailable, isWindowsProcessStartTimeAvailable, + readWindowsProcessIdentityTable, + readWindowsProcessIdentityTableFresh, readWindowsProcessTable, readWindowsProcessTableFresh, - resetWindowsProcessTableForTests + resetWindowsProcessTableForTests, + type WindowsProcessIdentityRow, + type WindowsProcessRow } from './windows-process-table' import { resetWindowsCommandLineRecoveryHealthForTests } from './windows-command-line-recovery-health' +/** None | CreationTime, and CommandLine on top of it. Memory (1) is never asked for. */ +const IDENTITY_FLAGS = 4 +const DETAILED_FLAGS = 6 + const getAllProcesses = vi.fn() // A real snapshot always contains the querying process; the reader rejects a @@ -21,23 +29,115 @@ const getAllProcesses = vi.fn() // returns -- an empty list rather than an error. It also always carries our own // command line, since a process can always open itself -- an empty one there is // the host-wide-refusal signal, not a fixture detail. -const SELF = { pid: process.pid, ppid: 0, name: 'vitest.exe', commandLine: 'vitest.exe --run' } -const NATIVE = [ +type NativeRow = { + pid: number + ppid: number + name: string + commandLine?: string + creationTimeMs?: number +} + +const SELF: NativeRow = { pid: process.pid, ppid: 0, name: 'vitest.exe', commandLine: 'vitest.exe --run' } +const NATIVE: NativeRow[] = [ SELF, { pid: 100, ppid: 4, name: 'orca.exe', commandLine: '"C:/a b/orca.exe" --x', - memory: 4096, creationTimeMs: 1_700_000_000_000 } ] +/** + * The vendored wrapper, faithfully: one `requestInProgress` latch over a shared + * callback queue, resolved asynchronously. A second caller that arrives while a + * request is in flight has its `flags` DISCARDED and is served the first + * caller's rows -- the defect this module's read gate has to exclude. A + * synchronous mock cannot express it, because nothing ever overlaps. + */ +let coalescingCalls: { flags: number }[] = [] +let maxConcurrentNativeCalls = 0 + +function coalescingModule(): { + ProcessDataFlag: { None: number; Memory: number; CommandLine: number; CreationTime: number } + getAllProcesses: (cb: (rows: NativeRow[] | undefined) => void, flags?: number) => void +} { + let requestInProgress = false + const queue: ((rows: NativeRow[]) => void)[] = [] + return { + ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2, CreationTime: 4 }, + getAllProcesses: (cb, flags) => { + queue.push(cb) + if (requestInProgress) { + return + } + requestInProgress = true + coalescingCalls.push({ flags: flags ?? 0 }) + // The rows the addon would produce for exactly these flags. Each field is + // gated on its OWN bit: reusing the CommandLine bit for both would strip + // creationTimeMs from an identity read that did request CreationTime, and + // no case could then tell a served-someone-else's-rows bug from a + // correctly-shaped cheap read. + const requested = flags ?? 0 + const rows: NativeRow[] = NATIVE.map((row) => ({ + pid: row.pid, + ppid: row.ppid, + name: row.name, + ...(requested & 2 && row.commandLine !== undefined ? { commandLine: row.commandLine } : {}), + ...(requested & 4 && row.creationTimeMs !== undefined + ? { creationTimeMs: row.creationTimeMs } + : {}) + })) + setTimeout(() => { + while (queue.length) { + queue.splice(0).forEach((callback) => callback(rows)) + } + requestInProgress = false + }, 0) + } + } +} + +/** One instance for the whole test: the latch it models is module-global. */ +function installCoalescingModule(): void { + const native = coalescingModule() + __setWindowsProcessTreeLoaderForTests(() => native) +} + +/** + * The relay's bare addon: `adaptAddon` over `getProcessList`, with no queue of + * any kind. Two simultaneous `CreateToolhelp32Snapshot` calls are the crash the + * vendor's queue exists to prevent, so here re-entry is observable rather than + * silently absorbed. + * + * Concurrency has to be measured against this and never against the coalescing + * mock, whose own latch means it can only ever report one call in flight -- an + * assertion that holds whether or not this module excludes anything. + */ +function installBareAddonModule(): void { + let inFlight = 0 + const native = { + ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2, CreationTime: 4 }, + getAllProcesses: (cb: (rows: NativeRow[] | undefined) => void, flags?: number) => { + coalescingCalls.push({ flags: flags ?? 0 }) + inFlight += 1 + maxConcurrentNativeCalls = Math.max(maxConcurrentNativeCalls, inFlight) + setTimeout(() => { + inFlight -= 1 + cb(NATIVE) + }, 0) + } + } + __setWindowsProcessTreeLoaderForTests(() => native) +} + describe('windows process table', () => { let platform: PropertyDescriptor | undefined beforeEach(() => { + coalescingCalls = [] + maxConcurrentNativeCalls = 0 getAllProcesses.mockReset() getAllProcesses.mockImplementation((cb: (rows: unknown) => void) => cb(NATIVE)) platform = Object.getOwnPropertyDescriptor(process, 'platform') @@ -69,13 +169,157 @@ describe('windows process table', () => { ]) }) - it('requests the command line and creation time, never memory', async () => { + it('asks for the command line but never for memory', async () => { + // Memory costs a second OpenProcess(PROCESS_VM_READ) per process and no + // caller reads a working set off this table. await readWindowsProcessTableFresh() - // CommandLine (2) | CreationTime (4). The Memory bit (1) stays clear: the - // addon opens a second PROCESS_VM_READ handle per process to serve it and - // nothing reads a working set off this table. - expect(getAllProcesses.mock.calls[0]?.[1]).toBe(6) - expect((getAllProcesses.mock.calls[0]?.[1] as number) & 1).toBe(0) + expect(getAllProcesses.mock.calls[0]?.[1]).toBe(DETAILED_FLAGS) + }) + + it('reads the identity table with no per-process handle flag at all', async () => { + await readWindowsProcessIdentityTableFresh() + expect(getAllProcesses.mock.calls[0]?.[1]).toBe(IDENTITY_FLAGS) + }) + + it('drops the command line from identity rows rather than leaving it empty', async () => { + const rows = await readWindowsProcessIdentityTableFresh() + expect(rows).toEqual([ + { pid: process.pid, ppid: 0, name: 'vitest.exe' }, + { pid: 100, ppid: 4, name: 'orca.exe', creationTimeMs: 1_700_000_000_000 } + ]) + expect(rows.every((row) => !('command' in row))).toBe(true) + }) + + it('collapses a 32-wide burst into one scan per flag set', async () => { + installCoalescingModule() + const [identity, detailed] = await Promise.all([ + Promise.all(Array.from({ length: 16 }, () => readWindowsProcessIdentityTable())), + Promise.all(Array.from({ length: 16 }, () => readWindowsProcessTable())) + ]) + expect(coalescingCalls.map((call) => call.flags).sort()).toEqual([ + IDENTITY_FLAGS, + DETAILED_FLAGS + ]) + expect(identity).toHaveLength(16) + expect(detailed).toHaveLength(16) + }) + + // The npm wrapper coalesces rather than queues: a second concurrent caller's + // flags are discarded and it is served the first caller's rows. Overlapping an + // identity read with a detailed one therefore used to hand agent recognition a + // table with every command line empty. + async function expectEachViewGotItsOwnFlags( + identity: Promise, + detailed: Promise + ): Promise { + const [identityRows, detailedRows] = await Promise.all([identity, detailed]) + expect(detailedRows.some((row) => row.command === '"C:/a b/orca.exe" --x')).toBe(true) + expect(identityRows.every((row) => !('command' in row))).toBe(true) + // Both sets carry what their own flags asked for. Not redundant with the + // flags check below: that one catches a read served the OTHER set's rows, + // this one catches field shaping -- identity dropping CreationTime from its + // flags, or toIdentityRow failing to forward it. Neither sees the other's + // failure, so keep both. + expect(identityRows.map((row) => row.creationTimeMs)).toEqual([undefined, 1_700_000_000_000]) + expect(detailedRows.map((row) => row.creationTimeMs)).toEqual([undefined, 1_700_000_000_000]) + // Two calls, each with its own flags. Concurrency is asserted separately, + // against the bare addon: this mock's own latch means it could never report + // more than one call in flight, whatever this module did. + expect(coalescingCalls.map((call) => call.flags).sort()).toEqual([ + IDENTITY_FLAGS, + DETAILED_FLAGS + ]) + } + + it('gives each flag set its own data when the identity read is issued first', async () => { + installCoalescingModule() + const identity = readWindowsProcessIdentityTableFresh() + const detailed = readWindowsProcessTableFresh() + await expectEachViewGotItsOwnFlags(identity, detailed) + }) + + it('gives each flag set its own data when the detailed read is issued first', async () => { + installCoalescingModule() + const detailed = readWindowsProcessTableFresh() + const identity = readWindowsProcessIdentityTableFresh() + await expectEachViewGotItsOwnFlags(identity, detailed) + }) + + /** Microtasks only: the mocks call back on a timer, so nothing completes. */ + async function parkPendingReadsOnTheGate(): Promise { + for (let tick = 0; tick < 20; tick += 1) { + await Promise.resolve() + } + } + + it('never re-enters the bare relay addon when both flag sets overlap', async () => { + installBareAddonModule() + const detailed = readWindowsProcessTableFresh() + const identity = readWindowsProcessIdentityTableFresh() + await Promise.all([detailed, identity]) + expect(coalescingCalls.map((call) => call.flags).sort()).toEqual([ + IDENTITY_FLAGS, + DETAILED_FLAGS + ]) + expect(maxConcurrentNativeCalls).toBe(1) + }) + + it('keeps one read in flight across a test reset', async () => { + // Replacing the gate rather than chaining onto it lets a waiter still + // holding the old chain run beside a read queued on the new one. Reachable + // only from the test hooks -- which is the problem: it hands a suite two + // concurrent calls into its own mock, the exact condition the cases above + // exist to detect. + installBareAddonModule() + const inFlight = readWindowsProcessTableFresh() + const waiter = readWindowsProcessIdentityTableFresh() + await parkPendingReadsOnTheGate() + resetWindowsProcessTableForTests() + const afterReset = readWindowsProcessTableFresh() + + await Promise.allSettled([inFlight, waiter, afterReset]) + expect(maxConcurrentNativeCalls).toBe(1) + }) + + it('does not serve one flag set from the other cache', async () => { + await readWindowsProcessTable() + await readWindowsProcessIdentityTable() + expect(getAllProcesses).toHaveBeenCalledTimes(2) + }) + + it('rejects an empty identity snapshot rather than reporting an idle machine', async () => { + getAllProcesses.mockImplementation((cb: (rows: unknown) => void) => cb([])) + resetWindowsProcessTableForTests() + await expect(readWindowsProcessIdentityTableFresh()).rejects.toThrow(/unreadable/) + }) + + it('applies the deadline to the identity read too', async () => { + vi.useFakeTimers() + getAllProcesses.mockImplementation(() => {}) + resetWindowsProcessTableForTests() + const pending = readWindowsProcessIdentityTableFresh() + const assertion = expect(pending).rejects.toThrow(/timed out/) + await vi.advanceTimersByTimeAsync(3_000) + await assertion + vi.useRealTimers() + }) + + it('shares the wedge gate across flag sets, because they share one addon', async () => { + // One wedged read latches the vendored `requestInProgress` and pins the one + // libuv slot whichever flags asked for it, so a per-flag-set gate would let + // the other reader keep parking callbacks behind it. + vi.useFakeTimers() + getAllProcesses.mockImplementation(() => {}) + resetWindowsProcessTableForTests() + const wedge = readWindowsProcessIdentityTableFresh() + const wedgeAssertion = expect(wedge).rejects.toThrow(/timed out/) + await vi.advanceTimersByTimeAsync(3_000) + await wedgeAssertion + + await expect(readWindowsProcessTableFresh()).rejects.toThrow(/wedged/) + await expect(readWindowsProcessIdentityTableFresh()).rejects.toThrow(/wedged/) + expect(getAllProcesses).toHaveBeenCalledTimes(1) + vi.useRealTimers() }) it('only advertises PID-safe ownership when the native creation-time field exists', () => { @@ -173,6 +417,27 @@ describe('PowerShell fallback when the native binding is absent', () => { expect(cimScan).toHaveBeenCalledTimes(1) }) + it('serves the identity view from the one scan a relay can afford', async () => { + // With no binding there is only one scan to run and it costs ~1.4s and a + // powershell.exe, so the cheap view must ride it rather than fork a second. + __setWindowsProcessTreeLoaderForTests(() => null) + // Projected, not merely widened: an identity row carries no command line on + // any host, so nothing can come to depend on the fallback happening to have + // one. + await expect(readWindowsProcessIdentityTableFresh()).resolves.toEqual([ + { pid: process.pid, ppid: 0, name: 'node.exe' }, + { pid: 200, ppid: process.pid, name: 'claude.exe' } + ]) + await readWindowsProcessTable() + expect(cimScan).toHaveBeenCalledTimes(1) + }) + + it('rejects an identity read that omits our own pid', async () => { + __setWindowsProcessTreeLoaderForTests(() => null) + cimScan.mockResolvedValue([{ pid: 200, ppid: 4, name: 'claude.exe', command: 'claude' }]) + await expect(readWindowsProcessIdentityTableFresh()).rejects.toThrow(/unreadable/) + }) + it('does not engage when the native binding is present', async () => { const getAllProcesses = vi.fn() getAllProcesses.mockImplementation((cb: (rows: unknown) => void) => cb(NATIVE)) @@ -425,7 +690,7 @@ describe('resolving the native reader', () => { expect(isWindowsProcessTableAvailable()).toBe(true) }) - it('asks the addon for the command line but not memory, as the package path does', async () => { + it('asks the addon for the command line, as the package path does', async () => { const addon = addonReturning(NATIVE) __setWindowsProcessTreeRequireForTests((specifier: string) => { if (specifier === ADDON_SPECIFIER) { @@ -434,12 +699,26 @@ describe('resolving the native reader', () => { throw new Error('MODULE_NOT_FOUND') }) await readWindowsProcessTableFresh() - // CommandLine only: a bare snapshot would silently drop the command line - // every agent-recognition caller matches on first, and the relay addon - // exposes no CreationTime bit to add. + // CommandLine alone: a bare snapshot would silently drop the command line + // every agent-recognition caller matches on first, and Memory would add a + // second per-process handle nothing reads. expect(addon.getProcessList).toHaveBeenCalledWith(expect.any(Function), 2) }) + it('asks the addon for nothing per-process on the identity path', async () => { + const addon = addonReturning(NATIVE) + __setWindowsProcessTreeRequireForTests((specifier: string) => { + if (specifier === ADDON_SPECIFIER) { + return addon + } + throw new Error('MODULE_NOT_FOUND') + }) + await readWindowsProcessIdentityTableFresh() + // The relay addon exposes no CreationTime bit, so this is a bare Toolhelp32 + // walk: zero OpenProcess calls. + expect(addon.getProcessList).toHaveBeenCalledWith(expect.any(Function), 0) + }) + it('reaches the CIM scan when neither the package nor the addon is present', async () => { const cimScan = vi .fn() diff --git a/src/main/windows/windows-process-table.ts b/src/main/windows/windows-process-table.ts index 6683770435d..0a1acd7ae1c 100644 --- a/src/main/windows/windows-process-table.ts +++ b/src/main/windows/windows-process-table.ts @@ -21,30 +21,41 @@ import { readWindowsProcessRowsWithCim } from './windows-process-table-cim-scan' * 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 + * Two flag sets, because only some callers need a command line, and exactly one + * native read in flight at a time, because the vendored wrapper coalesces + * differing flags -- see docs/reference/windows-process-enumeration.md. * - * Those are the module's published figures for both extra fields together; the - * only flag set this module asks for is `CommandLine` (+ `CreationTime`, free), - * which sits between the two rows and has not been separately measured. + * Measured on Windows 11 (492 processes), p50 / p95: + * identity pid+ppid+name 6.3 / 7.0 ms 0 OpenProcess + * detailed +commandLine 12.3 / 13.4 ms 1 OpenProcess/process + * (retired) +memory +commandLine 13.1 / 14.1 ms 2 OpenProcess/process + * PowerShell CIM 706 / 723 ms * - * Both Toolhelp32 rows assume the optional `windows-process-tree.node` addon. + * Dropping Memory removed the second per-process handle: it took an + * OpenProcess(...|VM_READ) it never read through. CommandLine's own read is no + * longer a PEB walk either -- the patched addon asks the kernel, so identity is + * now the only flag set that opens nothing at all. + * + * All Toolhelp32 rows assume the optional `windows-process-tree.node` addon. * The desktop bundles it; no released relay carries it, so on an SSH host the * CIM row is the operative number and the child process is not avoided at all. */ -export type WindowsProcessRow = { +/** Everything a Toolhelp32 walk alone can answer. */ +export type WindowsProcessIdentityRow = { pid: number ppid: number name: string - /** Full command line. Empty when the process denied a query handle. */ - command: string /** Process creation time in Unix milliseconds, when the native snapshot provides it. */ creationTimeMs?: number } +/** Adds the kernel-supplied command line. Only ask for this if you read it. */ +export type WindowsProcessRow = WindowsProcessIdentityRow & { + /** Full command line. Empty when the process denied a query handle. */ + command: string +} + type NativeProcessInfo = { pid: number ppid: number @@ -82,9 +93,11 @@ let requireNative: NativeRequire = requireFromMain * * The published package's `lib/index.js` adds only a queue over this call, and * that queue is the wedge this module already defends against: it latches a - * module-global `requestInProgress` with no try/catch. We hold our own - * single-flight and deadline, so binding straight to the addon drops the - * duplicate queue rather than nesting inside it. + * module-global `requestInProgress` with no try/catch. `nativeReadGate` holds + * the mutual exclusion instead -- and must, because this addon has no queue of + * its own and two simultaneous `CreateToolhelp32Snapshot` calls are the crash + * the vendor's queue exists to prevent. With one native call ever outstanding, + * binding straight to the addon drops a duplicate rather than losing a guard. */ type WindowsProcessTreeAddon = { getProcessList: ( @@ -95,7 +108,8 @@ type WindowsProcessTreeAddon = { /** * Mirrors the package's enum; the addon takes the raw bit field. `Memory` (1) - * is listed for completeness and is deliberately never set — see `flags` below. + * is listed for completeness and is deliberately never set — see the projections + * below. */ const PROCESS_DATA_FLAG = { None: 0, Memory: 1, CommandLine: 2 } as const @@ -221,26 +235,122 @@ const WINDOWS_PROCESS_QUERY_TIMEOUT_MS = 3_000 * Reads that missed their deadline and have not called back yet. * Refusing re-entry bounds both vendored callbacks and relay addon workers to * one; read ids keep a late callback from clearing a newer wedge. + * + * One gate for both flag sets, not one each: they call the same addon, so a + * wedged read latches the one `requestInProgress` and pins the one libuv slot + * whichever flags asked for it. Retention stays at exactly one callback rather + * than one per reader because `nativeReadGate` below already admits only one + * native call at a time; read ids are module-global and monotonic, so a late + * callback can only clear its own wedge. */ const unreturnedReads = new Set() let readSequence = 0 let nativeReaderEpoch = 0 +/** + * Admits one native read at a time, across both flag sets. Nothing else does. + * + * The npm wrapper coalesces rather than queues: `getRawProcessList` pushes the + * callback onto one list and only calls the addon when no request is in + * progress, so a second concurrent caller's `flags` are DISCARDED and it is + * handed the first caller's rows. An identity read racing a detailed read + * therefore returns a table with every command line EMPTY, which agent + * recognition reads as "no agent" -- silently, and only under concurrency. + * Measured against the real addon: identity issued first, both callers got the + * same array, 0 of 541 rows with a command line. + * + * Nothing above stops that. Each cache single-flights only within itself + * (`inFlight` is a closure per reader) and the wedge set latches only after a + * read misses its 3s deadline, so through the healthy ~12ms of a scan neither + * excludes the other. Overlap is the normal state, not an edge case: panes poll + * detailed every 750ms while a teardown takes identity snapshots. + * + * It also has to be here for the relay's bare addon, which has no queue at all: + * two simultaneous `CreateToolhelp32Snapshot` calls are the crash the vendor's + * queue exists to prevent. + * + * Every link settles -- a wedged read still rejects on its deadline -- so a + * waiter is never stranded; it re-checks the wedge and rejects instead. + */ +let nativeReadGate: Promise = Promise.resolve() + function resetNativeReaderState(): void { nativeReaderEpoch += 1 unreturnedReads.clear() + // Chain, never replace. Dropping the old chain lets a waiter still holding it + // run against a read queued on the new one -- two concurrent calls into one + // mock addon, which is precisely the coalescing these suites exist to catch. + // Every link settles within the deadline, so the wait this costs is bounded. + nativeReadGate = nativeReadGate.then(ignoreSettlement, ignoreSettlement) } -function readNativeRows(): Promise { +/** A flag set and the row shape it can honestly produce. */ +type ProcessRowProjection = { + flags: (native: WindowsProcessTreeModule) => number + fromNative: (row: NativeProcessInfo) => Row + /** + * The no-binding scan, on the one flag set it can serve. Absent on the other, + * because a relay must never run two `Get-CimInstance` scans at ~1.4s each -- + * `readWindowsProcessIdentityTable` projects the detailed snapshot instead. + */ + cimFallback?: () => Promise +} + +function toIdentityRow(row: { + pid: number + ppid: number + name: string + creationTimeMs?: number +}): WindowsProcessIdentityRow { + return { + pid: row.pid, + ppid: row.ppid, + name: row.name, + ...(typeof row.creationTimeMs === 'number' ? { creationTimeMs: row.creationTimeMs } : {}) + } +} + +/** + * Toolhelp32 and nothing else: no `OpenProcess` per process, so this read has + * none of the shape an EDR scores as walking another process's memory. + */ +const IDENTITY_PROJECTION: ProcessRowProjection = { + flags: (native) => native.ProcessDataFlag.None | (native.ProcessDataFlag.CreationTime ?? 0), + fromNative: toIdentityRow +} + +/** + * Adds, per process, one `OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION)` and an + * `NtQueryInformationProcess(ProcessCommandLineInformation)` -- which is what + * agent recognition and port attribution match on. `Memory` is deliberately + * absent: it took a second handle carrying `PROCESS_VM_READ` and then never read + * through it, and no caller reads a working set off this table (the Resource + * Manager runs its own sweep, and the native field wraps above 4 GB anyway). + */ +const DETAILED_PROJECTION: ProcessRowProjection = { + flags: (native) => IDENTITY_PROJECTION.flags(native) | native.ProcessDataFlag.CommandLine, + fromNative: (row) => ({ ...toIdentityRow(row), command: row.commandLine ?? '' }), + cimFallback: readCimRows +} + +function ignoreSettlement(): void {} + +function readNativeRows(projection: ProcessRowProjection): Promise { + const attempt = nativeReadGate.then(() => readOneSnapshot(projection)) + nativeReadGate = attempt.then(ignoreSettlement, ignoreSettlement) + return attempt +} + +function readOneSnapshot(projection: ProcessRowProjection): Promise { const native = moduleLoader() if (!native) { - if (process.platform === 'win32') { + if (process.platform === 'win32' && projection.cimFallback) { // Why only when the module is absent: a binding that loads is the fast // path even when a read fails or wedges, so a failing native reader must // never silently start forking shells at the caller's poll rate. Absence // is the one condition that can never resolve itself — see // docs/reference/windows-process-enumeration.md. - return readCimRows() + return projection.cimFallback() } // 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 @@ -254,16 +364,7 @@ function readNativeRows(): Promise { } const readId = ++readSequence const readerEpoch = nativeReaderEpoch - // Why CommandLine but not Memory: each flag costs one OpenProcess per process - // inside the addon (CommandLine's is a kernel query, not a memory read), and - // every caller of this table matches on `command`, while nothing reads a - // working set off it -- the Resource Manager runs its own CIM sweep because it - // needs commit and CPU time in one pass, and `process.cc` truncates the working - // set into a DWORD anyway. Dropping Memory halves the per-snapshot handle - // count; the remaining flags stay in ONE flag set because every read shares one - // snapshot, so a 32-wide teardown collapses into a single scan. Splitting the - // cache per field set would restore exactly the fan-out it exists to prevent. - const flags = native.ProcessDataFlag.CommandLine | (native.ProcessDataFlag.CreationTime ?? 0) + const flags = projection.flags(native) return new Promise((resolve, reject) => { // Hoisted so a synchronous throw from getAllProcesses can clear it. An // orphaned timer would otherwise fire later and wedge a reader that had @@ -303,17 +404,7 @@ function readNativeRows(): Promise { if ((flags & native.ProcessDataFlag.CommandLine) !== 0) { reportWindowsCommandLineRecoveryHealth(processes) } - resolve( - processes.map((row) => ({ - pid: row.pid, - ppid: row.ppid, - name: row.name, - command: row.commandLine ?? '', - ...(typeof row.creationTimeMs === 'number' - ? { creationTimeMs: row.creationTimeMs } - : {}) - })) - ) + resolve(processes.map(projection.fromNative)) }, flags) } catch (error) { clearTimeout(deadline) @@ -340,14 +431,38 @@ async function readCimRows(): Promise { // 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, +// +// Why two caches are safe where N would not be: the fan-out this prevents is +// one scan per *caller*, and each reader below still serves every caller that +// wants its flag set, so a 32-wide teardown collapses into one scan per flag +// set. Two is the number of distinct native calls that exist -- a third cache +// would need a third flag set, never a third caller. +const identityReader = createProcessTableSnapshotReader({ + runPs: () => readNativeRows(IDENTITY_PROJECTION), + now: () => Date.now() +}) +const detailedReader = createProcessTableSnapshotReader({ + runPs: () => readNativeRows(DETAILED_PROJECTION), now: () => Date.now() }) -/** Cached snapshot, refreshed on the shared TTL. */ +/** + * With no binding there is only one scan to run and it is the expensive one, so + * the identity view rides the detailed snapshot rather than forking a second + * `powershell.exe` at ~1.4 s a scan. Projected, not merely widened: an identity + * row must not carry a command line on any host. + */ +async function readIdentityRows(fresh: boolean): Promise { + if (moduleLoader() === null) { + const rows = await (fresh ? detailedReader.getFreshSnapshot() : detailedReader.getSnapshot()) + return rows.map(toIdentityRow) + } + return fresh ? identityReader.getFreshSnapshot() : identityReader.getSnapshot() +} + +/** Cached command-line snapshot, refreshed on the shared TTL. */ export function readWindowsProcessTable(): Promise { - return snapshotReader.getSnapshot() + return detailedReader.getSnapshot() } /** @@ -357,7 +472,17 @@ export function readWindowsProcessTable(): Promise { * the very process exit it is being asked about. */ export function readWindowsProcessTableFresh(): Promise { - return snapshotReader.getFreshSnapshot() + return detailedReader.getFreshSnapshot() +} + +/** Cached pid/ppid/name snapshot. Prefer this whenever no command line is read. */ +export function readWindowsProcessIdentityTable(): Promise { + return readIdentityRows(false) +} + +/** The identity snapshot, from a scan that starts after this call. */ +export function readWindowsProcessIdentityTableFresh(): Promise { + return readIdentityRows(true) } /** Whether the native table can be read at all on this host. */ @@ -376,6 +501,11 @@ export function isWindowsProcessStartTimeAvailable(): boolean { return native !== null && typeof native.ProcessDataFlag.CreationTime === 'number' } +function resetSnapshotReaders(): void { + identityReader.reset() + detailedReader.reset() +} + /** * Test-only: substitute the native module. * @@ -389,7 +519,7 @@ export function __setWindowsProcessTreeLoaderForTests( moduleLoader = loader ?? loadWindowsProcessTree cachedModule = undefined resetNativeReaderState() - snapshotReader.reset() + resetSnapshotReaders() } /** @@ -403,7 +533,7 @@ export function __setWindowsProcessTreeRequireForTests(resolve?: NativeRequire): moduleLoader = loadWindowsProcessTree cachedModule = undefined resetNativeReaderState() - snapshotReader.reset() + resetSnapshotReaders() } /** Test-only: substitute the no-binding PowerShell scan, which spawns a child. */ @@ -411,12 +541,12 @@ export function __setWindowsProcessTableCimScanForTests( scan?: () => Promise ): void { cimScan = scan ?? readWindowsProcessRowsWithCim - snapshotReader.reset() + resetSnapshotReaders() } -/** Test-only: drop the shared snapshot so suites cannot serve each other's rows. */ +/** Test-only: drop the shared snapshots so suites cannot serve each other's rows. */ export function resetWindowsProcessTableForTests(): void { - snapshotReader.reset() + resetSnapshotReaders() cachedModule = undefined resetNativeReaderState() }