diff --git a/src/main/daemon/terminal-host-cheap-tier-ps-scan-volume.test.ts b/src/main/daemon/terminal-host-cheap-tier-ps-scan-volume.test.ts index 0633c811ba7..aadd4eb1040 100644 --- a/src/main/daemon/terminal-host-cheap-tier-ps-scan-volume.test.ts +++ b/src/main/daemon/terminal-host-cheap-tier-ps-scan-volume.test.ts @@ -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() + return { + nameDarwinTerminals: (stdout: string, _deps: unknown, signal?: AbortSignal) => + psFixture.nameTerminals(original.nameDarwinTerminals, stdout, signal) + } +}) + +vi.mock('node:fs/promises', async (importOriginal) => ({ + ...(await importOriginal()), + 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 { 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 diff --git a/src/main/daemon/terminal-host-process-inspection-cheap-tier.test.ts b/src/main/daemon/terminal-host-process-inspection-cheap-tier.test.ts index dc91c8918a2..d624be84eab 100644 --- a/src/main/daemon/terminal-host-process-inspection-cheap-tier.test.ts +++ b/src/main/daemon/terminal-host-process-inspection-cheap-tier.test.ts @@ -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() + return { + nameDarwinTerminals: (stdout: string, _deps: unknown, signal?: AbortSignal) => + psFixture.nameTerminals(original.nameDarwinTerminals, stdout, signal) + } +}) + +vi.mock('node:fs/promises', async (importOriginal) => ({ + ...(await importOriginal()), + 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> + result: Awaited> }> { 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 { 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) diff --git a/src/main/providers/agent-foreground-process-ps-scan-volume.test.ts b/src/main/providers/agent-foreground-process-ps-scan-volume.test.ts index 178ff34660d..6c0269d65d4 100644 --- a/src/main/providers/agent-foreground-process-ps-scan-volume.test.ts +++ b/src/main/providers/agent-foreground-process-ps-scan-volume.test.ts @@ -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() + return { + nameDarwinTerminals: (stdout: string, _deps: unknown, signal?: AbortSignal) => + psFixture.nameTerminals(original.nameDarwinTerminals, stdout, signal) + } +}) + +vi.mock('node:fs/promises', async (importOriginal) => ({ + ...(await importOriginal()), + 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) }) }) diff --git a/src/shared/darwin-process-table-capture.test.ts b/src/shared/darwin-process-table-capture.test.ts new file mode 100644 index 00000000000..76d5433cfaf --- /dev/null +++ b/src/shared/darwin-process-table-capture.test.ts @@ -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((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((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) + }) +}) diff --git a/src/shared/darwin-process-table-capture.ts b/src/shared/darwin-process-table-capture.ts new file mode 100644 index 00000000000..6d8c36404dd --- /dev/null +++ b/src/shared/darwin-process-table-capture.ts @@ -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 | null = null + +export async function captureDarwinProcessTable( + capture: (args: readonly string[], timeoutMs?: number) => Promise, + validate: (stdout: string) => string, + budgetMs: number +): Promise { + 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 => 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 +} diff --git a/src/shared/darwin-process-table-inventory-drain.test.ts b/src/shared/darwin-process-table-inventory-drain.test.ts new file mode 100644 index 00000000000..19dc21817e0 --- /dev/null +++ b/src/shared/darwin-process-table-inventory-drain.test.ts @@ -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 => Promise.reject(new Error('ENOENT')) + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((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() + let closeStarted = false + async function* entries() { + try { + yield { name: 'ttys009' } + } finally { + closeStarted = true + await close.promise + } + } + const opened = deferred>() + 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) + }) +}) diff --git a/src/shared/darwin-terminal-device-stats.test.ts b/src/shared/darwin-terminal-device-stats.test.ts new file mode 100644 index 00000000000..528d72a1926 --- /dev/null +++ b/src/shared/darwin-terminal-device-stats.test.ts @@ -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 }] + ]) + }) +}) diff --git a/src/shared/darwin-terminal-names-real-ps.test.ts b/src/shared/darwin-terminal-names-real-ps.test.ts new file mode 100644 index 00000000000..36368ef8af9 --- /dev/null +++ b/src/shared/darwin-terminal-names-real-ps.test.ts @@ -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 { + return new Map( + stdout + .split('\n') + .filter((line) => line.trim()) + .map((line) => [line.trim().split(/\s+/)[0], line]) + ) +} + +function ttyByPid(stdout: string): Map { + 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([]) + }) +}) diff --git a/src/shared/darwin-terminal-names.test.ts b/src/shared/darwin-terminal-names.test.ts new file mode 100644 index 00000000000..313e6f80977 --- /dev/null +++ b/src/shared/darwin-terminal-names.test.ts @@ -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> => { + 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=' + ]) + }) +}) diff --git a/src/shared/darwin-terminal-names.ts b/src/shared/darwin-terminal-names.ts new file mode 100644 index 00000000000..f0a5db9257b --- /dev/null +++ b/src/shared/darwin-terminal-names.ts @@ -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> + 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 { + 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, + deps: DarwinTerminalNameDeps, + signal?: AbortSignal +): Promise> { + const names = new Map() + 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, + names: Map, + deps: DarwinTerminalNameDeps, + signal?: AbortSignal +): Promise { + 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 { + signal?.throwIfAborted() + const devices = new Set() + 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) ?? '') ?? '??') + }) +} diff --git a/src/shared/process-table-capture-parsing.test.ts b/src/shared/process-table-capture-parsing.test.ts new file mode 100644 index 00000000000..1c55cb4fdcb --- /dev/null +++ b/src/shared/process-table-capture-parsing.test.ts @@ -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) + }) +}) diff --git a/src/shared/process-table-ps-test-fixture.ts b/src/shared/process-table-ps-test-fixture.ts new file mode 100644 index 00000000000..b4274744b7f --- /dev/null +++ b/src/shared/process-table-ps-test-fixture.ts @@ -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 } +} diff --git a/src/shared/process-table-snapshot-reader.ts b/src/shared/process-table-snapshot-reader.ts index 06d3407f3b9..a5f34aff034 100644 --- a/src/shared/process-table-snapshot-reader.ts +++ b/src/shared/process-table-snapshot-reader.ts @@ -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 = { value: T; capturedAtMs: number; completedAtMs: number } @@ -42,7 +45,7 @@ export function createProcessTableSnapshotReader( getFreshSnapshot: () => Promise reset: () => void } { - const ttlMs = deps.ttlMs ?? DEFAULT_SNAPSHOT_TTL_MS + const ttlMs = deps.ttlMs ?? PROCESS_TABLE_SNAPSHOT_MAX_STALENESS_MS let cached: Snapshot | null = null let inFlight: Promise | 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 | undefined> { - if (process.platform !== 'linux') { - return undefined - } +): Promise> { 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 { +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 { const processTableReader = createProcessTableSnapshotReader({ 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() } diff --git a/src/shared/process-table-snapshot.test.ts b/src/shared/process-table-snapshot.test.ts index 19aa4312764..f7e126f5566 100644 --- a/src/shared/process-table-snapshot.test.ts +++ b/src/shared/process-table-snapshot.test.ts @@ -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. diff --git a/src/shared/shell-foreground-snapshot.test.ts b/src/shared/shell-foreground-snapshot.test.ts index e4cfa5f53a5..ab4227d05a1 100644 --- a/src/shared/shell-foreground-snapshot.test.ts +++ b/src/shared/shell-foreground-snapshot.test.ts @@ -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=') + ]) +})