Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions src/utils/__tests__/usage-fetch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
12 changes: 11 additions & 1 deletion src/utils/usage-fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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'
Expand Down