perf(windows): split the process table into two flag sets (#17866)

* perf(windows): split the process table into two flag sets

MDE flags "suspicious memory activity" on the process-table reader: it
opened a handle into every process on the box and read each one's PEB on
a repeating cadence. Two changes narrow that.

Drop `Memory` outright. It cost a second
OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ) plus
GetProcessMemoryInfo per process, and nothing reads a working set off
this table -- the Resource Manager runs its own sweep, and the addon
stores WorkingSetSize into a DWORD so anything above 4 GB wraps.

Split the rest in two. `readWindowsProcessIdentityTable[Fresh]` is a
bare Toolhelp32 walk with zero per-process handles, and returns
`WindowsProcessIdentityRow`, which has no `command` to read.
`readWindowsProcessTable[Fresh]` keeps the command line for the callers
that match on it. PTY root identity and the owner start-time probe move
to the cheap reader; agent recognition, port attribution, codex turn
processes and structured-TUI matching all genuinely need the command
line and stay.

Two independently single-flighted caches, never one per caller: the
fan-out this module prevents is one scan per caller, and each reader
still serves every caller wanting its flag set. The wedge gate and the
3s deadline stay shared, because both readers call the same addon and
one wedged read latches its one `requestInProgress`. With no binding
there is only the 1.4s PowerShell scan to run, so the identity view
rides the detailed snapshot rather than forking a second one.

Measured on Windows 11, 492 processes (p50/p95): identity 6.3/7.0 ms,
detailed 12.3/13.4 ms, previous memory+commandLine 13.1/14.1 ms.

* fix(windows): serialize native process-table reads across flag sets

The two flag-set readers could both be in flight at once, and the
vendored wrapper does not tolerate that. `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:
identity issued first, both callers got the same array, 0 of 541 rows
with a command line. A detailed read overlapping an identity read
therefore returned a table with every command line empty, which agent
recognition reads as "no agent" -- silently, and only under concurrency.

Nothing already here excluded that. Each snapshot cache single-flights
only within itself, and the wedge set latches only after a read misses
its 3s deadline, so through the healthy ~12ms of a scan neither reader
excluded the other. Overlap is the normal state: panes poll detailed at
750ms while a teardown takes identity snapshots.

`nativeReadGate` admits one native read at a time across both flag sets.
It also fixes the relay path, where `adaptAddon` has no queue at all and
two simultaneous CreateToolhelp32Snapshot calls are the crash the
vendor's queue exists to prevent. Every link settles, so a wedged read
never strands a waiter; the waiter re-checks the wedge and rejects. With
one call outstanding, retention stays bounded at one callback rather
than one per reader.

Also from review:

- The CIM fallback now belongs to the detailed flag set alone, and the
  identity view projects that snapshot through `toIdentityRow`, so an
  identity row carries no command line on a no-binding host either.
- The concurrency test modelled the wrapper's coalescing queue, which
  the previous synchronous mock could not express; verified failing
  without the gate and passing with it.
- `agent-session-process-identity-probe` early-returns when the
  creation-time flag is unavailable, which no shipped addon build
  provides, instead of scanning the table to produce null.
- Corrected the cost framing: Memory took an OpenProcess(...|VM_READ)
  it never read through, so dropping it halves per-process handle opens
  and leaves the PEB/ReadProcessMemory telemetry unchanged.

* test(windows): keep read exclusion across resets and flag each field

Two review follow-ups, both about tests passing for the wrong reason.

`resetNativeReaderState` replaced the read gate with a resolved promise,
so waiters still holding the old chain ran beside reads queued on the
new one. Reachable only from the `__set*ForTests` hooks, which is what
makes it worth fixing: it hands a suite two concurrent calls into its
own mock addon -- the exact condition the concurrency tests exist to
detect. Chain onto the gate instead; every link settles within the
deadline, so the bounded wait that costs is the right trade.

The coalescing mock shaped every field off the CommandLine bit, so an
identity read that did request CreationTime got `creationTimeMs`
stripped. The identity-side assertion was then only `!('command' in
row)`, which a correctly flagged read and a coalesced one satisfy
equally: a future regression losing identity flags under concurrency
would have kept the case green. Gate each field on its own bit and
assert `creationTimeMs` positively, inside the helper both orderings
share.

Concurrency assertions move to a new bare-addon mock. The coalescing
mock's own latch means it can never report more than one call in
flight, so measuring exclusion there proved nothing; the bare addon has
no queue -- like `adaptAddon` on a relay, where re-entering
CreateToolhelp32Snapshot is a real crash -- and makes re-entry visible.

Verified by deletion: restoring `nativeReadGate = Promise.resolve()`
fails the reset case with `expected 2 to be 1`, and restoring the
single-bit mock fails both overlap orderings on `creationTimeMs`.

* docs(windows): count the third test defect in the list that names them

The section opened "Two defects have now shipped", numbered two, then
described the third in its closing paragraph -- a list that reads as a
complete account while quietly omitting one, which is the exact failure
the section exists to warn about. Say three and number it, and note that
the third arrived inside the fix for the first two.

Also record why the creationTimeMs and flags-array assertions are not
redundant, in the doc and beside the assertions: the flags array catches
a read served another flag set's rows, the positional creationTimeMs
check catches field shaping (identity dropping CreationTime, or
toIdentityRow not forwarding it). Neither sees the other's failure.

* docs(windows): stop describing a PEB read this release removed

Every comment here that justified the flag split in terms of PEB reads became
false when the command-line reader moved to the kernel. Left alone, the
enumeration doc contradicted itself inside one file: the flag-set section
described three chained `ReadProcessMemory` calls per process while the
sections below it explained that the addon contains no such primitive and has
no PEB fallback.

The measurement is now attributed rather than merged. Dropping `Memory` halved
the per-process handle opens and nothing else -- both handles carried
`PROCESS_VM_READ` at the time -- and it was replacing the PEB walk that took
`PROCESS_VM_READ` and `ReadProcessMemory` out of the addon. Neither change
substitutes for the other, which is worth keeping straight: the split's
remaining value is the handle itself, not the memory access.

Also adds `relay/windows-port-scan.ts` to the caller table, the one caller this
effort introduced, and records that it reads only pid/name through the detailed
reader -- free while a pane is polling, not free on a headless relay.

* test(windows): pin the fresh links path against the identity TTL cache

The identity and detailed tables are separate snapshot readers with
independent TTLs, so the detailed path's existing freshness guard says
nothing about the ancestry walk's. Cover the identity reader on its own.

---------

Co-authored-by: Orca Worker <orca-worker@localhost>
This commit is contained in:
OrcaWin
2026-09-05 21:42:34 -07:00
committed by GitHub
co-authored by Orca Worker
parent f811ee0740
commit fc5fa16870
9 changed files with 727 additions and 108 deletions
+21 -18
View File
@@ -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
+178 -22
View File
@@ -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
@@ -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])
})
})
@@ -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<readonly WindowsPr
return projectProcessRows(await readWindowsProcessTableFresh())
}
/**
* The same fresh scan for an ancestry walk, which reads only pid/ppid.
*
* Returns identity rows so the command line is not merely unused but absent:
* asking for it costs an `OpenProcess` per process on the box.
*/
export async function queryWindowsProcessLinksFresh(): Promise<WindowsProcessIdentityRow[]> {
return readWindowsProcessIdentityTableFresh()
}
export async function queryWindowsProcessDescendants(
rootPid: number,
options: { fresh?: boolean } = {}
@@ -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<number | null> {
// 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
+2 -2
View File
@@ -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) {
@@ -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 }]
})
}
+293 -14
View File
@@ -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<WindowsProcessIdentityRow[]>,
detailed: Promise<WindowsProcessRow[]>
): Promise<void> {
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<void> {
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()
+179 -49
View File
@@ -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<number>()
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<unknown> = 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<WindowsProcessRow[]> {
/** A flag set and the row shape it can honestly produce. */
type ProcessRowProjection<Row> = {
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<Row[]>
}
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<WindowsProcessIdentityRow> = {
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<WindowsProcessRow> = {
flags: (native) => IDENTITY_PROJECTION.flags(native) | native.ProcessDataFlag.CommandLine,
fromNative: (row) => ({ ...toIdentityRow(row), command: row.commandLine ?? '' }),
cimFallback: readCimRows
}
function ignoreSettlement(): void {}
function readNativeRows<Row>(projection: ProcessRowProjection<Row>): Promise<Row[]> {
const attempt = nativeReadGate.then(() => readOneSnapshot(projection))
nativeReadGate = attempt.then(ignoreSettlement, ignoreSettlement)
return attempt
}
function readOneSnapshot<Row>(projection: ProcessRowProjection<Row>): Promise<Row[]> {
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<WindowsProcessRow[]> {
}
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<WindowsProcessRow[]> {
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<WindowsProcessRow[]> {
// Why still cache: the snapshot is cheap but not free, and a worktree delete
// tears down PTYs 32-wide. The shared TTL + single-in-flight reader collapses
// that burst into one scan, exactly as the PowerShell path had to.
const snapshotReader = createProcessTableSnapshotReader<WindowsProcessRow[]>({
runPs: readNativeRows,
//
// 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<WindowsProcessIdentityRow[]>({
runPs: () => readNativeRows(IDENTITY_PROJECTION),
now: () => Date.now()
})
const detailedReader = createProcessTableSnapshotReader<WindowsProcessRow[]>({
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<WindowsProcessIdentityRow[]> {
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<WindowsProcessRow[]> {
return snapshotReader.getSnapshot()
return detailedReader.getSnapshot()
}
/**
@@ -357,7 +472,17 @@ export function readWindowsProcessTable(): Promise<WindowsProcessRow[]> {
* the very process exit it is being asked about.
*/
export function readWindowsProcessTableFresh(): Promise<WindowsProcessRow[]> {
return snapshotReader.getFreshSnapshot()
return detailedReader.getFreshSnapshot()
}
/** Cached pid/ppid/name snapshot. Prefer this whenever no command line is read. */
export function readWindowsProcessIdentityTable(): Promise<WindowsProcessIdentityRow[]> {
return readIdentityRows(false)
}
/** The identity snapshot, from a scan that starts after this call. */
export function readWindowsProcessIdentityTableFresh(): Promise<WindowsProcessIdentityRow[]> {
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<WindowsProcessRow[]>
): 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()
}