mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
* 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>
553 lines
23 KiB
TypeScript
553 lines
23 KiB
TypeScript
import { readFileSync } from 'node:fs'
|
|
import { createRequire } from 'node:module'
|
|
import { createProcessTableSnapshotReader } from '../../shared/process-table-snapshot-reader'
|
|
import { reportWindowsCommandLineRecoveryHealth } from './windows-command-line-recovery-health'
|
|
import { readWindowsProcessRowsWithCim } from './windows-process-table-cim-scan'
|
|
|
|
/**
|
|
* The only place Orca reads the Windows process table.
|
|
*
|
|
* Every previous reader forked `powershell.exe` to run a `Get-CimInstance
|
|
* Win32_Process` scan (with a `wmic` fallback that Windows 11 24H2 has
|
|
* removed). Seven of them existed, on independent cadences. That is why:
|
|
*
|
|
* - a PowerShell Transcription policy recorded ~289 GB across 1.4 million
|
|
* files, because a scan ran every ~2 seconds (#15209);
|
|
* - a Group Policy or AV block turned a process query into "unavailable",
|
|
* which callers read as "no evidence", which is how a PTY tree survived its
|
|
* own teardown (#9045, #10475);
|
|
* - the scan cost ~700 ms and ran per pane, so panes multiplied it (#15036).
|
|
*
|
|
* A Toolhelp32 snapshot answers the same question in ~16 ms with no child
|
|
* process at all, so none of those failure modes have anywhere to live.
|
|
*
|
|
* 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.
|
|
*
|
|
* 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
|
|
*
|
|
* 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.
|
|
*/
|
|
|
|
/** Everything a Toolhelp32 walk alone can answer. */
|
|
export type WindowsProcessIdentityRow = {
|
|
pid: number
|
|
ppid: number
|
|
name: 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
|
|
name: string
|
|
commandLine?: string
|
|
creationTimeMs?: number
|
|
}
|
|
|
|
type WindowsProcessTreeModule = {
|
|
ProcessDataFlag: {
|
|
None: number
|
|
CommandLine: number
|
|
CreationTime?: number
|
|
}
|
|
getAllProcesses: (
|
|
callback: (processes: NativeProcessInfo[] | undefined) => void,
|
|
flags?: number
|
|
) => void
|
|
}
|
|
|
|
const requireFromMain = createRequire(__filename)
|
|
|
|
/** `resolve` is optional so a test can inject a bare function for the require alone. */
|
|
type NativeRequire = ((specifier: string) => unknown) & {
|
|
resolve?: (specifier: string) => string
|
|
}
|
|
|
|
// Why injectable: `createRequire` bypasses the module mocker, and the two
|
|
// resolution steps below are the exact thing #15749 shipped untested -- the
|
|
// relay suites replaced the loader wholesale, so nothing exercised the require.
|
|
let requireNative: NativeRequire = requireFromMain
|
|
|
|
/**
|
|
* The bare addon a relay host receives, with no npm package around it.
|
|
*
|
|
* 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. `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: (
|
|
callback: (processes: NativeProcessInfo[] | undefined) => void,
|
|
flags: number
|
|
) => void
|
|
}
|
|
|
|
/**
|
|
* Mirrors the package's enum; the addon takes the raw bit field. `Memory` (1)
|
|
* 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
|
|
|
|
/** Staged beside the relay bundle by build-relay; see RELAY_ARTIFACTS. */
|
|
const RELAY_ADDON_FILENAME = './windows-process-tree.node'
|
|
|
|
/** The import whose absence tells the patched binary from the published prebuilt. */
|
|
const FLAGGED_ADDON_IMPORT = 'ReadProcessMemory'
|
|
|
|
/**
|
|
* Refuse a staged relay addon built from unpatched source.
|
|
*
|
|
* The build asserts this on the artifact it produces, but a relay bundle and the
|
|
* addon beside it are redeployed independently: a host that has not taken a new
|
|
* bundle keeps whatever `.node` is already there, and the published prebuilt is
|
|
* node-addon-api, so it binds cleanly and then opens every process with
|
|
* `PROCESS_VM_READ` to walk its PEB -- the primitive MDE scores as credential
|
|
* dumping. Nothing checked that at load until here.
|
|
*
|
|
* Same predicate as `inspectWindowsProcessTreeAddon` in
|
|
* `config/scripts/windows-process-tree-gyp-rebuild.mjs`, which cannot be
|
|
* imported here: it is install-time tooling that pulls in node-gyp and
|
|
* `child_process`, and this module is bundled into the app and the relay.
|
|
*
|
|
* Falling back to the CIM scan is the correct loss: it is slower, and it is not
|
|
* the thing an EDR quarantines the host for.
|
|
*/
|
|
function stagedRelayAddonIsUnpatched(): boolean {
|
|
// No resolver means an injected test double, so there is no file to inspect.
|
|
// Production always has one, and a require that just succeeded proves the
|
|
// path is readable -- "cannot tell" here is never a real deployment.
|
|
const addonPath = requireNative.resolve?.(RELAY_ADDON_FILENAME)
|
|
if (!addonPath) {
|
|
return false
|
|
}
|
|
try {
|
|
return readFileSync(addonPath).includes(FLAGGED_ADDON_IMPORT)
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
let cachedModule: WindowsProcessTreeModule | null | undefined
|
|
let moduleLoader: () => WindowsProcessTreeModule | null = loadWindowsProcessTree
|
|
let cimScan: () => Promise<WindowsProcessRow[]> = readWindowsProcessRowsWithCim
|
|
|
|
/** Present the bare addon through the same shape as the npm package. */
|
|
function adaptAddon(addon: WindowsProcessTreeAddon): WindowsProcessTreeModule {
|
|
return {
|
|
ProcessDataFlag: PROCESS_DATA_FLAG,
|
|
getAllProcesses: (callback, flags) => addon.getProcessList(callback, flags ?? 0)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolve the native reader, or null where it cannot be used.
|
|
*
|
|
* Two sources, because two very different deployments need it. The desktop app
|
|
* installs the npm package. A relay host has no node_modules of ours at all, so
|
|
* build-relay stages the bare addon next to the bundle and we bind to that.
|
|
*
|
|
* Why tolerate absence: it stays optional and Windows-only, so a macOS/Linux
|
|
* install legitimately has no binary, and a relay built before this artifact
|
|
* existed has no file. Callers must treat null the same way they treat any
|
|
* other unavailable evidence -- `readNativeRows` then falls back to the CIM
|
|
* scan, which needs nothing installed.
|
|
*/
|
|
function loadWindowsProcessTree(): WindowsProcessTreeModule | null {
|
|
if (cachedModule !== undefined) {
|
|
return cachedModule
|
|
}
|
|
if (process.platform !== 'win32') {
|
|
cachedModule = null
|
|
return cachedModule
|
|
}
|
|
try {
|
|
cachedModule = requireNative('@vscode/windows-process-tree') as WindowsProcessTreeModule
|
|
return cachedModule
|
|
} catch {
|
|
// Not an error here: the relay never has the package. Try the staged addon.
|
|
}
|
|
try {
|
|
const addon = requireNative(RELAY_ADDON_FILENAME) as WindowsProcessTreeAddon
|
|
// Why check the shape: a truncated upload or an addon built for another
|
|
// arch can load and still not answer. Binding to it would then reject every
|
|
// read forever, where falling through reaches a scan that works.
|
|
if (typeof addon?.getProcessList !== 'function') {
|
|
/* v8 ignore next 2 */
|
|
cachedModule = null
|
|
return cachedModule
|
|
}
|
|
if (stagedRelayAddonIsUnpatched()) {
|
|
console.warn(
|
|
`[windows-process-table] the addon staged beside the relay bundle still imports ` +
|
|
`${FLAGGED_ADDON_IMPORT}, so it was built from unpatched source and reads every ` +
|
|
'process address space. Refusing it and falling back to the CIM scan; redeploy the ' +
|
|
'relay so the staged addon is rebuilt.'
|
|
)
|
|
cachedModule = null
|
|
return cachedModule
|
|
}
|
|
cachedModule = adaptAddon(addon)
|
|
} catch {
|
|
cachedModule = null
|
|
}
|
|
return cachedModule
|
|
}
|
|
|
|
/**
|
|
* Upper bound on one snapshot.
|
|
*
|
|
* Why any bound at all: the vendored reader sets a module-global
|
|
* `requestInProgress` and clears it only after draining its callback queue,
|
|
* with no try/catch. One throw or one worker that never calls back leaves it
|
|
* latched, every later call enqueues a callback that never fires, and the
|
|
* single-flight cache above then holds a promise that never settles — the
|
|
* process table is dead for the life of the app. The PowerShell reader this
|
|
* replaced self-healed in 3s because execFile owned a timeout; keep that.
|
|
*/
|
|
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)
|
|
}
|
|
|
|
/** 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' && 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 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
|
|
// tree dead. "Unavailable" has to stay distinguishable from "empty".
|
|
return Promise.reject(new Error('windows process table unavailable'))
|
|
}
|
|
if (unreturnedReads.size > 0) {
|
|
return Promise.reject(
|
|
new Error('windows process table is wedged: an earlier read has not returned')
|
|
)
|
|
}
|
|
const readId = ++readSequence
|
|
const readerEpoch = nativeReaderEpoch
|
|
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
|
|
// already recovered.
|
|
let deadline: ReturnType<typeof setTimeout> | undefined
|
|
try {
|
|
deadline = setTimeout(() => {
|
|
// Test resets invalidate deadlines owned by the prior injected reader.
|
|
if (readerEpoch === nativeReaderEpoch) {
|
|
unreturnedReads.add(readId)
|
|
}
|
|
reject(new Error('windows process table timed out'))
|
|
}, WINDOWS_PROCESS_QUERY_TIMEOUT_MS)
|
|
deadline.unref?.()
|
|
native.getAllProcesses((processes) => {
|
|
clearTimeout(deadline)
|
|
// A callback proves this read drained, so stop refusing. Unconditional:
|
|
// dropping an id that was never added is a no-op, and only the read
|
|
// that actually wedged can be holding the gate shut.
|
|
unreturnedReads.delete(readId)
|
|
if (!processes) {
|
|
reject(new Error('windows process table returned no snapshot'))
|
|
return
|
|
}
|
|
// Why check for ourselves: the native snapshot returns an EMPTY list --
|
|
// not an error -- when CreateToolhelp32Snapshot fails, which is the
|
|
// normal outcome under an EDR hook or a restricted token. An empty
|
|
// table reads to callers as "nothing is running", and teardown acts on
|
|
// that by concluding a live PTY root is already gone. Our own pid is
|
|
// unfalsifiably present in any honest snapshot, so this one predicate
|
|
// catches empty, truncated and permission-filtered tables alike.
|
|
if (!processes.some((row) => row.pid === process.pid)) {
|
|
reject(new Error('windows process table is unreadable'))
|
|
return
|
|
}
|
|
// Only meaningful when a command line was actually asked for.
|
|
if ((flags & native.ProcessDataFlag.CommandLine) !== 0) {
|
|
reportWindowsCommandLineRecoveryHealth(processes)
|
|
}
|
|
resolve(processes.map(projection.fromNative))
|
|
}, flags)
|
|
} catch (error) {
|
|
clearTimeout(deadline)
|
|
reject(error instanceof Error ? error : new Error(String(error)))
|
|
}
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Whole-table read for hosts with no native binding (the relay).
|
|
*
|
|
* Applies the same self-presence guard as the native path: a scan that omits
|
|
* our own pid is truncated or permission-filtered, not empty, and must reject
|
|
* so nothing downstream reads it as proof a process died.
|
|
*/
|
|
async function readCimRows(): Promise<WindowsProcessRow[]> {
|
|
const rows = await cimScan()
|
|
if (!rows.some((row) => row.pid === process.pid)) {
|
|
throw new Error('windows process table is unreadable')
|
|
}
|
|
return rows
|
|
}
|
|
|
|
// 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.
|
|
//
|
|
// 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()
|
|
})
|
|
|
|
/**
|
|
* 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 detailedReader.getSnapshot()
|
|
}
|
|
|
|
/**
|
|
* A snapshot taken after this call returns.
|
|
*
|
|
* Identity checks during teardown must not reuse a cached row — it can predate
|
|
* the very process exit it is being asked about.
|
|
*/
|
|
export function readWindowsProcessTableFresh(): Promise<WindowsProcessRow[]> {
|
|
return 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. */
|
|
export function isWindowsProcessTableAvailable(): boolean {
|
|
return moduleLoader() !== null
|
|
}
|
|
|
|
/**
|
|
* PID-reuse-safe ownership needs the native creation-time field, not merely a
|
|
* process list. Older addon builds expose the table without that field; keep
|
|
* structured ownership unavailable on those hosts instead of fabricating proof
|
|
* from a PID.
|
|
*/
|
|
export function isWindowsProcessStartTimeAvailable(): boolean {
|
|
const native = moduleLoader()
|
|
return native !== null && typeof native.ProcessDataFlag.CreationTime === 'number'
|
|
}
|
|
|
|
function resetSnapshotReaders(): void {
|
|
identityReader.reset()
|
|
detailedReader.reset()
|
|
}
|
|
|
|
/**
|
|
* Test-only: substitute the native module.
|
|
*
|
|
* Why an injector and not `vi.mock`: the module is resolved through
|
|
* `createRequire` so a macOS/Linux install can legitimately not have it, and
|
|
* `createRequire` bypasses the module mocker.
|
|
*/
|
|
export function __setWindowsProcessTreeLoaderForTests(
|
|
loader?: () => WindowsProcessTreeModule | null
|
|
): void {
|
|
moduleLoader = loader ?? loadWindowsProcessTree
|
|
cachedModule = undefined
|
|
resetNativeReaderState()
|
|
resetSnapshotReaders()
|
|
}
|
|
|
|
/**
|
|
* Test-only: substitute the require that resolves the package and the addon.
|
|
*
|
|
* Attach a `resolve` to the injected function to also exercise the staged-addon
|
|
* binary check; without one, the loader has no path to inspect.
|
|
*/
|
|
export function __setWindowsProcessTreeRequireForTests(resolve?: NativeRequire): void {
|
|
requireNative = resolve ?? requireFromMain
|
|
moduleLoader = loadWindowsProcessTree
|
|
cachedModule = undefined
|
|
resetNativeReaderState()
|
|
resetSnapshotReaders()
|
|
}
|
|
|
|
/** Test-only: substitute the no-binding PowerShell scan, which spawns a child. */
|
|
export function __setWindowsProcessTableCimScanForTests(
|
|
scan?: () => Promise<WindowsProcessRow[]>
|
|
): void {
|
|
cimScan = scan ?? readWindowsProcessRowsWithCim
|
|
resetSnapshotReaders()
|
|
}
|
|
|
|
/** Test-only: drop the shared snapshots so suites cannot serve each other's rows. */
|
|
export function resetWindowsProcessTableForTests(): void {
|
|
resetSnapshotReaders()
|
|
cachedModule = undefined
|
|
resetNativeReaderState()
|
|
}
|