mirror of
https://github.com/stablyai/orca.git
synced 2026-10-04 08:02:09 +00:00
Second audit wave, targeting three more junk patterns: - assertion-free cases that run code and assert nothing, so they pass no matter what the code does; - inventory literals re-typed from a production declaration, where the only way the assertion can fail is someone editing one of the two copies; - export key-set and export-shape loops (`typeof x === 'function'` over every export) that restate what TypeScript already enforces. Yield is much smaller than wave 1 on purpose: the assertion-free scanner has a high false-positive rate, because many flagged blocks assert through a shared helper or their oracle is "this must not throw". Those were kept. `mobileWebCheckArgs` in `config/scripts/run-mobile-web-app-checks.mjs` is de-exported — after the inventory comparison went away, nothing outside the module read it.
722 lines
24 KiB
TypeScript
722 lines
24 KiB
TypeScript
import { readFileSync } from 'node:fs'
|
|
import { join } from 'node:path'
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn() }))
|
|
|
|
vi.mock('node:child_process', () => ({ execFile: execFileMock }))
|
|
|
|
import {
|
|
createProcessTableSnapshotReader,
|
|
getProcessTableSnapshot,
|
|
getStrictProcessTableSnapshot,
|
|
getStrictProcessTableSnapshotWithAge,
|
|
PROCESS_TABLE_EVIDENCE_BUDGET_MS,
|
|
PS_MAX_BUFFER_BYTES,
|
|
PS_TIMEOUT_MS,
|
|
resetProcessTableSnapshotForTests
|
|
} from './process-table-snapshot-reader'
|
|
import {
|
|
parseProcessTableRows,
|
|
parseStrictProcessTableRows,
|
|
ProcessTableCaptureError
|
|
} from './process-table-snapshot'
|
|
import {
|
|
buildProcessTableIndex,
|
|
getProcessTableIndex,
|
|
type ProcessTableIndexStats
|
|
} from './process-table-index'
|
|
|
|
function deferred<T>(): {
|
|
promise: Promise<T>
|
|
resolve: (v: T) => void
|
|
reject: (e: unknown) => void
|
|
} {
|
|
let resolve!: (v: T) => void
|
|
let reject!: (e: unknown) => void
|
|
const promise = new Promise<T>((res, rej) => {
|
|
resolve = res
|
|
reject = rej
|
|
})
|
|
return { promise, resolve, reject }
|
|
}
|
|
|
|
describe('process-table-snapshot reader', () => {
|
|
it('collapses concurrent calls into a single ps scan', async () => {
|
|
let scans = 0
|
|
const gate = deferred<string>()
|
|
const reader = createProcessTableSnapshotReader({
|
|
runPs: () => {
|
|
scans += 1
|
|
return gate.promise
|
|
},
|
|
now: () => 0
|
|
})
|
|
|
|
const a = reader.getSnapshot()
|
|
const b = reader.getSnapshot()
|
|
const c = reader.getSnapshot()
|
|
gate.resolve('ps-output')
|
|
|
|
expect(await a).toBe('ps-output')
|
|
expect(await b).toBe('ps-output')
|
|
expect(await c).toBe('ps-output')
|
|
// Why: the in-flight promise is shared, so a burst of panes inspecting at
|
|
// once forks `ps` exactly once.
|
|
expect(scans).toBe(1)
|
|
})
|
|
|
|
it('reuses the cached snapshot within the TTL window', async () => {
|
|
let scans = 0
|
|
let clock = 0
|
|
const reader = createProcessTableSnapshotReader({
|
|
runPs: () => {
|
|
scans += 1
|
|
return Promise.resolve(`scan-${scans}`)
|
|
},
|
|
now: () => clock,
|
|
ttlMs: 500
|
|
})
|
|
|
|
expect(await reader.getSnapshot()).toBe('scan-1')
|
|
clock = 499
|
|
expect(await reader.getSnapshot()).toBe('scan-1')
|
|
expect(scans).toBe(1)
|
|
})
|
|
|
|
it('rescans once the TTL expires', async () => {
|
|
let scans = 0
|
|
let clock = 0
|
|
const reader = createProcessTableSnapshotReader({
|
|
runPs: () => {
|
|
scans += 1
|
|
return Promise.resolve(`scan-${scans}`)
|
|
},
|
|
now: () => clock,
|
|
ttlMs: 500
|
|
})
|
|
|
|
expect(await reader.getSnapshot()).toBe('scan-1')
|
|
clock = 500
|
|
expect(await reader.getSnapshot()).toBe('scan-2')
|
|
expect(scans).toBe(2)
|
|
})
|
|
|
|
it('stamps capture time after the scan resolves so a slow ps cannot serve a stale snapshot', async () => {
|
|
let scans = 0
|
|
let clock = 0
|
|
const gate = deferred<string>()
|
|
const reader = createProcessTableSnapshotReader({
|
|
runPs: () => {
|
|
scans += 1
|
|
return scans === 1 ? gate.promise : Promise.resolve(`scan-${scans}`)
|
|
},
|
|
now: () => clock,
|
|
ttlMs: 500
|
|
})
|
|
|
|
const first = reader.getSnapshot()
|
|
// The scan takes 600ms of wall clock to return — longer than the TTL.
|
|
clock = 600
|
|
gate.resolve('scan-1')
|
|
expect(await first).toBe('scan-1')
|
|
|
|
// capturedAt is stamped at now()=600, so a call at 900 is still within TTL.
|
|
clock = 900
|
|
expect(await reader.getSnapshot()).toBe('scan-1')
|
|
expect(scans).toBe(1)
|
|
})
|
|
|
|
it('reports the age of the capture instant, not of the moment ps finished', async () => {
|
|
let clock = 0
|
|
const gate = deferred<string>()
|
|
const reader = createProcessTableSnapshotReader({
|
|
runPs: () => gate.promise,
|
|
now: () => clock,
|
|
ttlMs: 500
|
|
})
|
|
|
|
const first = reader.getSnapshot()
|
|
// A capture that took 4s of wall clock describes the machine as it was 4s ago.
|
|
clock = 4_000
|
|
gate.resolve('scan-1')
|
|
await first
|
|
|
|
// Age used to start at the callback, so a capture older than the destructive
|
|
// consumer's ceiling was admitted as if it had just been taken.
|
|
expect((await reader.getSnapshotWithAge()).capturedAgeMs).toBe(4_000)
|
|
})
|
|
|
|
it('does not cache failures and retries on the next call', async () => {
|
|
let scans = 0
|
|
const reader = createProcessTableSnapshotReader({
|
|
runPs: () => {
|
|
scans += 1
|
|
if (scans === 1) {
|
|
return Promise.reject(new Error('ps timed out'))
|
|
}
|
|
return Promise.resolve('recovered')
|
|
},
|
|
now: () => 0
|
|
})
|
|
|
|
await expect(reader.getSnapshot()).rejects.toThrow('ps timed out')
|
|
// Why: a transient ps failure must not poison the cache — the next
|
|
// inspection re-scans rather than returning a cached error.
|
|
expect(await reader.getSnapshot()).toBe('recovered')
|
|
expect(scans).toBe(2)
|
|
})
|
|
|
|
it('forces a post-request scan even when a same-tick cache exists', async () => {
|
|
let scans = 0
|
|
const reader = createProcessTableSnapshotReader({
|
|
runPs: async () => `scan-${++scans}`,
|
|
now: () => 0
|
|
})
|
|
|
|
expect(await reader.getSnapshot()).toBe('scan-1')
|
|
expect(await reader.getFreshSnapshot()).toBe('scan-2')
|
|
expect(scans).toBe(2)
|
|
})
|
|
|
|
it('shares same-turn fresh requests but queues one scan after a pre-existing scan', async () => {
|
|
let scans = 0
|
|
const first = deferred<string>()
|
|
const second = deferred<string>()
|
|
const reader = createProcessTableSnapshotReader({
|
|
runPs: () => {
|
|
scans += 1
|
|
return scans === 1 ? first.promise : second.promise
|
|
},
|
|
now: () => 0
|
|
})
|
|
|
|
const stale = reader.getSnapshot()
|
|
const freshA = reader.getFreshSnapshot()
|
|
const freshB = reader.getFreshSnapshot()
|
|
expect(scans).toBe(1)
|
|
first.resolve('stale')
|
|
expect(await stale).toBe('stale')
|
|
await Promise.resolve()
|
|
expect(scans).toBe(2)
|
|
second.resolve('fresh')
|
|
expect(await freshA).toBe('fresh')
|
|
expect(await freshB).toBe('fresh')
|
|
expect(scans).toBe(2)
|
|
})
|
|
|
|
it('does not let an ordinary same-turn miss race a queued fresh scan', async () => {
|
|
let scans = 0
|
|
const reader = createProcessTableSnapshotReader({
|
|
runPs: async () => `scan-${++scans}`,
|
|
now: () => 0
|
|
})
|
|
|
|
const fresh = reader.getFreshSnapshot()
|
|
const ordinary = reader.getSnapshot()
|
|
|
|
await expect(Promise.all([fresh, ordinary])).resolves.toEqual(['scan-1', 'scan-1'])
|
|
expect(scans).toBe(1)
|
|
})
|
|
|
|
it('shares one parsed-rows array across a burst so panes do not each re-parse', async () => {
|
|
// Mirrors the POSIX default reader: runPs parses inside the deduped scan, so
|
|
// every caller in the TTL window gets the SAME ProcessTableRow[] instance
|
|
// instead of re-tokenizing identical stdout per pane.
|
|
let parses = 0
|
|
const gate = deferred<ReturnType<typeof parseProcessTableRows>>()
|
|
const reader = createProcessTableSnapshotReader<ReturnType<typeof parseProcessTableRows>>({
|
|
runPs: () => {
|
|
parses += 1
|
|
return gate.promise
|
|
},
|
|
now: () => 0
|
|
})
|
|
|
|
const a = reader.getSnapshot()
|
|
const b = reader.getSnapshot()
|
|
gate.resolve(parseProcessTableRows('100 1 Ss+ /bin/zsh'))
|
|
|
|
const rowsA = await a
|
|
const rowsB = await b
|
|
expect(parses).toBe(1)
|
|
// Reference identity: the burst reuses one parse, not one-per-caller.
|
|
expect(rowsA).toBe(rowsB)
|
|
})
|
|
})
|
|
|
|
describe('shared process-table capture', () => {
|
|
beforeEach(() => {
|
|
execFileMock.mockReset()
|
|
resetProcessTableSnapshotForTests()
|
|
})
|
|
|
|
function mockPsCaptures(...stdouts: string[]): () => number {
|
|
let forks = 0
|
|
execFileMock.mockImplementation(
|
|
(_command: string, _args: string[], _options: unknown, callback: unknown) => {
|
|
const stdout = stdouts[Math.min(forks, stdouts.length - 1)] ?? ''
|
|
forks += 1
|
|
;(callback as (err: unknown, result: { stdout: string; stderr: string }) => void)(null, {
|
|
stdout,
|
|
stderr: ''
|
|
})
|
|
}
|
|
)
|
|
return () => forks
|
|
}
|
|
|
|
it('serves the strict and lenient views from ONE ps fork per TTL window', async () => {
|
|
// Why: both views run byte-identical argv, so separate memoizers would double
|
|
// the relay's idle fork rate — the regression issue #6288 removed.
|
|
const forks = mockPsCaptures('100 1 100 100 Ss+ /bin/zsh\n', '200 1 200 200 Ss+ /bin/bash\n')
|
|
|
|
const [lenient, strict] = await Promise.all([
|
|
getProcessTableSnapshot(),
|
|
getStrictProcessTableSnapshot()
|
|
])
|
|
|
|
expect(forks()).toBe(1)
|
|
expect(lenient.map((row) => row.pid)).toEqual([100])
|
|
expect(strict.map((row) => row.pid)).toEqual([100])
|
|
})
|
|
|
|
it('reuses the cached capture for a later strict read inside the TTL', async () => {
|
|
const forks = mockPsCaptures('100 1 100 100 Ss+ /bin/zsh\n', '200 1 200 200 Ss+ /bin/bash\n')
|
|
|
|
await getProcessTableSnapshot()
|
|
const strict = await getStrictProcessTableSnapshot()
|
|
|
|
expect(forks()).toBe(1)
|
|
expect(strict).toEqual([
|
|
{
|
|
pid: 100,
|
|
ppid: 1,
|
|
pgid: 100,
|
|
tpgid: 100,
|
|
stat: 'Ss+',
|
|
command: '/bin/zsh'
|
|
}
|
|
])
|
|
})
|
|
|
|
it('keeps the lenient view readable when the same capture is strictly unreadable', async () => {
|
|
const forks = mockPsCaptures('100 1 Ss+ /bin/zsh\n')
|
|
|
|
const lenient = await getProcessTableSnapshot()
|
|
await expect(getStrictProcessTableSnapshot()).rejects.toBeInstanceOf(ProcessTableCaptureError)
|
|
|
|
expect(forks()).toBe(1)
|
|
expect(lenient).toEqual([{ pid: 100, ppid: 1, stat: 'Ss+', command: '/bin/zsh' }])
|
|
})
|
|
})
|
|
|
|
describe('parseProcessTableRows', () => {
|
|
it('parses pid/ppid/stat and keeps the full command (including spaces)', () => {
|
|
const rows = parseProcessTableRows(
|
|
['501 1 S /bin/zsh', '600 501 S+ node /path/bin/codex --flag'].join('\n')
|
|
)
|
|
expect(rows).toEqual([
|
|
{ pid: 501, ppid: 1, stat: 'S', command: '/bin/zsh' },
|
|
{
|
|
pid: 600,
|
|
ppid: 501,
|
|
stat: 'S+',
|
|
command: 'node /path/bin/codex --flag'
|
|
}
|
|
])
|
|
})
|
|
|
|
it('tolerates CRLF and skips header/blank/non-matching lines', () => {
|
|
const rows = parseProcessTableRows(' PID PPID STAT COMMAND\r\n42 1 Ss /sbin/launchd\r\n\r\n')
|
|
expect(rows).toEqual([{ pid: 42, ppid: 1, stat: 'Ss', command: '/sbin/launchd' }])
|
|
})
|
|
})
|
|
|
|
describe('parseStrictProcessTableRows', () => {
|
|
it('accepts Linux kernel roots and bracketed comm values', () => {
|
|
const capture = readFileSync(
|
|
join(__dirname, '__fixtures__', 'linux-process-table-kernel-rows.txt'),
|
|
'utf8'
|
|
)
|
|
expect(parseStrictProcessTableRows(capture)).toEqual([
|
|
{ pid: 1, ppid: 0, pgid: 1, tpgid: 0, stat: 'Ss', command: '/sbin/init' },
|
|
{ pid: 2, ppid: 0, pgid: 0, tpgid: -1, stat: 'S', command: '[kthreadd]' },
|
|
{
|
|
pid: 3,
|
|
ppid: 2,
|
|
pgid: 0,
|
|
tpgid: -1,
|
|
stat: 'I',
|
|
command: '[pool_workqueue_release]'
|
|
},
|
|
{
|
|
pid: 4,
|
|
ppid: 2,
|
|
pgid: 0,
|
|
tpgid: -1,
|
|
stat: 'I',
|
|
command: '[kworker/R-rcu_g]'
|
|
},
|
|
{
|
|
pid: 5,
|
|
ppid: 2,
|
|
pgid: 0,
|
|
tpgid: -1,
|
|
stat: 'I',
|
|
command: '[kworker/R-sync_wq]'
|
|
},
|
|
{
|
|
pid: 6,
|
|
ppid: 2,
|
|
pgid: 0,
|
|
tpgid: -1,
|
|
stat: 'I',
|
|
command: '[kworker/R-slub_]'
|
|
},
|
|
{
|
|
pid: 100,
|
|
ppid: 1,
|
|
pgid: 100,
|
|
tpgid: 100,
|
|
stat: 'Ss+',
|
|
command: '/bin/bash -l'
|
|
},
|
|
{
|
|
pid: 101,
|
|
ppid: 100,
|
|
pgid: 101,
|
|
tpgid: 101,
|
|
stat: 'S+',
|
|
command: 'node /opt/codex'
|
|
}
|
|
])
|
|
})
|
|
|
|
it('extracts pgid/tpgid across CRLF framing while retaining command spacing', () => {
|
|
expect(
|
|
parseStrictProcessTableRows(
|
|
' PID PPID PGID TPGID STAT COMMAND\r\n 100 1 100 101 Ss /bin/zsh -l\r\n 101 100 101 101 S+ node /opt/codex --flag value\r\n'
|
|
)
|
|
).toEqual([
|
|
{
|
|
pid: 100,
|
|
ppid: 1,
|
|
pgid: 100,
|
|
tpgid: 101,
|
|
stat: 'Ss',
|
|
command: '/bin/zsh -l'
|
|
},
|
|
{
|
|
pid: 101,
|
|
ppid: 100,
|
|
pgid: 101,
|
|
tpgid: 101,
|
|
stat: 'S+',
|
|
command: 'node /opt/codex --flag value'
|
|
}
|
|
])
|
|
})
|
|
|
|
it('accepts no-controlling-tty sentinels for later unverifiable classification', () => {
|
|
expect(parseStrictProcessTableRows('100 1 100 0 Ss /bin/zsh')).toEqual([
|
|
{
|
|
pid: 100,
|
|
ppid: 1,
|
|
pgid: 100,
|
|
tpgid: 0,
|
|
stat: 'Ss',
|
|
command: '/bin/zsh'
|
|
}
|
|
])
|
|
expect(parseStrictProcessTableRows('100 1 100 -1 Ss /bin/zsh')).toEqual([
|
|
{
|
|
pid: 100,
|
|
ppid: 1,
|
|
pgid: 100,
|
|
tpgid: -1,
|
|
stat: 'Ss',
|
|
command: '/bin/zsh'
|
|
}
|
|
])
|
|
})
|
|
|
|
it('still rejects truncated captures as unreadable', () => {
|
|
expect(() => parseStrictProcessTableRows('100 1 100 100 Ss+')).toThrow(ProcessTableCaptureError)
|
|
})
|
|
|
|
it.each([
|
|
'0 0 0 0 S [invalid-pid]',
|
|
'100 1 -1 100 S [invalid-pgid]',
|
|
'100 1 100 -2 S [invalid-tpgid]'
|
|
])('rejects domain-invalid numeric values (%s)', (capture) => {
|
|
expect(() => parseStrictProcessTableRows(capture)).toThrow(ProcessTableCaptureError)
|
|
})
|
|
|
|
it('rejects an empty or header-only capture as unreadable', () => {
|
|
expect(() => parseStrictProcessTableRows('')).toThrow(ProcessTableCaptureError)
|
|
expect(() => parseStrictProcessTableRows('PID PPID PGID TPGID STAT COMMAND')).toThrow(
|
|
ProcessTableCaptureError
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('getProcessTableIndex', () => {
|
|
it('reuses one index for the same snapshot identity', () => {
|
|
const rows = parseProcessTableRows(
|
|
['100 1 Ss bash', '101 100 S node codex', '102 100 S vim'].join('\n')
|
|
)
|
|
|
|
const first = getProcessTableIndex(rows)
|
|
const second = getProcessTableIndex(rows)
|
|
|
|
expect(second).toBe(first)
|
|
expect(first.byPid.get(100)).toBe(rows[0])
|
|
expect(first.childrenByPpid.get(100)).toEqual([rows[1], rows[2]])
|
|
})
|
|
|
|
it('does not reuse an index across distinct snapshot arrays', () => {
|
|
const firstRows = parseProcessTableRows('100 1 Ss bash')
|
|
const secondRows = parseProcessTableRows('100 1 Ss bash')
|
|
|
|
expect(getProcessTableIndex(secondRows)).not.toBe(getProcessTableIndex(firstRows))
|
|
})
|
|
|
|
it('resolves a duplicated pid to the FIRST row, as `rows.find()` did', () => {
|
|
// Preserve rows.find() semantics if a malformed table repeats a pid: an argv
|
|
// newline makes `ps` print a continuation line that can parse as a spurious
|
|
// row, and that row always follows the real one it was split from.
|
|
const rows = parseProcessTableRows(['100 1 Ss bash', '100 1 Ss+ zsh'].join('\n'))
|
|
|
|
expect(getProcessTableIndex(rows).byPid.get(100)).toBe(rows[0])
|
|
expect(buildProcessTableIndex(rows).byPid.get(100)).toBe(rows[0])
|
|
})
|
|
|
|
it('keeps the memo out of measured builds so a cache hit cannot satisfy a perf gate', () => {
|
|
const rows = parseProcessTableRows('100 1 Ss bash')
|
|
const stats: ProcessTableIndexStats = {
|
|
indexBuilds: 0,
|
|
rowVisits: 0,
|
|
indexLookups: 0
|
|
}
|
|
|
|
const memoized = getProcessTableIndex(rows)
|
|
const measured = buildProcessTableIndex(rows, stats)
|
|
|
|
expect(memoized.stats).toBeUndefined()
|
|
expect(measured).not.toBe(memoized)
|
|
expect(stats).toEqual({ indexBuilds: 1, rowVisits: 1, indexLookups: 0 })
|
|
// The measured build must not evict or replace the shared memo.
|
|
expect(getProcessTableIndex(rows)).toBe(memoized)
|
|
})
|
|
})
|
|
|
|
describe('process-table capture completeness', () => {
|
|
const NODE_DEFAULT_MAX_BUFFER_BYTES = 1024 * 1024
|
|
|
|
beforeEach(() => {
|
|
execFileMock.mockReset()
|
|
resetProcessTableSnapshotForTests()
|
|
})
|
|
|
|
/**
|
|
* Emulate Node's own execFile buffering: over `maxBuffer` it kills the child and
|
|
* calls back with ERR_CHILD_PROCESS_STDIO_MAXBUFFER plus the truncated bytes. A
|
|
* mock that always resolves would hide the very ceiling under test.
|
|
*/
|
|
function mockPsWithNodeBufferSemantics(stdout: string): void {
|
|
execFileMock.mockImplementation(
|
|
(_command: string, _args: string[], options: unknown, callback: unknown) => {
|
|
const done = callback as (err: unknown, result: { stdout: string; stderr: string }) => void
|
|
const maxBuffer =
|
|
(options as { maxBuffer?: number })?.maxBuffer ?? NODE_DEFAULT_MAX_BUFFER_BYTES
|
|
if (Buffer.byteLength(stdout, 'utf-8') > maxBuffer) {
|
|
const error = Object.assign(new Error('stdout maxBuffer length exceeded'), {
|
|
code: 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER'
|
|
})
|
|
done(error, { stdout: stdout.slice(0, maxBuffer), stderr: '' })
|
|
return
|
|
}
|
|
done(null, { stdout, stderr: '' })
|
|
}
|
|
)
|
|
}
|
|
|
|
function busyHostTable(processCount: number): string {
|
|
// ~285 bytes/row, so 4k processes clears 1MB the way a real busy host does.
|
|
const argv = `/usr/bin/node ${'--inspect-brk-and-a-long-flag '.repeat(8)}server.js`
|
|
return `${Array.from(
|
|
{ length: processCount },
|
|
(_, index) => `${1000 + index} 1 ${1000 + index} ${1000 + index} S+ ${argv}`
|
|
).join('\n')}\n`
|
|
}
|
|
|
|
it('reads a busy host whose table overflows execFile 1MB default', async () => {
|
|
const table = busyHostTable(4_000)
|
|
expect(Buffer.byteLength(table, 'utf-8')).toBeGreaterThan(NODE_DEFAULT_MAX_BUFFER_BYTES)
|
|
mockPsWithNodeBufferSemantics(table)
|
|
|
|
// Without an explicit maxBuffer this rejects, so the whole subsystem degrades
|
|
// to "unverifiable" on every capture for as long as the host stays busy.
|
|
await expect(getProcessTableSnapshot()).resolves.toHaveLength(4_000)
|
|
})
|
|
|
|
it('reports a truncated capture as unreadable rather than a silently short table', async () => {
|
|
const table = busyHostTable(200)
|
|
const truncated = table + 'x'.repeat(PS_MAX_BUFFER_BYTES - table.length)
|
|
// A capture that stopped at the ceiling but still resolved: the lenient parser
|
|
// drops the cut tail without complaint, so 200 of N processes would read as
|
|
// the complete list — a false "no agent" the execution boundary forbids.
|
|
expect(parseProcessTableRows(truncated)).toHaveLength(200)
|
|
execFileMock.mockImplementation(
|
|
(_command: string, _args: string[], _options: unknown, callback: unknown) => {
|
|
;(callback as (err: unknown, result: { stdout: string; stderr: string }) => void)(null, {
|
|
stdout: truncated,
|
|
stderr: ''
|
|
})
|
|
}
|
|
)
|
|
|
|
await expect(getProcessTableSnapshot()).rejects.toThrow(ProcessTableCaptureError)
|
|
await expect(getProcessTableSnapshot()).rejects.toThrow('capture_truncated')
|
|
})
|
|
|
|
it('names a buffer-ceiling rejection as truncation on both views', async () => {
|
|
mockPsWithNodeBufferSemantics('x'.repeat(PS_MAX_BUFFER_BYTES + 1))
|
|
|
|
await expect(getProcessTableSnapshot()).rejects.toThrow('capture_truncated')
|
|
resetProcessTableSnapshotForTests()
|
|
await expect(getStrictProcessTableSnapshot()).rejects.toThrow('capture_truncated')
|
|
})
|
|
|
|
it('reports an empty capture as unreadable rather than an empty process table', async () => {
|
|
// The lenient view used to answer [] here: zero processes on a machine that is
|
|
// by definition running at least `ps` itself.
|
|
execFileMock.mockImplementation(
|
|
(_command: string, _args: string[], _options: unknown, callback: unknown) => {
|
|
;(callback as (err: unknown, result: { stdout: string; stderr: string }) => void)(null, {
|
|
stdout: ' \n',
|
|
stderr: ''
|
|
})
|
|
}
|
|
)
|
|
|
|
await expect(getProcessTableSnapshot()).rejects.toThrow(ProcessTableCaptureError)
|
|
await expect(getProcessTableSnapshot()).rejects.toThrow('empty_capture')
|
|
})
|
|
|
|
/**
|
|
* Emulate Node's own execFile deadline: past `timeout` it SIGTERMs the child and calls
|
|
* back with `Command failed: <argv>` and no stderr -- indistinguishable from a broken
|
|
* `ps` unless the budget itself is under test.
|
|
*/
|
|
function mockPsTakingMs(durationMs: number, stdout: string): void {
|
|
execFileMock.mockImplementation(
|
|
(command: string, args: string[], options: unknown, callback: unknown) => {
|
|
const done = callback as (err: unknown, result: { stdout: string; stderr: string }) => void
|
|
const timeout = (options as { timeout?: number })?.timeout
|
|
if (timeout !== undefined && durationMs > timeout) {
|
|
const error = Object.assign(
|
|
new Error(`Command failed: ${[command, ...args].join(' ')}\n`),
|
|
{ code: null, killed: true, signal: 'SIGTERM' }
|
|
)
|
|
done(error, { stdout: '', stderr: '' })
|
|
return
|
|
}
|
|
done(null, { stdout, stderr: '' })
|
|
}
|
|
)
|
|
}
|
|
|
|
it('reads a loaded host whose whole-machine ps runs for seconds', async () => {
|
|
// Measured on a 1,948-process host at load 27: the `command=` argv read alone costs
|
|
// 1.15s, and contention stretched the same capture to 6.0s. The old 3s budget killed
|
|
// 6 of 20 consecutive captures, so a readable table answered "unverifiable".
|
|
mockPsTakingMs(6_000, busyHostTable(200))
|
|
|
|
expect(PS_TIMEOUT_MS).toBeGreaterThan(6_000)
|
|
await expect(getProcessTableSnapshot()).resolves.toHaveLength(200)
|
|
})
|
|
|
|
it('still bounds a ps that never returns', async () => {
|
|
mockPsTakingMs(PS_TIMEOUT_MS + 1, busyHostTable(200))
|
|
|
|
await expect(getProcessTableSnapshot()).rejects.toThrow('Command failed: ps')
|
|
})
|
|
})
|
|
|
|
/**
|
|
* The evidence-publishing read has a far shorter budget than identity proof, and the two are not
|
|
* interchangeable. Identity proof asks whether a process exists and must not read a slow capture
|
|
* as an absent one, so it waits out `PS_TIMEOUT_MS`. These consumers ask whether an observation
|
|
* describes NOW: past this budget it cannot, so the answer they need is a prompt `unverifiable`,
|
|
* which both relay call sites already produce from a rejection.
|
|
*/
|
|
describe('evidence-publishing capture budget', () => {
|
|
beforeEach(() => {
|
|
execFileMock.mockReset()
|
|
resetProcessTableSnapshotForTests()
|
|
vi.useFakeTimers()
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
/** A `ps` that answers after `durationMs` of wall clock, the way a loaded host does. */
|
|
function mockPsTaking(durationMs: number, stdout = '1 0 1 1 S+ ?? Jan 1 00:00:00 2026 bash\n') {
|
|
execFileMock.mockImplementation(
|
|
(_command: string, _args: string[], _options: unknown, callback: unknown) => {
|
|
const done = callback as (err: unknown, result: { stdout: string; stderr: string }) => void
|
|
setTimeout(() => done(null, { stdout, stderr: '' }), durationMs)
|
|
}
|
|
)
|
|
}
|
|
|
|
it('answers from a capture that lands one tick inside the budget', async () => {
|
|
mockPsTaking(PROCESS_TABLE_EVIDENCE_BUDGET_MS - 1)
|
|
const pending = getStrictProcessTableSnapshotWithAge()
|
|
|
|
await vi.advanceTimersByTimeAsync(PROCESS_TABLE_EVIDENCE_BUDGET_MS)
|
|
|
|
// The age carries the capture's own duration, which is the whole reason the budget is this
|
|
// far under the 2,000ms admission ceiling rather than under PS_TIMEOUT_MS.
|
|
expect((await pending).capturedAgeMs).toBe(PROCESS_TABLE_EVIDENCE_BUDGET_MS - 1)
|
|
})
|
|
|
|
it('gives up on a capture one tick past the budget instead of waiting out PS_TIMEOUT_MS', async () => {
|
|
mockPsTaking(PROCESS_TABLE_EVIDENCE_BUDGET_MS + 1)
|
|
const pending = getStrictProcessTableSnapshotWithAge()
|
|
const settled = pending.then(
|
|
() => 'resolved',
|
|
(error: Error) => error.message
|
|
)
|
|
|
|
await vi.advanceTimersByTimeAsync(PROCESS_TABLE_EVIDENCE_BUDGET_MS)
|
|
|
|
expect(await settled).toContain('process table unreadable')
|
|
})
|
|
|
|
it('leaves the abandoned capture running to fill the cache for the next reader', async () => {
|
|
// Why this matters: a whole-machine `ps` is the most expensive thing the host does, and the
|
|
// host that blows the budget is by definition the one that can least afford a second one.
|
|
mockPsTaking(PROCESS_TABLE_EVIDENCE_BUDGET_MS + 100)
|
|
const abandoned = getStrictProcessTableSnapshotWithAge().catch(() => null)
|
|
|
|
await vi.advanceTimersByTimeAsync(PROCESS_TABLE_EVIDENCE_BUDGET_MS)
|
|
expect(await abandoned).toBeNull()
|
|
|
|
await vi.advanceTimersByTimeAsync(100)
|
|
expect(await getStrictProcessTableSnapshotWithAge()).toMatchObject({
|
|
capturedAgeMs: PROCESS_TABLE_EVIDENCE_BUDGET_MS + 100
|
|
})
|
|
expect(execFileMock).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('keeps the identity budget far above its own', () => {
|
|
// A slow capture must still not read as an absent process; only the consumers that need the
|
|
// observation to describe NOW gave up waiting for it.
|
|
expect(PROCESS_TABLE_EVIDENCE_BUDGET_MS).toBeLessThan(PS_TIMEOUT_MS)
|
|
})
|
|
})
|