From be8e8cd22de3050fa55eb296e2ec190f5577de25 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 30 May 2026 09:51:44 -0700 Subject: [PATCH] fix: ignore stale inactive codex previews (#3776) --- src/main/rate-limits/service.test.ts | 24 ++++++++++ src/main/rate-limits/service.ts | 67 +++++++++++++++++++++++++++- 2 files changed, 89 insertions(+), 2 deletions(-) diff --git a/src/main/rate-limits/service.test.ts b/src/main/rate-limits/service.test.ts index 10d5a74c4..2c4bfd5d0 100644 --- a/src/main/rate-limits/service.test.ts +++ b/src/main/rate-limits/service.test.ts @@ -425,6 +425,30 @@ describe('RateLimitService', () => { await firstFetch }) + it('does not recache an inactive Codex account that becomes active during fetch-on-open', async () => { + const service = new RateLimitService() + const accountFetch = deferred() + let inactiveAccounts = [{ id: 'account-b', managedHomePath: '/tmp/account-b/home' }] + service.setInactiveCodexAccountsResolver(() => inactiveAccounts) + service.setCodexHomePathResolver(() => '/tmp/account-b/home') + vi.mocked(fetchCodexRateLimits) + .mockReturnValueOnce(accountFetch.promise) + .mockResolvedValueOnce(okProvider('codex', 7, Date.now())) + + const fetchOnOpen = service.fetchInactiveCodexAccountsOnOpen() + await Promise.resolve() + expect(service.getState().inactiveCodexAccounts).toEqual([ + { accountId: 'account-b', claude: null, updatedAt: 0, isFetching: true } + ]) + + inactiveAccounts = [] + await service.refreshForCodexAccountChange('account-a') + accountFetch.resolve(okProvider('codex', 42, Date.now())) + await fetchOnOpen + + expect(service.getState().inactiveCodexAccounts).toEqual([]) + }) + it('preserves Gemini buckets through getState after fetch', async () => { const service = new RateLimitService() diff --git a/src/main/rate-limits/service.ts b/src/main/rate-limits/service.ts index 82ac7753f..eb55cef18 100644 --- a/src/main/rate-limits/service.ts +++ b/src/main/rate-limits/service.ts @@ -98,6 +98,7 @@ export class RateLimitService { private lastInactiveClaudeFetchAt = 0 private inactiveClaudeAccountsGeneration = 0 private lastInactiveCodexFetchAt = 0 + private inactiveCodexAccountsGeneration = 0 private stateListeners = new Set<(state: RateLimitState) => void>() constructor() {} @@ -142,6 +143,8 @@ export class RateLimitService { setInactiveCodexAccountsResolver(resolver: () => InactiveCodexAccountInfo[]): void { this.inactiveCodexAccountsResolver = resolver + this.inactiveCodexAccountsGeneration += 1 + this.pruneInactiveCodexState() } attach(mainWindow: BrowserWindow): void { @@ -181,6 +184,8 @@ export class RateLimitService { } getState(): RateLimitState { + this.pruneInactiveClaudeState() + this.pruneInactiveCodexState() return { ...this.state, claudeTarget: this.claudeFetchTarget, @@ -219,6 +224,8 @@ export class RateLimitService { } this.codexFetchTarget = nextTarget this.codexFetchGeneration += 1 + this.inactiveCodexAccountsGeneration += 1 + this.pruneInactiveCodexState() this.lastInactiveCodexFetchAt = 0 // Why: switching the selected Codex account must immediately clear the old // Codex quota view. Keeping stale values visible would show the previous @@ -350,6 +357,7 @@ export class RateLimitService { if (Date.now() - this.lastInactiveCodexFetchAt < INACTIVE_FETCH_DEBOUNCE_MS) { return } + this.pruneInactiveCodexState() if (this.inactiveCodexFetching.size > 0) { return } @@ -357,6 +365,9 @@ export class RateLimitService { if (accounts.length === 0) { return } + // Why: account switching can make a previewed account active while its + // RPC-only usage fetch is still in flight; stale results must be ignored. + const fetchGeneration = this.inactiveCodexAccountsGeneration for (const account of accounts) { this.inactiveCodexFetching.add(account.id) @@ -364,6 +375,17 @@ export class RateLimitService { this.pushToRenderer() for (const account of accounts) { + if ( + fetchGeneration !== this.inactiveCodexAccountsGeneration || + !this.isCurrentInactiveCodexAccount(account.id) + ) { + this.inactiveCodexFetching.delete(account.id) + if (!this.isCurrentInactiveCodexAccount(account.id)) { + this.inactiveCodexCache.delete(account.id) + } + this.pushToRenderer() + continue + } try { // Why: fetchCodexRateLimits already accepts codexHomePath, so we can // point it at the managed account's home directory directly without @@ -375,16 +397,35 @@ export class RateLimitService { codexHomePath: account.managedHomePath, allowPtyFallback: false }) + if ( + fetchGeneration !== this.inactiveCodexAccountsGeneration || + !this.isCurrentInactiveCodexAccount(account.id) + ) { + this.inactiveCodexFetching.delete(account.id) + if (!this.isCurrentInactiveCodexAccount(account.id)) { + this.inactiveCodexCache.delete(account.id) + } + this.pushToRenderer() + continue + } const cached = this.inactiveCodexCache.get(account.id) ?? null this.inactiveCodexCache.set(account.id, this.applyStalePolicy(fresh, cached)) } catch { // Why: per-account try/catch prevents one failure from aborting the batch. + if ( + fetchGeneration !== this.inactiveCodexAccountsGeneration || + !this.isCurrentInactiveCodexAccount(account.id) + ) { + this.inactiveCodexCache.delete(account.id) + } } this.inactiveCodexFetching.delete(account.id) this.pushToRenderer() } - this.lastInactiveCodexFetchAt = Date.now() + if (fetchGeneration === this.inactiveCodexAccountsGeneration) { + this.lastInactiveCodexFetchAt = Date.now() + } } evictInactiveClaudeCache(accountId: string): void { @@ -400,6 +441,12 @@ export class RateLimitService { ) } + private isCurrentInactiveCodexAccount(accountId: string): boolean { + return (this.inactiveCodexAccountsResolver?.() ?? []).some( + (account) => account.id === accountId + ) + } + private pruneInactiveClaudeState(): void { const currentIds = new Set( (this.inactiveClaudeAccountsResolver?.() ?? []).map((account) => account.id) @@ -416,7 +463,24 @@ export class RateLimitService { } } + private pruneInactiveCodexState(): void { + const currentIds = new Set( + (this.inactiveCodexAccountsResolver?.() ?? []).map((account) => account.id) + ) + for (const accountId of this.inactiveCodexCache.keys()) { + if (!currentIds.has(accountId)) { + this.inactiveCodexCache.delete(accountId) + } + } + for (const accountId of this.inactiveCodexFetching) { + if (!currentIds.has(accountId)) { + this.inactiveCodexFetching.delete(accountId) + } + } + } + evictInactiveCodexCache(accountId: string): void { + this.inactiveCodexAccountsGeneration += 1 this.inactiveCodexCache.delete(accountId) this.inactiveCodexFetching.delete(accountId) this.pushToRenderer() @@ -949,7 +1013,6 @@ export class RateLimitService { cache: Map, fetching: Set ): InactiveAccountUsage[] { - this.pruneInactiveClaudeState() const result: InactiveAccountUsage[] = [] for (const [accountId, limits] of cache) { result.push({