From a514a46f750f017a74d96612bfcb62f074f5b706 Mon Sep 17 00:00:00 2001 From: Serge Baranov Date: Sun, 26 Jul 2026 00:50:22 -0700 Subject: [PATCH] fix(usage): recover from a lock deadline that can never expire The JSON usage lock stores an absolute blockedUntil, so unlike the mtime-based lock it replaced it cannot age out. One bogus timestamp (a mocked clock, a system clock jump) blocks usage fetching permanently: readActiveUsageLock keeps reporting an active lock, the API is never called, and no code path can clear the file, so every usage widget renders [Timeout] forever. Seen in the wild as a lock with blockedUntil in 2039, left behind by the pre-#432 test suite writing into the real ~/.cache/ccstatusline on Windows. The stale-cache fallback could not mask it either, because that cache predated token fingerprinting and so failed the tokenHash check. Ignore a deadline more than 24h ahead and fetch instead. The fetch rewrites the file with a sane deadline, so a poisoned lock self-heals on the next render. The bound stays well above any real Retry-After backoff, which the added test pins at 12h. --- src/utils/__tests__/usage-fetch.test.ts | 65 +++++++++++++++++++++++++ src/utils/usage-fetch.ts | 12 ++++- 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/src/utils/__tests__/usage-fetch.test.ts b/src/utils/__tests__/usage-fetch.test.ts index 8163f014..efc5ee74 100644 --- a/src/utils/__tests__/usage-fetch.test.ts +++ b/src/utils/__tests__/usage-fetch.test.ts @@ -829,6 +829,71 @@ describe('fetchUsageData error handling', () => { } }); + it('ignores a lock whose deadline is implausibly far in the future', () => { + const harness = createProbeHarness(); + + try { + const home = harness.createTokenHome('lock-beyond-horizon'); + const cacheDir = path.join(home.home, '.cache', 'ccstatusline'); + fs.mkdirSync(cacheDir, { recursive: true }); + fs.writeFileSync(path.join(cacheDir, 'usage.lock'), JSON.stringify({ + blockedUntil: Math.floor(nowMs / 1000) + (10 * 365 * 24 * 60 * 60), + error: 'timeout' + })); + + const result = harness.runProbe({ + claudeConfigDir: home.claudeConfig, + home: home.home, + mode: 'success', + nowMs, + pathDir: home.bin, + responseBody: successResponseBody + }); + + // The JSON lock stores an absolute deadline and so cannot age out. + // Honoring a bogus one strands every usage widget on [Timeout] with + // no way back; the fetch must run and overwrite the poisoned file. + expect(result.requestCount).toBe(1); + expect(result.first).toEqual({ + sessionUsage: 42, + sessionResetAt: '2030-01-01T00:00:00.000Z', + weeklyUsage: 17, + weeklyResetAt: '2030-01-07T00:00:00.000Z' + }); + expect(result.lockExists).toBe(false); + } finally { + harness.cleanup(); + } + }); + + it('honors a long but plausible rate-limit lock', () => { + const harness = createProbeHarness(); + + try { + const home = harness.createTokenHome('lock-within-horizon'); + const cacheDir = path.join(home.home, '.cache', 'ccstatusline'); + fs.mkdirSync(cacheDir, { recursive: true }); + fs.writeFileSync(path.join(cacheDir, 'usage.lock'), JSON.stringify({ + blockedUntil: Math.floor(nowMs / 1000) + (12 * 60 * 60), + error: 'rate-limited' + })); + + const result = harness.runProbe({ + claudeConfigDir: home.claudeConfig, + home: home.home, + mode: 'unexpected', + nowMs, + pathDir: home.bin + }); + + // The horizon must not undercut a genuine Retry-After backoff. + expect(result.requestCount).toBe(0); + expect(result.first).toEqual({ error: 'rate-limited' }); + } finally { + harness.cleanup(); + } + }); + it('preserves the in-flight lock after a successful fetch missing required fields', () => { const harness = createProbeHarness(); diff --git a/src/utils/usage-fetch.ts b/src/utils/usage-fetch.ts index e2f48285..0ed18104 100644 --- a/src/utils/usage-fetch.ts +++ b/src/utils/usage-fetch.ts @@ -26,6 +26,13 @@ const LOCK_FILE = path.join(CACHE_DIR, 'usage.lock'); const CACHE_MAX_AGE = 180; // seconds const LOCK_MAX_AGE = 30; // rate limit: only try API once per 30 seconds const DEFAULT_RATE_LIMIT_BACKOFF = 300; // seconds +// Upper bound on how far ahead a lock may block fetching. The longest +// legitimate lock is a 429 Retry-After, which servers keep far below a day. +// The JSON lock stores an absolute deadline, so unlike the legacy mtime lock +// it cannot age out on its own: one bogus timestamp (a mocked clock, a system +// clock jump) otherwise wedges usage fetching permanently, with every widget +// stuck on [Timeout] and no code path able to recover. +const MAX_LOCK_HORIZON = 24 * 60 * 60; // seconds const MACOS_USAGE_CREDENTIALS_SERVICE = 'Claude Code-credentials'; const MACOS_SECURITY_DUMP_MAX_BUFFER = 8 * 1024 * 1024; @@ -561,7 +568,10 @@ function readActiveUsageLock(now: number): { blockedUntil: number; error: UsageL const parsed = parseJsonWithSchema(fs.readFileSync(LOCK_FILE, 'utf8'), UsageLockSchema); if (parsed) { hasValidJsonLock = true; - if (parsed.blockedUntil > now) { + // Past deadline, or one implausibly far ahead: treat as no lock and + // fetch. The fetch rewrites the file with a sane deadline, so a + // poisoned lock self-heals on the very next render. + if (parsed.blockedUntil > now && parsed.blockedUntil <= now + MAX_LOCK_HORIZON) { return { blockedUntil: parsed.blockedUntil, error: parsed.error ?? 'timeout'