Skip to content

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

Merged
sirmalloc merged 10 commits into
sirmalloc:integration/pr501from
elhoim:perf/terminal-width-cache-clean
Sep 17, 2026
Merged

sirmalloc merged 10 commits into
sirmalloc:integration/pr501from
elhoim:perf/terminal-width-cache-clean

Conversation

@elhoim

@elhoim elhoim commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
@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
elhoim and others added 4 commits July 21, 2026 12:41
…flicts

Conflicts were additive on both sides (new RenderContext fields for
terminal-width caching vs. git-CI-status review checks, and independent
README changelog sections) — kept both.
- Serialize writes to the persistent width cache with a best-effort
  file lock (O_CREAT|O_EXCL, stale-lock recovery), closing a
  read-modify-write race where two concurrent ccstatusline processes
  (e.g. two Claude Code sessions) could silently drop each other's
  cache entry.
- Only ever persist a "no TTY" probe result across process boundaries;
  a discovered numeric width is no longer written to the cross-process
  cache, since it's keyed by session_id and the same session can be
  resumed in a differently-sized terminal, which would otherwise serve
  a stale width for up to terminalWidthCacheTtlSeconds.
- Correct the README's claim that the cache is "shared ... across
  sessions" -- it's deliberately isolated per session_id, which is the
  point of keying it that way.
- Remove context.terminalWidthCacheTtlSeconds: it was write-only dead
  state (the TTL is already consumed by the getTerminalWidth() call
  that builds the context).
- Add a Terminal Width Cache TTL entry to the TUI's status-line config
  menu, mirroring the existing Git Cache TTL entry -- the setting was
  previously only reachable by hand-editing settings.json.
- Add integration/wiring tests for the sessionId/ttlSeconds path
  (previously only unit-tested in isolation) and for the new locking
  behavior.
…ocks

Around twenty suites replace child_process wholesale with
vi.mock('child_process', ...). That factory form is not file-scoped under
the bun runner, so the replacement stays installed in the module registry
for every file that runs afterwards.

When one of those suites runs before this one, vi.spyOn hands back the
already-installed mock together with its accumulated call history instead
of a fresh spy. The stderr-silencing test iterates every recorded call and
asserts stdio is ['ignore', 'pipe', 'ignore'], so it was inspecting git and
terminal probes from other files, which legitimately use
['pipe', 'pipe', 'ignore'], and failed.

The test passes in isolation and fails in the full suite, which is why CI
reported it as the only failure. Clearing the call history when the spy is
installed scopes every assertion in this file to the calls its own test
made.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
@sirmalloc
sirmalloc changed the base branch from main to integration/pr501 September 17, 2026 14:47
@sirmalloc
sirmalloc merged commit b46b2ad into sirmalloc:integration/pr501 Sep 17, 2026
3 checks passed
sirmalloc added a commit that referenced this pull request Sep 17, 2026
Memoize terminal width probes, use native Linux detection, and add a
configurable session cache for no-TTY results. Preserve immediate detection
of terminal resizes and honor CCSTATUSLINE_WIDTH before the session cache.

Integrate the terminal-width TTL control with the shared cache settings
editor and cover explicit overrides and three-digit TTL input in tests.

PR: #501

Co-authored-by: David André <[email protected]>
sirmalloc added a commit that referenced this pull request Sep 17, 2026
Memoize terminal width probes, use native Linux detection, and add a
configurable session cache for no-TTY results. Preserve immediate detection
of terminal resizes and honor CCSTATUSLINE_WIDTH before the session cache.

Integrate the terminal-width TTL control with the shared cache settings
editor and cover explicit overrides and three-digit TTL input in tests.

PR: #501

Co-authored-by: David André <[email protected]>
@elhoim
elhoim deleted the perf/terminal-width-cache-clean branch September 19, 2026 22:27
pcvelz added a commit to pcvelz/ccstatusline-usage that referenced this pull request Sep 25, 2026
Upstream: TUI kept off the render path (sirmalloc#575), flex mode default full (sirmalloc#590), usage cache fingerprinted by refresh token (sirmalloc#536), CLAUDE_CONFIG_DIR keychain credential first (sirmalloc#573), llms.txt (sirmalloc#527), faster terminal width probing (sirmalloc#501), git/jj symbol slots (sirmalloc#574), model-scoped 0% quota as real zero (sirmalloc#534), custom-command output cache + timeout (sirmalloc#539), usage-percent widgets on a shared module (sirmalloc#545), hideable reset-timer placeholders (sirmalloc#542), git command timeouts (sirmalloc#559, sirmalloc#585)

Hand edits outside conflicts:
- src/widgets/shared/usage-percent-widget.ts: compat fix - pass RenderContext to getUsageProgressBarWidth (fork narrow/medium bar widths) and add fork short labels WS:/WO: that the extracted Sonnet/Opus widgets used to render; point the fable-weekly kind at the fork field weeklyFableUsage / resolveWeeklyFableUsageWindow (upstream's fableUsage / resolveFableUsageWindow do not exist in the fork and crashed the render)
- .fork-keep-deleted: drop llms.txt (points agents at the upstream package and at docs the fork deletes)

Conflict resolutions that deviate from upstream on purpose:
- src/types/Settings.ts: keep fork default flexMode full-minus-40 (upstream sirmalloc#590 switched to full)
- src/utils/terminal.ts: keep fork tmux $TMUX_PANE width probe, ported to execFileSync; upstream's CCSTATUSLINE_WIDTH override in getTerminalWidth replaces the fork copy
- src/ccstatusline.ts: keep getTerminalWidth() without the per-session width cache options (fork invariant); add upstream customCommandCacheTtlSeconds
- src/widgets/WeeklyFableUsage.ts (+ test): keep fork widget (Fable:/F: labels, 0% for accounts without Fable, weeklyFableUsage field) instead of upstream's shared-module FableWeeklyUsage
- src/utils/__tests__/usage-fetch.test.ts: adopt upstream sirmalloc#534 real-zero semantics on the fork field weeklyFableUsage
- docs/test-retirements.md: ledger entries for four tests upstream renamed or replaced (sirmalloc#542, sirmalloc#534) and one duplicate upstream test dropped
- src/utils/__tests__/usage-fetch.test.ts: compat - upstream's model-scoped real-zero test expects the fork field weeklyFableUsage
- src/widgets/__tests__/WeeklyFableUsage.test.ts: compat - set the shared suite's new required expectedWholePercentTime
- src/tui/components/__tests__/ImportPreviewDialog.test.ts: fork deviation - default flexMode is full-minus-40, so the non-default side is full
- src/utils/__tests__/usage-fetch.test.ts: drop upstream's duplicate "missing fable window" test that used the upstream-only fableUsage field
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.

2 participants