Skip to content

fix(usage): replace cache and lock files instead of writing them in place - #815

Open
eric-engberg wants to merge 4 commits into
sirmalloc:mainfrom
eric-engberg:fix/usage-lock-atomic-write
Open

eric-engberg wants to merge 4 commits into
sirmalloc:mainfrom
eric-engberg:fix/usage-lock-atomic-write

Conversation

@eric-engberg

Copy link
Copy Markdown
Contributor

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/ccstatusline were written in place with fs.writeFileSync: usage.json, usage.lock, usage-credentials.lock, claude-status.json and claude-status.lock. Every write is wrapped in a try that ignores errors. A file can stop being writable while its folder stays writable. For example, a run under sudo creates 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 with Retry-After: 600, and the status page answers 503. Before the first render, usage.lock holds an expired lock ({"blockedUntil": <now - 60>, "error": "timeout"}) and claude-status.lock is an hour old. Both are mode 0444. Then three renders in a row:

Runtime Branch Usage API requests after renders 1 / 2 / 3 Status page requests after renders 1 / 2 / 3 Render times usage.lock afterwards
Bun 1.4.2 main 1 / 2 / 3 1 / 2 / 3 0.20s, 0.16s, 0.17s still the expired timeout lock, mode 0444
Bun 1.4.2 this branch 1 / 1 / 1 1 / 1 / 1 0.20s, 0.14s, 0.14s {"blockedUntil": <now + 600>, "error": "rate-limited"}, mode 0644
Node 26.10.0 main 1 / 2 / 3 1 / 2 / 3 0.29s, 0.29s, 0.29s still the expired timeout lock, mode 0444
Node 26.10.0 this branch 1 / 1 / 1 1 / 1 / 1 0.29s, 0.26s, 0.26s {"blockedUntil": <now + 600>, "error": "rate-limited"}, mode 0644

Every render shows [Rate limited] | Claude: ? on both branches. On main, 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 on main adds 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:

  • A read-only usage.json: a successful fetch can't be saved, so every later render fetches again.
  • A read-only 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 runs security twice.
  • A read-only claude-status.json: every render past the first fetches the status page again.

How

  • New replaceFileSync(filePath, data) in src/utils/replace-file.ts. It writes the data to <file>.tmp in 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.
  • The temp file name is fixed, not unique to the process, as in git.ts's writePersistentCache. 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.ts writes usage.json, usage.lock and usage-credentials.lock through the helper. claude-service-status.ts does the same for claude-status.json and claude-status.lock. Each call site keeps its own try and ignores errors as before. Removing a lock (rmSync) already worked on a read-only file, so it is unchanged.
  • New tests, each running every render in its own child process with a scratch HOME, in the style of each file's existing tests:
    • usage-fetch.test.ts: an expired, read-only usage.lock and a 429 with Retry-After: 600, then a second render inside the backoff, which must send no request. Also a read-only stale usage.json, whose next render must be served from the cache, and a read-only expired usage-credentials.lock, whose next render must skip the lookup.
    • claude-service-status.test.ts: a read-only expired claude-status.lock after a failed fetch, whose second render must send no request. Also a read-only stale claude-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:

  • On Windows, a read-only file can't be renamed over or written, so the backoff still stops there. The read-only tests are skipped on Windows, as other permission tests in this repo are.
  • A folder that can't be written is not covered, because the temp file can't be created there either. The in-place fallback still updates a file in that folder if the file itself is writable.
  • A replaced file gets the default mode for a new file, not the mode of the file it replaces. These are cache and lock files, so a leftover 0444 is not worth keeping.

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

  • On main as of 2.2.34 (0c94df7): it merges cleanly. bun run lint is clean, and bun test on Linux CI gives 4412 pass, 0 fail (4403 on main). The built CLI under Bun 1.4.4 and Node 26.13.0 agrees across runtimes and matches main in 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.
  • The 9 new tests are 3 in usage-fetch.test.ts, 2 in claude-service-status.test.ts and 4 in the new replace-file.test.ts. The 5 tests in existing files all fail on main, and the helper's tests can't run on main since the helper is new. bun run lint is clean.
  • Node 26.10.0 vitest, on the three changed test files: replace-file.test.ts passes. In the other two files, the only failures are the child-process probe tests. In usage-fetch.test.ts they fail the same way on main (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.ts passes fully under Node on main and now has 2 Node failures, from its 2 new probe tests, for the same reason. That is 41 failures on main and 46 here.
  • The built CLI, with a line of Session Usage and Claude Status: plain and Powerline output agree across Bun 1.4.2 and Node 26.10.0 and match main. The TUI opens and exits cleanly under both.
  • End to end with the built CLI and the local stand-in for both servers: the table under Why.

…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
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