Files
orca/src/relay/pty-shell-utils.ts
T
Neil 406bd0e378 perf(relay): cache process-table descendant indexes (#17646)
* perf(relay): cache process-table descendant indexes

* fix(relay): keep the process-table index first-wins and narrow

Two defects in the memoized index this PR introduced.

- Restore the first-wins duplicate-pid tie-break the relay had as
  `rows.find()`. A process whose argv contains a newline makes `ps` print a
  continuation line that the lenient parser can accept as a spurious row
  duplicating a real pid; that row always FOLLOWS the real one, so last-wins let
  it capture the pane's foreground. The rule now lives in
  `buildProcessTableIndex`, so the batched evidence resolver's `byPid.get(rootPid)`
  root lookup gets the same semantics the subsystem had before indexing.
- Build only the two indexes a resolver reads. `byPgid`/`byTpgid` have no readers
  repo-wide, and delegating to a four-map build made a one-pane relay pay more
  per 500ms capture than the single `childrenByParent` map it replaced --
  a regression in the majority topology, in a PR whose point is relay CPU.

Matches the same deletion in #17763 line for line so whichever merges second
resolves trivially.
2026-08-31 18:38:03 -07:00

369 lines
12 KiB
TypeScript

import { execFile as execFileCb, execFileSync } from 'node:child_process'
import { existsSync, readFileSync } from 'node:fs'
import { homedir } from 'node:os'
import { win32 as pathWin32 } from 'node:path'
import { promisify } from 'node:util'
import {
isAgentForegroundWrapperProcess,
isExpectedAgentProcess,
recognizeAgentProcess,
recognizeAgentProcessFromCommandLine
} from '../shared/agent-process-recognition'
import { getFirstCommandToken } from '../shared/command-token-scanner'
import {
getProcessTableIndex,
getProcessTableSnapshot,
scoreForegroundCandidateRow,
type ProcessTableIndex,
type ProcessTableRow
} from '../shared/process-table-snapshot'
import {
resolveOuterWrapperForegroundProcess,
shouldInspectOuterWrapperForegroundProcess
} from '../shared/foreground-wrapper-agent'
import { isShellProcess } from '../shared/shell-process-detection'
import {
resolveWindowsAgentForegroundProcess,
shouldInspectWindowsAgentForeground
} from '../main/providers/windows-agent-foreground-process'
const execFile = promisify(execFileCb)
const OPENSSH_REGISTRY_KEY = 'HKLM\\SOFTWARE\\OpenSSH'
let openSshDefaultShell: string | undefined
export function readOpenSshDefaultShell(): string {
if (openSshDefaultShell !== undefined) {
return openSshDefaultShell
}
try {
const output = execFileSync('reg.exe', ['query', OPENSSH_REGISTRY_KEY, '/v', 'DefaultShell'], {
encoding: 'utf8',
timeout: 3000,
windowsHide: true
})
const match = output.match(/^\s*DefaultShell\s+REG_\w+\s+(.+?)\s*$/im)
openSshDefaultShell = match?.[1] ?? ''
} catch {
openSshDefaultShell = ''
}
return openSshDefaultShell
}
export function resolveWindowsDefaultShell(
env: NodeJS.ProcessEnv = process.env,
existsPath: (path: string) => boolean = existsSync,
readDefaultShell: () => string = readOpenSshDefaultShell
): string {
const envShell = env.SHELL
if (envShell && existsPath(envShell)) {
return envShell
}
const configuredShell = readDefaultShell()
if (configuredShell && existsPath(configuredShell)) {
return configuredShell
}
const systemRoot = env.SystemRoot || env.WINDIR || env.windir || 'C:\\Windows'
const windowsPowerShell = pathWin32.join(
systemRoot,
'System32',
'WindowsPowerShell',
'v1.0',
'powershell.exe'
)
if (existsPath(windowsPowerShell)) {
return windowsPowerShell
}
const comspec = env.ComSpec || env.COMSPEC
if (comspec && existsPath(comspec)) {
return comspec
}
return comspec || 'powershell.exe'
}
/**
* Resolve the default shell for PTY spawning.
* Prefers $SHELL, then common fallbacks.
*/
export function resolveDefaultShell(): string {
if (process.platform === 'win32') {
return resolveWindowsDefaultShell()
}
const envShell = process.env.SHELL
if (envShell && existsSync(envShell)) {
return envShell
}
for (const candidate of ['/bin/bash', '/bin/zsh', '/bin/sh']) {
if (existsSync(candidate)) {
return candidate
}
}
return '/bin/sh'
}
export function resolveDefaultCwd(
env: NodeJS.ProcessEnv = process.env,
platform: NodeJS.Platform = process.platform,
homeDir = homedir()
): string {
if (platform === 'win32') {
const driveHome = env.HOMEDRIVE && env.HOMEPATH ? `${env.HOMEDRIVE}${env.HOMEPATH}` : undefined
return env.USERPROFILE || env.HOME || driveHome || homeDir || `${env.SystemDrive || 'C:'}\\`
}
return env.HOME || homeDir || '/'
}
/**
* Resolve the current working directory of a process by pid.
* Tries /proc on Linux and lsof on macOS before falling back to `fallbackCwd`.
*/
export async function resolveProcessCwd(pid: number, fallbackCwd: string): Promise<string> {
// Try to read /proc/{pid}/cwd on Linux. Skip an existsSync gate — the
// check+read pair races a concurrent exit anyway, and the catch already
// falls through to lsof.
try {
const { readlinkSync } = await import('node:fs')
return readlinkSync(`/proc/${pid}/cwd`)
} catch {
// Fall through
}
// Fallback: use lsof on macOS
// Why: `-d cwd` restricts output to the cwd file descriptor only. Without it,
// lsof returns ALL open files (sockets, log files, TTYs) and the first `n`-line
// could be any of them — not the actual working directory.
try {
// Why: `-a` ANDs the -p and -d filters. Without it, macOS lsof ORs them
// and emits cwd records for every process on the system, so the n-line
// scan below picks up the first unrelated process (often pid ~391 with
// cwd `/`) and returns `/` regardless of the target pid's real cwd.
const { stdout: output } = await execFile(
'lsof',
['-a', '-p', String(pid), '-d', 'cwd', '-Fn'],
{
encoding: 'utf-8',
timeout: 3000
}
)
const lines = output.split('\n')
for (const line of lines) {
if (line.startsWith('n') && line.includes('/')) {
// Why: lsof -d cwd is authoritative — don't second-guess it with
// existsSync. A concurrent rmdir would race the check and cause us
// to drop the correct answer; node-pty handles a missing cwd on
// spawn anyway.
return line.slice(1)
}
}
} catch {
// Fall through
}
return fallbackCwd
}
/**
* Check whether a process has child processes (via pgrep).
*/
export async function processHasChildren(pid: number): Promise<boolean> {
try {
const { stdout } = await execFile('pgrep', ['-P', String(pid)], {
encoding: 'utf-8',
timeout: 3000
})
return stdout.trim().length > 0
} catch {
return false
}
}
// Why: signal 0 probes existence without delivering a signal. Only ESRCH ("no
// such process") proves the pid is gone; EPERM means it exists but is
// unsignalable, so treat every non-ESRCH outcome as alive. Kept conservative so
// a liveness check can only ever declare a *provably* dead process dead.
export function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0)
return true
} catch (err) {
return (err as NodeJS.ErrnoException).code !== 'ESRCH'
}
}
function collectDescendants(
index: ProcessTableIndex,
rootPid: number
): (ProcessTableRow & { depth: number })[] {
const descendants: (ProcessTableRow & { depth: number })[] = []
const stack = (index.childrenByPpid.get(rootPid) ?? []).map((row) => ({ row, depth: 1 }))
while (stack.length > 0) {
const { row, depth } = stack.pop()!
descendants.push({ ...row, depth })
for (const child of index.childrenByPpid.get(row.pid) ?? []) {
stack.push({ row: child, depth: depth + 1 })
}
}
return descendants
}
async function getRecognizedForegroundDescendant(
pid: number,
fallbackProcess?: string | null
): Promise<string | null> {
try {
const rows = await getProcessTableSnapshot()
return getForegroundProcessNameFromProcessTable(rows, pid, fallbackProcess)
} catch {
// Fall through to node-pty's process name or the root command name.
}
return null
}
// Why: returns null (never the fallback) so `getForegroundProcessName` keeps
// owning the fallback ladder — its wrapper branch answers with the RECOGNIZED
// process name, which is normalized where node-pty's raw name is not.
function getForegroundProcessNameFromProcessTable(
rows: ProcessTableRow[],
pid: number,
fallbackProcess?: string | null
): string | null {
// Why: one memoized index per capture, so N panes sharing the TTL-cached
// snapshot no longer each rebuild the parent/child map over every row.
const index = getProcessTableIndex(rows)
const root = index.byPid.get(pid)
const candidates = collectDescendants(index, pid).sort(
(a, b) => scoreForegroundCandidateRow(b) - scoreForegroundCandidateRow(a)
)
// Why: SSH relays do not have the daemon's async wrapper cache. Inspect the
// remote process tree so node/python agent entrypoints become real agents.
const foregroundIsKnown =
root?.stat.includes('+') === true ||
candidates.some((candidate) => candidate.stat.includes('+'))
const foregroundCandidates = foregroundIsKnown
? candidates.filter((candidate) => candidate.stat.includes('+'))
: candidates
const inspectionCandidates =
fallbackProcess && isAgentForegroundWrapperProcess(fallbackProcess)
? foregroundCandidates.filter((candidate) =>
isExpectedAgentProcess(getFirstCommandToken(candidate.command), fallbackProcess)
)
: foregroundCandidates
if (
fallbackProcess &&
isAgentForegroundWrapperProcess(fallbackProcess) &&
inspectionCandidates.length !== 1
) {
return null
}
for (const candidate of inspectionCandidates) {
const recognized = recognizeAgentProcessFromCommandLine(candidate.command)
if (recognized) {
// Why: return the outer wrapper (omp) rather than the deeper wrapped child
// (pi) of a shell→omp→pi tree — see resolveOuterWrapperForegroundProcess.
return resolveOuterWrapperForegroundProcess(recognized, candidate, candidates)
}
}
return null
}
/**
* Get the foreground process name of a given pid (via ps).
*/
export async function getForegroundProcessName(
pid: number,
fallbackProcess?: string | null
): Promise<string | null> {
if (fallbackProcess) {
const fallbackRecognition = recognizeAgentProcess(fallbackProcess)
if (fallbackRecognition) {
// Why: node-pty can report OMP's wrapped Pi; enrich only that ambiguous
// fallback so authoritative OMP reads keep the zero-subprocess fast path.
if (shouldInspectOuterWrapperForegroundProcess(fallbackRecognition)) {
if (process.platform === 'win32') {
return (
(await resolveWindowsAgentForegroundProcess(pid, fallbackProcess, {})) ??
fallbackRecognition.processName
)
}
return (
(await getRecognizedForegroundDescendant(pid, fallbackProcess)) ??
fallbackRecognition.processName
)
}
return fallbackRecognition.processName
}
if (process.platform === 'win32') {
if (!shouldInspectWindowsAgentForeground(fallbackProcess)) {
return fallbackProcess
}
return (
(await resolveWindowsAgentForegroundProcess(pid, fallbackProcess, {})) ?? fallbackProcess
)
}
if (!isShellProcess(fallbackProcess) && !isAgentForegroundWrapperProcess(fallbackProcess)) {
return fallbackProcess
}
}
const recognized = await getRecognizedForegroundDescendant(pid, fallbackProcess)
if (recognized) {
return recognized
}
if (fallbackProcess) {
return fallbackProcess
}
try {
const { stdout } = await execFile('ps', ['-o', 'comm=', '-p', String(pid)], {
encoding: 'utf-8',
timeout: 3000
})
return stdout.trim() || null
} catch {
return null
}
}
export function listShellProfiles(): { name: string; path: string }[] {
const profiles: { name: string; path: string }[] = []
const seen = new Set<string>()
try {
const content = readFileSync('/etc/shells', 'utf-8')
for (const line of content.split('\n')) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#')) {
continue
}
if (!existsSync(trimmed)) {
continue
}
if (seen.has(trimmed)) {
continue
}
seen.add(trimmed)
const name = trimmed.split('/').pop() || trimmed
profiles.push({ name, path: trimmed })
}
} catch {
// /etc/shells may not exist on all systems; fall back to known shells
for (const candidate of ['/bin/bash', '/bin/zsh', '/bin/sh']) {
if (existsSync(candidate) && !seen.has(candidate)) {
seen.add(candidate)
const name = candidate.split('/').pop()!
profiles.push({ name, path: candidate })
}
}
}
return profiles
}