From d21222e46aa0c4d143d81141b269c00c75dcfabf Mon Sep 17 00:00:00 2001 From: Bernat Gabor Date: Fri, 26 Jun 2026 11:59:06 -0700 Subject: [PATCH 1/2] fix(usage): drop in-flight lock on fetch success The pre-fetch usage.lock is written as a 'timeout' guard before the API call but is never removed on success, so it lingers for LOCK_MAX_AGE. A cache miss in that window (e.g. an account switch invalidating the token fingerprint) then returns getStaleUsageOrError('timeout', ...), showing a spurious [Timeout] while the API is healthy. Clear the lock after a successful cache write. Genuine error and rate-limit backoff locks are untouched. Fixes #486 --- src/utils/__tests__/usage-fetch.test.ts | 51 +++++++++++++++++++++++-- src/utils/usage-fetch.ts | 14 +++++++ 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/src/utils/__tests__/usage-fetch.test.ts b/src/utils/__tests__/usage-fetch.test.ts index 1067c25c..caeb1c94 100644 --- a/src/utils/__tests__/usage-fetch.test.ts +++ b/src/utils/__tests__/usage-fetch.test.ts @@ -674,11 +674,17 @@ describe('fetchUsageData error handling', () => { expect(result.second).toEqual(result.first); expect(result.requestCount).toBe(1); + // The probe writes usage.json with a real-wall-clock mtime, so derive + // 'now' from it (not the mocked epoch) to keep the cache within + // CACHE_MAX_AGE. This exercises the file-cache fast path a real later + // render takes, rather than depending on a lingering lock to suppress + // the refetch. + const cacheMtimeMs = fs.statSync(path.join(home.home, '.cache', 'ccstatusline', 'usage.json')).mtimeMs; const cachedResult = harness.runProbe({ claudeConfigDir: home.claudeConfig, home: home.home, mode: 'unexpected', - nowMs: nowMs + 10000, + nowMs: cacheMtimeMs + 10000, pathDir: home.bin, requiredFields }); @@ -691,6 +697,33 @@ describe('fetchUsageData error handling', () => { } }); + it('clears the in-flight lock after a successful fetch', () => { + const harness = createProbeHarness(); + + try { + const home = harness.createTokenHome('success-clears-lock'); + const result = harness.runProbe({ + claudeConfigDir: home.claudeConfig, + home: home.home, + mode: 'success', + nowMs, + pathDir: home.bin, + responseBody: successResponseBody + }); + + // fetchUsageData writes a short 'timeout' lock before the request as an + // in-flight guard. A successful fetch must remove it; otherwise the lock + // lingers for LOCK_MAX_AGE and a later cache miss (e.g. an account switch + // invalidating the fingerprint) reports a spurious [Timeout] while the + // API is healthy. + expect(result.cacheExists).toBe(true); + expect(result.lockExists).toBe(false); + expect(result.lockContents).toBeNull(); + } finally { + harness.cleanup(); + } + }); + it('refetches a fresh cache when the token fingerprint changes (account switch)', () => { const harness = createProbeHarness(); @@ -849,11 +882,17 @@ describe('fetchUsageData error handling', () => { expect(result.second).toEqual(result.first); expect(result.requestCount).toBe(1); + // The probe writes usage.json with a real-wall-clock mtime, so derive + // 'now' from it (not the mocked epoch) to keep the cache within + // CACHE_MAX_AGE. This exercises the file-cache fast path a real later + // render takes, rather than depending on a lingering lock to suppress + // the refetch. + const cacheMtimeMs = fs.statSync(path.join(home.home, '.cache', 'ccstatusline', 'usage.json')).mtimeMs; const cachedResult = harness.runProbe({ claudeConfigDir: home.claudeConfig, home: home.home, mode: 'unexpected', - nowMs: nowMs + 10000, + nowMs: cacheMtimeMs + 10000, pathDir: home.bin, requiredFields }); @@ -895,11 +934,17 @@ describe('fetchUsageData error handling', () => { expect(result.second).toEqual(result.first); expect(result.requestCount).toBe(1); + // The probe writes usage.json with a real-wall-clock mtime, so derive + // 'now' from it (not the mocked epoch) to keep the cache within + // CACHE_MAX_AGE. This exercises the file-cache fast path a real later + // render takes, rather than depending on a lingering lock to suppress + // the refetch. + const cacheMtimeMs = fs.statSync(path.join(home.home, '.cache', 'ccstatusline', 'usage.json')).mtimeMs; const cachedResult = harness.runProbe({ claudeConfigDir: home.claudeConfig, home: home.home, mode: 'unexpected', - nowMs: nowMs + 10000, + nowMs: cacheMtimeMs + 10000, pathDir: home.bin, requiredFields }); diff --git a/src/utils/usage-fetch.ts b/src/utils/usage-fetch.ts index d9da4c63..b9fe7e29 100644 --- a/src/utils/usage-fetch.ts +++ b/src/utils/usage-fetch.ts @@ -428,6 +428,14 @@ function writeUsageLock(blockedUntil: number, error: UsageLockError): void { } } +function clearUsageLock(): void { + try { + fs.rmSync(LOCK_FILE, { force: true }); + } catch { + // Ignore lock file errors + } +} + function readActiveUsageLock(now: number): { blockedUntil: number; error: UsageLockError } | null { let hasValidJsonLock = false; @@ -664,6 +672,12 @@ export async function fetchUsageData(options: FetchUsageDataOptions = {}): Promi // Ignore cache write errors } + // Clear the in-flight lock written above: a successful fetch supersedes + // it. Leaving it behind keeps a 'timeout'-labelled lock active for up to + // LOCK_MAX_AGE, so a later cache miss (e.g. an account switch invalidating + // the fingerprint) surfaces a spurious [Timeout] while the API is healthy. + clearUsageLock(); + return cacheUsageData(usageData, now); } catch { writeUsageLock(now + LOCK_MAX_AGE, 'parse-error'); From d4d67a2daf00306fef8e424b56f94602fc829011 Mon Sep 17 00:00:00 2001 From: Matthew Breedlove Date: Tue, 30 Jun 2026 11:26:04 -0400 Subject: [PATCH 2/2] fix(usage): preserve throttle for incomplete usage fetches Only clear the in-flight usage lock after a successful API response satisfies the fields requested by the caller. Aggregate-only responses that are missing per-model fields remain cached, but keep the short timeout lock so later renders do not refetch the API on every status line render. Add regression coverage for a weekly Sonnet usage request receiving an aggregate-only 200 response, verifying that the second fetch is throttled instead of issuing another API request. --- src/utils/__tests__/usage-fetch.test.ts | 33 +++++++++++++++++++++++++ src/utils/usage-fetch.ts | 11 +++++---- 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/src/utils/__tests__/usage-fetch.test.ts b/src/utils/__tests__/usage-fetch.test.ts index caeb1c94..f4c05167 100644 --- a/src/utils/__tests__/usage-fetch.test.ts +++ b/src/utils/__tests__/usage-fetch.test.ts @@ -724,6 +724,39 @@ describe('fetchUsageData error handling', () => { } }); + it('preserves the in-flight lock after a successful fetch missing required fields', () => { + const harness = createProbeHarness(); + + try { + const home = harness.createTokenHome('success-missing-required-fields'); + const result = harness.runProbe({ + claudeConfigDir: home.claudeConfig, + home: home.home, + mode: 'success', + nowMs, + pathDir: home.bin, + requiredFields: ['weeklySonnetUsage'], + responseBody: successResponseBody + }); + + expect(result.first).toEqual({ + sessionUsage: 42, + sessionResetAt: '2030-01-01T00:00:00.000Z', + weeklyUsage: 17, + weeklyResetAt: '2030-01-07T00:00:00.000Z' + }); + expect(result.second).toEqual({ error: 'timeout' }); + expect(result.cacheExists).toBe(true); + expect(result.requestCount).toBe(1); + expect(parseLockContents(result.lockContents)).toEqual({ + blockedUntil: Math.floor(nowMs / 1000) + 30, + error: 'timeout' + }); + } finally { + harness.cleanup(); + } + }); + it('refetches a fresh cache when the token fingerprint changes (account switch)', () => { const harness = createProbeHarness(); diff --git a/src/utils/usage-fetch.ts b/src/utils/usage-fetch.ts index b9fe7e29..5f252b07 100644 --- a/src/utils/usage-fetch.ts +++ b/src/utils/usage-fetch.ts @@ -672,11 +672,12 @@ export async function fetchUsageData(options: FetchUsageDataOptions = {}): Promi // Ignore cache write errors } - // Clear the in-flight lock written above: a successful fetch supersedes - // it. Leaving it behind keeps a 'timeout'-labelled lock active for up to - // LOCK_MAX_AGE, so a later cache miss (e.g. an account switch invalidating - // the fingerprint) surfaces a spurious [Timeout] while the API is healthy. - clearUsageLock(); + // Clear the in-flight lock written above only once this response satisfies + // the caller's requested fields. Incomplete 200 responses are cached but + // still need the short throttle so later renders do not refetch every time. + if (hasRequiredUsageFields(usageData, requiredFields)) { + clearUsageLock(); + } return cacheUsageData(usageData, now); } catch {