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
127 changes: 127 additions & 0 deletions app/electron/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")')
Expand Down
95 changes: 85 additions & 10 deletions app/electron/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()))
Expand All @@ -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 }
}
try {
return await runAction(spec, args, timeoutMs)
} finally {
releaseSlot()
}
const spec = spawnSpecFor(target, args)
})()
}

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 = ''
Expand Down
14 changes: 14 additions & 0 deletions app/electron/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 9 additions & 4 deletions app/electron/main.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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' })
Expand Down
2 changes: 1 addition & 1 deletion app/electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Loading