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:
Neil
2026-10-10 16:45:23 -07:00
committed by GitHub
co-authored by Giorgio Zamparelli out-of-place
parent e9bf6d1c3f
commit db2ed7ca36
15 changed files with 1811 additions and 146 deletions
@@ -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([])
})
})
+500
View File
@@ -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='
])
})
})
+127
View File
@@ -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 }
}
+18 -12
View File
@@ -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()
}
+19 -4
View File
@@ -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.
+112 -3
View File
@@ -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=')
])
})