mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-07-29 10:55:35 +00:00
perf(app): cap CLI spawn concurrency, background-priority prefetch, default to today
This commit is contained in:
parent
d00b33b13d
commit
f26bf69f88
8 changed files with 282 additions and 28 deletions
|
|
@ -384,6 +384,133 @@ describe('killAll', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('spawnCli concurrency scheduler', () => {
|
||||
// A fake CLI that records each spawn (by subcommand) and then blocks until a
|
||||
// release file named after that subcommand appears, so the test controls
|
||||
// exactly when each child exits and can observe how many run at once.
|
||||
function schedulerBin(startedFile: string, releaseDir: string): void {
|
||||
fakeBin(
|
||||
'sched.js',
|
||||
`const fs = require('fs'); const path = require('path');
|
||||
const cmd = process.argv[2];
|
||||
fs.appendFileSync(${JSON.stringify(startedFile)}, cmd + '\\n');
|
||||
const rel = path.join(${JSON.stringify(releaseDir)}, cmd);
|
||||
const t = setInterval(() => {
|
||||
if (fs.existsSync(rel)) { clearInterval(t); process.stdout.write('{}'); process.exit(0); }
|
||||
}, 5);`,
|
||||
)
|
||||
}
|
||||
function startedList(startedFile: string): string[] {
|
||||
try { return readFileSync(startedFile, 'utf8').split('\n').filter(Boolean) } catch { return [] }
|
||||
}
|
||||
function release(releaseDir: string, cmd: string): void { writeFileSync(join(releaseDir, cmd), '') }
|
||||
async function waitUntil(cond: () => boolean, timeoutMs = 3000): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (!cond()) {
|
||||
if (Date.now() > deadline) throw new Error('waitUntil timed out')
|
||||
await new Promise(r => setTimeout(r, 10))
|
||||
}
|
||||
}
|
||||
const delay = (ms: number) => new Promise(r => setTimeout(r, ms))
|
||||
|
||||
// Reset scheduler state so a leaked running slot can never starve the next test.
|
||||
afterEach(() => { killAll() })
|
||||
|
||||
it('runs at most two children at once; a third waits for a freed slot', async () => {
|
||||
const startedFile = join(dir, 'started')
|
||||
const releaseDir = join(dir, 'release'); mkdirSync(releaseDir)
|
||||
schedulerBin(startedFile, releaseDir)
|
||||
|
||||
const p1 = spawnCli(['status'])
|
||||
const p2 = spawnCli(['models'])
|
||||
const p3 = spawnCli(['sessions'])
|
||||
await waitUntil(() => startedList(startedFile).length === 2)
|
||||
await delay(100) // the cap must keep the third from sneaking in
|
||||
expect(startedList(startedFile).sort()).toEqual(['models', 'status'])
|
||||
|
||||
release(releaseDir, 'status') // free one slot
|
||||
await waitUntil(() => startedList(startedFile).includes('sessions'))
|
||||
|
||||
release(releaseDir, 'models'); release(releaseDir, 'sessions')
|
||||
await Promise.all([p1, p2, p3])
|
||||
})
|
||||
|
||||
it('lets a later interactive spawn preempt an earlier queued background one', async () => {
|
||||
const startedFile = join(dir, 'started')
|
||||
const releaseDir = join(dir, 'release'); mkdirSync(releaseDir)
|
||||
schedulerBin(startedFile, releaseDir)
|
||||
|
||||
// Fill both slots so the next two calls must queue.
|
||||
const p1 = spawnCli(['fill1'], { priority: 'background' })
|
||||
const p2 = spawnCli(['fill2'], { priority: 'background' })
|
||||
await waitUntil(() => startedList(startedFile).length === 2)
|
||||
|
||||
// Queue a background first, then an interactive.
|
||||
const pbg = spawnCli(['bg'], { priority: 'background' })
|
||||
const pint = spawnCli(['inter'], { priority: 'interactive' })
|
||||
await delay(50)
|
||||
expect(startedList(startedFile)).not.toContain('bg')
|
||||
expect(startedList(startedFile)).not.toContain('inter')
|
||||
|
||||
// Free exactly one slot: the interactive must take it despite queueing later.
|
||||
release(releaseDir, 'fill1')
|
||||
await waitUntil(() => startedList(startedFile).length === 3)
|
||||
expect(startedList(startedFile)).toContain('inter')
|
||||
expect(startedList(startedFile)).not.toContain('bg')
|
||||
|
||||
release(releaseDir, 'fill2'); release(releaseDir, 'inter'); release(releaseDir, 'bg')
|
||||
await Promise.all([p1, p2, pbg, pint])
|
||||
})
|
||||
|
||||
it('does not spend a slot on a coalesced (same-argv) call', async () => {
|
||||
const startedFile = join(dir, 'started')
|
||||
const releaseDir = join(dir, 'release'); mkdirSync(releaseDir)
|
||||
schedulerBin(startedFile, releaseDir)
|
||||
|
||||
const p1 = spawnCli(['status'])
|
||||
const p2 = spawnCli(['models'])
|
||||
await waitUntil(() => startedList(startedFile).length === 2)
|
||||
|
||||
const p1b = spawnCli(['status']) // coalesces onto p1's child — no new slot
|
||||
const p3 = spawnCli(['sessions']) // genuinely new — queued behind the cap
|
||||
await delay(100)
|
||||
expect(startedList(startedFile).length).toBe(2) // still just the two originals
|
||||
|
||||
release(releaseDir, 'status') // frees the slot the coalesced pair shared
|
||||
await waitUntil(() => startedList(startedFile).includes('sessions'))
|
||||
|
||||
release(releaseDir, 'models'); release(releaseDir, 'sessions')
|
||||
const [a, b] = await Promise.all([p1, p1b])
|
||||
expect(a).toEqual(b) // one child served both callers
|
||||
await Promise.all([p2, p3])
|
||||
})
|
||||
|
||||
it('cancels a queued spawn on killAll instead of letting it spawn later', async () => {
|
||||
const startedFile = join(dir, 'started')
|
||||
const releaseDir = join(dir, 'release'); mkdirSync(releaseDir)
|
||||
schedulerBin(startedFile, releaseDir)
|
||||
|
||||
const p1 = spawnCli(['status'])
|
||||
const p2 = spawnCli(['models'])
|
||||
await waitUntil(() => startedList(startedFile).length === 2)
|
||||
const p3 = spawnCli(['sessions']) // queued behind the cap, no child yet
|
||||
await delay(50)
|
||||
expect(startedList(startedFile)).not.toContain('sessions')
|
||||
|
||||
killAll() // reaps the two running AND cancels the queued third
|
||||
// Attach all three rejection handlers synchronously: the queued spawn rejects
|
||||
// at once, so it must not sit unhandled while the killed children settle.
|
||||
await Promise.all([
|
||||
expect(p1).rejects.toMatchObject({ kind: 'nonzero' }),
|
||||
expect(p2).rejects.toMatchObject({ kind: 'nonzero' }),
|
||||
expect(p3).rejects.toMatchObject({ kind: 'nonzero' }),
|
||||
])
|
||||
|
||||
await delay(50)
|
||||
expect(startedList(startedFile)).not.toContain('sessions') // never spawned
|
||||
})
|
||||
})
|
||||
|
||||
describe('spawnCliAction', () => {
|
||||
it('returns stdout and ok:true on success', async () => {
|
||||
fakeBin('action-ok.js', 'process.stdout.write("currency updated")')
|
||||
|
|
|
|||
|
|
@ -9,6 +9,14 @@ import { delimiter, dirname, isAbsolute, join } from 'node:path'
|
|||
export type CliErrorKind = 'not-found' | 'nonzero' | 'bad-json' | 'timeout' | 'too-large' | 'bad-args'
|
||||
export type ActionResult = { ok: boolean; stdout: string; stderr: string; code: number | null }
|
||||
|
||||
/**
|
||||
* Scheduling class for a CLI spawn. 'interactive' is what a user is waiting on
|
||||
* (the visible poll, a Settings mutation); 'background' is speculative work
|
||||
* (the overview prefetch). Interactive always dequeues first, so a burst of
|
||||
* background warms can never delay the fetch behind a click.
|
||||
*/
|
||||
export type SpawnPriority = 'interactive' | 'background'
|
||||
|
||||
/**
|
||||
* A resolved CLI target. `external` is a standalone `codeburn` executable (the
|
||||
* dev repo build, a persisted path, or one found on PATH) spawned directly.
|
||||
|
|
@ -51,16 +59,60 @@ const MAX_OUTPUT_BYTES = 16 * 1024 * 1024
|
|||
// Same-cadence pollers fire near-identical read spawns; share one child and hold
|
||||
// its result briefly so six overview hooks don't launch six processes at once.
|
||||
const COALESCE_TTL_MS = 5_000
|
||||
// A cold-cache CLI spawn costs seconds at ~120% CPU; letting every poll +
|
||||
// prefetch launch at once saturates the machine. Cap how many children run
|
||||
// concurrently — the rest queue and drain as slots free (interactive first).
|
||||
const MAX_CONCURRENT_CLI = 2
|
||||
|
||||
// Every live child so `before-quit` can reap them (Electron does not on macOS).
|
||||
const activeChildren = new Set<ChildProcess>()
|
||||
const readInflight = new Map<string, Promise<unknown>>()
|
||||
const readCache = new Map<string, { at: number; value: unknown }>()
|
||||
|
||||
/** SIGKILL every in-flight child. Wired to Electron's `before-quit`. */
|
||||
// Concurrency scheduler. `running` counts spawned (not queued) children; waiters
|
||||
// hold the slot-grant resolver for a queued spawn. Two queues so interactive
|
||||
// work jumps ahead of any already-queued background warm the moment a slot frees.
|
||||
type SlotWaiter = { resolve: () => void; reject: (err: unknown) => void }
|
||||
let running = 0
|
||||
const interactiveQueue: SlotWaiter[] = []
|
||||
const backgroundQueue: SlotWaiter[] = []
|
||||
|
||||
/** Grant free slots to queued waiters, interactive first, up to the cap. */
|
||||
function pumpSlots(): void {
|
||||
while (running < MAX_CONCURRENT_CLI) {
|
||||
const waiter = interactiveQueue.shift() ?? backgroundQueue.shift()
|
||||
if (!waiter) return
|
||||
running += 1
|
||||
waiter.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve once a run slot is free. The per-call timeout starts only after this
|
||||
* resolves (i.e. at real spawn time), never while queued. */
|
||||
function acquireSlot(priority: SpawnPriority): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
;(priority === 'background' ? backgroundQueue : interactiveQueue).push({ resolve, reject })
|
||||
pumpSlots()
|
||||
})
|
||||
}
|
||||
|
||||
function releaseSlot(): void {
|
||||
running = Math.max(0, running - 1)
|
||||
pumpSlots()
|
||||
}
|
||||
|
||||
/** SIGKILL every in-flight child and cancel anything still queued for a slot.
|
||||
* Wired to Electron's `before-quit`. */
|
||||
export function killAll(): void {
|
||||
for (const child of activeChildren) child.kill('SIGKILL')
|
||||
activeChildren.clear()
|
||||
// A queued waiter has no child to reap, so releaseSlot never fires for it;
|
||||
// reject it explicitly so its caller settles instead of hanging past quit.
|
||||
const waiting = [...interactiveQueue, ...backgroundQueue]
|
||||
interactiveQueue.length = 0
|
||||
backgroundQueue.length = 0
|
||||
running = 0
|
||||
for (const waiter of waiting) waiter.reject(new CliError('nonzero', 'codeburn cancelled'))
|
||||
}
|
||||
|
||||
// Homebrew + common Node version managers, mirroring mac/CodeburnCLI.swift so a
|
||||
|
|
@ -331,7 +383,7 @@ function runCli(spec: SpawnSpec, cmdLabel: string, timeoutMs: number, onStderr?:
|
|||
*/
|
||||
export function spawnCli(
|
||||
args: string[],
|
||||
opts: { timeoutMs?: number; onStderr?: (chunk: string) => void; extraEnv?: NodeJS.ProcessEnv } = {},
|
||||
opts: { timeoutMs?: number; onStderr?: (chunk: string) => void; extraEnv?: NodeJS.ProcessEnv; priority?: SpawnPriority } = {},
|
||||
): Promise<unknown> {
|
||||
const target = resolveTarget()
|
||||
if (!target) return Promise.reject(new CliError('not-found', 'codeburn CLI not found', notFoundStage()))
|
||||
|
|
@ -344,26 +396,49 @@ export function spawnCli(
|
|||
const existing = readInflight.get(key)
|
||||
// A same-cadence re-poll during a slow cold warmup coalesces onto the one
|
||||
// in-flight child (which already carries onStderr); no second cold parse.
|
||||
// Coalesce/cache hits settle here, BEFORE queueing, so they never hold a slot.
|
||||
if (existing) return existing
|
||||
|
||||
const flight = runCli(spec, args[0] ?? '', opts.timeoutMs ?? DEFAULT_TIMEOUT_MS, opts.onStderr)
|
||||
const priority = opts.priority ?? 'interactive'
|
||||
const flight = (async () => {
|
||||
await acquireSlot(priority)
|
||||
try {
|
||||
return await runCli(spec, args[0] ?? '', opts.timeoutMs ?? DEFAULT_TIMEOUT_MS, opts.onStderr)
|
||||
} finally {
|
||||
releaseSlot()
|
||||
}
|
||||
})()
|
||||
.then(value => { readCache.set(key, { at: Date.now(), value }); return value })
|
||||
.finally(() => { readInflight.delete(key) })
|
||||
readInflight.set(key, flight)
|
||||
return flight
|
||||
}
|
||||
|
||||
/** Spawn a config-mutating CLI command and return its text output verbatim. */
|
||||
/** Spawn a config-mutating CLI command and return its text output verbatim.
|
||||
* Mutations count as interactive, so they take a run slot ahead of any queued
|
||||
* background warm — a Settings save is never stuck behind speculative prefetch. */
|
||||
export function spawnCliAction(args: string[], opts: { timeoutMs?: number } = {}): Promise<ActionResult> {
|
||||
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
||||
return new Promise<ActionResult>(resolve => {
|
||||
const target = resolveTarget()
|
||||
if (!target) {
|
||||
resolve({ ok: false, stdout: '', stderr: 'codeburn CLI not found', code: null })
|
||||
return
|
||||
const target = resolveTarget()
|
||||
if (!target) return Promise.resolve({ ok: false, stdout: '', stderr: 'codeburn CLI not found', code: null })
|
||||
const spec = spawnSpecFor(target, args)
|
||||
return (async () => {
|
||||
try {
|
||||
await acquireSlot('interactive')
|
||||
} catch {
|
||||
// Slot grant was cancelled (killAll during quit); never reached a spawn.
|
||||
return { ok: false, stdout: '', stderr: 'codeburn cancelled', code: null }
|
||||
}
|
||||
const spec = spawnSpecFor(target, args)
|
||||
try {
|
||||
return await runAction(spec, args, timeoutMs)
|
||||
} finally {
|
||||
releaseSlot()
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
function runAction(spec: SpawnSpec, args: string[], timeoutMs: number): Promise<ActionResult> {
|
||||
return new Promise<ActionResult>(resolve => {
|
||||
const child = spawn(spec.bin, spec.args, { shell: false, stdio: ['ignore', 'pipe', 'pipe'], env: spec.env })
|
||||
activeChildren.add(child)
|
||||
let stdout = ''
|
||||
|
|
|
|||
|
|
@ -429,6 +429,20 @@ describe('createBridgeHandlers (cold-start warmup)', () => {
|
|||
expect(opts[1]?.extraEnv).toBeUndefined()
|
||||
})
|
||||
|
||||
it('drops a warmed overview to background priority only when the prefetch flag is set', async () => {
|
||||
const opts: Array<Record<string, unknown> | undefined> = []
|
||||
const spawnCli = vi.fn(async (_args: string[], o?: Record<string, unknown>) => { opts.push(o); return { current: { cost: 1 } } })
|
||||
const handlers = createBridgeHandlers(base({ spawnCli, emitProgress: vi.fn() }))
|
||||
|
||||
await handlers['codeburn:getOverview']!('30days', 'all') // cold warmup → interactive
|
||||
await handlers['codeburn:getOverview']!('30days', 'claude') // warmed, no flag → interactive
|
||||
await handlers['codeburn:getOverview']!('30days', 'grok', undefined, null, true) // prefetch → background
|
||||
|
||||
expect(opts[0]?.priority).toBeUndefined()
|
||||
expect(opts[1]?.priority).toBeUndefined()
|
||||
expect(opts[2]?.priority).toBe('background')
|
||||
})
|
||||
|
||||
it('re-arms the long timeout when the first overview fails (cache is still cold)', async () => {
|
||||
const opts: Array<{ timeoutMs?: number } | undefined> = []
|
||||
let n = 0
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { app, BrowserWindow, dialog, ipcMain, Menu, nativeTheme, shell, type MenuItemConstructorOptions } from 'electron'
|
||||
import path from 'node:path'
|
||||
|
||||
import { CliError, killAll, resolveCodeburnPath, spawnCli, spawnCliAction, type ActionResult } from './cli'
|
||||
import { CliError, killAll, resolveCodeburnPath, spawnCli, spawnCliAction, type ActionResult, type SpawnPriority } from './cli'
|
||||
import { getQuota, sanitizeError } from './quota'
|
||||
import { Telemetry } from './telemetry'
|
||||
import { createUpdateChecker, type UpdateChecker, type UpdateStatus } from './updates'
|
||||
|
|
@ -214,7 +214,7 @@ function cliErrorProps(err: unknown, cmd: string | undefined): Record<string, un
|
|||
}
|
||||
|
||||
type Deps = {
|
||||
spawnCli: (args: string[], opts?: { timeoutMs?: number; onStderr?: (chunk: string) => void; extraEnv?: NodeJS.ProcessEnv }) => Promise<unknown>
|
||||
spawnCli: (args: string[], opts?: { timeoutMs?: number; onStderr?: (chunk: string) => void; extraEnv?: NodeJS.ProcessEnv; priority?: SpawnPriority }) => Promise<unknown>
|
||||
spawnCliAction: (args: string[], opts?: { timeoutMs?: number }) => Promise<ActionResult>
|
||||
resolveCodeburnPath: () => string | null
|
||||
getQuota: typeof getQuota
|
||||
|
|
@ -274,15 +274,20 @@ export function createBridgeHandlers(deps: Deps = { spawnCli, spawnCliAction, re
|
|||
...providerArgs(vProvider(provider)), ...rangeArgs(vRange(range)), ...configSourceArgs(vConfigSource(configSource)),
|
||||
]
|
||||
|
||||
const getOverview: Handler = async (period: string, provider: string, range?: DateRange, configSource?: string | null) => {
|
||||
// `background` (renderer prefetch only) drops this fetch to background priority
|
||||
// so it yields the CLI's run slots to any interactive poll or click. Optional
|
||||
// and defaulting to interactive, so an older preload that omits it is unchanged.
|
||||
const getOverview: Handler = async (period: string, provider: string, range?: DateRange, configSource?: string | null, background?: boolean) => {
|
||||
coldStartBegan ??= Date.now()
|
||||
const priority: SpawnPriority | undefined = background ? 'background' : undefined
|
||||
try {
|
||||
const args = buildOverviewArgs(period, provider, range, configSource)
|
||||
if (overviewWarmed) return { ok: true, value: await deps.spawnCli(args) }
|
||||
if (overviewWarmed) return { ok: true, value: await deps.spawnCli(args, priority ? { priority } : undefined) }
|
||||
const value = await deps.spawnCli(args, {
|
||||
timeoutMs: WARMUP_TIMEOUT_MS,
|
||||
extraEnv: { CODEBURN_PROGRESS: '1' },
|
||||
onStderr: makeProgressReader(emitProgress),
|
||||
...(priority ? { priority } : {}),
|
||||
})
|
||||
overviewWarmed = true
|
||||
emitProgress({ kind: 'done' })
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ async function invoke<T>(channel: string, ...args: unknown[]): Promise<T> {
|
|||
// renderer-side where `window.codeburn` is declared as CodeburnBridge.
|
||||
const bridge = {
|
||||
getQuota: (force?: boolean) => invoke('codeburn:getQuota', force),
|
||||
getOverview: (period: string, provider: string, range?: DateRange, configSource?: string | null) => invoke('codeburn:getOverview', period, provider, range, configSource),
|
||||
getOverview: (period: string, provider: string, range?: DateRange, configSource?: string | null, background?: boolean) => invoke('codeburn:getOverview', period, provider, range, configSource, background),
|
||||
getPlans: (period: string) => invoke('codeburn:getPlans', period),
|
||||
getActReport: () => invoke('codeburn:getActReport'),
|
||||
getModels: (period: string, provider: string, byTask: boolean, range?: DateRange) => invoke('codeburn:getModels', period, provider, byTask, range),
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ vi.stubGlobal('localStorage', {
|
|||
})
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getOverview: vi.fn<(period: string, provider: string, range?: DateRange, configSource?: string | null) => Promise<MenubarPayload>>(),
|
||||
getOverview: vi.fn<(period: string, provider: string, range?: DateRange, configSource?: string | null, background?: boolean) => Promise<MenubarPayload>>(),
|
||||
getSpendFlow: vi.fn<(period: string, provider: string, range?: DateRange) => Promise<SpendFlow>>(),
|
||||
getOptimizeReport: vi.fn<(period: string, provider: string, range?: DateRange) => Promise<OptimizeJsonReport>>(),
|
||||
getModels: vi.fn(),
|
||||
|
|
@ -177,6 +177,9 @@ describe('App shortcuts', () => {
|
|||
beforeEach(() => {
|
||||
installDefaultMocks()
|
||||
localStorage.clear()
|
||||
// Pin the boot period so the provider/config tests below are independent of
|
||||
// the app-wide default ('today'); tests that exercise the default set it.
|
||||
localStorage.setItem('codeburn.defaultPeriod', '30days')
|
||||
document.documentElement.removeAttribute('data-theme')
|
||||
})
|
||||
|
||||
|
|
@ -188,7 +191,13 @@ describe('App shortcuts', () => {
|
|||
})
|
||||
|
||||
it('boots with the persisted default period from Settings', async () => {
|
||||
localStorage.setItem('codeburn.defaultPeriod', 'today')
|
||||
localStorage.setItem('codeburn.defaultPeriod', 'week')
|
||||
render(<App />)
|
||||
await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('week', 'all'))
|
||||
})
|
||||
|
||||
it('boots to today when no default period is persisted', async () => {
|
||||
localStorage.removeItem('codeburn.defaultPeriod')
|
||||
render(<App />)
|
||||
await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('today', 'all'))
|
||||
})
|
||||
|
|
@ -482,6 +491,9 @@ describe('provider prefetch storm', () => {
|
|||
// Pin the cadence to 30s so the fake-timer soak math below is independent of
|
||||
// the app-wide default (bumped to 60s for energy).
|
||||
localStorage.setItem('codeburn.refreshInterval', '30s')
|
||||
// Pin the boot period so these prefetch assertions are independent of the
|
||||
// app-wide default ('today').
|
||||
localStorage.setItem('codeburn.defaultPeriod', '30days')
|
||||
__resetPolledMemo()
|
||||
})
|
||||
|
||||
|
|
@ -502,7 +514,8 @@ describe('provider prefetch storm', () => {
|
|||
|
||||
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,
|
||||
// Prefetch warms carry the background-priority flag (5th arg).
|
||||
c => c[0] === '30days' && c[1] === id && c[2] === undefined && c[3] === undefined && c[4] === true,
|
||||
)
|
||||
expect(spawns.length, `prefetch spawns for ${id}`).toBe(1)
|
||||
}
|
||||
|
|
@ -579,6 +592,9 @@ describe('currency correctness', () => {
|
|||
// Reset the module-level display currency so a prior test never bleeds in.
|
||||
setActiveCurrency(USD)
|
||||
localStorage.clear()
|
||||
// Pin the boot period so the memo keys below match the app's boot fetch,
|
||||
// independent of the app-wide default ('today').
|
||||
localStorage.setItem('codeburn.defaultPeriod', '30days')
|
||||
__resetPolledMemo()
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -135,7 +135,9 @@ export function overviewMemoKey(provider: string, period: Period, range: DateRan
|
|||
// provider at a time at low priority so the background scan never competes with
|
||||
// the interaction the user is actually having.
|
||||
const PREFETCH_START_DELAY_MS = 1500
|
||||
const PREFETCH_STAGGER_MS = 400
|
||||
// A warm spawn takes seconds, so a 400ms stagger let the loop fire the whole set
|
||||
// almost at once; pace it wide enough that each warm genuinely trails the last.
|
||||
const PREFETCH_STAGGER_MS = 2000
|
||||
// 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
|
||||
|
|
@ -148,11 +150,11 @@ function isPeriod(value: string): value is Period {
|
|||
return (STANDARD_PERIODS as string[]).includes(value)
|
||||
}
|
||||
|
||||
/** Boot period = the persisted "Default period" Settings writes, else 30 days. */
|
||||
/** Boot period = the persisted "Default period" Settings writes, else today. */
|
||||
function initialPeriod(): Period {
|
||||
let saved: string | null = null
|
||||
try { saved = globalThis.localStorage?.getItem('codeburn.defaultPeriod') ?? null } catch { /* storage can be unavailable */ }
|
||||
return saved && isPeriod(saved) ? saved : '30days'
|
||||
return saved && isPeriod(saved) ? saved : 'today'
|
||||
}
|
||||
|
||||
/** Persisted Claude config override (empty/absent = aggregate all configs). */
|
||||
|
|
@ -349,6 +351,10 @@ function AppMain() {
|
|||
// 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.
|
||||
// Mirror the visible overview's fetch state into a ref so the prefetch can hold
|
||||
// for a user-triggered fetch without re-arming the whole loop on each toggle.
|
||||
const overviewBusyRef = useRef(false)
|
||||
overviewBusyRef.current = overview.loading
|
||||
const warmedKeys = useRef<Set<string>>(new Set())
|
||||
useEffect(() => {
|
||||
if (!ready || overview.data == null || customRange || claudeConfigSource) return
|
||||
|
|
@ -356,16 +362,25 @@ function AppMain() {
|
|||
if (targets.length === 0) return
|
||||
let cancelled = false
|
||||
const warm = async () => {
|
||||
for (const id of targets) {
|
||||
if (cancelled) return
|
||||
const key = overviewMemoKey(id, period, null, null)
|
||||
if (warmedKeys.current.has(key) || hasPolledMemo(key)) continue
|
||||
for (let i = 0; i < targets.length && !cancelled; ) {
|
||||
const key = overviewMemoKey(targets[i]!, period, null, null)
|
||||
if (warmedKeys.current.has(key) || hasPolledMemo(key)) { i++; continue }
|
||||
// Only warm while the visible overview is idle: a user fetch in flight
|
||||
// takes priority (background-classed CLI spawns yield their slot to it),
|
||||
// so hold and retry this provider after the stagger rather than race it.
|
||||
if (overviewBusyRef.current) {
|
||||
await new Promise(resolve => setTimeout(resolve, PREFETCH_STAGGER_MS))
|
||||
continue
|
||||
}
|
||||
warmedKeys.current.add(key)
|
||||
try {
|
||||
const value = await codeburn.getOverview(period, id)
|
||||
// background priority (5th arg) so this never delays an interactive
|
||||
// poll; ignored by an older preload, degrading to current behavior.
|
||||
const value = await codeburn.getOverview(period, targets[i]!, undefined, undefined, true)
|
||||
if (!cancelled) primePolledMemo(key, value)
|
||||
} catch { /* best-effort warm; a real switch will fetch and surface any error */ }
|
||||
if (!cancelled) await new Promise(resolve => setTimeout(resolve, PREFETCH_STAGGER_MS))
|
||||
i++
|
||||
if (!cancelled && i < targets.length) await new Promise(resolve => setTimeout(resolve, PREFETCH_STAGGER_MS))
|
||||
}
|
||||
}
|
||||
const start = setTimeout(() => { void warm() }, PREFETCH_START_DELAY_MS)
|
||||
|
|
|
|||
|
|
@ -586,7 +586,9 @@ export interface CodeburnBridge {
|
|||
/** Subscribe to pushed update-availability status; returns an unsubscribe fn. */
|
||||
onUpdateStatus(cb: (status: UpdateStatus) => void): () => void
|
||||
getQuota(force?: boolean): Promise<QuotaProvider[]>
|
||||
getOverview(period: Period, provider: string, range?: DateRange, configSource?: string | null): Promise<MenubarPayload>
|
||||
// `background` (prefetch only) requests background CLI-spawn priority; optional
|
||||
// so an older preload that ignores it degrades to interactive priority.
|
||||
getOverview(period: Period, provider: string, range?: DateRange, configSource?: string | null, background?: boolean): Promise<MenubarPayload>
|
||||
getPlans(period: Period): Promise<StatusJson>
|
||||
getActReport(): Promise<ActReportJson>
|
||||
readonly platform: string
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue