mirror of
https://github.com/jarrodwatts/claude-hud.git
synced 2026-05-21 15:52:37 +00:00
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:
164
src/usage-api.ts
164
src/usage-api.ts
@@ -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
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user