mirror of
https://github.com/stablyai/orca.git
synced 2026-10-08 00:02:38 +00:00
* fix(windows): ship the process-table addon to the relocated daemon host The Windows terminal daemon runs from a copy of the app under %LOCALAPPDATA%\Orca\daemon-host\<version>. That copy took node-pty but not @vscode/windows-process-tree, so the daemon's bare require of the addon found nothing and every process-table read (foreground tracking, descendant sweeps) fell back to a powershell.exe Get-CimInstance scan (#16905). - Copy the addon's runtime files (package.json, lib/, the .node binary) into the host; the ~25MB of gyp intermediates beside them are filtered out. - Treat a host missing those files as unmaterialized, so hosts built before this are rebuilt, and skip relocation if the install itself lacks them. - Log the daemon's native/CIM capability at startup and warn once when the process table falls back to CIM. Revives #19525 on current main. * test(windows): locate update-survival loss before relaunch * test(windows): preserve daemon tree before update-survival proof * test(windows): distinguish Electron exit from launcher close timeout * test(windows): verify process exit when inherited pipes delay close * test(windows): trace installer process checks in isolated survival runs * fix(windows): probe process-query capability before installer sweep * fix(windows): match installer probe and process-check profile behavior * fix(windows): use NSIS separators for the process-check include * test(windows): dismiss session-search overlay in survival harness --------- Co-authored-by: m4air <m4air@m4airs-Air.localdomain>
610 lines
25 KiB
TypeScript
610 lines
25 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.
|
|
*
|
|
* Both Toolhelp32 rows predate `CreationTime`, which both flag sets now also
|
|
* ask for and which is unmeasured here: it costs one
|
|
* OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION) plus GetProcessTimes per
|
|
* process, so identity no longer opens nothing at all -- but that pair is far
|
|
* cheaper than either handle the rows above measure.
|
|
*
|
|
* 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
|
|
}
|
|
/**
|
|
* Flag bits the COMPILED addon reports, straight from `addon.cc`. Absent on a
|
|
* build that predates the patch — which is not the same question as the enum
|
|
* above, because pnpm patches the source tree and leaves the tarball's
|
|
* prebuilt `.node` in place.
|
|
*/
|
|
supportedProcessDataFlags?: number
|
|
getProcessCreationTime?: (pid: number) => number | undefined
|
|
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 = {
|
|
getProcessCreationTime?: (pid: number) => number | undefined
|
|
getProcessList: (
|
|
callback: (processes: NativeProcessInfo[] | undefined) => void,
|
|
flags: number
|
|
) => void
|
|
supportedProcessDataFlags?: number
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
* Naming `CreationTime` here only decides what we ASK for; whether the binary
|
|
* answers is `supportedProcessDataFlags`, which the addon reports itself.
|
|
*/
|
|
const PROCESS_DATA_FLAG = { None: 0, Memory: 1, CommandLine: 2, CreationTime: 4 } 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
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Once per process, for the main and relay processes whose console is real. The
|
|
* daemon's stderr is destroyed once it reports ready, so it logs this capability
|
|
* to daemonLog at startup instead (daemon-entry.ts).
|
|
*/
|
|
let warnedAboutCimFallback = 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,
|
|
supportedProcessDataFlags: addon.supportedProcessDataFlags,
|
|
getProcessCreationTime: addon.getProcessCreationTime,
|
|
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
|
|
warnedAboutCimFallback = false
|
|
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.
|
|
if (!warnedAboutCimFallback) {
|
|
warnedAboutCimFallback = true
|
|
console.warn(
|
|
'[windows-process-table] no native binding; falling back to a powershell.exe CIM scan at each caller poll. 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.
|
|
*
|
|
* Why the binary's own answer and not the enum: pnpm patches the package's
|
|
* source tree but leaves the tarball's prebuilt `.node` at the same
|
|
* `build/Release/` path, so a host can hold a patched `lib/index.js` — enum and
|
|
* all — over a binary that ignores flag 4. CI produced exactly that: the enum
|
|
* said available, and every row came back without `creationTimeMs`. Answering
|
|
* true there is worse than answering false: the descendant snapshot then
|
|
* returns null forever and the exit proof latches `unverifiable`, while
|
|
* structured chat believes it has a reaper.
|
|
*/
|
|
export function isWindowsProcessStartTimeAvailable(): boolean {
|
|
const native = moduleLoader()
|
|
return (
|
|
native !== null &&
|
|
((native.supportedProcessDataFlags ?? 0) & PROCESS_DATA_FLAG.CreationTime) !== 0
|
|
)
|
|
}
|
|
|
|
/** Fresh identity for synchronous profile admission; opens one PID without taking a table snapshot. */
|
|
export function readWindowsProcessCreationTime(pid: number): number | null {
|
|
if (process.platform !== 'win32' || !Number.isSafeInteger(pid) || pid <= 0 || pid > 0xffffffff) {
|
|
return null
|
|
}
|
|
try {
|
|
const value = moduleLoader()?.getProcessCreationTime?.(pid)
|
|
return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 ? value : null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
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()
|
|
}
|