mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
277 lines
11 KiB
TypeScript
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)
|
|
})
|
|
})
|