Skip to content
Merged
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
84 changes: 81 additions & 3 deletions src/utils/__tests__/usage-fetch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
});
Expand All @@ -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();

Expand Down Expand Up @@ -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
});
Expand Down Expand Up @@ -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
});
Expand Down
15 changes: 15 additions & 0 deletions src/utils/usage-fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

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