fix: ignore stale inactive codex previews (#3776)

This commit is contained in:
Neil 2026-05-30 09:51:44 -07:00 committed by GitHub
parent d3e0840915
commit be8e8cd22d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 89 additions and 2 deletions

View File

@ -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<ProviderRateLimits>()
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()

View File

@ -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<string, ProviderRateLimits>,
fetching: Set<string>
): InactiveAccountUsage[] {
this.pruneInactiveClaudeState()
const result: InactiveAccountUsage[] = []
for (const [accountId, limits] of cache) {
result.push({