Skip to content

[low] perf(render): skip unused skills read, cache account email by mtime - #638

Open
elhoim wants to merge 2 commits into
sirmalloc:mainfrom
elhoim:perf/skip-unused-reads
Open

elhoim wants to merge 2 commits into
sirmalloc:mainfrom
elhoim:perf/skip-unused-reads

Conversation

@elhoim

@elhoim elhoim commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

BLUF

  • Priority: low. (Measurable only with a large ~/.claude.json — 10–22% at 1–5 MB; noise at a typical ~100 KB.)
  • Account email widget: the claude-account-email widget used to read and JSON.parse all of .claude.json on every render. That file also stores Claude Code's per-project history, so it can grow to megabytes. The widget now caches the extracted email in ~/.cache/ccstatusline/claude-account-email.json, keyed on the file's path + size + mtime. A cache hit costs one stat plus a read of about 150 bytes.
  • Saving (email widget configured, no transcript):
    • 1.1 MB .claude.json: render CPU -10.8%.
    • 5.6 MB .claude.json: render CPU -21.6%.
    • In-process, the 5.6 MB read+parse goes from 77 ms to 1.2 ms.
    • At a typical size of about 100 KB the difference is within noise, so the gain only shows up for users with a large .claude.json.
  • Skills metrics: getSkillsMetrics() read the per-session skills log on every render even when no skills widget was configured. It now only runs when a skills widget is configured, the same way the speed, compaction and session-name inputs already work. The saving is at noise level, because the skills hook is removed together with the widget, but the read had no purpose.
  • Output: byte-identical to main in every case tested.

Details

  • src/ccstatusline.ts: adds hasSkillsWidget. skillsMetrics stays null when no skills widget is configured. Only Skills.tsx reads context.skillsMetrics.
  • src/utils/claude-account-email.ts (new, dependency-injectable like terminal-width-cache.ts): getClaudeAccountEmail() does the following:
    1. Stats .claude.json and returns null if the stat fails. The old code also returned null when the file was missing.
    2. On a cache hit (same path, size and mtimeMs), returns the cached email. A cached "no email" (null) also counts as a hit.
    3. Otherwise it reads and parses the file with the same rules as before: the email must be a non-empty string, and any read or parse error returns null.
    4. It then writes the cache on a best-effort basis (tmp file + rename, mode 0600).
    • Safety: the stat happens before the read. If Claude Code rewrites the file in between, the stored key is older than the stored content, so the next render just re-reads the file; it never serves a stale value. Parse failures are not cached, so they are retried on every render, as before.
    • Caveats:
      • On a filesystem with coarse mtime, a rewrite that keeps the same size within one mtime tick would not be detected.
      • The cache holds one entry. Two profiles with different CLAUDE_CONFIG_DIR values that render alternately will evict each other. That is still correct because the path is part of the key; the worst case is the old cost plus a small cache write.
  • src/widgets/ClaudeAccountEmail.ts: now calls the helper. The widget test now sets HOME to its temp dir, so the cache is never written to the real ~/.cache.
  • Also looked at, not changed: getRemoteControlStatus() (readdir + parse + zod over sessions/*.json). With 14 session files and no match (the worst case), the most a cache could save end to end is +42 ms / 2.7%, which is within noise. In-process it costs 0.5 ms warm and 4.8 ms on the first call; with 50 files, 2.0 ms and 11.5 ms. Claude Code writes one file per live process, so a cache is not worth adding.
  • includeSubagents: true only takes effect together with the already-gated speed metrics, so it was left alone.
  • Overlap with other open PRs: this PR also touches src/ccstatusline.ts, as do [medium] perf(render): skip the transcript scan when no widget reads it #634, [medium] perf(custom-command): run custom commands concurrently in-process before the render #630 and [medium] perf(startup): enable Node's module compile cache via a tiny bootstrap entry #637 (small, adjacent edits in the widget-gating block of the render path). It does not touch claude-settings.ts.

Measurements

Each render is a fork/exec of node dist/ccstatusline.js. Baseline main and the patched build ran interleaved round-robin in one run, with a node -e 0 control arm. CPU is user+sys including reaped children, in ms. The host was heavily loaded, so compare the ratios within a run rather than the absolute values.

Run A: payload with no transcript, default config + claude-account-email, 30 passes, load1 median 50 (min 41, max 61) on 6 cores.

arm CPU median CPU p90 wall median
control node -e 0 104 135 766
main, 1.1 MB .claude.json 1304 1443 8573
this PR, 1.1 MB 1163 (-10.8%) 1284 8007
main, 5.6 MB .claude.json 1414 1538 10020
this PR, 5.6 MB 1109 (-21.6%) 1336 7760

Run B: 5 MB transcript payload (so the render itself is heavier), 20 passes, load1 median 60 (min 50, max 68).

arm CPU median CPU p90 wall median
control node -e 0 111 143 946
main, email, 115 KB 1560 1748 10678
this PR, email, 115 KB 1553 1800 12008
main, email, 1.1 MB 1550 1788 11662
this PR, email, 1.1 MB 1600 1802 12477
main, email, 5.6 MB 1681 1991 11748
this PR, email, 5.6 MB 1539 (-8.5%) 1790 12630
main, default + stale 300-line skills log 1567 1839 10607
this PR, default + stale skills log 1599 1723 11714

In-process (bun, median of 15 runs, email extraction only):

.claude.json main cache miss cache hit
1.1 MB 15.0 ms 19.5 ms 1.3 ms
5.6 MB 76.7 ms 76.7 ms 1.2 ms

A synthetic .claude.json of 411 / 1999 projects was used, with the usual per-project fields and prompt history.

Checks

  • bun run lint: clean. This covers tsc --noEmit and eslint with --max-warnings=0.
  • bun run build: OK.
  • bun test: 2312 pass / 56 fail. Unmodified main on the same host gave 2298 pass / 60 fail. The failures are the same flaky families on both: the fetchUsageData 5 s timeouts, the TUI menu snapshot tests, and custom-command capture timing, all under load 50-60. None are in files this PR touches. The new claude-account-email tests pass (10/10), and so do the ClaudeAccountEmail widget and skills tests.
  • Output equivalence: stdout is byte-identical between main and this PR for these cases:
    • .claude.json with the email present, missing, non-string, null, corrupt, or absent, in both labelled and raw mode, on both the cache-miss and cache-hit render.
    • skills, default and heavy configs with 1 MB and 5 MB transcripts.

🤖 Generated with Claude Code

Two per-render file reads did work that the output does not need:

- getSkillsMetrics() existsSync+read+parsed the per-session skills log on
  every render even with no `skills` widget configured. It is now gated on
  a skills widget being present, like the other widget-specific inputs
  (speed, compaction, session-name). Saving is noise-level in practice
  (the skills hook is removed together with the widget, so the file is
  usually absent), but the read is pure waste.

- The claude-account-email widget read and JSON.parsed the whole
  .claude.json on every render. That file also holds Claude Code's
  per-project history and grows to megabytes. The extracted email (or its
  absence) is now cached in ~/.cache/ccstatusline/claude-account-email.json
  (mode 0600) keyed on path + size + mtimeMs, so a hit costs one stat and
  a ~150-byte read. Stat precedes the read, so a concurrent rewrite can
  only cause an extra re-read, never a stale value; parse failures are not
  cached. Behaviour is unchanged (output byte-identical for present,
  missing, non-string, empty, corrupt and absent cases, miss and hit).

Measured (bench of fork/exec renders, 30 interleaved passes, host load
~50 on 6 cores; CPU user+sys median / p90, ms), config = default + email
widget, no transcript:
  .claude.json 1.1 MB: 1304 / 1443 -> 1163 / 1284  (-10.8%)
  .claude.json 5.6 MB: 1414 / 1538 -> 1109 / 1336  (-21.6%)
In-process: 1 MB read+parse 15.0 ms -> 1.3 ms on a hit; 5.6 MB 76.7 ms ->
1.2 ms; a miss costs the same as before plus a small cache write.
At ~100 KB the difference is within noise.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@elhoim elhoim changed the title [medium] perf(render): skip unused skills read, cache account email by mtime [low] perf(render): skip unused skills read, cache account email by mtime Sep 30, 2026
Co-Authored-By: Claude Opus 5 (1M context) <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