Skip to content

[low] perf(claude-status): hold a lock while the status page fetch is in flight - #635

Merged
sirmalloc merged 5 commits into
sirmalloc:mainfrom
elhoim:perf/status-inflight-lock
Oct 30, 2026
Merged

sirmalloc merged 5 commits into
sirmalloc:mainfrom
elhoim:perf/status-inflight-lock

Conversation

@elhoim

@elhoim elhoim commented Sep 30, 2026

Copy link
Copy Markdown
Contributor

BLUF

  • Priority: low. Stops a burst of redundant status-page requests each time the Claude status cache expires.
  • Problem: fetchClaudeServiceStatus writes its lock only after a failed fetch. When the 300 s cache expires, every render that starts before the first fetch returns sends its own request(s) to status.claude.com. Those are renders from other sessions or later renders of the same session. Each of them also blocks on its request, for up to the 5 s timeout.
  • Fix: write the lock before the fetch, as usage-fetch.ts already does. Concurrent renders then serve the expired cache while one fetch is in flight. A successful fetch removes the lock only if it still holds the lock it wrote, so a concurrent failure lock survives. This is the ownership rule from [low] fix(usage): only clear the in-flight lock this render wrote #624.
  • Measured (6 concurrent renders of the built dist per cache expiry, network faked): at a 2 s round trip, requests per expiry went from a median of 4 (range 2–5) to 1. At 250 ms with simultaneous starts, the median was 1 in both cases and the max went from 3 to 1. The single-render output is byte-identical.

Details

  • writeInFlightLock() writes claude-status.lock just before Promise.all(fetchStatusPagePath(...)). The contents (pid:nowMs:random) are unique to that fetch.
  • The lock is still judged by mtime alone (isFetchLockActive, renamed from isFailureLockActive), so the empty lock that older versions write keeps blocking for 30 s. The failure path still refreshes it with writeFailureLock(), so the 30 s backoff after a failure is unchanged.
  • On success, the fresh cache is written first and then clearOwnFetchLock() removes the lock, but only if its contents are still ours. Before this change, the unconditional rmSync ran before the cache write. That left a short window where a render could see neither a lock nor a fresh cache.
  • If a process dies mid-fetch, the lock expires after the existing 30 s backoff.
  • UX note: a render that hits the in-flight lock with no usable cache shows the widget's existing ? fallback for that render. That happens on the very first fetch in a fresh ~/.cache with several sessions running. It also happens when sessions with and without incident history share the cache: while a status-only session's fetch is in flight, a history-enabled session's renders show ? for that round trip, because a status-only cache cannot serve the history. That repeats at most once per 300 s refresh and clears as soon as the history session fetches. With a usable cache, it shows the expired value, which is at most one round trip older than what it would have fetched. The usage widgets already behave this way under their timeout in-flight lock.
  • There is still a check-then-write window between statSync and writeFileSync. It is microseconds long, against the round trip it closes. The max of 2 requests seen once under heavy host load comes from that window.
  • No file overlap with our open [low] fix(usage): only clear the in-flight lock this render wrote #624, which changes usage-fetch.ts. This PR uses the same lock-ownership pattern.

Measurements

The herd cannot be seen in a sequential fork/exec bench. Each arm launched 6 node dist/ccstatusline.js renders at once against one isolated HOME with a 400 s-old claude-status.json. A --require preload replaced https.request with a fixed-delay fake, so nothing reached the real status page. The config was model | claude-status. Old and new dist builds alternated trial by trial within each run.

Scenario Arm Requests per expiry (median, range) Herd CPU ms (median / p90) Per-render wall ms (median / p90)
2 s RTT, renders 200 ms apart, 6 trials main 4 (2–5) 6896 / 7389 10470 / 11925
this PR 1 (0–2) 6818 / 7139 9649 / 10332
250 ms RTT, simultaneous, 10 trials main 1 (1–3) 6590 / 7212 7972 / 14919
this PR 1 (1–1) 6541 / 6935 8737 / 10049
  • The host was heavily loaded (loadavg 53–55 on 6 cores), so node startup dominates the absolute CPU and wall figures. Compare the request counts, not the timings.
  • CPU differences are noise. The per-render wall time in the 2 s RTT scenario is the one timing that separates: the PR's p90 (10332 ms) is below main's median (10470 ms), because blocked renders no longer wait out the round trip.
  • Three follow-up trials of main at 2 s RTT gave 2, 2 and 2 requests. Eleven follow-up trials of this PR gave exactly 1 each. The one 0 in the table did not reproduce.
  • A render with a fresh cache takes the same path as before. stdout of the old and new dist is byte-identical on the default, heavy and livelike benchmark configs.

Checks

  • New cross-process tests in claude-service-status.test.ts run real probe processes with a held fake request:

    • A concurrent render serves the expired cache and sends no request. This test fails on main: it saw 1 request.
    • A failure lock written during another process's fetch survives that fetch's success. This test fails on main: the lock was deleted.
    • A legacy empty lock still blocks.
  • bun run lint: clean.

  • bun test: all tests in claude-service-status.test.ts and ClaudeStatus.test.ts pass. The host load was about 60 on 6 cores, and at the default 5 s timeout 60 tests failed in unrelated files. With --timeout 60000, 26 still failed:

    • the known fetchUsageData error handling > preserves root errors… timeout;
    • timing-sensitive custom command capture tests;
    • TUI menu and editor tests (RefreshIntervalMenu, UsageLocaleEditor, …).

    I re-ran the same files with this PR's two files reverted to main on the same host. The same suites failed there (21 failures).

  • bun run build: clean.

🤖 Generated with Claude Code

elhoim and others added 2 commits September 30, 2026 17:20
…ight

The claude-status widget only wrote its lock after a failed fetch. When the
300 s cache expired, every render that started before the first fetch
finished (other sessions, or renders of the same session) sent its own
request(s) to status.claude.com and blocked on them.

Write the lock before the fetch, as usage-fetch.ts already does, so
concurrent renders serve the expired cache for that one fetch. The lock is
still judged by mtime, so an empty lock from an older version keeps
working. It now holds contents unique to the fetch, and the success path
removes it only while it is still this render's own lock: a concurrent
failure lock survives. The lock is removed after the fresh cache is
written, not before.

Measured with 6 concurrent renders of the built dist per cache expiry,
https.request faked with a fixed round trip:
- 2 s round trip, renders 200 ms apart: requests per expiry median 4
  (2-5) before, 1 after.
- 250 ms round trip, simultaneous: median 1 both, max 3 before, 1 after.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sirmalloc
sirmalloc merged commit 62a371b into sirmalloc:main Oct 30, 2026
3 checks passed
@elhoim
elhoim deleted the perf/status-inflight-lock branch October 31, 2026 22:52
pcvelz added a commit to pcvelz/ccstatusline-usage that referenced this pull request Nov 2, 2026
Upstream: weekly limit timers (sirmalloc#768), prompt cache miss widgets (sirmalloc#770), context window size env override (sirmalloc#352, sirmalloc#407), windowed speed fixes (sirmalloc#769, sirmalloc#771), separator/renderer fixes (sirmalloc#773), custom-command styling (sirmalloc#775), clickable cwd links (sirmalloc#744), account email from live credentials (sirmalloc#724), cache timer reported expiry + short toggle (sirmalloc#576, sirmalloc#592), claude-status lock (sirmalloc#635, sirmalloc#772), dedup token metrics (sirmalloc#622, sirmalloc#725, sirmalloc#735).
Not adopted: sirmalloc#765 terminal width from COLUMNS with fixed reserve - the fork keeps flexMode/compactThreshold and tmux pane priority.

Hand edits outside conflicts:
- README.md: kept fork Configure flex separator behavior bullet and older-updates details wrapper; dropped upstream Related Projects additions (claude-eta)
- AGENTS.md: kept fork terminal width wording (sirmalloc#765 not adopted)
- src/types/FlexMode.ts, src/tui/components/TerminalWidthMenu.tsx, src/tui/components/__tests__/TerminalWidthMenu.test.ts, src/tui/components/TerminalOptionsMenu.tsx, src/tui/components/__tests__/TerminalOptionsMenu.test.ts: restored, fork keeps terminal width settings
- src/tui/App.tsx, src/tui/components/index.ts, src/types/index.ts: re-wired terminal width menu and FlexMode export
- src/types/StatusJSON.ts: dropped fork duplicate prompt_cache key, upstream schema is a superset
- src/utils/terminal.ts: COLUMNS source gated on non-tmux so tmux pane width keeps priority
- src/widgets/WeeklyLimitTimer.ts, src/utils/usage-prefetch.ts, src/widgets/__tests__/WeeklyLimitTimer.test.ts: compat fix - fableUsage/fableResetAt -> weeklyFableUsage/weeklyFableResetAt
- src/widgets/ApiUsage.tsx: compat fix - Context Bar honors effective context window (override, clamp, 1.0M format); Weekly renders a valid value despite a session error
- src/widgets/__tests__/ApiUsage.localModelFallback.test.ts: output tokens no longer counted in context (upstream sirmalloc#730)
- src/utils/__tests__/renderer-flex-width.test.ts, src/utils/__tests__/renderer-ansi.test.ts, src/utils/__tests__/gradient.test.ts, src/utils/__tests__/renderer-separator-collapse.test.ts, src/utils/__tests__/config.test.ts, src/tui/__tests__/App.test.ts: fork deviation - fork width model (flexMode, 6-column full reserve, kept settings, Terminal Width menu entry)
- src/utils/__tests__/default-colors.test.ts: fork deviation - accept ansi256:/hex: defaults
- src/utils/__tests__/usage-prefetch.test.ts: fork deviation - weekly usage comes from the API, fork widget imports from ApiUsage
- src/widgets/__tests__/CurrentWorkingDir.test.ts, src/widgets/__tests__/CustomCommand.test.ts: Settings literal gains flexMode/compactThreshold
- src/utils/__tests__/context-window.test.ts, src/utils/__tests__/jsonl-metrics.test.ts: restored fork tests upstream dropped
- docs/test-retirements.md: ledger for the dev-reload snapshot-path and RemoteControlStatus glyph (sirmalloc#605) tests upstream replaced
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