fix(usage): prevent cross-process usage API stampedes (#174)

* fix(usage): dedupe concurrent usage API refreshes

* chore: stop tracking dist artifacts

* chore: restore tracked dist artifacts
This commit is contained in:
Jarrod Watts
2026-03-06 18:05:16 +11:00
committed by GitHub
parent daab333f3e
commit 67ddceb38a
2 changed files with 248 additions and 20 deletions

View File

@@ -45,21 +45,48 @@ interface UsageApiResult {
// File-based cache (HUD runs as new process each render, so in-memory cache won't persist)
const CACHE_TTL_MS = 60_000; // 60 seconds
const CACHE_FAILURE_TTL_MS = 15_000; // 15 seconds for failed requests
const CACHE_LOCK_STALE_MS = 30_000;
const CACHE_LOCK_WAIT_MS = 2_000;
const CACHE_LOCK_POLL_MS = 50;
const KEYCHAIN_TIMEOUT_MS = 3000;
const KEYCHAIN_BACKOFF_MS = 60_000; // Backoff on keychain failures to avoid re-prompting
const USAGE_API_TIMEOUT_MS_DEFAULT = 15_000;
export const USAGE_API_USER_AGENT = 'claude-code/2.1';
export const USAGE_API_USER_AGENT = 'claude-hud';
interface CacheFile {
data: UsageData;
timestamp: number;
}
interface CacheState {
data: UsageData;
timestamp: number;
isFresh: boolean;
}
type CacheLockStatus = 'acquired' | 'busy' | 'unsupported';
function getCachePath(homeDir: string): string {
return path.join(getHudPluginDir(homeDir), '.usage-cache.json');
}
function readCache(homeDir: string, now: number): UsageData | null {
function getCacheLockPath(homeDir: string): string {
return path.join(getHudPluginDir(homeDir), '.usage-cache.lock');
}
function hydrateCacheData(data: UsageData): UsageData {
// JSON.stringify converts Date to ISO string, so we need to reconvert on read.
// new Date() handles both Date objects and ISO strings safely.
if (data.fiveHourResetAt) {
data.fiveHourResetAt = new Date(data.fiveHourResetAt);
}
if (data.sevenDayResetAt) {
data.sevenDayResetAt = new Date(data.sevenDayResetAt);
}
return data;
}
function readCacheState(homeDir: string, now: number): CacheState | null {
try {
const cachePath = getCachePath(homeDir);
if (!fs.existsSync(cachePath)) return null;
@@ -69,24 +96,21 @@ function readCache(homeDir: string, now: number): UsageData | null {
// Check TTL - use shorter TTL for failure results
const ttl = cache.data.apiUnavailable ? CACHE_FAILURE_TTL_MS : CACHE_TTL_MS;
if (now - cache.timestamp >= ttl) return null;
// JSON.stringify converts Date to ISO string, so we need to reconvert on read.
// new Date() handles both Date objects and ISO strings safely.
const data = cache.data;
if (data.fiveHourResetAt) {
data.fiveHourResetAt = new Date(data.fiveHourResetAt);
}
if (data.sevenDayResetAt) {
data.sevenDayResetAt = new Date(data.sevenDayResetAt);
}
return data;
return {
data: hydrateCacheData(cache.data),
timestamp: cache.timestamp,
isFresh: now - cache.timestamp < ttl,
};
} catch {
return null;
}
}
function readCache(homeDir: string, now: number): UsageData | null {
const cache = readCacheState(homeDir, now);
return cache?.isFresh ? cache.data : null;
}
function writeCache(homeDir: string, data: UsageData, timestamp: number): void {
try {
const cachePath = getCachePath(homeDir);
@@ -103,6 +127,87 @@ function writeCache(homeDir: string, data: UsageData, timestamp: number): void {
}
}
function readLockTimestamp(lockPath: string): number | null {
try {
if (!fs.existsSync(lockPath)) return null;
const raw = fs.readFileSync(lockPath, 'utf8').trim();
const parsed = Number.parseInt(raw, 10);
return Number.isFinite(parsed) ? parsed : null;
} catch {
return null;
}
}
function tryAcquireCacheLock(homeDir: string): CacheLockStatus {
const lockPath = getCacheLockPath(homeDir);
const cacheDir = path.dirname(lockPath);
try {
if (!fs.existsSync(cacheDir)) {
fs.mkdirSync(cacheDir, { recursive: true });
}
const fd = fs.openSync(lockPath, 'wx');
try {
fs.writeFileSync(fd, String(Date.now()), 'utf8');
} finally {
fs.closeSync(fd);
}
return 'acquired';
} catch (error) {
const maybeError = error as NodeJS.ErrnoException;
if (maybeError.code !== 'EEXIST') {
debug('Usage cache lock unavailable, continuing without coordination:', maybeError.message);
return 'unsupported';
}
}
const lockTimestamp = readLockTimestamp(lockPath);
if (lockTimestamp != null && Date.now() - lockTimestamp > CACHE_LOCK_STALE_MS) {
try {
fs.unlinkSync(lockPath);
} catch {
return 'busy';
}
return tryAcquireCacheLock(homeDir);
}
return 'busy';
}
function releaseCacheLock(homeDir: string): void {
try {
const lockPath = getCacheLockPath(homeDir);
if (fs.existsSync(lockPath)) {
fs.unlinkSync(lockPath);
}
} catch {
// Ignore lock cleanup failures
}
}
async function waitForFreshCache(
homeDir: string,
now: () => number,
timeoutMs: number = CACHE_LOCK_WAIT_MS
): Promise<UsageData | null> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, CACHE_LOCK_POLL_MS));
const cached = readCache(homeDir, now());
if (cached) {
return cached;
}
if (!fs.existsSync(getCacheLockPath(homeDir))) {
break;
}
}
return readCache(homeDir, now());
}
// Dependency injection for testing
export type UsageApiDeps = {
homeDir: () => string;
@@ -132,12 +237,27 @@ export async function getUsage(overrides: Partial<UsageApiDeps> = {}): Promise<U
const homeDir = deps.homeDir();
// Check file-based cache first
const cached = readCache(homeDir, now);
if (cached) {
return cached;
const cacheState = readCacheState(homeDir, now);
if (cacheState?.isFresh) {
return cacheState.data;
}
let holdsCacheLock = false;
const lockStatus = tryAcquireCacheLock(homeDir);
if (lockStatus === 'busy') {
if (cacheState) {
return cacheState.data;
}
return await waitForFreshCache(homeDir, deps.now);
}
holdsCacheLock = lockStatus === 'acquired';
try {
const refreshedCache = readCache(homeDir, deps.now());
if (refreshedCache) {
return refreshedCache;
}
const credentials = readCredentials(homeDir, now, deps.readKeychain);
if (!credentials) {
return null;
@@ -192,6 +312,10 @@ export async function getUsage(overrides: Partial<UsageApiDeps> = {}): Promise<U
} catch (error) {
debug('getUsage failed:', error);
return null;
} finally {
if (holdsCacheLock) {
releaseCacheLock(homeDir);
}
}
}
@@ -719,6 +843,10 @@ export function clearCache(homeDir?: string): void {
if (fs.existsSync(cachePath)) {
fs.unlinkSync(cachePath);
}
const lockPath = getCacheLockPath(homeDir);
if (fs.existsSync(lockPath)) {
fs.unlinkSync(lockPath);
}
} catch {
// Ignore
}

View File

@@ -506,8 +506,8 @@ describe('getUsage', () => {
});
});
test('usage API user agent matches the working Claude Code identifier', () => {
assert.equal(USAGE_API_USER_AGENT, 'claude-code/2.1');
test('usage API user agent uses a non-empty claude-hud identifier', () => {
assert.match(USAGE_API_USER_AGENT, /^claude-hud(?:\/|$)/);
});
describe('getKeychainServiceName', () => {
@@ -643,6 +643,106 @@ describe('getUsage caching behavior', () => {
await getUsage({ homeDir: () => tempHome, fetchApi, now: () => 2000, readKeychain: () => null });
assert.equal(fetchCalls, 2);
});
test('deduplicates concurrent refreshes when cache is missing', async () => {
await writeCredentials(tempHome, buildCredentials());
let fetchCalls = 0;
let releaseFetch = () => {};
let signalFetchStarted = () => {};
const fetchStarted = new Promise((resolve) => {
signalFetchStarted = resolve;
});
const fetchGate = new Promise((resolve) => {
releaseFetch = resolve;
});
const fetchApi = async () => {
fetchCalls += 1;
signalFetchStarted();
await fetchGate;
return buildApiResult({
data: buildApiResponse({
five_hour: {
utilization: 42,
resets_at: '2026-01-06T15:00:00Z',
},
}),
});
};
const first = getUsage({ homeDir: () => tempHome, fetchApi, now: () => 1000, readKeychain: () => null });
await fetchStarted;
const second = getUsage({ homeDir: () => tempHome, fetchApi, now: () => 1000, readKeychain: () => null });
const third = getUsage({ homeDir: () => tempHome, fetchApi, now: () => 1000, readKeychain: () => null });
releaseFetch();
const results = await Promise.all([first, second, third]);
assert.equal(fetchCalls, 1);
assert.deepEqual(results.map((result) => result?.fiveHour), [42, 42, 42]);
});
test('returns stale cache while another process refreshes expired data', async () => {
await writeCredentials(tempHome, buildCredentials());
let nowValue = 1000;
await getUsage({
homeDir: () => tempHome,
fetchApi: async () => buildApiResult(),
now: () => nowValue,
readKeychain: () => null,
});
nowValue += 61_000;
let fetchCalls = 0;
let releaseFetch = () => {};
let signalFetchStarted = () => {};
const fetchStarted = new Promise((resolve) => {
signalFetchStarted = resolve;
});
const fetchGate = new Promise((resolve) => {
releaseFetch = resolve;
});
const fetchApi = async () => {
fetchCalls += 1;
signalFetchStarted();
await fetchGate;
return buildApiResult({
data: buildApiResponse({
five_hour: {
utilization: 88,
resets_at: '2026-01-06T16:00:00Z',
},
}),
});
};
const leader = getUsage({
homeDir: () => tempHome,
fetchApi,
now: () => nowValue,
readKeychain: () => null,
});
await fetchStarted;
const follower = await getUsage({
homeDir: () => tempHome,
fetchApi,
now: () => nowValue,
readKeychain: () => null,
});
assert.equal(fetchCalls, 1);
assert.equal(follower?.fiveHour, 25);
releaseFetch();
const refreshed = await leader;
assert.equal(refreshed?.fiveHour, 88);
});
});
describe('getUsageApiTimeoutMs', () => {