mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
perf(mac): name terminals in Orca instead of ps tty= (#27146)
Ask ps for raw terminal device numbers and name them in Orca: a verified direct lookup of /dev/ttys<minor> per distinct terminal, with an exact first-alias /dev scan fallback. Removes Apple ps's per-row /dev scan (~1 s CPU per capture on a busy Mac). Fixes #24889 Co-authored-by: Giorgio Zamparelli <giorgio.zamparelli@gmail.com> Co-authored-by: out-of-place <contact@outofplace.space>
This commit is contained in:
co-authored by
Giorgio Zamparelli
out-of-place
parent
e9bf6d1c3f
commit
db2ed7ca36
@@ -1,3 +1,15 @@
|
||||
import type * as FsPromises from 'node:fs/promises'
|
||||
import type { Session } from './session'
|
||||
import type * as DarwinTerminalNames from '../../shared/darwin-terminal-names'
|
||||
import type * as FullReader from '../../shared/process-table-snapshot-reader'
|
||||
import {
|
||||
createProcessTablePsFixture,
|
||||
type PsFixtureRow
|
||||
} from '../../shared/process-table-ps-test-fixture'
|
||||
import type * as CheapReader from '../../shared/cheap-process-table-snapshot-reader'
|
||||
import type * as TrackerModule from './pty-subprocess/foreground-process-tracker'
|
||||
import type * as Inspection from './terminal-host-process-inspection'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
// Measurement for the cheap-tier process inspection. Drives the REAL daemon inspection
|
||||
// entrypoint (`inspectTerminalHostProcess`) for 8 idle agent panes over a simulated 60s idle
|
||||
// cadence (POLL_TIER_INTERVAL_MS.idle = 2,000ms) and counts `ps` forks BY COLUMN SET: a fork
|
||||
@@ -8,7 +20,6 @@
|
||||
//
|
||||
// The second test is the zero-trade-off proof: the same tick sequence, including an agent exit
|
||||
// and a restart, produces the identical foregroundProcess series with the cheap tier on and off.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// Two seams because the two tiers spawn differently: the full evidence reader still forks
|
||||
// through node:child_process, the cheap reader through Orca's runProcess entry point.
|
||||
@@ -19,11 +30,24 @@ const { execFileMock, runProcessMock } = vi.hoisted(() => ({
|
||||
vi.mock('node:child_process', () => ({ execFile: execFileMock }))
|
||||
vi.mock('@orca/process-host', () => ({ runProcess: runProcessMock }))
|
||||
|
||||
import { resetCheapProcessTableSnapshotForTests } from '../../shared/cheap-process-table-snapshot-reader'
|
||||
import { resetProcessTableSnapshotForTests } from '../../shared/process-table-snapshot-reader'
|
||||
import { createPtyForegroundProcessTracker } from './pty-subprocess/foreground-process-tracker'
|
||||
import { inspectTerminalHostProcess } from './terminal-host-process-inspection'
|
||||
import type { Session } from './session'
|
||||
// Inject /dev data without bypassing the production translator.
|
||||
vi.mock('../../shared/darwin-terminal-names', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof DarwinTerminalNames>()
|
||||
return {
|
||||
nameDarwinTerminals: (stdout: string, _deps: unknown, signal?: AbortSignal) =>
|
||||
psFixture.nameTerminals(original.nameDarwinTerminals, stdout, signal)
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('node:fs/promises', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof FsPromises>()),
|
||||
readFile: async (path: string) => readProcStatMock(path)
|
||||
}))
|
||||
|
||||
let cheapReader: typeof CheapReader
|
||||
let fullReader: typeof FullReader
|
||||
let trackerModule: typeof TrackerModule
|
||||
let inspection: typeof Inspection
|
||||
|
||||
const PANE_COUNT = 8
|
||||
const IDLE_POLL_INTERVAL_MS = 2_000 // POLL_TIER_INTERVAL_MS.idle
|
||||
@@ -33,50 +57,92 @@ const TICKS = Math.floor((WINDOW_SECONDS * 1000) / IDLE_POLL_INTERVAL_MS)
|
||||
const shellPid = (pane: number): number => 1000 + pane * 100
|
||||
const agentPid = (pane: number): number => shellPid(pane) + 1
|
||||
|
||||
type PaneState = { agent: boolean; agentStart: string }
|
||||
type PaneState = { agent: boolean; agentStart: string; agentStartTicks: number }
|
||||
const panes: PaneState[] = Array.from({ length: PANE_COUNT }, () => ({
|
||||
agent: true,
|
||||
agentStart: 'Thu Sep 3 16:02:05 2026'
|
||||
agentStart: 'Thu Sep 3 16:02:05 2026',
|
||||
agentStartTicks: 1_400
|
||||
}))
|
||||
|
||||
const forks = { full: 0, cheap: 0 }
|
||||
|
||||
function renderRows(): { full: string; cheap: string } {
|
||||
const full: string[] = []
|
||||
const cheap: string[] = []
|
||||
panes.forEach((pane, i) => {
|
||||
const s = shellPid(i)
|
||||
const a = agentPid(i)
|
||||
const tpgid = pane.agent ? a : s
|
||||
const shellStat = pane.agent ? 'Ss' : 'Ss+'
|
||||
cheap.push(`${s} 1 ${s} ${tpgid} ${shellStat} Thu Sep 3 16:02:01 2026`)
|
||||
full.push(`${s} 1 ${s} ${tpgid} ${shellStat} ttys00${i} Thu Sep 3 16:02:01 2026 -zsh`)
|
||||
function processRows(): PsFixtureRow[] {
|
||||
return panes.flatMap((pane, i) => {
|
||||
const shell = shellPid(i)
|
||||
const agent = agentPid(i)
|
||||
const rows: PsFixtureRow[] = [
|
||||
{
|
||||
pid: shell,
|
||||
ppid: 1,
|
||||
pgid: shell,
|
||||
tpgid: pane.agent ? agent : shell,
|
||||
stat: pane.agent ? 'Ss' : 'Ss+',
|
||||
terminalMinor: i,
|
||||
startTime: 'Thu Sep 3 16:02:01 2026',
|
||||
startTicks: 1_000,
|
||||
command: '-zsh'
|
||||
}
|
||||
]
|
||||
if (pane.agent) {
|
||||
cheap.push(`${a} ${s} ${a} ${a} S+ ${pane.agentStart}`)
|
||||
full.push(`${a} ${s} ${a} ${a} S+ ttys00${i} ${pane.agentStart} node /usr/local/bin/claude`)
|
||||
rows.push({
|
||||
pid: agent,
|
||||
ppid: shell,
|
||||
pgid: agent,
|
||||
tpgid: agent,
|
||||
stat: 'S+',
|
||||
terminalMinor: i,
|
||||
startTime: pane.agentStart,
|
||||
startTicks: pane.agentStartTicks,
|
||||
command: 'node /usr/local/bin/claude'
|
||||
})
|
||||
}
|
||||
return rows
|
||||
})
|
||||
return { full: `${full.join('\n')}\n`, cheap: `${cheap.join('\n')}\n` }
|
||||
}
|
||||
const psFixture = createProcessTablePsFixture(processRows)
|
||||
const readProcStatMock = vi.fn((path: string) => psFixture.readProcStat(path))
|
||||
|
||||
function installCountingPs(): void {
|
||||
execFileMock.mockImplementation((_cmd: string, args: string[], _opts: unknown, cb: unknown) => {
|
||||
expect(args[1]).toContain('command=')
|
||||
forks.full += 1
|
||||
;(cb as (err: unknown, r: { stdout: string; stderr: string }) => void)(null, {
|
||||
stdout: renderRows().full,
|
||||
stderr: ''
|
||||
})
|
||||
})
|
||||
runProcessMock.mockImplementation(async (spec: { args: readonly string[] }) => {
|
||||
expect(spec.args[1]).not.toContain('command=')
|
||||
execFileMock.mockImplementation(
|
||||
(
|
||||
cmd: string,
|
||||
args: string[],
|
||||
_opts: unknown,
|
||||
callback: (err: unknown, r: { stdout: string; stderr: string }) => void
|
||||
) => {
|
||||
expect(cmd).toBe('ps')
|
||||
expect(args).toEqual(
|
||||
process.platform === 'darwin'
|
||||
? ['-axo', 'pid=,ppid=,pgid=,tpgid=,stat=,tdev=,lstart=,command=']
|
||||
: ['-axo', 'pid=,ppid=,pgid=,tpgid=,stat=,tty=,etimes=,command=']
|
||||
)
|
||||
forks.full += 1
|
||||
callback(null, {
|
||||
stdout: psFixture.render(args[1]),
|
||||
stderr: ''
|
||||
})
|
||||
}
|
||||
)
|
||||
runProcessMock.mockImplementation(async (spec: { program: string; args: readonly string[] }) => {
|
||||
expect(spec.program).toBe('ps')
|
||||
expect(spec.args).toEqual(
|
||||
process.platform === 'darwin'
|
||||
? ['-axo', 'pid=,ppid=,pgid=,tpgid=,stat=,lstart=']
|
||||
: ['-axo', 'pid=,ppid=,pgid=,tpgid=,stat=']
|
||||
)
|
||||
forks.cheap += 1
|
||||
return { code: 0, signal: null, stdout: renderRows().cheap, stderr: '', timedOut: false }
|
||||
return {
|
||||
code: 0,
|
||||
signal: null,
|
||||
stdout: psFixture.render(spec.args[1]),
|
||||
stderr: '',
|
||||
timedOut: false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function createSession(pane: number): Session {
|
||||
const tracker = createPtyForegroundProcessTracker({
|
||||
const tracker = trackerModule.createPtyForegroundProcessTracker({
|
||||
process: {
|
||||
pid: shellPid(pane),
|
||||
get process() {
|
||||
@@ -106,7 +172,7 @@ async function settle(): Promise<void> {
|
||||
async function runTick(sessions: Session[], steadyState: boolean): Promise<(string | null)[]> {
|
||||
const results = await Promise.all(
|
||||
sessions.map((session, pane) =>
|
||||
inspectTerminalHostProcess({
|
||||
inspection.inspectTerminalHostProcess({
|
||||
sessionId: `wt:pane-${pane}`,
|
||||
session,
|
||||
...(steadyState ? { steadyState: true } : {}),
|
||||
@@ -119,22 +185,30 @@ async function runTick(sessions: Session[], steadyState: boolean): Promise<(stri
|
||||
return results.map((r) => r.foregroundProcess)
|
||||
}
|
||||
|
||||
describe('cheap-tier ps scan volume at 8 idle agent panes over 60s', () => {
|
||||
describe.each(['darwin', 'linux'])('cheap-tier ps scan volume (%s)', (hostPlatform) => {
|
||||
let platform: PropertyDescriptor | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
execFileMock.mockReset()
|
||||
readProcStatMock.mockClear()
|
||||
runProcessMock.mockReset()
|
||||
resetProcessTableSnapshotForTests()
|
||||
resetCheapProcessTableSnapshotForTests()
|
||||
forks.full = 0
|
||||
forks.cheap = 0
|
||||
panes.forEach((pane) => {
|
||||
pane.agent = true
|
||||
pane.agentStart = 'Thu Sep 3 16:02:05 2026'
|
||||
pane.agentStartTicks = 1_400
|
||||
})
|
||||
platform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' })
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: hostPlatform })
|
||||
// Column constants are chosen at import time, so each host needs a fresh module graph.
|
||||
vi.resetModules()
|
||||
fullReader = await import('../../shared/process-table-snapshot-reader')
|
||||
fullReader.resetProcessTableSnapshotForTests()
|
||||
cheapReader = await import('../../shared/cheap-process-table-snapshot-reader')
|
||||
cheapReader.resetCheapProcessTableSnapshotForTests()
|
||||
trackerModule = await import('./pty-subprocess/foreground-process-tracker')
|
||||
inspection = await import('./terminal-host-process-inspection')
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
vi.setSystemTime(1_000_000)
|
||||
installCountingPs()
|
||||
@@ -145,6 +219,15 @@ describe('cheap-tier ps scan volume at 8 idle agent panes over 60s', () => {
|
||||
if (platform) {
|
||||
Object.defineProperty(process, 'platform', platform)
|
||||
}
|
||||
if (hostPlatform === 'darwin') {
|
||||
expect(readProcStatMock).not.toHaveBeenCalled()
|
||||
} else {
|
||||
expect(readProcStatMock).toHaveBeenCalled()
|
||||
}
|
||||
// Production catches read errors; assert each path resolved against the fixture at read time.
|
||||
for (const [index, result] of readProcStatMock.mock.results.entries()) {
|
||||
expect(result.type, readProcStatMock.mock.calls[index][0]).toBe('return')
|
||||
}
|
||||
})
|
||||
|
||||
it('replaces ~all full captures with cheap ones once every pane holds an anchor', async () => {
|
||||
@@ -176,11 +259,12 @@ describe('cheap-tier ps scan volume at 8 idle agent panes over 60s', () => {
|
||||
|
||||
it('completion detection is byte-for-byte unchanged: exit, idle, and restart resolve identically with and without the cheap tier', async () => {
|
||||
const script = async (steadyState: boolean): Promise<(string | null)[][]> => {
|
||||
resetProcessTableSnapshotForTests()
|
||||
resetCheapProcessTableSnapshotForTests()
|
||||
fullReader.resetProcessTableSnapshotForTests()
|
||||
cheapReader.resetCheapProcessTableSnapshotForTests()
|
||||
panes.forEach((pane) => {
|
||||
pane.agent = true
|
||||
pane.agentStart = 'Thu Sep 3 16:02:05 2026'
|
||||
pane.agentStartTicks = 1_400
|
||||
})
|
||||
const sessions = Array.from({ length: PANE_COUNT }, (_, pane) => createSession(pane))
|
||||
const series: (string | null)[][] = []
|
||||
@@ -192,6 +276,7 @@ describe('cheap-tier ps scan volume at 8 idle agent panes over 60s', () => {
|
||||
if (tick === 12) {
|
||||
panes[2].agent = true // ...and is restarted with a new start time
|
||||
panes[2].agentStart = 'Thu Sep 3 16:30:00 2026'
|
||||
panes[2].agentStartTicks = 2_000
|
||||
}
|
||||
if (tick === 20) {
|
||||
panes[6].agent = false
|
||||
|
||||
@@ -1,3 +1,16 @@
|
||||
import type * as FsPromises from 'node:fs/promises'
|
||||
import type { Session } from './session'
|
||||
import type * as DarwinTerminalNames from '../../shared/darwin-terminal-names'
|
||||
import type * as FullReader from '../../shared/process-table-snapshot-reader'
|
||||
import {
|
||||
createProcessTablePsFixture,
|
||||
type PsFixtureRow
|
||||
} from '../../shared/process-table-ps-test-fixture'
|
||||
import type * as CheapReader from '../../shared/cheap-process-table-snapshot-reader'
|
||||
import type * as TrackerModule from './pty-subprocess/foreground-process-tracker'
|
||||
import type * as Inspection from './terminal-host-process-inspection'
|
||||
import type { TerminalHostInspectionTier } from './terminal-host-process-inspection'
|
||||
import type * as AnchorModule from './terminal-host-steady-state-anchor'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// Two seams because the two tiers spawn differently: the full evidence reader still forks
|
||||
@@ -9,15 +22,25 @@ const { execFileMock, runProcessMock } = vi.hoisted(() => ({
|
||||
vi.mock('node:child_process', () => ({ execFile: execFileMock }))
|
||||
vi.mock('@orca/process-host', () => ({ runProcess: runProcessMock }))
|
||||
|
||||
import { resetCheapProcessTableSnapshotForTests } from '../../shared/cheap-process-table-snapshot-reader'
|
||||
import { resetProcessTableSnapshotForTests } from '../../shared/process-table-snapshot-reader'
|
||||
import { createPtyForegroundProcessTracker } from './pty-subprocess/foreground-process-tracker'
|
||||
import {
|
||||
inspectTerminalHostProcess,
|
||||
type TerminalHostInspectionTier
|
||||
} from './terminal-host-process-inspection'
|
||||
import { getSteadyStateAnchor } from './terminal-host-steady-state-anchor'
|
||||
import type { Session } from './session'
|
||||
// Inject /dev data without bypassing the production translator.
|
||||
vi.mock('../../shared/darwin-terminal-names', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof DarwinTerminalNames>()
|
||||
return {
|
||||
nameDarwinTerminals: (stdout: string, _deps: unknown, signal?: AbortSignal) =>
|
||||
psFixture.nameTerminals(original.nameDarwinTerminals, stdout, signal)
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('node:fs/promises', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof FsPromises>()),
|
||||
readFile: async (path: string) => readProcStatMock(path)
|
||||
}))
|
||||
|
||||
let cheapReader: typeof CheapReader
|
||||
let fullReader: typeof FullReader
|
||||
let trackerModule: typeof TrackerModule
|
||||
let inspection: typeof Inspection
|
||||
let anchorModule: typeof AnchorModule
|
||||
|
||||
const SHELL_PID = 4242
|
||||
const AGENT_PID = 4300
|
||||
@@ -26,60 +49,99 @@ const START_AGENT = 'Thu Sep 3 16:02:05 2026'
|
||||
|
||||
type Table = { agent: 'claude' | 'stopped' | 'gone' | 'replaced'; children?: number }
|
||||
|
||||
/** One host table rendered in both column sets, so each fork answers by the args it asked for. */
|
||||
function renderTable(table: Table): { full: string; cheap: string } {
|
||||
const shellTpgid = table.agent === 'claude' || table.agent === 'replaced' ? AGENT_PID : SHELL_PID
|
||||
const shellStat = shellTpgid === SHELL_PID ? 'Ss+' : 'Ss'
|
||||
const rows: { cheap: string; full: string }[] = [
|
||||
{
|
||||
cheap: `${SHELL_PID} 1 ${SHELL_PID} ${shellTpgid} ${shellStat} ${START_SHELL}`,
|
||||
full: `${SHELL_PID} 1 ${SHELL_PID} ${shellTpgid} ${shellStat} ttys004 ${START_SHELL} -zsh`
|
||||
},
|
||||
{
|
||||
cheap: `9000 1 9000 9000 Ss+ Thu Sep 3 12:00:00 2026`,
|
||||
full: `9000 1 9000 9000 Ss+ ttys009 Thu Sep 3 12:00:00 2026 -zsh`
|
||||
}
|
||||
]
|
||||
if (table.agent !== 'gone') {
|
||||
const stat = table.agent === 'stopped' ? 'T' : 'S+'
|
||||
const start = table.agent === 'replaced' ? 'Thu Sep 3 16:30:00 2026' : START_AGENT
|
||||
rows.push({
|
||||
cheap: `${AGENT_PID} ${SHELL_PID} ${AGENT_PID} ${shellTpgid} ${stat} ${start}`,
|
||||
full: `${AGENT_PID} ${SHELL_PID} ${AGENT_PID} ${shellTpgid} ${stat} ttys004 ${start} node /usr/local/bin/claude`
|
||||
})
|
||||
for (let i = 0; i < (table.children ?? 0); i += 1) {
|
||||
const pid = AGENT_PID + 10 + i
|
||||
rows.push({
|
||||
cheap: `${pid} ${AGENT_PID} ${AGENT_PID} ${shellTpgid} S+ Thu Sep 3 16:05:0${i} 2026`,
|
||||
full: `${pid} ${AGENT_PID} ${AGENT_PID} ${shellTpgid} S+ ttys004 Thu Sep 3 16:05:0${i} 2026 rg --files`
|
||||
})
|
||||
}
|
||||
}
|
||||
return {
|
||||
full: `${rows.map((r) => r.full).join('\n')}\n`,
|
||||
cheap: `${rows.map((r) => r.cheap).join('\n')}\n`
|
||||
}
|
||||
}
|
||||
|
||||
const forks = { full: 0, cheap: 0 }
|
||||
let table: Table = { agent: 'claude' }
|
||||
|
||||
function installPs(): void {
|
||||
execFileMock.mockImplementation((_cmd: string, args: string[], _opts: unknown, cb: unknown) => {
|
||||
expect(args[1]).toContain('command=')
|
||||
forks.full += 1
|
||||
;(cb as (err: unknown, r: { stdout: string; stderr: string }) => void)(null, {
|
||||
stdout: renderTable(table).full,
|
||||
stderr: ''
|
||||
function processRows(): PsFixtureRow[] {
|
||||
const shellTpgid = table.agent === 'claude' || table.agent === 'replaced' ? AGENT_PID : SHELL_PID
|
||||
const rows: PsFixtureRow[] = [
|
||||
{
|
||||
pid: SHELL_PID,
|
||||
ppid: 1,
|
||||
pgid: SHELL_PID,
|
||||
tpgid: shellTpgid,
|
||||
stat: shellTpgid === SHELL_PID ? 'Ss+' : 'Ss',
|
||||
terminalMinor: 4,
|
||||
startTime: START_SHELL,
|
||||
startTicks: 1_000,
|
||||
command: '-zsh'
|
||||
},
|
||||
{
|
||||
pid: 9000,
|
||||
ppid: 1,
|
||||
pgid: 9000,
|
||||
tpgid: 9000,
|
||||
stat: 'Ss+',
|
||||
terminalMinor: 9,
|
||||
startTime: 'Thu Sep 3 12:00:00 2026',
|
||||
startTicks: 500,
|
||||
command: '-zsh'
|
||||
}
|
||||
]
|
||||
if (table.agent !== 'gone') {
|
||||
rows.push({
|
||||
pid: AGENT_PID,
|
||||
ppid: SHELL_PID,
|
||||
pgid: AGENT_PID,
|
||||
tpgid: shellTpgid,
|
||||
stat: table.agent === 'stopped' ? 'T' : 'S+',
|
||||
terminalMinor: 4,
|
||||
startTime: table.agent === 'replaced' ? 'Thu Sep 3 16:30:00 2026' : START_AGENT,
|
||||
startTicks: table.agent === 'replaced' ? 2_000 : 1_400,
|
||||
command: 'node /usr/local/bin/claude'
|
||||
})
|
||||
})
|
||||
runProcessMock.mockImplementation(async (spec: { args: readonly string[] }) => {
|
||||
expect(spec.args[1]).not.toContain('command=')
|
||||
for (let i = 0; i < (table.children ?? 0); i += 1) {
|
||||
rows.push({
|
||||
pid: AGENT_PID + 10 + i,
|
||||
ppid: AGENT_PID,
|
||||
pgid: AGENT_PID,
|
||||
tpgid: shellTpgid,
|
||||
stat: 'S+',
|
||||
terminalMinor: 4,
|
||||
startTime: `Thu Sep 3 16:05:0${i} 2026`,
|
||||
startTicks: 1_500 + i,
|
||||
command: 'rg --files'
|
||||
})
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
const psFixture = createProcessTablePsFixture(processRows)
|
||||
const readProcStatMock = vi.fn((path: string) => psFixture.readProcStat(path))
|
||||
|
||||
function installPs(): void {
|
||||
execFileMock.mockImplementation(
|
||||
(
|
||||
cmd: string,
|
||||
args: string[],
|
||||
_opts: unknown,
|
||||
callback: (err: unknown, r: { stdout: string; stderr: string }) => void
|
||||
) => {
|
||||
expect(cmd).toBe('ps')
|
||||
expect(args).toEqual(
|
||||
process.platform === 'darwin'
|
||||
? ['-axo', 'pid=,ppid=,pgid=,tpgid=,stat=,tdev=,lstart=,command=']
|
||||
: ['-axo', 'pid=,ppid=,pgid=,tpgid=,stat=,tty=,etimes=,command=']
|
||||
)
|
||||
forks.full += 1
|
||||
callback(null, {
|
||||
stdout: psFixture.render(args[1]),
|
||||
stderr: ''
|
||||
})
|
||||
}
|
||||
)
|
||||
runProcessMock.mockImplementation(async (spec: { program: string; args: readonly string[] }) => {
|
||||
expect(spec.program).toBe('ps')
|
||||
expect(spec.args).toEqual(
|
||||
process.platform === 'darwin'
|
||||
? ['-axo', 'pid=,ppid=,pgid=,tpgid=,stat=,lstart=']
|
||||
: ['-axo', 'pid=,ppid=,pgid=,tpgid=,stat=']
|
||||
)
|
||||
forks.cheap += 1
|
||||
return {
|
||||
code: 0,
|
||||
signal: null,
|
||||
stdout: renderTable(table).cheap,
|
||||
stdout: psFixture.render(spec.args[1]),
|
||||
stderr: '',
|
||||
timedOut: false
|
||||
}
|
||||
@@ -88,7 +150,7 @@ function installPs(): void {
|
||||
|
||||
function createSession(processName: () => string): Session {
|
||||
let dead = false
|
||||
const tracker = createPtyForegroundProcessTracker({
|
||||
const tracker = trackerModule.createPtyForegroundProcessTracker({
|
||||
process: {
|
||||
pid: SHELL_PID,
|
||||
get process() {
|
||||
@@ -120,10 +182,10 @@ async function inspect(
|
||||
options: { steadyState?: boolean; expectedIncarnationId?: string } = {}
|
||||
): Promise<{
|
||||
tier: TerminalHostInspectionTier
|
||||
result: Awaited<ReturnType<typeof inspectTerminalHostProcess>>
|
||||
result: Awaited<ReturnType<typeof inspection.inspectTerminalHostProcess>>
|
||||
}> {
|
||||
let tier: TerminalHostInspectionTier = 'full'
|
||||
const result = await inspectTerminalHostProcess({
|
||||
const result = await inspection.inspectTerminalHostProcess({
|
||||
sessionId: 'wt-1:pane-1',
|
||||
session,
|
||||
...options,
|
||||
@@ -147,19 +209,27 @@ async function advance(ms: number): Promise<void> {
|
||||
vi.setSystemTime(Date.now() + ms)
|
||||
}
|
||||
|
||||
describe('daemon cheap-tier process inspection', () => {
|
||||
describe.each(['darwin', 'linux'])('daemon cheap-tier process inspection (%s)', (hostPlatform) => {
|
||||
let platform: PropertyDescriptor | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
execFileMock.mockReset()
|
||||
readProcStatMock.mockClear()
|
||||
runProcessMock.mockReset()
|
||||
resetProcessTableSnapshotForTests()
|
||||
resetCheapProcessTableSnapshotForTests()
|
||||
forks.full = 0
|
||||
forks.cheap = 0
|
||||
table = { agent: 'claude' }
|
||||
platform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' })
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: hostPlatform })
|
||||
// Column constants are chosen at import time, so each host needs a fresh module graph.
|
||||
vi.resetModules()
|
||||
fullReader = await import('../../shared/process-table-snapshot-reader')
|
||||
fullReader.resetProcessTableSnapshotForTests()
|
||||
cheapReader = await import('../../shared/cheap-process-table-snapshot-reader')
|
||||
cheapReader.resetCheapProcessTableSnapshotForTests()
|
||||
trackerModule = await import('./pty-subprocess/foreground-process-tracker')
|
||||
inspection = await import('./terminal-host-process-inspection')
|
||||
anchorModule = await import('./terminal-host-steady-state-anchor')
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
vi.setSystemTime(1_000_000)
|
||||
installPs()
|
||||
@@ -170,6 +240,13 @@ describe('daemon cheap-tier process inspection', () => {
|
||||
if (platform) {
|
||||
Object.defineProperty(process, 'platform', platform)
|
||||
}
|
||||
if (hostPlatform === 'darwin') {
|
||||
expect(readProcStatMock).not.toHaveBeenCalled()
|
||||
}
|
||||
// Production catches read errors; assert each path resolved against the fixture at read time.
|
||||
for (const [index, result] of readProcStatMock.mock.results.entries()) {
|
||||
expect(result.type, readProcStatMock.mock.calls[index][0]).toBe('return')
|
||||
}
|
||||
})
|
||||
|
||||
/** Bring a session to a recognized anchor the way production does: one full cadence tick. */
|
||||
@@ -179,7 +256,11 @@ describe('daemon cheap-tier process inspection', () => {
|
||||
await settle()
|
||||
expect(first.tier).toBe('full')
|
||||
expect(first.result.foregroundProcess).toBe('claude')
|
||||
expect(getSteadyStateAnchor(session)?.agentName).toBe('claude')
|
||||
expect(first.result.foregroundProcessEvidence).toMatchObject({
|
||||
verdict: 'live',
|
||||
processName: 'claude'
|
||||
})
|
||||
expect(anchorModule.getSteadyStateAnchor(session)?.agentName).toBe('claude')
|
||||
return session
|
||||
}
|
||||
|
||||
@@ -209,6 +290,9 @@ describe('daemon cheap-tier process inspection', () => {
|
||||
}
|
||||
expect(forks.cheap).toBe(4)
|
||||
expect(forks.full).toBe(fullBefore)
|
||||
if (hostPlatform === 'linux') {
|
||||
expect(readProcStatMock).toHaveBeenCalled()
|
||||
}
|
||||
})
|
||||
|
||||
it('a request without steadyState (old client, remote client, restore path) always gets the full capture with evidence', async () => {
|
||||
@@ -286,7 +370,7 @@ describe('daemon cheap-tier process inspection', () => {
|
||||
table = { agent: 'gone' }
|
||||
await advance(2_000)
|
||||
await inspect(session, { steadyState: true })
|
||||
expect(getSteadyStateAnchor(session)).toBeNull()
|
||||
expect(anchorModule.getSteadyStateAnchor(session)).toBeNull()
|
||||
// Back with a new agent, but the pane must re-anchor via a FULL capture first.
|
||||
table = { agent: 'claude' }
|
||||
await advance(2_000)
|
||||
|
||||
@@ -4,10 +4,18 @@
|
||||
// Drives the REAL local call site (`resolveAgentForegroundProcess`) under the
|
||||
// documented agent-completion cadence (ACTIVE_POLL_INTERVAL_MS = 750ms in
|
||||
// agent-completion-coordinator.ts) across several concurrently-inspecting agent
|
||||
// panes, and counts how many `ps -axo pid=,ppid=,stat=,command=` scans actually
|
||||
// panes, and counts how many full process-table scans actually
|
||||
// spawn. Pre-fix the call site forked one `ps` per pane per tick; with the
|
||||
// shared snapshot cache the scans collapse to ~one per tick regardless of pane
|
||||
// count, while each pane still resolves the same foreground identity.
|
||||
import type * as FsPromises from 'node:fs/promises'
|
||||
import type * as DarwinTerminalNames from '../../shared/darwin-terminal-names'
|
||||
import type * as FullReader from '../../shared/process-table-snapshot-reader'
|
||||
import {
|
||||
createProcessTablePsFixture,
|
||||
type PsFixtureRow
|
||||
} from '../../shared/process-table-ps-test-fixture'
|
||||
import type * as Foreground from './agent-foreground-process'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { execFileMock, psScanCount } = vi.hoisted(() => ({
|
||||
@@ -17,8 +25,22 @@ const { execFileMock, psScanCount } = vi.hoisted(() => ({
|
||||
|
||||
vi.mock('child_process', () => ({ execFile: execFileMock }))
|
||||
|
||||
import { resetProcessTableSnapshotForTests } from '../../shared/process-table-snapshot-reader'
|
||||
import { resolveAgentForegroundProcess } from './agent-foreground-process'
|
||||
// Inject /dev data without bypassing the production translator.
|
||||
vi.mock('../../shared/darwin-terminal-names', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof DarwinTerminalNames>()
|
||||
return {
|
||||
nameDarwinTerminals: (stdout: string, _deps: unknown, signal?: AbortSignal) =>
|
||||
psFixture.nameTerminals(original.nameDarwinTerminals, stdout, signal)
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('node:fs/promises', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof FsPromises>()),
|
||||
readFile: async (path: string) => readProcStatMock(path)
|
||||
}))
|
||||
|
||||
let fullReader: typeof FullReader
|
||||
let foreground: typeof Foreground
|
||||
|
||||
const ACTIVE_POLL_INTERVAL_MS = 750 // mirrors agent-completion-coordinator.ts
|
||||
const PANE_COUNT = 6 // reporter saw it with "only three projects" -> several agent panes
|
||||
@@ -30,33 +52,63 @@ const shellPid = (pane: number): number => 100 + pane * 1000
|
||||
// A real `ps` returns the whole system, so one shared snapshot must contain
|
||||
// every pane's shell + foreground codex child. Each pane resolves its own
|
||||
// agent from the single scan.
|
||||
const PS_OUTPUT = Array.from({ length: PANE_COUNT }, (_, pane) => {
|
||||
const shell = shellPid(pane)
|
||||
return [
|
||||
`${shell} 99 Ss bash -i`,
|
||||
`${shell + 1} ${shell} S+ node /Users/dev/.nvm/versions/node/bin/codex`
|
||||
].join('\n')
|
||||
}).join('\n')
|
||||
function processRows(): PsFixtureRow[] {
|
||||
return Array.from({ length: PANE_COUNT }, (_, pane) => {
|
||||
const shell = shellPid(pane)
|
||||
const agent = shell + 1
|
||||
const base = { terminalMinor: pane, startTime: 'Thu Sep 3 16:02:01 2026', startTicks: 1_000 }
|
||||
return [
|
||||
{ ...base, pid: shell, ppid: 99, pgid: shell, tpgid: agent, stat: 'Ss', command: 'bash -i' },
|
||||
{
|
||||
...base,
|
||||
pid: agent,
|
||||
ppid: shell,
|
||||
pgid: agent,
|
||||
tpgid: agent,
|
||||
stat: 'S+',
|
||||
command: 'node /Users/dev/.nvm/versions/node/bin/codex'
|
||||
}
|
||||
]
|
||||
}).flat()
|
||||
}
|
||||
const psFixture = createProcessTablePsFixture(processRows)
|
||||
const readProcStatMock = vi.fn((path: string) => psFixture.readProcStat(path))
|
||||
|
||||
function installCountingPsMock(): void {
|
||||
execFileMock.mockImplementation((cmd: string, args: string[], _opts: unknown, cb: unknown) => {
|
||||
const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void
|
||||
if (cmd === 'ps' && Array.isArray(args) && args.includes('-axo')) {
|
||||
execFileMock.mockImplementation(
|
||||
(
|
||||
cmd: string,
|
||||
args: string[],
|
||||
_opts: unknown,
|
||||
callback: (err: unknown, result: { stdout: string; stderr: string }) => void
|
||||
) => {
|
||||
expect(cmd).toBe('ps')
|
||||
expect(args[0]).toBe('-axo')
|
||||
expect(args[1]).toBe(
|
||||
process.platform === 'darwin'
|
||||
? 'pid=,ppid=,pgid=,tpgid=,stat=,tdev=,lstart=,command='
|
||||
: 'pid=,ppid=,pgid=,tpgid=,stat=,tty=,etimes=,command='
|
||||
)
|
||||
psScanCount.value += 1
|
||||
callback(null, { stdout: psFixture.render(args[1]), stderr: '' })
|
||||
}
|
||||
callback(null, { stdout: PS_OUTPUT, stderr: '' })
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
describe('#6288 agent foreground inspection ps-scan volume', () => {
|
||||
describe.each(['darwin', 'linux'])('#6288 foreground ps scan volume (%s)', (hostPlatform) => {
|
||||
let platform: PropertyDescriptor | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
execFileMock.mockReset()
|
||||
resetProcessTableSnapshotForTests()
|
||||
readProcStatMock.mockClear()
|
||||
psScanCount.value = 0
|
||||
platform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' })
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: hostPlatform })
|
||||
// Column constants are chosen at import time, so each host needs a fresh module graph.
|
||||
vi.resetModules()
|
||||
fullReader = await import('../../shared/process-table-snapshot-reader')
|
||||
fullReader.resetProcessTableSnapshotForTests()
|
||||
foreground = await import('./agent-foreground-process')
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
vi.setSystemTime(0)
|
||||
})
|
||||
@@ -66,6 +118,15 @@ describe('#6288 agent foreground inspection ps-scan volume', () => {
|
||||
if (platform) {
|
||||
Object.defineProperty(process, 'platform', platform)
|
||||
}
|
||||
if (hostPlatform === 'darwin') {
|
||||
expect(readProcStatMock).not.toHaveBeenCalled()
|
||||
} else {
|
||||
expect(readProcStatMock).toHaveBeenCalled()
|
||||
}
|
||||
// Production catches read errors; assert each path resolved against the fixture at read time.
|
||||
for (const [index, result] of readProcStatMock.mock.results.entries()) {
|
||||
expect(result.type, readProcStatMock.mock.calls[index][0]).toBe('return')
|
||||
}
|
||||
})
|
||||
|
||||
it('bounds ps scans by poll ticks, not by pane count, while resolving every pane', async () => {
|
||||
@@ -76,7 +137,7 @@ describe('#6288 agent foreground inspection ps-scan volume', () => {
|
||||
// All panes inspect concurrently within the tick (worst case for a busy relay).
|
||||
const resolved = await Promise.all(
|
||||
Array.from({ length: PANE_COUNT }, (_, pane) =>
|
||||
resolveAgentForegroundProcess(shellPid(pane), 'node')
|
||||
foreground.resolveAgentForegroundProcess(shellPid(pane), 'node')
|
||||
)
|
||||
)
|
||||
// Caching must not change the answer: every pane still resolves the agent.
|
||||
@@ -87,7 +148,7 @@ describe('#6288 agent foreground inspection ps-scan volume', () => {
|
||||
// Pre-fix this equals totalInspections (one scan per inspection). With the
|
||||
// shared cache, concurrent panes within a tick share one scan and the 500ms
|
||||
// TTL forces a fresh scan each new 750ms tick -> ~one scan per tick.
|
||||
expect(psScanCount.value).toBeLessThanOrEqual(TICKS + 1)
|
||||
expect(psScanCount.value).toBe(TICKS)
|
||||
expect(psScanCount.value).toBeLessThan(totalInspections / 2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { naming } = vi.hoisted(() => ({ naming: vi.fn() }))
|
||||
vi.mock('./darwin-terminal-names', () => ({ nameDarwinTerminals: naming }))
|
||||
|
||||
import {
|
||||
captureDarwinProcessTable,
|
||||
resetDarwinProcessTableCaptureForTests
|
||||
} from './darwin-process-table-capture'
|
||||
import { PS_ARGS } from './process-table-snapshot'
|
||||
|
||||
const raw = '123 1 123 123 S+ 16/9 Fri Oct 9 12:34:56 2026 /bin/zsh\n'
|
||||
const canonical = raw.replace('16/9', 'ttys009')
|
||||
const validate = (stdout: string): string => stdout
|
||||
const capture = vi.fn(async (args: readonly string[]) =>
|
||||
args[1].includes('tdev=') ? raw : canonical
|
||||
)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.clearAllMocks()
|
||||
resetDarwinProcessTableCaptureForTests()
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('Darwin translation deadline', () => {
|
||||
it('rejects on deadline, refuses new inventory jobs until drain, and discards late names', async () => {
|
||||
let finish!: (stdout: string) => void
|
||||
const pending = new Promise<string>((resolve) => {
|
||||
finish = resolve
|
||||
})
|
||||
let signal: AbortSignal | undefined
|
||||
naming.mockImplementation((_stdout: string, _deps: undefined, current: AbortSignal) => {
|
||||
signal = current
|
||||
return pending
|
||||
})
|
||||
|
||||
const first = captureDarwinProcessTable(capture, validate, 100)
|
||||
const rejected = expect(first).rejects.toThrow('capture_over_budget')
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
await rejected
|
||||
expect(signal?.aborted).toBe(true)
|
||||
expect(capture.mock.calls.map(([args]) => args)).toEqual([
|
||||
['-axo', PS_ARGS[1].replace('tty=', 'tdev=')]
|
||||
])
|
||||
for (let index = 0; index < 5; index++) {
|
||||
expect(await captureDarwinProcessTable(capture, validate, 100)).toBe(canonical)
|
||||
}
|
||||
expect(naming).toHaveBeenCalledTimes(1)
|
||||
|
||||
finish(raw.replace('16/9', 'stale-name'))
|
||||
await pending
|
||||
await expect(first).rejects.toThrow('capture_over_budget')
|
||||
naming.mockResolvedValue(raw.replace('16/9', 'fresh-name'))
|
||||
expect(await captureDarwinProcessTable(capture, validate, 100)).toBe(
|
||||
raw.replace('16/9', 'fresh-name')
|
||||
)
|
||||
expect(naming).toHaveBeenCalledTimes(2)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('only gives naming the budget left after ps completes', async () => {
|
||||
let finish!: (stdout: string) => void
|
||||
const pending = new Promise<string>((resolve) => {
|
||||
finish = resolve
|
||||
})
|
||||
naming.mockReturnValue(pending)
|
||||
const slowCapture = vi.fn(async (args: readonly string[]) => {
|
||||
if (args[1].includes('tdev=')) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 80))
|
||||
return raw
|
||||
}
|
||||
return canonical
|
||||
})
|
||||
const first = captureDarwinProcessTable(slowCapture, validate, 100)
|
||||
const rejected = expect(first).rejects.toThrow('capture_over_budget')
|
||||
await vi.advanceTimersByTimeAsync(99)
|
||||
expect(slowCapture).toHaveBeenCalledTimes(1)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await rejected
|
||||
expect(slowCapture).toHaveBeenCalledTimes(1)
|
||||
finish(raw)
|
||||
await pending
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('does not start naming after the capture has consumed its budget', async () => {
|
||||
const lateCapture = vi.fn(async (args: readonly string[]) => {
|
||||
if (args[1].includes('tdev=')) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
return raw
|
||||
}
|
||||
return canonical
|
||||
})
|
||||
|
||||
const first = captureDarwinProcessTable(lateCapture, validate, 100)
|
||||
const rejected = expect(first).rejects.toThrow('capture_over_budget')
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
await rejected
|
||||
expect(naming).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('only gives the canonical fallback the time left after an inventory error', async () => {
|
||||
naming.mockImplementation(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 80))
|
||||
throw new Error('directory failed')
|
||||
})
|
||||
const boundedCapture = vi.fn(async (args: readonly string[], timeoutMs?: number) => {
|
||||
expect(timeoutMs).toBe(args[1].includes('tdev=') ? 100 : 20)
|
||||
return args[1].includes('tdev=') ? raw : canonical
|
||||
})
|
||||
const result = captureDarwinProcessTable(boundedCapture, validate, 100)
|
||||
await vi.advanceTimersByTimeAsync(80)
|
||||
|
||||
expect(await result).toBe(canonical)
|
||||
expect(boundedCapture).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('rejects late successful names even when Promise completion beats the overdue timer', async () => {
|
||||
let clock = 0
|
||||
vi.spyOn(performance, 'now').mockImplementation(() => clock)
|
||||
naming.mockImplementation(async () => {
|
||||
await Promise.resolve()
|
||||
clock = 101
|
||||
return canonical
|
||||
})
|
||||
|
||||
await expect(captureDarwinProcessTable(capture, validate, 100)).rejects.toThrow(
|
||||
'capture_over_budget'
|
||||
)
|
||||
expect(capture).toHaveBeenCalledTimes(1)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('rejects expiry during output validation without another capture', async () => {
|
||||
let clock = 0
|
||||
vi.spyOn(performance, 'now').mockImplementation(() => clock)
|
||||
naming.mockResolvedValue(canonical)
|
||||
const slowValidation = (stdout: string): string => {
|
||||
clock = 101
|
||||
return stdout
|
||||
}
|
||||
|
||||
await expect(captureDarwinProcessTable(capture, slowValidation, 100)).rejects.toThrow(
|
||||
'capture_over_budget'
|
||||
)
|
||||
expect(capture).toHaveBeenCalledTimes(1)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,94 @@
|
||||
import { nameDarwinTerminals } from './darwin-terminal-names'
|
||||
import { errorMessage } from './error-message'
|
||||
import { ProcessTableCaptureError, PS_ARGS } from './process-table-snapshot'
|
||||
import { withTimeout } from './promise-timeout-fallback'
|
||||
|
||||
let deviceColumnUnsupported = false
|
||||
let terminalNamingInFlight: Promise<string> | null = null
|
||||
|
||||
export async function captureDarwinProcessTable(
|
||||
capture: (args: readonly string[], timeoutMs?: number) => Promise<string>,
|
||||
validate: (stdout: string) => string,
|
||||
budgetMs: number
|
||||
): Promise<string> {
|
||||
const startedAt = performance.now()
|
||||
const remainingBudget = (): number => {
|
||||
const remaining = Math.floor(budgetMs - (performance.now() - startedAt))
|
||||
if (remaining <= 0) {
|
||||
throw new ProcessTableCaptureError('capture_over_budget')
|
||||
}
|
||||
return remaining
|
||||
}
|
||||
const canonicalCapture = (): Promise<string> => capture(PS_ARGS, remainingBudget())
|
||||
if (deviceColumnUnsupported || terminalNamingInFlight) {
|
||||
return canonicalCapture()
|
||||
}
|
||||
let captured: string
|
||||
try {
|
||||
captured = await capture(['-axo', PS_ARGS[1].replace('tty=', 'tdev=')], remainingBudget())
|
||||
} catch (error) {
|
||||
if (
|
||||
!(error instanceof Error) ||
|
||||
!('code' in error) ||
|
||||
(error.code !== 1 && error.code !== 2) ||
|
||||
('killed' in error && error.killed === true) ||
|
||||
!/^(?:ps: tdev: keyword not found|error: unknown user-defined format specifier "tdev")$/m.test(
|
||||
errorMessage(error)
|
||||
)
|
||||
) {
|
||||
throw error
|
||||
}
|
||||
deviceColumnUnsupported = true
|
||||
return canonicalCapture()
|
||||
}
|
||||
remainingBudget()
|
||||
if (terminalNamingInFlight) {
|
||||
return canonicalCapture()
|
||||
}
|
||||
const controller = new AbortController()
|
||||
const pending = nameDarwinTerminals(captured, undefined, controller.signal)
|
||||
terminalNamingInFlight = pending
|
||||
// Uncancelable filesystem calls keep the slot until they drain.
|
||||
const clearNaming = (): void => {
|
||||
if (terminalNamingInFlight === pending) {
|
||||
terminalNamingInFlight = null
|
||||
}
|
||||
}
|
||||
void pending.then(clearNaming, clearNaming)
|
||||
let remainingMs: number
|
||||
try {
|
||||
remainingMs = remainingBudget()
|
||||
} catch (error) {
|
||||
controller.abort()
|
||||
throw error
|
||||
}
|
||||
const named = await withTimeout<{ value: string } | { error: unknown } | null>(
|
||||
pending.then(
|
||||
(value) => ({ value }),
|
||||
(error: unknown) => ({ error })
|
||||
),
|
||||
remainingMs,
|
||||
null
|
||||
)
|
||||
if (named === null) {
|
||||
controller.abort()
|
||||
throw new ProcessTableCaptureError('capture_over_budget')
|
||||
}
|
||||
remainingBudget()
|
||||
if ('error' in named) {
|
||||
return canonicalCapture()
|
||||
}
|
||||
let validated: string
|
||||
try {
|
||||
validated = validate(named.value)
|
||||
} catch {
|
||||
return canonicalCapture()
|
||||
}
|
||||
remainingBudget()
|
||||
return validated
|
||||
}
|
||||
|
||||
export function resetDarwinProcessTableCaptureForTests(): void {
|
||||
deviceColumnUnsupported = false
|
||||
terminalNamingInFlight = null
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { lstatMock, opendirMock } = vi.hoisted(() => ({
|
||||
lstatMock: vi.fn(),
|
||||
opendirMock: vi.fn()
|
||||
}))
|
||||
vi.mock('node:fs/promises', () => ({ lstat: lstatMock, opendir: opendirMock }))
|
||||
|
||||
import {
|
||||
captureDarwinProcessTable,
|
||||
resetDarwinProcessTableCaptureForTests
|
||||
} from './darwin-process-table-capture'
|
||||
|
||||
const raw = '123 1 123 123 S+ 16/9 Fri Oct 9 12:34:56 2026 /bin/zsh\n'
|
||||
const canonical = raw.replace('16/9', 'ttys009')
|
||||
const capture = vi.fn(async (args: readonly string[]) =>
|
||||
args[1].includes('tdev=') ? raw : canonical
|
||||
)
|
||||
const validate = (stdout: string): string => stdout
|
||||
const candidatePath = '/dev/ttys009'
|
||||
const missingCandidate = (): Promise<never> => Promise.reject(new Error('ENOENT'))
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((finish) => {
|
||||
resolve = finish
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.resetAllMocks()
|
||||
resetDarwinProcessTableCaptureForTests()
|
||||
})
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
describe('Darwin inventory drain admission', () => {
|
||||
it('keeps a held stat tracked after an earlier invocation throws synchronously', async () => {
|
||||
opendirMock.mockResolvedValue(
|
||||
Array.from({ length: 4 }, (_, index) => ({ name: `entry-${index}` }))
|
||||
)
|
||||
const pending = deferred<{
|
||||
rdev: bigint
|
||||
isCharacterDevice: () => boolean
|
||||
}>()
|
||||
lstatMock.mockImplementation((path: string) => {
|
||||
if (path === candidatePath) {
|
||||
return missingCandidate()
|
||||
}
|
||||
if (lstatMock.mock.calls.length === 2) {
|
||||
throw new Error('stat invocation failed')
|
||||
}
|
||||
return pending.promise
|
||||
})
|
||||
const first = captureDarwinProcessTable(capture, validate, 100)
|
||||
let settled = false
|
||||
void first.then(() => {
|
||||
settled = true
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(settled).toBe(false)
|
||||
expect(lstatMock).toHaveBeenCalledTimes(3)
|
||||
expect(await captureDarwinProcessTable(capture, validate, 100)).toBe(canonical)
|
||||
expect(opendirMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
const stat = { rdev: 0x10000009n, isCharacterDevice: () => true }
|
||||
lstatMock.mockResolvedValue(stat)
|
||||
pending.resolve(stat)
|
||||
expect(await first).toBe(raw.replace('16/9', 'entry-1'))
|
||||
expect(lstatMock).toHaveBeenCalledTimes(3)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('holds admission until its one uncancelable stat drains and admits no later reads', async () => {
|
||||
opendirMock.mockResolvedValue(
|
||||
Array.from({ length: 12 }, (_, index) => ({ name: `entry-${index}` }))
|
||||
)
|
||||
const pending = deferred<{
|
||||
rdev: bigint
|
||||
isCharacterDevice: () => boolean
|
||||
}>()
|
||||
lstatMock.mockImplementation((path: string) =>
|
||||
path === candidatePath ? missingCandidate() : pending.promise
|
||||
)
|
||||
|
||||
const first = captureDarwinProcessTable(capture, validate, 100)
|
||||
const rejected = expect(first).rejects.toThrow('capture_over_budget')
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
await rejected
|
||||
expect(lstatMock).toHaveBeenCalledTimes(2)
|
||||
const stat = { rdev: 0x10000009n, isCharacterDevice: () => true }
|
||||
|
||||
for (let index = 0; index < 5; index++) {
|
||||
expect(await captureDarwinProcessTable(capture, validate, 100)).toBe(canonical)
|
||||
}
|
||||
expect(opendirMock).toHaveBeenCalledTimes(1)
|
||||
expect(lstatMock).toHaveBeenCalledTimes(2)
|
||||
|
||||
pending.resolve(stat)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(lstatMock).toHaveBeenCalledTimes(2)
|
||||
lstatMock.mockImplementation(async (path: string) =>
|
||||
path === candidatePath ? missingCandidate() : stat
|
||||
)
|
||||
expect(await captureDarwinProcessTable(capture, validate, 100)).toBe(
|
||||
raw.replace('16/9', 'entry-0')
|
||||
)
|
||||
expect(opendirMock).toHaveBeenCalledTimes(2)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('holds admission while an expired pty candidate stat drains', async () => {
|
||||
const pending = deferred<{
|
||||
rdev: bigint
|
||||
isCharacterDevice: () => boolean
|
||||
}>()
|
||||
lstatMock.mockReturnValue(pending.promise)
|
||||
|
||||
const first = captureDarwinProcessTable(capture, validate, 100)
|
||||
const rejected = expect(first).rejects.toThrow('capture_over_budget')
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
await rejected
|
||||
expect(await captureDarwinProcessTable(capture, validate, 100)).toBe(canonical)
|
||||
expect(lstatMock).toHaveBeenCalledTimes(1)
|
||||
expect(opendirMock).not.toHaveBeenCalled()
|
||||
|
||||
const stat = { rdev: 0x10000009n, isCharacterDevice: () => true }
|
||||
pending.resolve(stat)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
lstatMock.mockResolvedValue(stat)
|
||||
expect(await captureDarwinProcessTable(capture, validate, 100)).toBe(canonical)
|
||||
expect(lstatMock).toHaveBeenCalledTimes(2)
|
||||
expect(opendirMock).not.toHaveBeenCalled()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('closes a directory that opens after expiry and holds admission until its close drains', async () => {
|
||||
const close = deferred<void>()
|
||||
let closeStarted = false
|
||||
async function* entries() {
|
||||
try {
|
||||
yield { name: 'ttys009' }
|
||||
} finally {
|
||||
closeStarted = true
|
||||
await close.promise
|
||||
}
|
||||
}
|
||||
const opened = deferred<AsyncIterable<{ name: string }>>()
|
||||
opendirMock.mockReturnValue(opened.promise)
|
||||
lstatMock.mockImplementation(missingCandidate)
|
||||
|
||||
const first = captureDarwinProcessTable(capture, validate, 100)
|
||||
const rejected = expect(first).rejects.toThrow('capture_over_budget')
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
await rejected
|
||||
opened.resolve(entries())
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(closeStarted).toBe(true)
|
||||
expect(lstatMock.mock.calls).toEqual([[candidatePath, { bigint: true }]])
|
||||
expect(await captureDarwinProcessTable(capture, validate, 100)).toBe(canonical)
|
||||
expect(opendirMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
close.resolve()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
opendirMock.mockResolvedValue([{ name: 'fresh-name' }])
|
||||
lstatMock.mockImplementation(async (path: string) =>
|
||||
path === candidatePath
|
||||
? missingCandidate()
|
||||
: { rdev: 0x10000009n, isCharacterDevice: () => true }
|
||||
)
|
||||
expect(await captureDarwinProcessTable(capture, validate, 100)).toBe(
|
||||
raw.replace('16/9', 'fresh-name')
|
||||
)
|
||||
expect(opendirMock).toHaveBeenCalledTimes(2)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { lstatMock, opendirMock } = vi.hoisted(() => ({
|
||||
lstatMock: vi.fn(),
|
||||
opendirMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
lstat: lstatMock,
|
||||
get opendir() {
|
||||
return opendirMock()
|
||||
}
|
||||
}))
|
||||
|
||||
import { nameDarwinTerminals } from './darwin-terminal-names'
|
||||
|
||||
const row = '123 1 123 123 S+ 128/124 Fri Oct 9 12:34:56 2026 /bin/zsh\n'
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks()
|
||||
})
|
||||
|
||||
describe('Darwin device filesystem access', () => {
|
||||
it('does not access the Darwin directory API during import or no-terminal captures', async () => {
|
||||
expect(opendirMock).not.toHaveBeenCalled()
|
||||
expect(await nameDarwinTerminals(row.replace('128/124', '??'))).toBe(
|
||||
row.replace('128/124', '??')
|
||||
)
|
||||
expect(opendirMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reads exact bigint device metadata without following links', async () => {
|
||||
opendirMock.mockReturnValue(async () => [{ name: 'terminal' }])
|
||||
lstatMock.mockImplementation(async (path: string) => {
|
||||
if (path === join('/dev', 'ttys124')) {
|
||||
throw new Error('ENOENT')
|
||||
}
|
||||
return { rdev: 0xffffffff8000007cn, isCharacterDevice: () => true }
|
||||
})
|
||||
|
||||
expect(await nameDarwinTerminals(row)).toBe(row.replace('128/124', 'terminal'))
|
||||
expect(lstatMock.mock.calls).toEqual([
|
||||
[join('/dev', 'ttys124'), { bigint: true }],
|
||||
[join('/dev', 'terminal'), { bigint: true }]
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
import { type ChildProcess, execFileSync, spawn } from 'node:child_process'
|
||||
import { lstat } from 'node:fs/promises'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { nameDarwinTerminals } from './darwin-terminal-names'
|
||||
import { PS_ARGS } from './process-table-snapshot'
|
||||
|
||||
const RAW_ARGS = ['-axo', PS_ARGS[1].replace('tty=', 'tdev=')]
|
||||
|
||||
function rowsByPid(stdout: string): Map<string, string> {
|
||||
return new Map(
|
||||
stdout
|
||||
.split('\n')
|
||||
.filter((line) => line.trim())
|
||||
.map((line) => [line.trim().split(/\s+/)[0], line])
|
||||
)
|
||||
}
|
||||
|
||||
function ttyByPid(stdout: string): Map<string, string> {
|
||||
return new Map([...rowsByPid(stdout)].map(([pid, row]) => [pid, row.trim().split(/\s+/)[5]]))
|
||||
}
|
||||
|
||||
describe.runIf(process.platform === 'darwin')('nameDarwinTerminals against real ps', () => {
|
||||
let holder: ChildProcess | undefined
|
||||
let terminalPid = ''
|
||||
|
||||
beforeAll(async () => {
|
||||
// `script` gives its child a real pty, so a headless host still has one named terminal.
|
||||
holder = spawn('script', ['-q', '/dev/null', 'sleep', '30'], { stdio: 'ignore' })
|
||||
for (let attempt = 0; attempt < 100 && !terminalPid; attempt += 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
try {
|
||||
terminalPid = execFileSync('pgrep', ['-P', String(holder.pid)], { encoding: 'utf8' }).trim()
|
||||
} catch {
|
||||
// No child yet.
|
||||
}
|
||||
}
|
||||
})
|
||||
afterAll(() => {
|
||||
holder?.kill()
|
||||
})
|
||||
|
||||
it('names a real pty through the direct lookup without scanning /dev', async () => {
|
||||
const row = rowsByPid(execFileSync('ps', RAW_ARGS, { encoding: 'utf8' })).get(terminalPid)
|
||||
expect(row).toBeDefined()
|
||||
const named = await nameDarwinTerminals(`${row}\n`, {
|
||||
openDirectory: () => Promise.reject(new Error('direct lookup must not scan /dev')),
|
||||
readDevice: (path) => lstat(path, { bigint: true })
|
||||
})
|
||||
expect(ttyByPid(named).get(terminalPid)).toBe(
|
||||
ttyByPid(execFileSync('ps', [...PS_ARGS], { encoding: 'utf8' })).get(terminalPid)
|
||||
)
|
||||
expect(ttyByPid(named).get(terminalPid)).toMatch(/^ttys\d{3,}$/)
|
||||
})
|
||||
|
||||
it('matches ps terminal names for every process present in both captures', async () => {
|
||||
// Retry once: a terminal can close between the two ps runs.
|
||||
let mismatches: string[] = []
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
const raw = execFileSync('ps', RAW_ARGS, { encoding: 'utf8' })
|
||||
const canonical = ttyByPid(execFileSync('ps', [...PS_ARGS], { encoding: 'utf8' }))
|
||||
const named = ttyByPid(await nameDarwinTerminals(raw))
|
||||
mismatches = [...named]
|
||||
.filter(([pid, tty]) => canonical.has(pid) && canonical.get(pid) !== tty)
|
||||
.map(([pid, tty]) => `${pid}: ${tty} != ${canonical.get(pid)}`)
|
||||
if (mismatches.length === 0) {
|
||||
break
|
||||
}
|
||||
}
|
||||
expect(mismatches).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,500 @@
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { nameDarwinTerminals } from './darwin-terminal-names'
|
||||
import { PS_ARGS } from './process-table-snapshot'
|
||||
|
||||
type DeviceEntry = {
|
||||
name: string
|
||||
rdev: bigint
|
||||
kind?: 'character' | 'symlink' | 'file'
|
||||
delayMs?: number
|
||||
error?: Error
|
||||
}
|
||||
|
||||
class TestDeviceDirectory {
|
||||
entries: DeviceEntry[] = []
|
||||
openedPaths: string[] = []
|
||||
readPaths: string[] = []
|
||||
closedDirectories = 0
|
||||
peakReads = 0
|
||||
openError?: Error
|
||||
enumerationError?: Error
|
||||
private activeReads = 0
|
||||
|
||||
openDirectory = async (path: string): Promise<AsyncIterable<{ name: string }>> => {
|
||||
this.openedPaths.push(path)
|
||||
if (this.openError) {
|
||||
throw this.openError
|
||||
}
|
||||
return this.enumerate()
|
||||
}
|
||||
|
||||
private async *enumerate(): AsyncGenerator<{ name: string }> {
|
||||
try {
|
||||
for (const entry of this.entries) {
|
||||
yield { name: entry.name }
|
||||
if (this.enumerationError) {
|
||||
throw this.enumerationError
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.closedDirectories += 1
|
||||
}
|
||||
}
|
||||
|
||||
readDevice = async (
|
||||
path: string
|
||||
): Promise<{ rdev: bigint; isCharacterDevice: () => boolean }> => {
|
||||
this.readPaths.push(path)
|
||||
const entry = this.entries.find((candidate) => join('/dev', candidate.name) === path)
|
||||
if (!entry) {
|
||||
throw new Error('device disappeared')
|
||||
}
|
||||
this.activeReads += 1
|
||||
this.peakReads = Math.max(this.peakReads, this.activeReads)
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, entry.delayMs ?? 1))
|
||||
if (entry.error) {
|
||||
throw entry.error
|
||||
}
|
||||
return {
|
||||
rdev: entry.rdev,
|
||||
isCharacterDevice: () => (entry.kind ?? 'character') === 'character'
|
||||
}
|
||||
} finally {
|
||||
this.activeReads -= 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function darwinRow(
|
||||
device: string,
|
||||
command = '/bin/zsh',
|
||||
start = 'Fri Oct 9 12:34:56 2026'
|
||||
): string {
|
||||
return ` 123 1 123 123 S+ ${device} ${start} ${command}\r\n`
|
||||
}
|
||||
|
||||
describe('nameDarwinTerminals', () => {
|
||||
it.each([
|
||||
'Fri Oct 9 12:34:56 2026',
|
||||
'金 10月 9 12:34:56 2026',
|
||||
'Fr 9. Okt 12:34:56 2026',
|
||||
'Пт окт 9 12:34:56 2026'
|
||||
])('preserves localized start markers and complete command whitespace (%s)', async (start) => {
|
||||
const directory = new TestDeviceDirectory()
|
||||
directory.entries = [{ name: 'ttys009', rdev: 0x10000009n }]
|
||||
const command = 'node\t--date="2026 10 09"\t\t--device=16/9 --flag'
|
||||
|
||||
expect(await nameDarwinTerminals(darwinRow('16/9', command, start), directory)).toBe(
|
||||
darwinRow('ttys009', command, start)
|
||||
)
|
||||
expect(directory.openedPaths).toEqual([])
|
||||
expect(directory.readPaths).toEqual([join('/dev', 'ttys009')])
|
||||
})
|
||||
|
||||
it('preserves short and nonstandard opaque tails including date-only rows', async () => {
|
||||
const directory = new TestDeviceDirectory()
|
||||
directory.entries = [{ name: 'ttys009', rdev: 0x10000009n }]
|
||||
const tails = ['truncated', '2026年10月9日 12:34:56 node', 'Fri Oct 9 12:34:56 2026']
|
||||
const capture = tails.map((tail) => darwinRow('16/9', '', tail)).join('')
|
||||
expect(await nameDarwinTerminals(capture, directory)).toBe(
|
||||
tails.map((tail) => darwinRow('ttys009', '', tail)).join('')
|
||||
)
|
||||
})
|
||||
|
||||
it('discovers a previously unknown device on the next capture', async () => {
|
||||
const directory = new TestDeviceDirectory()
|
||||
expect(await nameDarwinTerminals(darwinRow('16/9'), directory)).toBe(darwinRow('??'))
|
||||
|
||||
directory.entries = [{ name: 'ttys009', rdev: 0x10000009n }]
|
||||
expect(await nameDarwinTerminals(darwinRow('16/9'), directory)).toBe(darwinRow('ttys009'))
|
||||
directory.entries = []
|
||||
expect(await nameDarwinTerminals(darwinRow('16/9'), directory)).toBe(darwinRow('??'))
|
||||
directory.entries = [{ name: 'ttys009', rdev: 0x10000009n }]
|
||||
expect(await nameDarwinTerminals(darwinRow('16/9'), directory)).toBe(darwinRow('ttys009'))
|
||||
expect(directory.openedPaths).toEqual(['/dev', '/dev'])
|
||||
})
|
||||
|
||||
it('updates a known device name after a rename between captures', async () => {
|
||||
const directory = new TestDeviceDirectory()
|
||||
directory.entries = [{ name: 'cu.before', rdev: 0x1600002an }]
|
||||
expect(await nameDarwinTerminals(darwinRow('22/42'), directory)).toBe(darwinRow('cu.before'))
|
||||
|
||||
directory.entries = [{ name: 'cu.after', rdev: 0x1600002an }]
|
||||
expect(await nameDarwinTerminals(darwinRow('22/42'), directory)).toBe(darwinRow('cu.after'))
|
||||
expect(directory.readPaths).toEqual(
|
||||
['ttys042', 'cu.before', 'ttys042', 'cu.after'].map((name) => join('/dev', name))
|
||||
)
|
||||
})
|
||||
|
||||
it('chooses the first directory alias despite lexical and completion order', async () => {
|
||||
const directory = new TestDeviceDirectory()
|
||||
directory.entries = [
|
||||
{ name: 'z-first', rdev: 0x10000009n, delayMs: 20 },
|
||||
{ name: 'a-later', rdev: 0x10000009n, delayMs: 0 }
|
||||
]
|
||||
|
||||
expect(await nameDarwinTerminals(darwinRow('16/9'), directory)).toBe(darwinRow('z-first'))
|
||||
})
|
||||
|
||||
it('scans until a serial name resolves while excluding links and files', async () => {
|
||||
const directory = new TestDeviceDirectory()
|
||||
directory.entries = [
|
||||
{ name: 'tty-link', rdev: 0x1600002an, kind: 'symlink' },
|
||||
{ name: 'tty-file', rdev: 0x1600002an, kind: 'file' },
|
||||
{ name: 'null', rdev: 0x03000002n },
|
||||
{ name: 'cu.serial-adapter', rdev: 0x1600002an },
|
||||
{ name: 'unrelated-entry', rdev: 0n, kind: 'file' }
|
||||
]
|
||||
|
||||
expect(await nameDarwinTerminals(darwinRow('22/42'), directory)).toBe(
|
||||
darwinRow('cu.serial-adapter')
|
||||
)
|
||||
expect(directory.readPaths).toEqual(
|
||||
['ttys042', 'tty-link', 'tty-file', 'null', 'cu.serial-adapter'].map((name) =>
|
||||
join('/dev', name)
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('stops scheduling stats after cancellation while draining all active reads', async () => {
|
||||
const directory = new TestDeviceDirectory()
|
||||
directory.entries = Array.from({ length: 20 }, (_, index) => ({
|
||||
name: `entry-${index}`,
|
||||
rdev: BigInt(index),
|
||||
delayMs: 10
|
||||
}))
|
||||
const controller = new AbortController()
|
||||
const named = nameDarwinTerminals(darwinRow('16/9'), directory, controller.signal)
|
||||
const rejection = expect(named).rejects.toThrow()
|
||||
await new Promise((resolve) => setTimeout(resolve, 2))
|
||||
expect(directory.readPaths).toHaveLength(2)
|
||||
controller.abort()
|
||||
await rejection
|
||||
|
||||
expect(directory.readPaths).toHaveLength(2)
|
||||
expect(directory.closedDirectories).toBe(1)
|
||||
expect(directory.peakReads).toBe(1)
|
||||
})
|
||||
|
||||
it('bounds large inventories and closes the directory without admitting device reads', async () => {
|
||||
const directory = new TestDeviceDirectory()
|
||||
directory.entries = Array.from({ length: 20_000 }, (_, index) => ({
|
||||
name: `entry-${index}-${'x'.repeat(240)}`,
|
||||
rdev: 0n
|
||||
}))
|
||||
|
||||
await expect(nameDarwinTerminals(darwinRow('16/9'), directory)).rejects.toThrow(
|
||||
'Directory listing is too large'
|
||||
)
|
||||
expect(directory.readPaths).toEqual([join('/dev', 'ttys009')])
|
||||
expect(directory.closedDirectories).toBe(1)
|
||||
})
|
||||
|
||||
it('skips individual stat failures and uses the next direct character alias', async () => {
|
||||
const directory = new TestDeviceDirectory()
|
||||
directory.entries = [
|
||||
{ name: 'gone', rdev: 0x10000009n, error: new Error('ENOENT') },
|
||||
{ name: 'denied', rdev: 0x10000009n, error: new Error('EACCES') },
|
||||
{ name: 'ttys009', rdev: 0x10000009n }
|
||||
]
|
||||
|
||||
expect(await nameDarwinTerminals(darwinRow('16/9'), directory)).toBe(darwinRow('ttys009'))
|
||||
})
|
||||
|
||||
it('rejects directory open failures so the reader can fall back to ordinary tty ps', async () => {
|
||||
const directory = new TestDeviceDirectory()
|
||||
directory.openError = new Error('cannot open /dev')
|
||||
|
||||
await expect(nameDarwinTerminals(darwinRow('16/9'), directory)).rejects.toThrow(
|
||||
'cannot open /dev'
|
||||
)
|
||||
expect(directory.readPaths).toEqual([join('/dev', 'ttys009')])
|
||||
})
|
||||
|
||||
it('closes an interrupted directory iterator and rejects partial inventories', async () => {
|
||||
const directory = new TestDeviceDirectory()
|
||||
directory.entries = [{ name: 'cu.device', rdev: 0x10000009n }]
|
||||
directory.enumerationError = new Error('directory enumeration failed')
|
||||
|
||||
await expect(nameDarwinTerminals(darwinRow('16/9'), directory)).rejects.toThrow(
|
||||
'directory enumeration failed'
|
||||
)
|
||||
expect(directory.closedDirectories).toBe(1)
|
||||
expect(directory.readPaths).toEqual([join('/dev', 'ttys009')])
|
||||
})
|
||||
|
||||
it('leaves valid no-terminal rows unchanged without a scan', async () => {
|
||||
const directory = new TestDeviceDirectory()
|
||||
directory.openError = new Error('must not open')
|
||||
const capture = `\r\n${darwinRow('??', 'node --device=16/9')}\n`
|
||||
|
||||
expect(await nameDarwinTerminals(capture, directory)).toBe(capture)
|
||||
expect(directory.openedPaths).toEqual([])
|
||||
})
|
||||
|
||||
it.each([
|
||||
'123 1 S+ node 16/9\n',
|
||||
'not-pid 1 123 123 S+ 16/9 tail\n',
|
||||
'123 nope 123 123 S+ 16/9 tail\n',
|
||||
'123 1 nope 123 S+ 16/9 tail\n',
|
||||
'123 1 123 nope S+ 16/9 tail\n',
|
||||
'PID PPID PGID TPGID STAT TDEV STARTED COMMAND\n',
|
||||
'123 1 123 123 S+ 16/9 \t\r\n'
|
||||
])('rejects unsupported row framing without a device scan (%s)', async (row) => {
|
||||
const directory = new TestDeviceDirectory()
|
||||
await expect(nameDarwinTerminals(darwinRow('16/9') + row, directory)).rejects.toThrow(
|
||||
'malformed_darwin_device_row'
|
||||
)
|
||||
expect(directory.openedPaths).toEqual([])
|
||||
})
|
||||
|
||||
it('maps malformed or out-of-range device tokens to the ordinary unknown marker', async () => {
|
||||
const directory = new TestDeviceDirectory()
|
||||
const capture = ['not-a-device', '-1/0', '1/-1', '256/0', '1/16777216', '1.0/1', '16,9']
|
||||
.map((device) => darwinRow(device))
|
||||
.join('')
|
||||
expect(await nameDarwinTerminals(capture, directory)).toBe(darwinRow('??').repeat(7))
|
||||
expect(directory.openedPaths).toEqual([])
|
||||
})
|
||||
|
||||
it('names zero-padded device tokens by their numeric device', async () => {
|
||||
const directory = new TestDeviceDirectory()
|
||||
directory.entries = [{ name: 'ttys009', rdev: 0x10000009n }]
|
||||
|
||||
expect(await nameDarwinTerminals(darwinRow('016/009'), directory)).toBe(darwinRow('ttys009'))
|
||||
expect(directory.readPaths).toEqual([join('/dev', 'ttys009')])
|
||||
})
|
||||
|
||||
it('rejects undecodable filenames and closes the directory before any stat reads', async () => {
|
||||
const directory = new TestDeviceDirectory()
|
||||
directory.entries = [
|
||||
{ name: 'cu.device', rdev: 0x10000009n },
|
||||
{ name: 'serial-\uFFFD', rdev: 0x1600002an }
|
||||
]
|
||||
await expect(nameDarwinTerminals(darwinRow('16/9'), directory)).rejects.toThrow(
|
||||
'undecodable_device_name'
|
||||
)
|
||||
expect(directory.closedDirectories).toBe(1)
|
||||
expect(directory.readPaths).toEqual([join('/dev', 'ttys009')])
|
||||
})
|
||||
|
||||
it('accepts signed and sign-extended device numbers while ignoring arbitrary wide rdev', async () => {
|
||||
const directory = new TestDeviceDirectory()
|
||||
directory.entries = [
|
||||
{ name: 'signed-device', rdev: -2147483525n },
|
||||
{ name: 'sign-extended-device', rdev: 0xffffffff8000007cn },
|
||||
{ name: 'high-device', rdev: 0xfffffffen },
|
||||
{ name: 'wide-device', rdev: 0x1001600002an }
|
||||
]
|
||||
const capture = ['128/123', '128/124', '255/16777214', '22/42']
|
||||
.map((device) => darwinRow(device))
|
||||
.join('')
|
||||
|
||||
expect(await nameDarwinTerminals(capture, directory)).toBe(
|
||||
['signed-device', 'sign-extended-device', 'high-device', '??']
|
||||
.map((device) => darwinRow(device))
|
||||
.join('')
|
||||
)
|
||||
})
|
||||
|
||||
it.each(['x'.repeat(255), `${'é'.repeat(127)}x`])(
|
||||
'uses the Apple buffer limit for the first alias (%s)',
|
||||
async (oversizedName) => {
|
||||
const directory = new TestDeviceDirectory()
|
||||
directory.entries = [
|
||||
{ name: oversizedName, rdev: 0x10000009n },
|
||||
{ name: 'later-short-alias', rdev: 0x10000009n }
|
||||
]
|
||||
|
||||
expect(await nameDarwinTerminals(darwinRow('16/9'), directory)).toBe(darwinRow('??'))
|
||||
}
|
||||
)
|
||||
|
||||
it('accepts a 254-byte multibyte alias without stripping its basename', async () => {
|
||||
const directory = new TestDeviceDirectory()
|
||||
const name = 'é'.repeat(127)
|
||||
directory.entries = [{ name, rdev: 0x10000009n }]
|
||||
|
||||
expect(await nameDarwinTerminals(darwinRow('16/9'), directory)).toBe(darwinRow(name))
|
||||
})
|
||||
|
||||
it('admits one device read at a time while inspecting the full directory', async () => {
|
||||
const directory = new TestDeviceDirectory()
|
||||
directory.entries = Array.from({ length: 21 }, (_, index) => ({
|
||||
name: `entry-${index}`,
|
||||
rdev: BigInt(index),
|
||||
delayMs: 1
|
||||
}))
|
||||
|
||||
expect(await nameDarwinTerminals(darwinRow('16/9'), directory)).toBe(darwinRow('??'))
|
||||
expect(directory.peakReads).toBe(1)
|
||||
expect(directory.readPaths).toHaveLength(directory.entries.length + 1)
|
||||
})
|
||||
|
||||
it('resolves a pty with one verified candidate stat and no directory scan', async () => {
|
||||
const directory = new TestDeviceDirectory()
|
||||
directory.entries = [{ name: 'ttys009', rdev: 0x10000009n }]
|
||||
directory.openError = new Error('must not open')
|
||||
|
||||
expect(await nameDarwinTerminals(darwinRow('16/9'), directory)).toBe(darwinRow('ttys009'))
|
||||
expect(directory.openedPaths).toEqual([])
|
||||
expect(directory.readPaths).toEqual([join('/dev', 'ttys009')])
|
||||
})
|
||||
|
||||
it('falls back to the first scanned alias when the pty candidate has another device', async () => {
|
||||
const directory = new TestDeviceDirectory()
|
||||
directory.entries = [
|
||||
{ name: 'cu.alias', rdev: 0x10000009n },
|
||||
{ name: 'ttys009', rdev: 0x0f000009n }
|
||||
]
|
||||
|
||||
expect(await nameDarwinTerminals(darwinRow('16/9'), directory)).toBe(darwinRow('cu.alias'))
|
||||
expect(directory.openedPaths).toEqual(['/dev'])
|
||||
})
|
||||
|
||||
it.each(['symlink', 'file'] as const)(
|
||||
'rejects a %s pty candidate and falls back to a character device',
|
||||
async (kind) => {
|
||||
const directory = new TestDeviceDirectory()
|
||||
directory.entries = [
|
||||
{ name: 'ttys009', rdev: 0x10000009n, kind },
|
||||
{ name: 'cu.real', rdev: 0x10000009n }
|
||||
]
|
||||
|
||||
expect(await nameDarwinTerminals(darwinRow('16/9'), directory)).toBe(darwinRow('cu.real'))
|
||||
expect(directory.openedPaths).toEqual(['/dev'])
|
||||
}
|
||||
)
|
||||
|
||||
it('falls back when the pty candidate stat rejects', async () => {
|
||||
const directory = new TestDeviceDirectory()
|
||||
directory.entries = [
|
||||
{ name: 'ttys009', rdev: 0x10000009n, error: new Error('EIO') },
|
||||
{ name: 'cu.real', rdev: 0x10000009n }
|
||||
]
|
||||
|
||||
expect(await nameDarwinTerminals(darwinRow('16/9'), directory)).toBe(darwinRow('cu.real'))
|
||||
})
|
||||
|
||||
it('falls back when the pty candidate stat throws synchronously', async () => {
|
||||
const directory = new TestDeviceDirectory()
|
||||
directory.entries = [
|
||||
{ name: 'ttys009', rdev: 0x10000009n },
|
||||
{ name: 'cu.real', rdev: 0x10000009n }
|
||||
]
|
||||
const deps = {
|
||||
openDirectory: directory.openDirectory,
|
||||
readDevice: (path: string) => {
|
||||
if (path === join('/dev', 'ttys009')) {
|
||||
throw new Error('stat invocation failed')
|
||||
}
|
||||
return directory.readDevice(path)
|
||||
}
|
||||
}
|
||||
|
||||
expect(await nameDarwinTerminals(darwinRow('16/9'), deps)).toBe(darwinRow('cu.real'))
|
||||
})
|
||||
|
||||
it('scans only for unresolved devices and stops once they resolve', async () => {
|
||||
const directory = new TestDeviceDirectory()
|
||||
directory.entries = [
|
||||
{ name: 'console', rdev: 0n },
|
||||
{ name: 'ttys001', rdev: 0x10000001n },
|
||||
{ name: 'ttys002', rdev: 0x10000002n },
|
||||
{ name: 'later', rdev: 0x1600002an }
|
||||
]
|
||||
const capture = ['16/1', '16/2', '0/0'].map((device) => darwinRow(device)).join('')
|
||||
|
||||
expect(await nameDarwinTerminals(capture, directory)).toBe(
|
||||
['ttys001', 'ttys002', 'console'].map((device) => darwinRow(device)).join('')
|
||||
)
|
||||
expect(directory.openedPaths).toEqual(['/dev'])
|
||||
expect(directory.readPaths).toEqual(
|
||||
['ttys001', 'ttys002', 'ttys000', 'console'].map((name) => join('/dev', name))
|
||||
)
|
||||
})
|
||||
|
||||
it('stops candidate stats after cancellation and rejects once the active read drains', async () => {
|
||||
const directory = new TestDeviceDirectory()
|
||||
directory.entries = [1, 2, 3].map((minor) => ({
|
||||
name: `ttys00${minor}`,
|
||||
rdev: 0x10000000n + BigInt(minor),
|
||||
delayMs: 10
|
||||
}))
|
||||
const capture = ['16/1', '16/2', '16/3'].map((device) => darwinRow(device)).join('')
|
||||
const controller = new AbortController()
|
||||
const named = nameDarwinTerminals(capture, directory, controller.signal)
|
||||
let settled = false
|
||||
void named.then(
|
||||
() => (settled = true),
|
||||
() => (settled = true)
|
||||
)
|
||||
const rejection = expect(named).rejects.toThrow()
|
||||
await new Promise((resolve) => setTimeout(resolve, 2))
|
||||
controller.abort()
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
expect(settled).toBe(false)
|
||||
await rejection
|
||||
|
||||
expect(directory.readPaths).toEqual([join('/dev', 'ttys001')])
|
||||
expect(directory.openedPaths).toEqual([])
|
||||
expect(directory.peakReads).toBe(1)
|
||||
})
|
||||
|
||||
it('admits one device read at a time across candidate and scan phases', async () => {
|
||||
const directory = new TestDeviceDirectory()
|
||||
directory.entries = [
|
||||
...Array.from({ length: 10 }, (_, minor) => ({
|
||||
name: `ttys00${minor}`,
|
||||
rdev: 0x10000000n + BigInt(minor),
|
||||
delayMs: 1
|
||||
})),
|
||||
{ name: 'cu.serial', rdev: 0x1600002an, delayMs: 1 }
|
||||
]
|
||||
const devices = [...Array.from({ length: 10 }, (_, minor) => `16/${minor}`), '22/42']
|
||||
|
||||
expect(
|
||||
await nameDarwinTerminals(devices.map((device) => darwinRow(device)).join(''), directory)
|
||||
).toBe(directory.entries.map((entry) => darwinRow(entry.name)).join(''))
|
||||
expect(directory.peakReads).toBe(1)
|
||||
expect(directory.openedPaths).toEqual(['/dev'])
|
||||
})
|
||||
|
||||
it('accepts a four-digit pty candidate only when its device matches', async () => {
|
||||
const directory = new TestDeviceDirectory()
|
||||
directory.entries = [{ name: 'ttys1000', rdev: 0x100003e8n }]
|
||||
expect(await nameDarwinTerminals(darwinRow('16/1000'), directory)).toBe(darwinRow('ttys1000'))
|
||||
expect(directory.openedPaths).toEqual([])
|
||||
|
||||
directory.entries = [{ name: 'ttys1000', rdev: 0x0f0003e8n }]
|
||||
expect(await nameDarwinTerminals(darwinRow('16/1000'), directory)).toBe(darwinRow('??'))
|
||||
expect(directory.openedPaths).toEqual(['/dev'])
|
||||
})
|
||||
|
||||
it('looks up each distinct device once across repeated rows', async () => {
|
||||
const directory = new TestDeviceDirectory()
|
||||
directory.entries = [
|
||||
{ name: 'ttys009', rdev: 0x10000009n },
|
||||
{ name: 'ttys010', rdev: 0x1000000an }
|
||||
]
|
||||
const capture = Array.from({ length: 50 }, () => darwinRow('16/9') + darwinRow('16/10')).join(
|
||||
''
|
||||
)
|
||||
|
||||
expect(await nameDarwinTerminals(capture, directory)).toBe(
|
||||
Array.from({ length: 50 }, () => darwinRow('ttys009') + darwinRow('ttys010')).join('')
|
||||
)
|
||||
expect(directory.readPaths).toEqual([join('/dev', 'ttys009'), join('/dev', 'ttys010')])
|
||||
})
|
||||
|
||||
it('keeps the canonical public ps arguments on both Darwin and Linux', () => {
|
||||
expect(PS_ARGS).toEqual([
|
||||
'-axo',
|
||||
process.platform === 'darwin'
|
||||
? 'pid=,ppid=,pgid=,tpgid=,stat=,tty=,lstart=,command='
|
||||
: 'pid=,ppid=,pgid=,tpgid=,stat=,tty=,etimes=,command='
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,127 @@
|
||||
import { lstat, opendir } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { DirectoryListingBudget } from './directory-listing-budget'
|
||||
import { ProcessTableCaptureError } from './process-table-snapshot'
|
||||
|
||||
type DarwinTerminalNameDeps = {
|
||||
openDirectory: (path: string) => Promise<AsyncIterable<{ name: string }>>
|
||||
readDevice: (path: string) => Promise<{ rdev: bigint; isCharacterDevice: () => boolean }>
|
||||
}
|
||||
|
||||
const deviceFilesystem: DarwinTerminalNameDeps = {
|
||||
openDirectory: (path) => opendir(path),
|
||||
readDevice: (path) => lstat(path, { bigint: true })
|
||||
}
|
||||
|
||||
// The full capture's first six columns are fixed; localized dates and argv stay opaque.
|
||||
const TDEV_COLUMN =
|
||||
/^([^\S\r\n]*\d+[^\S\r\n]+\d+[^\S\r\n]+-?\d+[^\S\r\n]+-?\d+[^\S\r\n]+\S+[^\S\r\n]+)(\S+)(?=[^\S\r\n]+\S)/
|
||||
const TDEV_COLUMNS = new RegExp(TDEV_COLUMN.source, 'gm')
|
||||
|
||||
function deviceToken(value: string): string | null {
|
||||
const match = value.match(/^(\d+)\/(\d+)$/)
|
||||
if (!match || Number(match[1]) > 255 || Number(match[2]) > 0xffffff) {
|
||||
return null
|
||||
}
|
||||
return `${Number(match[1])}/${Number(match[2])}`
|
||||
}
|
||||
|
||||
function deviceKey(rdev: bigint): string | null {
|
||||
const device = BigInt.asUintN(32, rdev)
|
||||
const signedDevice = BigInt.asIntN(32, rdev)
|
||||
if (rdev !== device && rdev !== signedDevice && rdev !== BigInt.asUintN(64, signedDevice)) {
|
||||
return null
|
||||
}
|
||||
return `${device >> 24n}/${device & 0xffffffn}`
|
||||
}
|
||||
|
||||
async function readDeviceKey(deps: DarwinTerminalNameDeps, name: string): Promise<string | null> {
|
||||
try {
|
||||
const stat = await deps.readDevice(join('/dev', name)).catch(() => null)
|
||||
return stat?.isCharacterDevice() ? deviceKey(stat.rdev) : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// XNU names every pty slave ttys%03d by minor with one /dev entry; verify rdev before trusting it.
|
||||
// Unlike devname, a root-made earlier alias of a pty cannot win here.
|
||||
async function readPtyNames(
|
||||
devices: ReadonlySet<string>,
|
||||
deps: DarwinTerminalNameDeps,
|
||||
signal?: AbortSignal
|
||||
): Promise<Map<string, string>> {
|
||||
const names = new Map<string, string>()
|
||||
for (const device of devices) {
|
||||
signal?.throwIfAborted()
|
||||
const name = `ttys${device.slice(device.indexOf('/') + 1).padStart(3, '0')}`
|
||||
if ((await readDeviceKey(deps, name)) === device) {
|
||||
names.set(device, name)
|
||||
}
|
||||
}
|
||||
signal?.throwIfAborted()
|
||||
return names
|
||||
}
|
||||
|
||||
async function readTerminalNames(
|
||||
devices: ReadonlySet<string>,
|
||||
names: Map<string, string>,
|
||||
deps: DarwinTerminalNameDeps,
|
||||
signal?: AbortSignal
|
||||
): Promise<void> {
|
||||
const entries: string[] = []
|
||||
const budget = new DirectoryListingBudget()
|
||||
signal?.throwIfAborted()
|
||||
for await (const entry of await deps.openDirectory('/dev')) {
|
||||
signal?.throwIfAborted()
|
||||
if (entry.name.includes('\uFFFD')) {
|
||||
throw new ProcessTableCaptureError('undecodable_device_name')
|
||||
}
|
||||
budget.record(entry.name)
|
||||
entries.push(entry.name)
|
||||
}
|
||||
let unresolved = [...devices].filter((device) => !names.has(device)).length
|
||||
for (const name of entries) {
|
||||
if (unresolved === 0) {
|
||||
break
|
||||
}
|
||||
signal?.throwIfAborted()
|
||||
const device = await readDeviceKey(deps, name)
|
||||
// Apple devname chooses the first direct character entry in native directory order.
|
||||
if (device !== null && devices.has(device) && !names.has(device)) {
|
||||
names.set(device, Buffer.byteLength(name, 'utf8') < 255 ? name : '??')
|
||||
unresolved -= 1
|
||||
}
|
||||
}
|
||||
signal?.throwIfAborted()
|
||||
}
|
||||
|
||||
/** Name terminals using this capture's /dev inventory, without retaining positive or misses. */
|
||||
export async function nameDarwinTerminals(
|
||||
stdout: string,
|
||||
deps: DarwinTerminalNameDeps = deviceFilesystem,
|
||||
signal?: AbortSignal
|
||||
): Promise<string> {
|
||||
signal?.throwIfAborted()
|
||||
const devices = new Set<string>()
|
||||
for (const line of stdout.split(/\r?\n/)) {
|
||||
if (!line.trim()) {
|
||||
continue
|
||||
}
|
||||
const match = line.match(TDEV_COLUMN)
|
||||
if (!match) {
|
||||
throw new ProcessTableCaptureError('malformed_darwin_device_row')
|
||||
}
|
||||
const device = deviceToken(match[2])
|
||||
if (device !== null) {
|
||||
devices.add(device)
|
||||
}
|
||||
}
|
||||
const names = await readPtyNames(devices, deps, signal)
|
||||
if (names.size < devices.size) {
|
||||
await readTerminalNames(devices, names, deps, signal)
|
||||
}
|
||||
return stdout.replace(TDEV_COLUMNS, (_row, head: string, device: string) => {
|
||||
return head + (names.get(deviceToken(device) ?? '') ?? '??')
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn() }))
|
||||
vi.mock('node:child_process', () => ({ execFile: execFileMock }))
|
||||
|
||||
import * as processTableFormat from './process-table-snapshot'
|
||||
import {
|
||||
getProcessTableSnapshot,
|
||||
getStrictProcessTableSnapshot,
|
||||
resetProcessTableSnapshotForTests
|
||||
} from './process-table-snapshot-reader'
|
||||
|
||||
const platform = Object.getOwnPropertyDescriptor(process, 'platform')!
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, 'platform', platform)
|
||||
vi.restoreAllMocks()
|
||||
resetProcessTableSnapshotForTests()
|
||||
})
|
||||
|
||||
describe('process table parsing on non-Linux hosts', () => {
|
||||
it.each(['darwin', 'win32'])('only parses the requested view on %s', async (hostPlatform) => {
|
||||
Object.defineProperty(process, 'platform', { value: hostPlatform })
|
||||
resetProcessTableSnapshotForTests()
|
||||
const lenient = vi.spyOn(processTableFormat, 'parseProcessTableRows')
|
||||
const strict = vi.spyOn(processTableFormat, 'parseStrictProcessTableRows')
|
||||
execFileMock.mockImplementation((_program, _args, _options, callback) => {
|
||||
callback(null, {
|
||||
stdout: '100 1 100 100 Ss+ ?? Fri Oct 9 12:34:56 2026 /bin/zsh\n',
|
||||
stderr: ''
|
||||
})
|
||||
})
|
||||
|
||||
expect((await getStrictProcessTableSnapshot())[0]?.pid).toBe(100)
|
||||
expect(lenient).not.toHaveBeenCalled()
|
||||
expect(strict).toHaveBeenCalledTimes(1)
|
||||
await getStrictProcessTableSnapshot()
|
||||
expect(strict).toHaveBeenCalledTimes(1)
|
||||
expect((await getProcessTableSnapshot())[0]?.pid).toBe(100)
|
||||
expect(lenient).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,93 @@
|
||||
import { basename } from 'node:path'
|
||||
import type { nameDarwinTerminals } from './darwin-terminal-names'
|
||||
|
||||
export type PsFixtureRow = {
|
||||
pid: number
|
||||
ppid: number
|
||||
pgid: number
|
||||
tpgid: number
|
||||
stat: string
|
||||
terminalMinor: number
|
||||
startTime: string
|
||||
startTicks: number
|
||||
command: string
|
||||
}
|
||||
|
||||
/** One synthetic host supplies requested ps columns, /dev devices, and stable /proc starts. */
|
||||
export function createProcessTablePsFixture(readRows: () => readonly PsFixtureRow[]) {
|
||||
function render(columns: string): string {
|
||||
const output = readRows()
|
||||
.map((row) =>
|
||||
columns
|
||||
.split(',')
|
||||
.map((column) => {
|
||||
switch (column) {
|
||||
case 'pid=':
|
||||
return row.pid
|
||||
case 'ppid=':
|
||||
return row.ppid
|
||||
case 'pgid=':
|
||||
return row.pgid
|
||||
case 'tpgid=':
|
||||
return row.tpgid
|
||||
case 'stat=':
|
||||
return row.stat
|
||||
case 'tdev=':
|
||||
return `16/${row.terminalMinor}`
|
||||
case 'tty=':
|
||||
return process.platform === 'darwin'
|
||||
? `ttys${String(row.terminalMinor).padStart(3, '0')}`
|
||||
: `pts/${row.terminalMinor}`
|
||||
case 'lstart=':
|
||||
return row.startTime
|
||||
case 'etimes=':
|
||||
// Deliberate drift makes /proc the only stable Linux start marker.
|
||||
return String(100 + Math.floor(Date.now() / 1000))
|
||||
case 'command=':
|
||||
return row.command
|
||||
default:
|
||||
throw new Error(`unsupported fixture ps column: ${column}`)
|
||||
}
|
||||
})
|
||||
.join(' ')
|
||||
)
|
||||
.join('\n')
|
||||
return `${output}\n`
|
||||
}
|
||||
|
||||
function readProcStat(path: string): string {
|
||||
const match = path.match(/^\/proc\/(\d+)\/stat$/)
|
||||
const row = match ? readRows().find((candidate) => candidate.pid === Number(match[1])) : null
|
||||
if (!row) {
|
||||
throw new Error(`no fixture process at ${path}`)
|
||||
}
|
||||
const start = String(row.startTicks)
|
||||
return `${row.pid} (fixture) ${['S', ...Array.from({ length: 18 }, () => '0'), start, '0'].join(' ')}`
|
||||
}
|
||||
|
||||
function nameTerminals(
|
||||
translate: typeof nameDarwinTerminals,
|
||||
stdout: string,
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
const minors = new Set(readRows().map((row) => row.terminalMinor))
|
||||
return translate(
|
||||
stdout,
|
||||
{
|
||||
openDirectory: async () =>
|
||||
(async function* () {
|
||||
for (const minor of minors) {
|
||||
yield { name: `ttys${String(minor).padStart(3, '0')}` }
|
||||
}
|
||||
})(),
|
||||
readDevice: async (path: string) => ({
|
||||
rdev: (16n << 24n) | BigInt(basename(path).slice(4)),
|
||||
isCharacterDevice: () => true
|
||||
})
|
||||
},
|
||||
signal
|
||||
)
|
||||
}
|
||||
|
||||
return { render, readProcStat, nameTerminals }
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
import { execFile as execFileCb } from 'node:child_process'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { promisify } from 'node:util'
|
||||
import {
|
||||
captureDarwinProcessTable,
|
||||
resetDarwinProcessTableCaptureForTests
|
||||
} from './darwin-process-table-capture'
|
||||
import {
|
||||
PROCESS_TABLE_SNAPSHOT_MAX_STALENESS_MS,
|
||||
PS_ARGS,
|
||||
@@ -23,7 +27,6 @@ const execFile = promisify(execFileCb)
|
||||
// whole subsystem answered "unverifiable" about a table it could read. This keeps a wedged
|
||||
// `ps` bounded while staying out of reach of a host that is merely busy.
|
||||
export const PS_TIMEOUT_MS = 15_000
|
||||
const DEFAULT_SNAPSHOT_TTL_MS = PROCESS_TABLE_SNAPSHOT_MAX_STALENESS_MS
|
||||
|
||||
type Snapshot<T> = { value: T; capturedAtMs: number; completedAtMs: number }
|
||||
|
||||
@@ -42,7 +45,7 @@ export function createProcessTableSnapshotReader<T = string>(
|
||||
getFreshSnapshot: () => Promise<T>
|
||||
reset: () => void
|
||||
} {
|
||||
const ttlMs = deps.ttlMs ?? DEFAULT_SNAPSHOT_TTL_MS
|
||||
const ttlMs = deps.ttlMs ?? PROCESS_TABLE_SNAPSHOT_MAX_STALENESS_MS
|
||||
let cached: Snapshot<T> | null = null
|
||||
let inFlight: Promise<T> | null = null
|
||||
let sequence = 0
|
||||
@@ -226,10 +229,7 @@ export function parseLinuxProcStatStartTime(stat: string): string | null {
|
||||
/** Read Linux's stable PID start-time ticks without spawning another process. */
|
||||
async function readLinuxProcessStartTimes(
|
||||
rows: readonly ProcessTableRow[]
|
||||
): Promise<ReadonlyMap<number, string> | undefined> {
|
||||
if (process.platform !== 'linux') {
|
||||
return undefined
|
||||
}
|
||||
): Promise<ReadonlyMap<number, string>> {
|
||||
const candidates = rows.filter((row) => row.tty !== undefined && row.tty !== '?')
|
||||
const starts = await Promise.all(
|
||||
candidates.map(async (row) => {
|
||||
@@ -252,12 +252,12 @@ async function readLinuxProcessStartTimes(
|
||||
return result
|
||||
}
|
||||
|
||||
async function captureProcessTable(args: readonly string[]): Promise<string> {
|
||||
async function captureProcessTable(args: readonly string[], timeout = PS_TIMEOUT_MS) {
|
||||
let stdout: string
|
||||
try {
|
||||
;({ stdout } = await execFile('ps', [...args], {
|
||||
encoding: 'utf-8',
|
||||
timeout: PS_TIMEOUT_MS,
|
||||
timeout,
|
||||
maxBuffer: PS_MAX_BUFFER_BYTES
|
||||
}))
|
||||
} catch (error) {
|
||||
@@ -272,10 +272,15 @@ async function captureProcessTable(args: readonly string[]): Promise<string> {
|
||||
|
||||
const processTableReader = createProcessTableSnapshotReader<ProcessTableCapture>({
|
||||
runPs: async () => {
|
||||
const stdout = await captureProcessTable(PS_ARGS)
|
||||
const baseCapture = createProcessTableCapture(stdout)
|
||||
const startTimesByPid = await readLinuxProcessStartTimes(baseCapture.lenient())
|
||||
return createProcessTableCapture(stdout, startTimesByPid, process.platform === 'linux')
|
||||
const stdout =
|
||||
process.platform === 'darwin'
|
||||
? await captureDarwinProcessTable(captureProcessTable, assertWholeCapture, PS_TIMEOUT_MS)
|
||||
: await captureProcessTable(PS_ARGS)
|
||||
if (process.platform !== 'linux') {
|
||||
return createProcessTableCapture(stdout)
|
||||
}
|
||||
const startTimesByPid = await readLinuxProcessStartTimes(parseProcessTableRows(stdout))
|
||||
return createProcessTableCapture(stdout, startTimesByPid, true)
|
||||
},
|
||||
now: () => Date.now()
|
||||
})
|
||||
@@ -355,4 +360,5 @@ export async function getStrictProcessTableSnapshotWithAge(): Promise<{
|
||||
export function resetProcessTableSnapshotForTests(): void {
|
||||
processTableReader.reset()
|
||||
shellForegroundReader.reset()
|
||||
resetDarwinProcessTableCaptureForTests()
|
||||
}
|
||||
|
||||
@@ -269,7 +269,11 @@ describe('shared process-table capture', () => {
|
||||
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 tty = process.platform === 'darwin' ? '??' : '?'
|
||||
const forks = mockPsCaptures(
|
||||
`100 1 100 100 Ss+ ${tty} Fri Oct 9 12:34:56 2026 /bin/zsh\n`,
|
||||
`200 1 200 200 Ss+ ${tty} Fri Oct 9 12:34:56 2026 /bin/bash\n`
|
||||
)
|
||||
|
||||
const [lenient, strict] = await Promise.all([
|
||||
getProcessTableSnapshot(),
|
||||
@@ -282,7 +286,11 @@ describe('shared process-table capture', () => {
|
||||
})
|
||||
|
||||
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')
|
||||
const tty = process.platform === 'darwin' ? '??' : '?'
|
||||
const forks = mockPsCaptures(
|
||||
`100 1 100 100 Ss+ ${tty} Fri Oct 9 12:34:56 2026 /bin/zsh\n`,
|
||||
`200 1 200 200 Ss+ ${tty} Fri Oct 9 12:34:56 2026 /bin/bash\n`
|
||||
)
|
||||
|
||||
await getProcessTableSnapshot()
|
||||
const strict = await getStrictProcessTableSnapshot()
|
||||
@@ -295,6 +303,8 @@ describe('shared process-table capture', () => {
|
||||
pgid: 100,
|
||||
tpgid: 100,
|
||||
stat: 'Ss+',
|
||||
tty,
|
||||
...(process.platform === 'linux' ? {} : { startTime: 'Fri Oct 9 12:34:56 2026' }),
|
||||
command: '/bin/zsh'
|
||||
}
|
||||
])
|
||||
@@ -306,7 +316,7 @@ describe('shared process-table capture', () => {
|
||||
const lenient = await getProcessTableSnapshot()
|
||||
await expect(getStrictProcessTableSnapshot()).rejects.toBeInstanceOf(ProcessTableCaptureError)
|
||||
|
||||
expect(forks()).toBe(1)
|
||||
expect(forks()).toBe(process.platform === 'darwin' ? 2 : 1)
|
||||
expect(lenient).toEqual([{ pid: 100, ppid: 1, stat: 'Ss+', command: '/bin/zsh' }])
|
||||
})
|
||||
})
|
||||
@@ -664,7 +674,10 @@ describe('evidence-publishing capture budget', () => {
|
||||
})
|
||||
|
||||
/** 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') {
|
||||
function mockPsTaking(
|
||||
durationMs: number,
|
||||
stdout = `1 0 1 1 S+ ${process.platform === 'darwin' ? '??' : '?'} Thu 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
|
||||
@@ -676,8 +689,10 @@ describe('evidence-publishing capture budget', () => {
|
||||
it('answers from a capture that lands one tick inside the budget', async () => {
|
||||
mockPsTaking(PROCESS_TABLE_EVIDENCE_BUDGET_MS - 1)
|
||||
const pending = getStrictProcessTableSnapshotWithAge()
|
||||
const settled = pending.catch(() => undefined)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(PROCESS_TABLE_EVIDENCE_BUDGET_MS)
|
||||
await settled
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -5,10 +5,13 @@ vi.mock('node:child_process', () => ({ execFile: execFileMock }))
|
||||
|
||||
import {
|
||||
getFreshShellForegroundSnapshot,
|
||||
getFreshProcessTableSnapshot,
|
||||
getProcessTableSnapshot,
|
||||
getStrictProcessTableSnapshotWithAge,
|
||||
resetProcessTableSnapshotForTests
|
||||
} from './process-table-snapshot-reader'
|
||||
import { parseShellForegroundRows } from './process-table-snapshot'
|
||||
import { parseShellForegroundRows, PS_ARGS } from './process-table-snapshot'
|
||||
import * as darwinTerminalNames from './darwin-terminal-names'
|
||||
|
||||
type Callback = (error: Error | null, result: { stdout: string; stderr: string }) => void
|
||||
const platform = Object.getOwnPropertyDescriptor(process, 'platform')!
|
||||
@@ -24,7 +27,7 @@ afterEach(() => Object.defineProperty(process, 'platform', platform))
|
||||
it('answers concurrent shell proofs without waiting for a pending full capture', async () => {
|
||||
let finishFull!: Callback
|
||||
execFileMock.mockImplementation((_program, args: string[], _options, callback: Callback) => {
|
||||
if (args[1]?.includes('tty=')) {
|
||||
if (args[1]?.includes('tdev=')) {
|
||||
finishFull = callback
|
||||
} else {
|
||||
expect(args).toEqual(['-axo', 'pid=,ppid=,pgid=,tpgid=,stat=,command='])
|
||||
@@ -41,7 +44,10 @@ it('answers concurrent shell proofs without waiting for a pending full capture',
|
||||
])
|
||||
expect(second).toBe(first)
|
||||
expect(execFileMock).toHaveBeenCalledTimes(2)
|
||||
finishFull(null, { stdout: shell, stderr: '' })
|
||||
finishFull(null, {
|
||||
stdout: '100 99 100 100 Ss+ ?? Fri Oct 9 12:34:56 2026 /bin/zsh -l',
|
||||
stderr: ''
|
||||
})
|
||||
await full
|
||||
await getFreshShellForegroundSnapshot()
|
||||
expect(execFileMock).toHaveBeenCalledTimes(3)
|
||||
@@ -75,3 +81,106 @@ it('rejects an unreadable shell capture', async () => {
|
||||
})
|
||||
await expect(getFreshShellForegroundSnapshot()).rejects.toThrow('empty_capture')
|
||||
})
|
||||
|
||||
it('recaptures ordinary tty columns when the optimized full row has incomplete framing', async () => {
|
||||
execFileMock.mockImplementation((_program, args: string[], _options, callback: Callback) => {
|
||||
callback(null, {
|
||||
stdout: args[1]?.includes('tdev=')
|
||||
? '100 99 100 100 Ss+ 16/9'
|
||||
: '100 99 100 100 Ss+ ttys009 Fri Oct 9 12:34:56 2026 /bin/zsh -l',
|
||||
stderr: ''
|
||||
})
|
||||
})
|
||||
|
||||
expect((await getProcessTableSnapshot())[0]?.tty).toBe('ttys009')
|
||||
expect(execFileMock.mock.calls.map((call) => call[1][1])).toEqual([
|
||||
PS_ARGS[1].replace('tty=', 'tdev='),
|
||||
PS_ARGS[1]
|
||||
])
|
||||
})
|
||||
|
||||
it('shares one translated capture and includes naming time in its age and completion TTL', async () => {
|
||||
const raw = '100 99 100 100 Ss+ 16/9 Fri Oct 9 12:34:56 2026 /bin/zsh -l'
|
||||
const named = raw.replace('16/9', 'ttys009')
|
||||
let finishNaming: (stdout: string) => void = () => {
|
||||
throw new Error('naming did not start')
|
||||
}
|
||||
const naming = vi.spyOn(darwinTerminalNames, 'nameDarwinTerminals').mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finishNaming = resolve
|
||||
})
|
||||
)
|
||||
const now = vi.spyOn(Date, 'now').mockReturnValue(1_000)
|
||||
try {
|
||||
execFileMock.mockImplementation((_program, _args, _options, callback: Callback) => {
|
||||
callback(null, { stdout: raw, stderr: '' })
|
||||
})
|
||||
const lenient = getProcessTableSnapshot()
|
||||
const strict = getStrictProcessTableSnapshotWithAge()
|
||||
await vi.waitFor(() => expect(naming).toHaveBeenCalledTimes(1))
|
||||
now.mockReturnValue(2_000)
|
||||
finishNaming(named)
|
||||
|
||||
expect((await lenient)[0]?.tty).toBe('ttys009')
|
||||
expect(await strict).toMatchObject({ capturedAgeMs: 1_000, rows: [{ tty: 'ttys009' }] })
|
||||
now.mockReturnValue(2_300)
|
||||
expect((await getProcessTableSnapshot())[0]?.tty).toBe('ttys009')
|
||||
expect(execFileMock).toHaveBeenCalledTimes(1)
|
||||
expect(naming).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
naming.mockRestore()
|
||||
now.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ message: 'ps: tdev: keyword not found', code: 1 },
|
||||
{ message: 'error: unknown user-defined format specifier "tdev"', code: 2 }
|
||||
])(
|
||||
'remembers an unsupported device column without caching terminal names ($message)',
|
||||
async ({ message, code }) => {
|
||||
execFileMock.mockImplementation((_program, args: string[], _options, callback: Callback) => {
|
||||
if (args[1]?.includes('tdev=')) {
|
||||
callback(Object.assign(new Error(message), { code }), { stdout: '', stderr: message })
|
||||
} else {
|
||||
callback(null, {
|
||||
stdout: '100 99 100 100 Ss+ ttys009 Fri Oct 9 12:34:56 2026 /bin/zsh -l',
|
||||
stderr: ''
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
expect((await getProcessTableSnapshot())[0]?.tty).toBe('ttys009')
|
||||
expect((await getFreshProcessTableSnapshot())[0]?.tty).toBe('ttys009')
|
||||
expect(execFileMock.mock.calls.map((call) => call[1][1])).toEqual([
|
||||
PS_ARGS[1].replace('tty=', 'tdev='),
|
||||
PS_ARGS[1],
|
||||
PS_ARGS[1]
|
||||
])
|
||||
resetProcessTableSnapshotForTests()
|
||||
await getProcessTableSnapshot()
|
||||
expect(execFileMock).toHaveBeenCalledTimes(5)
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
Object.assign(new Error('ps: another: keyword not found'), { code: 1 }),
|
||||
Object.assign(new Error('ps: Operation not permitted'), { code: 1 }),
|
||||
Object.assign(new Error('ps: tdev: keyword not found'), { code: 'EACCES' }),
|
||||
Object.assign(new Error('ps: tdev: keyword not found'), { code: 1, killed: true }),
|
||||
Object.assign(new Error('stdout maxBuffer length exceeded'), {
|
||||
code: 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER'
|
||||
})
|
||||
])('does not retry or remember an unrelated failed capture (%s)', async (error) => {
|
||||
execFileMock.mockImplementation((_program, _args, _options, callback: Callback) => {
|
||||
callback(error, { stdout: '', stderr: '' })
|
||||
})
|
||||
|
||||
await expect(getProcessTableSnapshot()).rejects.toThrow()
|
||||
await expect(getProcessTableSnapshot()).rejects.toThrow()
|
||||
expect(execFileMock.mock.calls.map((call) => call[1][1])).toEqual([
|
||||
PS_ARGS[1].replace('tty=', 'tdev='),
|
||||
PS_ARGS[1].replace('tty=', 'tdev=')
|
||||
])
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user