Skip to content

[medium] perf(terminal): skip the ps/stty walk when /proc shows no controlling TTY - #629

Open
elhoim wants to merge 1 commit into
sirmalloc:mainfrom
elhoim:perf/terminal-width-no-spawn
Open

elhoim wants to merge 1 commit into
sirmalloc:mainfrom
elhoim:perf/terminal-width-no-spawn

Conversation

@elhoim

@elhoim elhoim commented Sep 30, 2026

Copy link
Copy Markdown
Contributor

BLUF

  • Priority: medium. Removes up to 16 ps spawns per render on Linux when no ancestor has a terminal. The result is unchanged.
  • Problem. When the /proc probe (perf: stop re-probing the terminal width on every line of every render #501) finds no tty on any ancestor's stdio, we fall through to the portable walk: 2 ps per ancestor (up to 8 ancestors), then tput cols. With no TTY, tput returns 80 or $COLUMNS. That result is a number, so the session "no TTY" cache never kicks in and every render pays for the whole walk again.
  • Fix. The native walk already reads /proc/<pid>/stat. It now also reads tty_nr, the field ps -o tty= prints. If every ancestor the ps walk would visit has tty_nr == 0, that walk can't find anything, so we skip it. tput cols still runs, so the width is the same.
  • Measured. In a detached no-TTY run, spawns per render drop from 8 to 1 and PATH probes from about 88 to 11. Stdout is byte-identical. CPU median falls 26% on the default config and 19% on the heavy config (25 interleaved passes).

Details

  • terminal-native.ts adds probeTerminalNative(), which returns { width, noControllingTTY }, and parseTtyNrFromStat(). probeWidthNative() stays as a width-only wrapper.
  • noControllingTTY is set only when all of these hold:
    • every ancestor's stat was read;
    • every visited ancestor has tty_nr == 0;
    • pid 1 also has tty_nr == 0 (the ps walk checks pid 1 too, e.g. a shell running as a container's init);
    • the ancestor at the depth limit, whose stat the walk would not otherwise read, also has tty_nr == 0.
  • The full fallback still runs when:
    • a stat can't be read;
    • some ancestor has a controlling terminal but its stdio isn't a tty (redirected stdio, or a foreign-uid ancestor whose fd links can't be read);
    • the platform isn't Linux.
  • getTerminalWidth precedence is unchanged: CCSTATUSLINE_WIDTH (feat(terminal): honor CCSTATUSLINE_WIDTH env var to override probe #380), then the session "no TTY" cache (perf: stop re-probing the terminal width on every line of every render #501), then the probe. The cache is still written only for a null result.
  • The ps loop moved into probeAncestorWidth() without changes.
  • Relation to feat(terminal): read COLUMNS from Claude Code before probing #612 (not ours; it also edits src/utils/terminal.ts, so expect a small merge conflict): the two complement each other.
  • Follow-ups (not in this PR):
    • tput cols without a TTY only reports $COLUMNS or terminfo's default (80), so it could be replaced by reading COLUMNS directly. That changes the no-COLUMNS result from 80 to null, so it belongs in its own PR.
    • macOS/BSD still runs the ps/stty walk on every render, and a numeric width is never persisted. feat(terminal): read COLUMNS from Claude Code before probing #612 fixes that for current Claude Code. A sysctl/libproc-based walk could fix it otherwise, but I can't test macOS here, so I haven't implemented it.

Measurements

Setup: setsid -f reparents the run to init, so no ancestor holds a pty. That matches Claude Code spawning the statusline with no terminal in its ancestry. COLUMNS unset, p1 payload, git cache warm.

Spawn counts (strace -f -e execve), per render:

arm spawns execve incl. PATH probes
main 8 (7 × ps, 1 × tput) 88
patched 1 (tput) 11

Stdout from main and patched is byte-identical on the default, git0, heavy and livelike configs, and also with COLUMNS=137. No terminal-width.json cache is written in either arm, which confirms the result is numeric (80).

Interleaved bench: 25 passes, round-robin, CPU is user+sys including reaped children. Host load1 was min 53.9, median 61.3, max 66.8 on 6 cores, so compare ratios rather than absolute numbers.

arm CPU median (ms) CPU p90 (ms) wall median (ms)
control node -e 0 114 145 820
main, default config 1989 2087 15621
patched, default config 1471 (−26%) 1536 11167
main, heavy config 2318 2658 18304
patched, heavy config 1880 (−19%) 2135 15037

Checks

  • bun run lint (tsc + eslint): clean.
  • bun run build: OK.
  • bun test src/utils/__tests__/terminal*: 57 pass. New tests:
    • parseTtyNrFromStat;
    • conclusive and inconclusive cases for probeTerminalNative: tty_nr set on an ancestor, on pid 1, and at the depth limit; unreadable stat; our own tty_nr ignored; non-Linux;
    • in terminal.test.ts, a conclusive result gives exactly ['tput cols'], an inconclusive one still runs the ps/stty walk, and a native width spawns nothing.
  • Full bun test on this loaded host: 2297 pass, 74 fail. All failures are timing-related (fetchUsageData error handling, custom-command capture, Ink TUI menus). Re-running only the affected files on unmodified main gave the same result there (174 pass, 69 fail on both). None of the failures are in terminal code.

🤖 Generated with Claude Code

… 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 (sirmalloc#501)
never engages, and every render pays the whole walk again.

The native walk already reads /proc/<pid>/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 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant