perf(app): cap CLI spawn concurrency, background-priority prefetch, default to today

This commit is contained in:
iamtoruk 2026-07-18 16:28:30 -07:00
parent d00b33b13d
commit f26bf69f88
8 changed files with 282 additions and 28 deletions

View file

@ -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")')

View file

@ -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 = ''

View file

@ -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

View file

@ -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' })

View file

@ -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),

View file

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

View file

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

View file

@ -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