Skip to content

fix: time out keychain reads, drop tput's made-up width, expire future-dated usage cache - #683

Open
eric-engberg wants to merge 3 commits into
sirmalloc:mainfrom
eric-engberg:fix/subprocess-and-clock-edges
Open

eric-engberg wants to merge 3 commits into
sirmalloc:mainfrom
eric-engberg:fix/subprocess-and-clock-edges

Conversation

@eric-engberg

Copy link
Copy Markdown
Contributor

What

Three small fixes for cases where something outside ccstatusline misbehaves and the status line stalls or shows the wrong thing:

  1. Keychain reads time out. The macOS security calls behind the usage widgets give up after 5s, and a lookup that finds no OAuth login is not repeated for 30s.
  2. No made-up terminal width. When no terminal can be found, the line is no longer cut to fit 80 columns (74 in full-width mode, 40 in full-minus-40).
  3. A usage cache dated in the future expires. A cache file written while the clock ran ahead no longer counts as fresh until real time catches up.

Why

Keychain. security find-generic-password and, for the default profile on a miss, security dump-keychain had no timeout. If security blocks, for example on a keychain unlock prompt, the status line waits until someone answers it. With a fake security that sleeps 12s, every render took 24s under both Bun and Node. Separately, an API-key user with a usage widget has no OAuth login to find, so nothing is cached and every render repeats both security calls, the full keychain dump included.

Width. When the ps walk finds no ancestor with a terminal (IDE sessions, some Claude Code spawn paths), the probe fell back to tput cols. That runs with piped stdio, so tput can't see a terminal either. It prints $COLUMNS if exported, otherwise the terminfo default for $TERM:

$ TERM=xterm-256color tput cols </dev/null 2>/dev/null | cat
80

So the line was cut at 74 columns with ... however wide the terminal was, and flex separators padded to that width. And since the probe never returned "no width" in that case, the per-session no-width cache was never written and the ps walk ran again on every render.

Cache date. The usage cache is fresh while now - mtime < 180s. For a file modified in the future the difference is negative, so it always passed. After one render under a clock that ran ahead and was then corrected (NTP, a VM resume), the widgets kept showing that cached usage, with no refetch, until real time passed the file's mtime plus three minutes. A clock one day fast froze them for a day. The usage lock already ignores deadlines too far ahead for the same reason.

How

  • Keychain: both execFileSync('security', …) calls get timeout: 5000, the bound the git and usage API calls already use. A timed-out read is a miss, as any security error already was. When the whole lookup finds no login, ~/.cache/ccstatusline/usage-credentials.lock records { blockedUntil: now + 30, source }, and renders skip the lookup until then. That is the same 30s a single process (the TUI) already caches no-credentials for. source names the keychain service and config directory the lookup used, so one profile's miss never suppresses another profile's lookup. During the backoff the result is what a miss already gave: the stale cache if there is one, else [No credentials]. A login made in that window shows up when it ends, at most 30s later. A deadline more than 30s ahead is ignored, like the usage lock's 24h horizon, so a clock set back can't stretch the backoff. Off macOS the lookup is a single file read; the backoff applies there too, which costs nothing and keeps one rule.
  • Width: the tput cols fallback is removed. With no terminal found, getTerminalWidth() returns null, which the renderer already handles: no truncation, and flex separators render as plain separators (the documented no-width behavior). That null is now cached per session (terminalWidthCacheTtlSeconds), so the ps walk runs once per TTL instead of on every render. Detection with a terminal is unchanged (the Linux /proc probe, the ps/stty walk), and CCSTATUSLINE_WIDTH still overrides it. The one setup that loses a width is no terminal anywhere but COLUMNS exported, which tput passed through; CCSTATUSLINE_WIDTH covers that explicitly.
  • Cache date: the file cache is fresh while |now - mtime| < 180s. A file dated a few seconds ahead still counts, since a concurrent render can write it just after this one read the clock. Anything more than one cache lifetime ahead is refetched. The stale fallback used during API errors and backoffs is unchanged.

Docs: DEVELOPMENT.md lists the new lock file and the cache-date rule, and DEVELOPMENT.md and USAGE.md no longer mention tput.

Overlap with open upstream PRs, all textual: #532 (keychain account) edits readMacKeychainSecret's argument line, next to the options line this changes. #331 (Windows width) adds code right after probeTerminalWidth, where the fallback is removed. #266 (per-profile cache paths) rewrites readStaleUsageCache, which directly follows the new backoff code. #624 (lock clearing) touches usage-fetch.ts but none of these hunks.

Demo

The status line where no ancestor process has a terminal, as in an IDE session, rendered from the example payload (fake data) in a window about 110 columns wide. ps is stubbed to find no terminal and tput to print 80, which is what the real one prints there. Before, the line is cut at 74 columns and Terminal Width reads 80. After, the line renders whole and Terminal Width, with no width to show, hides itself. The keychain and cache-date fixes change nothing visible.

Powerline: before

Status line cut at 74 columns with no terminal found, Powerline mode, before

Powerline: after

Status line rendered whole with no terminal found, Powerline mode, after

Plain: before

Status line cut at 74 columns with no terminal found, plain mode, before

Plain: after

Status line rendered whole with no terminal found, plain mode, after

Testing

  • New tests, each failing on main: every keychain read passes a 5s timeout (usage-token.test.ts). A lookup that found nothing is skipped by a render 10s later, retried after 31s, and doesn't suppress another profile's lookup (usage-fetch.test.ts, real subprocess renders). A cache dated a day ahead is refetched, and one dated 2s ahead is still served (the second case passes on main too; it guards the tolerance). With no ancestor terminal, the width is null and tput is never called (terminal.test.ts, replacing the test that asserted the tput fallback).
  • bun test: 2786 pass, 0 fail. bun run lint passes.
  • Changed test files under Node 26.10.0 (Vitest): terminal.test.ts 22/22 pass. The other two can't run under Node on main either: usage-fetch.test.ts's probes import .ts sources in a Node subprocess, and usage-token.test.ts's node:child_process mock lacks spawn. The new tests there fail the same way as their neighbors (36 of 58 probe tests failing on main, 39 of 61 here; no new failing files), so the behavior was checked under Node through the built CLI instead.
  • Built CLI under Bun 1.4.2 and Node 26.10.0, scratch HOME, fake security/ps/tput/jj on PATH, no network (a local proxy counted CONNECT attempts and refused them). Both runtimes agreed:
    • security sleeping 12s: main took 24.2s on every render. This branch took 10.2s, then 0.13s (Bun) / 0.23s (Node) on the next render, with no security call.
    • security finding nothing (API-key user): main made 2 security calls on every render; this branch made 2, then 0.
    • No terminal found, a 178-column line: main cut it to 74 columns and ran ps and tput on every render. This branch rendered the whole line, and the next render ran no ps.
    • usage.json dated a day ahead: main served it with no request. This branch tried a refetch (1 CONNECT) and, with the API unreachable, fell back to the same cached value. Dated 5s ahead: served from cache on both.
  • runtime-check.sh: plain and Powerline piped renders agree across Bun and Node and match main (baseline config, terminal found). The TUI opens and exits cleanly under both.

On macOS the usage credentials come from `security find-generic-password`
and, for the default profile on a miss, `security dump-keychain`. Neither
call had a timeout, so a `security` that blocks (for example on a keychain
unlock prompt) held the whole status line until someone answered it. With
a fake `security` that sleeps 12s, every render took 24s.

Both calls now time out after 5s, like the git and usage API calls.

A lookup that finds no OAuth login was also repeated on every render: an
API-key user with a usage widget has nothing to cache, so each render ran
both `security` calls, the full keychain dump included. The miss is now
remembered for 30s across renders (as it already was within one process),
in ~/.cache/ccstatusline/usage-credentials.lock. The record names the
credential stores it covers (the keychain service and config directory),
so one profile's miss never suppresses another profile's lookup. As with
the usage lock, a deadline further ahead than the backoff itself is
ignored, so a clock set back cannot stretch it.

With the same fake `security`, the first render now takes 10s and the
next ones inside the backoff about 0.2s.
The file cache is fresh while `now - mtime` is under 180s. For a file
modified in the future that age is negative, so it always passed. After
one render under a clock running ahead (since corrected, e.g. by NTP or a
VM resume), the cached usage stayed on screen with no refetch until real
time passed that mtime plus three minutes: a clock a day fast froze the
usage widgets for a day.

A file dated ahead of now now counts as fresh only within one cache
lifetime. That still covers a concurrent render writing the file just
after this one read the clock; anything further ahead cannot be right and
is refetched. The usage lock already ignores deadlines too far ahead for
the same reason.
When the ancestor walk finds no terminal, the probe fell back to
`tput cols`. That runs with piped stdio, so tput cannot see a terminal
either: it prints $COLUMNS if exported, otherwise the terminfo default for
$TERM, which is 80. So with no terminal found and TERM set, the status
line was truncated at 74 columns in full-width mode (40 in full-minus-40)
however wide the real terminal was, and flex separators padded to that
made-up width.

It also meant the probe never returned null in that case, so the
per-session "no TTY" cache was never written and the whole `ps` ancestor
walk re-ran on every render.

The fallback is gone. With no terminal found the probe returns null, which
the renderer already handles: no truncation, and flex separators render as
plain separators. That result is now cached per session as intended.
CCSTATUSLINE_WIDTH still overrides detection for anyone who wants a fixed
width.
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