Files
orca/src/main/windows/windows-process-table.test.ts
T

277 lines
11 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
__setWindowsProcessTableCimScanForTests,
__setWindowsProcessTreeLoaderForTests,
isWindowsProcessTableAvailable,
readWindowsProcessTable,
readWindowsProcessTableFresh,
resetWindowsProcessTableForTests
} from './windows-process-table'
const getAllProcesses = vi.fn()
// A real snapshot always contains the querying process; the reader rejects a
// table without it, because that is what a blocked CreateToolhelp32Snapshot
// returns -- an empty list rather than an error.
const SELF = { pid: process.pid, ppid: 0, name: 'vitest.exe' }
const NATIVE = [
SELF,
{ pid: 100, ppid: 4, name: 'orca.exe', commandLine: '"C:/a b/orca.exe" --x', memory: 4096 }
]
describe('windows process table', () => {
let platform: PropertyDescriptor | undefined
beforeEach(() => {
getAllProcesses.mockReset()
getAllProcesses.mockImplementation((cb: (rows: unknown) => void) => cb(NATIVE))
platform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
__setWindowsProcessTreeLoaderForTests(() => ({
ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2 },
getAllProcesses
}))
})
afterEach(() => {
__setWindowsProcessTreeLoaderForTests()
if (platform) {
Object.defineProperty(process, 'platform', platform)
}
})
it('maps native rows, defaulting an unreadable command line to empty', async () => {
const rows = await readWindowsProcessTableFresh()
expect(rows).toEqual([
{ pid: process.pid, ppid: 0, name: 'vitest.exe', command: '', memoryBytes: undefined },
{
pid: 100,
ppid: 4,
name: 'orca.exe',
command: '"C:/a b/orca.exe" --x',
memoryBytes: 4096
}
])
})
it('requests memory and command line together', async () => {
await readWindowsProcessTableFresh()
expect(getAllProcesses.mock.calls[0]?.[1]).toBe(3)
})
it('serves repeat reads from the shared snapshot', async () => {
await readWindowsProcessTable()
await readWindowsProcessTable()
expect(getAllProcesses).toHaveBeenCalledTimes(1)
})
it('rejects rather than reporting an empty machine when the module is absent', async () => {
// A caller that reads "no processes" acts on it -- by declaring a tree dead,
// or by concluding a shell has no children. Absence must not look like that,
// and neither must a fallback that also fails.
__setWindowsProcessTreeLoaderForTests(() => null)
__setWindowsProcessTableCimScanForTests(async () => {
throw new Error('powershell unavailable')
})
await expect(readWindowsProcessTableFresh()).rejects.toThrow(/powershell unavailable/)
expect(isWindowsProcessTableAvailable()).toBe(false)
})
it('rejects when the snapshot itself fails', async () => {
getAllProcesses.mockImplementation((cb: (rows: unknown) => void) => cb(undefined))
resetWindowsProcessTableForTests()
await expect(readWindowsProcessTableFresh()).rejects.toThrow()
})
it('rejects an empty snapshot rather than reporting an idle machine', async () => {
// CreateToolhelp32Snapshot failing under an EDR hook or a restricted token
// yields an empty vector, not an error. Callers act on "nothing is running"
// by concluding a live PTY root is already gone.
getAllProcesses.mockImplementation((cb: (rows: unknown) => void) => cb([]))
resetWindowsProcessTableForTests()
await expect(readWindowsProcessTableFresh()).rejects.toThrow(/unreadable/)
})
it('rejects when the snapshot never calls back', async () => {
// The vendored reader latches a module-global on a wedge, so without a
// deadline one hang kills the process table for the life of the app.
vi.useFakeTimers()
getAllProcesses.mockImplementation(() => {})
resetWindowsProcessTableForTests()
const pending = readWindowsProcessTableFresh()
const assertion = expect(pending).rejects.toThrow(/timed out/)
await vi.advanceTimersByTimeAsync(3_000)
await assertion
vi.useRealTimers()
})
it('is unavailable off Windows without attempting a require', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' })
__setWindowsProcessTreeLoaderForTests()
expect(isWindowsProcessTableAvailable()).toBe(false)
})
})
// Why this path exists: relay deployment installs only node-pty and
// @parcel/watcher, so a Windows SSH host has no native binding and every read
// used to reject -- which agent recognition reads as "no evidence" forever.
describe('PowerShell fallback when the native binding is absent', () => {
let platform: PropertyDescriptor | undefined
const cimScan = vi.fn()
const CIM_ROWS = [
{ pid: process.pid, ppid: 0, name: 'node.exe', command: 'node relay.js' },
{ pid: 200, ppid: process.pid, name: 'claude.exe', command: 'claude --resume' }
]
beforeEach(() => {
platform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
cimScan.mockReset()
cimScan.mockResolvedValue(CIM_ROWS)
__setWindowsProcessTableCimScanForTests(cimScan)
})
afterEach(() => {
__setWindowsProcessTableCimScanForTests()
__setWindowsProcessTreeLoaderForTests()
if (platform) {
Object.defineProperty(process, 'platform', platform)
}
})
it('engages when the module cannot be required', async () => {
__setWindowsProcessTreeLoaderForTests(() => null)
await expect(readWindowsProcessTableFresh()).resolves.toEqual(CIM_ROWS)
expect(cimScan).toHaveBeenCalledTimes(1)
})
it('does not engage when the native binding is present', async () => {
const getAllProcesses = vi.fn()
getAllProcesses.mockImplementation((cb: (rows: unknown) => void) => cb(NATIVE))
__setWindowsProcessTreeLoaderForTests(() => ({
ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2 },
getAllProcesses
}))
await readWindowsProcessTableFresh()
expect(cimScan).not.toHaveBeenCalled()
})
it('does not engage when a present binding fails its read', async () => {
// A wedged or blocked reader must not silently start forking a shell at the
// caller's poll rate; only absence is unrecoverable.
const getAllProcesses = vi.fn()
getAllProcesses.mockImplementation((cb: (rows: unknown) => void) => cb([]))
__setWindowsProcessTreeLoaderForTests(() => ({
ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2 },
getAllProcesses
}))
await expect(readWindowsProcessTableFresh()).rejects.toThrow(/unreadable/)
expect(cimScan).not.toHaveBeenCalled()
})
it('rejects a scan missing our own pid instead of reporting an idle machine', async () => {
__setWindowsProcessTreeLoaderForTests(() => null)
cimScan.mockResolvedValue([{ pid: 200, ppid: 4, name: 'claude.exe', command: 'claude' }])
await expect(readWindowsProcessTableFresh()).rejects.toThrow(/unreadable/)
})
it('stays off Windows-only: darwin still reports unavailable', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' })
__setWindowsProcessTreeLoaderForTests(() => null)
await expect(readWindowsProcessTableFresh()).rejects.toThrow(/unavailable/)
expect(cimScan).not.toHaveBeenCalled()
})
})
describe('wedge cooldown', () => {
let platform: PropertyDescriptor | undefined
beforeEach(() => {
platform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
})
afterEach(() => {
vi.useRealTimers()
__setWindowsProcessTreeLoaderForTests()
if (platform) {
Object.defineProperty(process, 'platform', platform)
}
})
it('stops calling the reader after a timeout instead of queueing a callback per tick', async () => {
// The vendored reader latches a global while a request is in flight and
// drains its queue only when that request completes. In the wedge this
// guards against it never does, so every retry would add a closure that is
// never called. One probe per cooldown bounds that.
vi.useFakeTimers()
const getAllProcesses = vi.fn(() => {})
__setWindowsProcessTreeLoaderForTests(() => ({
ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2 },
getAllProcesses
}))
const first = readWindowsProcessTableFresh()
const firstAssertion = expect(first).rejects.toThrow(/timed out/)
await vi.advanceTimersByTimeAsync(3_000)
await firstAssertion
expect(getAllProcesses).toHaveBeenCalledTimes(1)
await expect(readWindowsProcessTableFresh()).rejects.toThrow(/cooling down/)
expect(getAllProcesses).toHaveBeenCalledTimes(1)
})
it('lets exactly one caller probe when the cooldown expires', async () => {
// Without claiming the recovery slot before probing, every concurrent
// caller passes the cooldown check at expiry and each enqueues a callback
// into the still-latched native queue -- so each cycle leaks another batch
// rather than bounding it to one probe.
vi.useFakeTimers()
const getAllProcesses = vi.fn(() => {})
__setWindowsProcessTreeLoaderForTests(() => ({
ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2 },
getAllProcesses
}))
const wedge = readWindowsProcessTableFresh()
const wedgeAssertion = expect(wedge).rejects.toThrow(/timed out/)
await vi.advanceTimersByTimeAsync(3_000)
await wedgeAssertion
expect(getAllProcesses).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(30_000)
const attempts = [
readWindowsProcessTableFresh().catch(() => 'rejected'),
readWindowsProcessTableFresh().catch(() => 'rejected'),
readWindowsProcessTableFresh().catch(() => 'rejected')
]
await vi.advanceTimersByTimeAsync(3_000)
await Promise.all(attempts)
// One recovery probe, not three.
expect(getAllProcesses).toHaveBeenCalledTimes(2)
})
it('clears the deadline when the reader throws synchronously', async () => {
// An orphaned timer would fire later and wedge a reader that had recovered.
vi.useFakeTimers()
const getAllProcesses = vi.fn(() => {
throw new Error('addon exploded')
})
__setWindowsProcessTreeLoaderForTests(() => ({
ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2 },
getAllProcesses
}))
await expect(readWindowsProcessTableFresh()).rejects.toThrow(/exploded/)
await vi.advanceTimersByTimeAsync(10_000)
// The recovered reader must answer, not report a wedge left by a dead timer.
__setWindowsProcessTreeLoaderForTests(() => ({
ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2 },
getAllProcesses: (cb: (rows: typeof NATIVE | undefined) => void) => cb(NATIVE)
}))
await expect(readWindowsProcessTableFresh()).resolves.toHaveLength(NATIVE.length)
})
})