Skip to content

perf: stop re-probing the terminal width on every line of every render#501

Open
elhoim wants to merge 6 commits into
sirmalloc:mainfrom
elhoim:perf/terminal-width-cache-clean
Open

perf: stop re-probing the terminal width on every line of every render#501
elhoim wants to merge 6 commits into
sirmalloc:mainfrom
elhoim:perf/terminal-width-cache-clean

Conversation

@elhoim

@elhoim elhoim commented Jul 13, 2026

Copy link
Copy Markdown

Problem

Claude Code spawns the statusline with no TTY, so probeTerminalWidth() returns null. Because every caller reads the width as context.terminalWidth ?? getTerminalWidth(), that null was never cached — it re-ran a ps/stty/tput ancestor walk once per configured line, on every render.

Measured on a 6-core Linux box: 122 execve per render, with 6.5s of a 9.9s CPU profile inside spawnSync. Sustained, this was 11.2% of the machine across active sessions.

It also produced the wrong answer: the tput cols fallback reported its no-TTY default of 80 on a 209-column terminal.

Fix

  1. Memoize the probe, including the null result (an explicit hasProbed flag — the actual bug).
  2. Zero-subprocess native probe on Linux: walk /proc/<pid>/stat for the ancestor chain, readlink its fds to find a /dev/pts/*, then TIOCGWINSZ via tty.WriteStream. Spawns nothing.
  3. execFileSync instead of execSync + shell in the remaining fallbacks (no shell process, and non-zero exits propagate instead of being swallowed by an | awk pipeline).
  4. TTL'd cross-process cache keyed by session_id, atomic tmp+rename, corruption treated as a miss. New setting terminalWidthCacheTtlSeconds (default 5s, mirroring gitCacheTtlSeconds).

Existing behaviour is preserved: the stty/tput fallbacks remain for non-Linux, and CCSTATUSLINE_WIDTH still wins.

Results

Metric Before After
execve per render 122 1 (node itself)
CPU per render 4.37 CPU-s 1.08 CPU-s (−75%)
sys per render 1.94 s 0.14 s (−93%)
Width on a 209-col terminal 80 ❌ 209

Cold and warm cache both cost 1 execve — the native probe spawns nothing, so even a cache miss is free.

Tests

32 new/updated tests across terminal.test.ts (15), terminal-native.test.ts (7), terminal-width-cache.test.ts (10). bun run lint clean.

🤖 Generated with Claude Code's help

elhoim and others added 6 commits July 13, 2026 12:17
Read ancestry from /proc/<pid>/stat, locate the pty by readlinking
/proc/<pid>/fd/{0,1,2}, and get columns via TIOCGWINSZ (tty.WriteStream)
instead of spawning sh+ps / sh+stty+awk / sh+tput. Returns null off Linux
so the portable walk remains the fallback for macOS/BSD.

Verified against a real pty: probeWidthNative() returns 209, matching
'stty -F /dev/pts/7 size' (88 209), with zero spawns.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Persists probed widths to ~/.cache/ccstatusline/terminal-width.json, keyed
by session_id (width is per-terminal, so a global value would be wrong).
Atomic tmp+rename writes, entries pruned after an hour, corrupt/unwritable
cache treated as a miss and never fatal.

readCachedWidth returns a wrapper ({width} | null) so a cached null -- 'we
probed, there is no TTY' -- is a HIT, not a miss. Collapsing that would make
the no-TTY case re-probe forever, which is the bug this exists to fix.

ttlSeconds 0 disables caching (always probe). Note this deliberately differs
from gitCacheTtlSeconds, where 0 means 'never expire'.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude Code spawns the statusline without a TTY, so probeTerminalWidth()
returns null. Callers read it as 'context.terminalWidth ?? getTerminalWidth()',
so the null re-triggered the full ps/stty ancestor walk once per configured
line. Cache the probe behind an explicit hasProbed flag so the negative
result is memoized too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
execSync with shell:'/bin/sh' spawned a shell in addition to the command,
doubling every probe; the stty|awk pipe cost three processes. Call the
binaries directly and parse 'rows cols' in JS. Drops the legacy
'stty size < /dev/tty' redirect form, which required a shell and is
redundant with the -F/-f forms.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Chain is now: CCSTATUSLINE_WIDTH override -> in-process memo -> shared L2
disk cache -> probe (native /proc+TIOCGWINSZ on Linux, ps/stty/tput
elsewhere). Only the entry point supplies a session_id, so only it reads or
writes the shared cache; renderer.ts and widgets/TerminalWidth.ts keep their
zero-arg calls and hit the memo.

Adds terminalWidthCacheTtlSeconds (default 5, 0 disables) to Settings and
RenderContext, and backfills it in two widget test fixtures that build a
full Settings literal (zod .default() makes the key required on the output
type).

Reads process.platform at call time in terminal-native's default deps: a
module-load snapshot ignored the per-test platform pin and ran the real
Linux probe under darwin-pinned tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@elhoim elhoim changed the title Perf/terminal width cache clean perf: stop re-probing the terminal width on every line of every render Jul 13, 2026
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