From 77470fa1d4cc11233953e713ef92dfdbb1fdc8cb Mon Sep 17 00:00:00 2001 From: elhoim Date: Wed, 30 Sep 2026 16:08:19 +0000 Subject: [PATCH] perf(terminal): skip the ps/stty walk when /proc shows no controlling TTY On Linux, when the native /proc probe finds no ancestor with a tty on its stdio, probeTerminalWidth fell through to the portable walk: two `ps` spawns per ancestor (up to 8) and then `tput cols`. In a no-TTY spawn (detached Claude Code, IDE hosts, headless runs) `tput` answers 80 or $COLUMNS, so the result is numeric, the session "no TTY" cache (#501) never engages, and every render pays the whole walk again. The native walk already reads /proc//stat for each ancestor. It now also reads tty_nr, the field `ps -o tty=` renders ("?" for 0). When every ancestor the ps walk would visit (including pid 1 and the one at the depth limit) has tty_nr == 0, that walk provably finds nothing and no stty ever runs, so it is skipped. `tput cols` is kept, so the returned width is unchanged. Any unreadable stat, a non-zero tty_nr (controlling terminal with redirected stdio, foreign-uid ancestor) or a non-Linux platform keeps the full fallback. Detached no-TTY run (setsid -f, strace): 8 spawns + ~80 PATH probes per render -> 1 spawn (tput) + ~10 probes; stdout byte-identical on the default/git0/heavy/livelike configs, with and without COLUMNS. 25-pass interleaved bench, load ~61 on 6 cores, CPU median: default 1989 -> 1471 ms (-26%), heavy 2318 -> 1880 ms (-19%). Co-Authored-By: Claude Opus 5.5 --- src/utils/__tests__/terminal-native.test.ts | 84 +++++++++++++++++++++ src/utils/__tests__/terminal.test.ts | 50 ++++++++++++ src/utils/terminal-native.ts | 84 +++++++++++++++++---- src/utils/terminal.ts | 52 ++++++++----- 4 files changed, 238 insertions(+), 32 deletions(-) diff --git a/src/utils/__tests__/terminal-native.test.ts b/src/utils/__tests__/terminal-native.test.ts index 5d0fe7d00..3ba1e4345 100644 --- a/src/utils/__tests__/terminal-native.test.ts +++ b/src/utils/__tests__/terminal-native.test.ts @@ -7,6 +7,8 @@ import { import type { NativeProbeDeps } from '../terminal-native'; import { parsePpidFromStat, + parseTtyNrFromStat, + probeTerminalNative, probeWidthNative } from '../terminal-native'; @@ -37,6 +39,18 @@ describe('parsePpidFromStat', () => { }); }); +describe('parseTtyNrFromStat', () => { + it('parses tty_nr when comm contains spaces and parens', () => { + expect(parseTtyNrFromStat('4242 (my ) weird proc) S 1234 4242 4242 34818 -1 0')).toBe(34818); + expect(parseTtyNrFromStat(TRICKY_STAT)).toBe(0); + }); + + it('returns null on garbage', () => { + expect(parseTtyNrFromStat('not a stat line')).toBeNull(); + expect(parseTtyNrFromStat('1 (node) S 2')).toBeNull(); + }); +}); + describe('probeWidthNative', () => { it('returns null on non-linux platforms', () => { expect(probeWidthNative(makeDeps({ platform: 'darwin' }))).toBeNull(); @@ -105,3 +119,73 @@ describe('probeWidthNative', () => { expect(closed).toEqual([7]); }); }); + +describe('probeTerminalNative', () => { + // Stat line for `pid` with parent `ppid` and controlling terminal `ttyNr`. + const stat = (pid: number, ppid: number, ttyNr = 0): string => `${pid} (proc) S ${ppid} ${pid} ${pid} ${ttyNr} -1 0 0 0 0 0 0 0 0 0 20 0 1 0 1 0 0`; + + // self -> 300 -> 200 -> 1, no fd of any ancestor points at a tty. + function chainDeps(ttyNrs: Record = {}, unreadable: number[] = []): NativeProbeDeps { + const parents: Record = { [process.pid]: 300, 300: 200, 200: 1, 1: 0 }; + return makeDeps({ + readFileSync: (p: string) => { + const pid = Number(/^\/proc\/(\d+)\/stat$/.exec(p)?.[1]); + const ppid = parents[pid]; + if (ppid === undefined || unreadable.includes(pid)) { + throw new Error(`EACCES: ${p}`); + } + + return stat(pid, ppid, ttyNrs[pid] ?? 0); + }, + readlinkSync: () => '/dev/null' + }); + } + + it('is conclusive when no ancestor (including pid 1) has a controlling terminal', () => { + expect(probeTerminalNative(chainDeps())).toEqual({ width: null, noControllingTTY: true }); + }); + + it('is inconclusive when an ancestor has a controlling terminal but no tty on its stdio', () => { + // e.g. stdio redirected away from the terminal: ps -o tty= still reports it. + expect(probeTerminalNative(chainDeps({ 200: 34818 }))).toEqual({ width: null, noControllingTTY: false }); + }); + + it('is inconclusive when pid 1 has a controlling terminal', () => { + // The ps walk queries pid 1's tty too (e.g. an interactive shell as a container's init). + expect(probeTerminalNative(chainDeps({ 1: 34816 }))).toEqual({ width: null, noControllingTTY: false }); + }); + + it('is inconclusive when an ancestor stat cannot be read', () => { + expect(probeTerminalNative(chainDeps({}, [200])).noControllingTTY).toBe(false); + expect(probeTerminalNative(chainDeps({}, [1])).noControllingTTY).toBe(false); + }); + + it('ignores our own controlling terminal, which the ps walk never checks', () => { + expect(probeTerminalNative(chainDeps({ [process.pid]: 34818 })).noControllingTTY).toBe(true); + }); + + it('checks the last ancestor when the depth limit is reached', () => { + // An endless chain: self -> 1000 -> 1001 -> ... The 8th ancestor is 1007. + const deps = (lastTtyNr: number) => makeDeps({ + readFileSync: (p: string) => { + const pid = Number(/^\/proc\/(\d+)\/stat$/.exec(p)?.[1]); + const next = pid === process.pid ? 1000 : pid + 1; + return stat(pid, next, pid === 1007 ? lastTtyNr : 0); + }, + readlinkSync: () => '/dev/null' + }); + + expect(probeTerminalNative(deps(0)).noControllingTTY).toBe(true); + expect(probeTerminalNative(deps(34818)).noControllingTTY).toBe(false); + }); + + it('is inconclusive on non-linux platforms', () => { + expect(probeTerminalNative(makeDeps({ platform: 'darwin' }))).toEqual({ width: null, noControllingTTY: false }); + }); + + it('reports a found width', () => { + const deps = chainDeps({ 200: 34818 }); + expect(probeTerminalNative({ ...deps, readlinkSync: (p: string) => (p === '/proc/200/fd/1' ? '/dev/pts/2' : '/dev/null') })) + .toEqual({ width: 209, noControllingTTY: false }); + }); +}); diff --git a/src/utils/__tests__/terminal.test.ts b/src/utils/__tests__/terminal.test.ts index 1f40ef987..581adc2ec 100644 --- a/src/utils/__tests__/terminal.test.ts +++ b/src/utils/__tests__/terminal.test.ts @@ -13,6 +13,7 @@ import { getTerminalWidth, resetTerminalWidthCache } from '../terminal'; +import * as terminalNative from '../terminal-native'; import * as terminalWidthCache from '../terminal-width-cache'; vi.mock('child_process', () => ({ @@ -350,6 +351,55 @@ describe('terminal utils', () => { expect(getTerminalWidth()).toBe(175); }); + describe('native /proc probe result', () => { + it('skips the ps/stty walk but keeps tput when /proc shows no ancestor has a controlling terminal', () => { + setPlatform('linux'); + vi.spyOn(terminalNative, 'probeTerminalNative').mockReturnValue({ width: null, noControllingTTY: true }); + mockExecFileSync.mockImplementation((file: string) => { + if (file === 'tput') { + return '80\n'; + } + + throw new Error(`Unexpected command: ${file}`); + }); + + expect(getTerminalWidth()).toBe(80); + expect(mockExecFileSync.mock.calls.map(([file, args]) => `${file as string} ${(args as string[]).join(' ')}`)).toEqual([ + 'tput cols' + ]); + }); + + it('still walks ancestors with ps when the /proc probe is inconclusive', () => { + setPlatform('linux'); + vi.spyOn(terminalNative, 'probeTerminalNative').mockReturnValue({ width: null, noControllingTTY: false }); + mockExecFileSync.mockImplementation((file: string, args: string[]) => { + if (file === 'ps' && args.join(' ') === `-o ppid= -p ${process.pid}`) { + return '1234\n'; + } + + if (file === 'ps' && args.join(' ') === '-o tty= -p 1234') { + return 'pts/3\n'; + } + + if (file === 'stty' && args.join(' ') === '-F /dev/pts/3 size') { + return '24 132\n'; + } + + throw new Error(`Unexpected command: ${file} ${args.join(' ')}`); + }); + + expect(getTerminalWidth()).toBe(132); + }); + + it('returns the native width without spawning anything', () => { + setPlatform('linux'); + vi.spyOn(terminalNative, 'probeTerminalNative').mockReturnValue({ width: 209, noControllingTTY: false }); + + expect(getTerminalWidth()).toBe(209); + expect(mockExecFileSync).not.toHaveBeenCalled(); + }); + }); + // Wiring coverage for the sessionId/ttlSeconds L2-cache integration: unlike // the tests above (which never pass options), these mock ../terminal-width-cache // directly and assert on the exact arguments getTerminalWidth calls it with. diff --git a/src/utils/terminal-native.ts b/src/utils/terminal-native.ts index dc490e823..fd467ae93 100644 --- a/src/utils/terminal-native.ts +++ b/src/utils/terminal-native.ts @@ -54,6 +54,36 @@ export function parsePpidFromStat(stat: string): number | null { return ppid; } +/** + * Parse tty_nr (field 7) out of a /proc//stat line: the controlling + * terminal's device number, 0 when the process has none. This is the value + * `ps -o tty=` renders, printing "?" for 0. + */ +export function parseTtyNrFromStat(stat: string): number | null { + const commEnd = stat.lastIndexOf(')'); + if (commEnd === -1) { + return null; + } + + // After "(comm)": state, ppid, pgrp, session, tty_nr, ... + const fields = stat.slice(commEnd + 1).trim().split(/\s+/); + const ttyNr = parseInt(fields[4] ?? '', 10); + return isNaN(ttyNr) ? null : ttyNr; +} + +function readStat(pid: number, deps: NativeProbeDeps): string | null { + try { + return deps.readFileSync(`/proc/${pid}/stat`); + } catch { + return null; + } +} + +function hasNoControllingTTY(pid: number, deps: NativeProbeDeps): boolean { + const stat = readStat(pid, deps); + return stat !== null && parseTtyNrFromStat(stat) === 0; +} + function findTTYDevice(pid: number, deps: NativeProbeDeps): string | null { for (const fd of STDIO_FDS) { try { @@ -92,28 +122,50 @@ function widthOfDevice(device: string, deps: NativeProbeDeps): number | null { } } +export interface NativeProbeResult { + width: number | null; + /** + * True only when the walk read /proc//stat for every ancestor the + * portable `ps` walk would visit (up to MAX_ANCESTOR_DEPTH, including pid 1) + * and none of them has a controlling terminal. `ps -o tty=` would then print + * "?" for each of them and no `stty` would ever run, so that walk can be + * skipped without changing the result. + */ + noControllingTTY: boolean; +} + /** * Probe terminal width with zero subprocesses, using /proc and TIOCGWINSZ. - * Linux only; returns null anywhere else so the caller falls back to the - * portable ps/stty/tput path. + * Linux only; anywhere else the result is inconclusive so the caller falls + * back to the portable ps/stty/tput path. */ -export function probeWidthNative(deps: NativeProbeDeps = defaultDeps): number | null { +export function probeTerminalNative(deps: NativeProbeDeps = defaultDeps): NativeProbeResult { + const inconclusive: NativeProbeResult = { width: null, noControllingTTY: false }; if (deps.platform !== 'linux') { - return null; + return inconclusive; } + let sawControllingTTY = false; let pid = process.pid; for (let depth = 0; depth < MAX_ANCESTOR_DEPTH; depth += 1) { - let stat: string; - try { - stat = deps.readFileSync(`/proc/${pid}/stat`); - } catch { - return null; + const stat = readStat(pid, deps); + if (stat === null) { + return inconclusive; + } + + // `pid` is the ancestor visited in the previous iteration (not ourselves). + if (depth > 0 && parseTtyNrFromStat(stat) !== 0) { + sawControllingTTY = true; } const parentPid = parsePpidFromStat(stat); - if (parentPid === null || parentPid <= 1) { - return null; + if (parentPid === null) { + return inconclusive; + } + + if (parentPid <= 1) { + // The ps walk stops here too, after also querying pid 1's tty. + return { width: null, noControllingTTY: !sawControllingTTY && hasNoControllingTTY(1, deps) }; } pid = parentPid; @@ -125,9 +177,15 @@ export function probeWidthNative(deps: NativeProbeDeps = defaultDeps): number | const width = widthOfDevice(device, deps); if (width !== null) { - return width; + return { width, noControllingTTY: false }; } } - return null; + // Depth limit reached: the last ancestor visited has not had its stat read yet. + return { width: null, noControllingTTY: !sawControllingTTY && hasNoControllingTTY(pid, deps) }; +} + +/** Width-only view of probeTerminalNative. */ +export function probeWidthNative(deps: NativeProbeDeps = defaultDeps): number | null { + return probeTerminalNative(deps).width; } diff --git a/src/utils/terminal.ts b/src/utils/terminal.ts index 5faf9ae7d..bdbd12b8d 100644 --- a/src/utils/terminal.ts +++ b/src/utils/terminal.ts @@ -2,7 +2,7 @@ import { execFileSync } from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; -import { probeWidthNative } from './terminal-native'; +import { probeTerminalNative } from './terminal-native'; import { readCachedWidth, writeCachedWidth @@ -45,13 +45,40 @@ function probeTerminalWidth(): number | null { return null; } - // Zero-subprocess path (Linux): /proc ancestry + TIOCGWINSZ. Returns null on - // other platforms and falls through to the portable ps/stty/tput walk below. - const nativeWidth = probeWidthNative(); - if (nativeWidth !== null) { - return nativeWidth; + // Zero-subprocess path (Linux): /proc ancestry + TIOCGWINSZ. Inconclusive + // on other platforms and falls through to the portable ps/stty/tput walk below. + const native = probeTerminalNative(); + if (native.width !== null) { + return native.width; } + // When /proc already showed that no ancestor has a controlling terminal, + // `ps -o tty=` would print "?" for every one of them, so the ps walk (two + // spawns per ancestor, on every render) cannot find a width. Skip it. + if (!native.noControllingTTY) { + const width = probeAncestorWidth(); + if (width !== null) { + return width; + } + } + + // Fallback: try tput cols which might work in some environments + try { + const width = execFileSync('tput', ['cols'], { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'ignore'], + windowsHide: true + }).trim(); + + return parsePositiveInteger(width); + } catch { + // tput also failed + } + + return null; +} + +function probeAncestorWidth(): number | null { // Claude Code can spawn ccstatusline with piped stdio, leaving the immediate // parent process without a controlling TTY. Walk up a few ancestors until we // find the shell process that owns the real PTY. @@ -75,19 +102,6 @@ function probeTerminalWidth(): number | null { } } - // Fallback: try tput cols which might work in some environments - try { - const width = execFileSync('tput', ['cols'], { - encoding: 'utf8', - stdio: ['pipe', 'pipe', 'ignore'], - windowsHide: true - }).trim(); - - return parsePositiveInteger(width); - } catch { - // tput also failed - } - return null; }