Repository navigation
fix(usage): replace cache and lock files instead of writing them in place - #815
Open
eric-engberg wants to merge 4 commits into
Open
eric-engberg wants to merge 4 commits into
eric-engberg wants to merge 4 commits into
Conversation
…lace The usage and Claude Status caches and their backoff locks (usage.json, usage.lock, usage-credentials.lock, claude-status.json and claude-status.lock) were written in place. When one of them can't be written but its folder can, such as a file that a run under sudo created and left owned by root, or one set to mode 0444, every write to it failed without a word. Each render is a new process, so these files are the only backoff between renders. With an expired usage.lock left read-only and the usage API answering 429 with Retry-After: 600, three renders in a row sent three usage requests instead of one. A read-only claude-status.lock did the same to the status page while it was failing, and a read-only usage.json made every render past its first fetch again. Write them through a small helper, replaceFileSync. It writes a temp file in the same folder and renames it over the target. A rename needs only a folder that can be written, so the stuck file is replaced, and a reader never sees a file half written by a single render. If the rename fails, the temp file is removed and the file is written in place as before, so a writable file in a folder that can't be written still gets its update.
The usage cache and lock writes keep this branch's temp+rename on upstream sirmalloc#266's per-profile file names, and upstream sirmalloc#724's account email backoff lock is written the same way.
# Conflicts: # src/utils/__tests__/claude-service-status.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
The usage and Claude Status widgets keep backing off between renders when one of their cache or lock files can't be written but its folder can. Before, a file in that state turned off the backoff. Every render then called the usage API again, even inside a 429's Retry-After, or tried the status page again while it was down.
Why
Five files in
~/.cache/ccstatuslinewere written in place withfs.writeFileSync:usage.json,usage.lock,usage-credentials.lock,claude-status.jsonandclaude-status.lock. Every write is wrapped in atrythat ignores errors. A file can stop being writable while its folder stays writable. For example, a run undersudocreates one and leaves it owned by root, or a file ends up with mode 0444. Each write to that file then failed, with no error shown. Every render is a new process, so these files are the only backoff between renders.Measured with the built CLI, a line with Session Usage and Claude Status, and a local stand-in for both servers, reached through
HTTPS_PROXY. The usage API answers 429 withRetry-After: 600, and the status page answers 503. Before the first render,usage.lockholds an expired lock ({"blockedUntil": <now - 60>, "error": "timeout"}) andclaude-status.lockis an hour old. Both are mode 0444. Then three renders in a row:usage.lockafterwardsmaintimeoutlock, mode 0444{"blockedUntil": <now + 600>, "error": "rate-limited"}, mode 0644maintimeoutlock, mode 0444{"blockedUntil": <now + 600>, "error": "rate-limited"}, mode 0644Every render shows
[Rate limited] | Claude: ?on both branches. Onmain, though, each one is a fresh 429, sent inside the 600 seconds the server asked for. The stand-in answers at once, so the render times barely differ here. Against the real API, each request onmainadds a network round trip, up to the 5s request timeout, to a render that should have been served from the lock.The same thing happens with the other three files:
usage.json: a successful fetch can't be saved, so every later render fetches again.usage-credentials.lock: a profile with no OAuth login looks up its credentials on every render instead of every 30s. On macOS, the default profile's lookup runssecuritytwice.claude-status.json: every render past the first fetches the status page again.How
replaceFileSync(filePath, data)insrc/utils/replace-file.ts. It writes the data to<file>.tmpin the same folder, then renames that over the file. A rename needs only a folder that can be written, so a read-only or root-owned file in a writable folder is replaced, and a reader never sees a file half written by a single render (two renders writing at the same moment share the temp file and can still tear it; a torn lock falls back to the 30 s mtime lock, and a torn cache reads as a miss). If the write or the rename fails, the temp file is removed, and the file is written in place as before. So a case that worked before still works, such as a writable file in a folder that can't be written. The helper throws only when neither way works.git.ts'swritePersistentCache. On Windows, a virus scanner or sync client holding the new temp file open can make both the rename and the cleanup fail. A name unique to the process would then leave one more orphan on every render. Two renders writing at the same moment can tear the shared temp file, but they could already tear the file itself when it was written in place. A torn usage lock still blocks for 30s by its mtime, and a torn cache reads as a miss.usage-fetch.tswritesusage.json,usage.lockandusage-credentials.lockthrough the helper.claude-service-status.tsdoes the same forclaude-status.jsonandclaude-status.lock. Each call site keeps its owntryand ignores errors as before. Removing a lock (rmSync) already worked on a read-only file, so it is unchanged.usage-fetch.test.ts: an expired, read-onlyusage.lockand a 429 withRetry-After: 600, then a second render inside the backoff, which must send no request. Also a read-only staleusage.json, whose next render must be served from the cache, and a read-only expiredusage-credentials.lock, whose next render must skip the lookup.claude-service-status.test.ts: a read-only expiredclaude-status.lockafter a failed fetch, whose second render must send no request. Also a read-only staleclaude-status.json, whose next render must be served from the cache.replace-file.test.ts: the helper creates a missing file and replaces a read-only one. It writes in place when the folder can't be written, and throws without leaving a temp file when neither way works.Left as is:
Demo
No demo GIFs: the status line looks the same before and after (
[Rate limited] | Claude: ?in both), and only the number of requests behind it changes. The table under Why gives the request counts and render times instead.Testing
mainas of 2.2.34 (0c94df7): it merges cleanly.bun run lintis clean, andbun teston Linux CI gives 4412 pass, 0 fail (4403 onmain). The built CLI under Bun 1.4.4 and Node 26.13.0 agrees across runtimes and matchesmainin plain and Powerline mode, and the TUI opens and exits cleanly under both. It adds no JavaScript or Node API newer than Node 14, the build's target.usage-fetch.test.ts, 2 inclaude-service-status.test.tsand 4 in the newreplace-file.test.ts. The 5 tests in existing files all fail onmain, and the helper's tests can't run onmainsince the helper is new.bun run lintis clean.replace-file.test.tspasses. In the other two files, the only failures are the child-process probe tests. Inusage-fetch.test.tsthey fail the same way onmain(ERR_MODULE_NOT_FOUND): the probe runs the TypeScript source directly in Node, and Node can't resolve its imports, which have no file extension.claude-service-status.test.tspasses fully under Node onmainand now has 2 Node failures, from its 2 new probe tests, for the same reason. That is 41 failures onmainand 46 here.main. The TUI opens and exits cleanly under both.