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
76 changes: 74 additions & 2 deletions app/renderer/App.test.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
// @vitest-environment jsdom
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'

import { App } from './App'
import { App, overviewMemoKey } from './App'
import { __resetPolledMemo, hasPolledMemo } from './hooks/usePolled'
import type { DateRange, MenubarPayload, OptimizeJsonReport, SpendFlow } from './lib/types'

const stored = new Map<string, string>()
Expand Down Expand Up @@ -429,3 +430,74 @@ describe('App shortcuts', () => {
await waitFor(() => expect(screen.queryByText(/Daily budget exceeded/)).not.toBeInTheDocument())
})
})

describe('provider prefetch storm', () => {
const PROVIDERS = [
'claude', 'codex', 'gemini', 'grok', 'copilot', 'droid',
'hermes', 'zcode', 'cursor', 'kiro', 'codewhale', 'openrouter',
]

function manyProviderPayload(): MenubarPayload {
const base = overviewPayload()
const providers: Record<string, number> = {}
PROVIDERS.forEach((id, i) => { providers[id] = PROVIDERS.length - i })
return { ...base, current: { ...base.current, providers } }
}

beforeEach(() => {
for (const mock of Object.values(mocks)) mock.mockReset()
mocks.getOverview.mockResolvedValue(manyProviderPayload())
mocks.getActReport.mockResolvedValue({ totals: { realizedCostUSD: 0, measuredActions: 0 } })
mocks.getYield.mockResolvedValue({
period: { label: 'Last 30 days', start: '', end: '' },
summary: {
productive: { costUSD: 0, sessions: 0, costPercent: 0, sessionPercent: 0 },
reverted: { costUSD: 0, sessions: 0, costPercent: 0, sessionPercent: 0 },
abandoned: { costUSD: 0, sessions: 0, costPercent: 0, sessionPercent: 0 },
total: { costUSD: 0, sessions: 0 },
productiveToRevertedCostRatio: null,
},
details: [],
})
localStorage.clear()
__resetPolledMemo()
})

// With MEMO_MAX too small (< providers + base keys) the base overview key
// LRU-evicts between polls, blanking the overview and re-arming the prefetch
// every 30s cycle: 12 redundant full-history parses forever. This asserts the
// fix — each provider is prefetched EXACTLY ONCE total across three cycles.
it('prefetches each detected provider exactly once across 3 poll cycles', async () => {
vi.useFakeTimers()
try {
render(<App />)
// Let the mount overview resolve so `ready` flips and the prefetch arms.
await act(async () => { await vi.advanceTimersByTimeAsync(3_000) })
// Three full 30s poll cycles plus the prefetch start delay and staggered
// per-provider warms (12 × 400ms). A re-arming storm would re-spawn some
// providers on cycles 2 and 3; the once-per-key guard must prevent it.
await act(async () => { await vi.advanceTimersByTimeAsync(30_000 * 3 + 12_000) })

for (const id of PROVIDERS) {
const spawns = mocks.getOverview.mock.calls.filter(
c => c[0] === '30days' && c[1] === id && c[2] === undefined && c[3] === undefined,
)
expect(spawns.length, `prefetch spawns for ${id}`).toBe(1)
}

// Sanity: the active 'all' view was polled every cycle (not prefetch-gated).
const allPolls = mocks.getOverview.mock.calls.filter(c => c[1] === 'all')
expect(allPolls.length).toBeGreaterThanOrEqual(3)

// The memo must be sized to hold every warmed provider so none LRU-evict
// between polls — the eviction that (in the real app) blanked the base
// overview key and re-armed the prefetch. Under the old fixed cap of 8,
// 7 of these 12 keys would have been evicted by soak's end.
for (const id of PROVIDERS) {
expect(hasPolledMemo(overviewMemoKey(id, '30days', null, null)), `warm key ${id}`).toBe(true)
}
} finally {
vi.useRealTimers()
}
})
})
33 changes: 28 additions & 5 deletions app/renderer/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'

import { EmptyNote } from './components/EmptyState'
import { ErrorBoundary } from './components/ErrorBoundary'
Expand All @@ -10,7 +10,7 @@ import { Splash } from './components/Splash'
import { ToastHost } from './components/ToastHost'
import { rangeLabel, TopBar } from './components/TopBar'
import { Window } from './components/Window'
import { hasPolledMemo, primePolledMemo, usePolled } from './hooks/usePolled'
import { hasPolledMemo, primePolledMemo, setPolledMemoMax, usePolled } from './hooks/usePolled'
import { readDailyBudget } from './lib/budget'
import { formatCompact, formatUsd, setActiveCurrency } from './lib/format'
import { motionClass } from './lib/motion'
Expand Down Expand Up @@ -78,8 +78,9 @@ const PERIOD_LABELS: Record<Period, string> = {
const STANDARD_PERIODS: Period[] = ['today', 'week', '30days', 'month', 'all']

// Instant-switch memo key for an overview result. Shared by the overview poll
// and the provider prefetcher so the two never drift out of sync.
function overviewMemoKey(provider: string, period: Period, range: DateRange | null, configSource: string | null): string {
// and the provider prefetcher so the two never drift out of sync. Exported so
// the prefetch-storm test can assert warmed keys survive between polls.
export function overviewMemoKey(provider: string, period: Period, range: DateRange | null, configSource: string | null): string {
return `overview|${provider}|${period}|${range?.from ?? ''}-${range?.to ?? ''}|${configSource ?? ''}`
}

Expand All @@ -88,6 +89,13 @@ function overviewMemoKey(provider: string, period: Period, range: DateRange | nu
// the interaction the user is actually having.
const PREFETCH_START_DELAY_MS = 1500
const PREFETCH_STAGGER_MS = 400
// Base instant-switch memo keys live during overview polling besides the per-
// provider prefetch entries: `overview|all`, `overview-act`, `overview-yield`,
// plus one slot of headroom for section navigation. The memo cap is sized to
// (detected providers + this) so warmed entries — and the base overview key —
// never LRU-evict between polls (which would blank the overview and re-arm the
// prefetch every cycle).
const BASE_MEMO_KEYS = 4

function isPeriod(value: string): value is Period {
return (STANDARD_PERIODS as string[]).includes(value)
Expand Down Expand Up @@ -252,6 +260,12 @@ function AppMain() {
setCurrencyTick(tick => tick + 1)
}, [overview.data?.currency?.code, overview.data?.currency?.rate, overview.data?.currency?.symbol])

// Size the instant-switch memo to hold every prefetched provider overview plus
// the base keys, so warmed entries survive between polls instead of evicting.
useEffect(() => {
setPolledMemoMax(detectedProviders.length + BASE_MEMO_KEYS)
}, [detectedProviders.length])

// Prefetch for millisecond switches: once the first overview has resolved,
// quietly warm the instant-switch memo for every OTHER detected provider at the
// current period, so a picker switch to one paints from memory instead of
Expand All @@ -260,6 +274,14 @@ function AppMain() {
// actually toggles between. The CLI's own read-cache + in-flight coalescing keep
// this from double-spawning against a live user fetch; hasPolledMemo skips any
// provider already warm (including one warmed by a real visit).
//
// `warmedKeys` is a session-lifetime once-per-key guard: each (provider,period)
// memo key is marked BEFORE its spawn, so an effect re-run — e.g. an overview
// poll that momentarily blanked `overview.data` — can never re-spawn a provider
// already warmed. New keys (a new provider id, or a period switch) still warm
// exactly once. Without this the prefetch re-fired every poll: 12 redundant
// full-history CLI parses every 30s, forever.
const warmedKeys = useRef<Set<string>>(new Set())
useEffect(() => {
if (!ready || overview.data == null || customRange || claudeConfigSource) return
const targets = detectedProviders.map(entry => entry.id).filter(id => id !== provider)
Expand All @@ -269,7 +291,8 @@ function AppMain() {
for (const id of targets) {
if (cancelled) return
const key = overviewMemoKey(id, period, null, null)
if (hasPolledMemo(key)) continue
if (warmedKeys.current.has(key) || hasPolledMemo(key)) continue
warmedKeys.current.add(key)
try {
const value = await codeburn.getOverview(period, id)
if (!cancelled) primePolledMemo(key, value)
Expand Down
3 changes: 3 additions & 0 deletions app/renderer/components/Onboarding.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@ export function Onboarding({ defaultEnabled, onDone }: { defaultEnabled: boolean
<button type="button" className="onboard-link" onClick={() => { void codeburn.openExternal?.(COLLECT_URL) }}>
What data we collect
</button>
<p className="onboard-hint">
Tip: if a provider looks empty, grant Full Disk Access in System Settings › Privacy &amp; Security.
</p>
</>
) : (
<>
Expand Down
4 changes: 3 additions & 1 deletion app/renderer/components/Splash.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,9 @@ function reduceProgress(state: Progress, event: ScanProgressEvent): Progress {
}
case 'provider': {
const order = state.order.includes(event.provider) ? state.order : [...state.order, event.provider]
const next: ProvStatus = event.state === 'done' ? 'done' : 'active'
// 'skipped' (a permission-locked provider) is terminal like 'done' so it
// settles the strip instead of showing as perpetually active.
const next: ProvStatus = event.state === 'done' || event.state === 'skipped' ? 'done' : 'active'
return { ...state, order, status: { ...state.status, [event.provider]: next } }
}
case 'tick':
Expand Down
30 changes: 26 additions & 4 deletions app/renderer/hooks/usePolled.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,32 @@ export type Polled<T> = {
// Module-level LRU of last-good results per memoKey. A section that switches deps
// to a previously-seen key (e.g. a provider switch, or a switch-back) paints the
// cached result in the same frame while a fresh fetch runs behind it — no blank,
// no stale-freeze. ~8 entries is plenty for the handful of live (provider ×
// period × range) combinations a user cycles through.
const MEMO_MAX = 8
// no stale-freeze.
//
// The cap must comfortably hold every key live at once: the base overview/act/
// yield polls PLUS one prefetched overview per detected provider. Sized too small
// it LRU-evicts the base `overview|all` key between polls, which blanks the
// overview and re-triggers the provider prefetch every cycle (the prefetch
// storm). The App raises it via setPolledMemoMax to (detected providers + base
// keys); DEFAULT_MEMO_MAX is the floor for isolated hook/component tests.
const DEFAULT_MEMO_MAX = 8
const MEMO_MAX_CAP = 24
let memoMax = DEFAULT_MEMO_MAX
const memoStore = new Map<string, unknown>()

/** Raise (or lower) the instant-switch memo cap so warmed entries survive between
* polls. Clamped to [DEFAULT_MEMO_MAX, MEMO_MAX_CAP]; trims immediately if the
* new cap is smaller than the current contents. Called by the App as the set of
* detected providers grows. */
export function setPolledMemoMax(n: number): void {
memoMax = Math.max(DEFAULT_MEMO_MAX, Math.min(MEMO_MAX_CAP, Math.floor(n)))
while (memoStore.size > memoMax) {
const oldest = memoStore.keys().next().value
if (oldest === undefined) break
memoStore.delete(oldest)
}
}

function memoGet<T>(key: string): T | undefined {
if (!memoStore.has(key)) return undefined
const value = memoStore.get(key) as T
Expand All @@ -37,7 +58,7 @@ function memoGet<T>(key: string): T | undefined {
function memoSet(key: string, value: unknown): void {
if (memoStore.has(key)) memoStore.delete(key)
memoStore.set(key, value)
while (memoStore.size > MEMO_MAX) {
while (memoStore.size > memoMax) {
const oldest = memoStore.keys().next().value
if (oldest === undefined) break
memoStore.delete(oldest)
Expand All @@ -48,6 +69,7 @@ function memoSet(key: string, value: unknown): void {
* one test never bleed into the next. */
export function __resetPolledMemo(): void {
memoStore.clear()
memoMax = DEFAULT_MEMO_MAX
}

/** Seed the instant-switch memo out of band. The prefetcher (App.tsx) warms the
Expand Down
2 changes: 1 addition & 1 deletion app/renderer/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -566,7 +566,7 @@ export type TelemetryStatus = {
* a warm launch's incremental re-parse emits the same events without it. */
export type ScanProgressEvent =
| { kind: 'providers'; providers: string[]; cold?: boolean }
| { kind: 'provider'; provider: string; state: 'start' | 'done'; files?: number }
| { kind: 'provider'; provider: string; state: 'start' | 'done' | 'skipped'; files?: number }
| { kind: 'tick'; provider: string; done: number; total: number }
| { kind: 'done' }

Expand Down
27 changes: 27 additions & 0 deletions app/renderer/sections/Plans.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,33 @@ describe('Plans', () => {
await waitFor(() => expect(getQuota).toHaveBeenCalledWith(true))
})

it('renders the honest rate-limited note on a 429 backoff, per provider owner', async () => {
getPlans.mockResolvedValue(baseStatus)
getQuota.mockResolvedValue([
{ provider: 'claude', connection: 'transientFailure', rateLimited: true, primary: null, details: [], planLabel: null, footerLines: [] },
{ provider: 'codex', connection: 'stale', rateLimited: true, primary: null, details: [], planLabel: null, footerLines: [] },
])

render(<Plans period="30days" />)

expect(await screen.findByText('Anthropic rate limited the quota endpoint, retrying in a few minutes')).toBeInTheDocument()
expect(screen.getByText('OpenAI rate limited the quota endpoint, retrying in a few minutes')).toBeInTheDocument()
// The rate-limited note replaces the generic waiting copy.
expect(screen.queryByText('waiting on the CLI…')).not.toBeInTheDocument()
})

it('falls back to the generic waiting note when a transient failure is not rate limited', async () => {
getPlans.mockResolvedValue(baseStatus)
getQuota.mockResolvedValue([
{ provider: 'claude', connection: 'transientFailure', rateLimited: false, primary: null, details: [], planLabel: null, footerLines: [] },
])

render(<Plans period="30days" />)

expect(await screen.findByText('waiting on the CLI…')).toBeInTheDocument()
expect(screen.queryByText(/rate limited the quota endpoint/)).not.toBeInTheDocument()
})

it('renders the keychain access-denied state with recovery copy and a locked indicator', async () => {
getPlans.mockResolvedValue(statusWithPlans)
getQuota.mockResolvedValue([
Expand Down
1 change: 1 addition & 0 deletions app/renderer/styles/plain.css
Original file line number Diff line number Diff line change
Expand Up @@ -965,6 +965,7 @@ td:first-child { font-size: var(--fs-body); font-weight: var(--fw-body); }
font: inherit; font-size: var(--fs-label); color: #b3a595; text-decoration: underline; text-underline-offset: 3px;
}
.onboard-link:hover, .onboard-link:focus-visible { color: #f4f1ec; outline: none; }
.onboard-hint { margin: 2px 0 0; max-width: 320px; font-size: var(--fs-label); line-height: 1.5; color: #8a7d6f; }
.onboard-controls {
margin-top: 14px; display: flex; align-items: center; justify-content: space-between; gap: 12px; width: 100%;
}
Expand Down
Loading