diff --git a/src/utils/__tests__/usage-fetch.test.ts b/src/utils/__tests__/usage-fetch.test.ts index 1067c25c..f4c05167 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,66 @@ 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('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(); @@ -849,11 +915,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 +967,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..5f252b07 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,13 @@ export async function fetchUsageData(options: FetchUsageDataOptions = {}): Promi // Ignore cache write errors } + // 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 { writeUsageLock(now + LOCK_MAX_AGE, 'parse-error');