mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-16 03:54:28 +00:00
perf: resident serve process for the desktop app — panel fetches in milliseconds
Every CLI spawn on a large corpus pays seconds of fixed cost before any
query work: node boot, a 100MB+ session-cache JSON.parse, the discovery +
fingerprint sweep, and serve-time classification. The desktop app spawns
one CLI per panel fetch, so it pays that cost per panel.
codeburn serve --stdio is the same CLI kept warm: the app holds one child,
sends {id, args} per line, and gets the command's stdout back. Three layers
make it fast, each disabled outside serve so one-shot runs stay byte-exact:
- loadCache memo (session-cache.ts): the parsed cache object is reused
while a stat() shows the file unchanged; saveCache updates it
write-through. A rewrite by another process still forces a fresh read.
- burst reuse (parser.ts, CODEBURN_PARSE_BURST_MS, serve sets 10s): panel
bursts anchor their range ends at their own new Date(), so the exact-key
memo never hits in real traffic; within the window a re-anchored range is
served by trimming the previous parse instead of re-running discovery.
- fresh commander program per request (main.ts buildProgram factory),
because commander option state is sticky across parses.
The server allows only the app's read queries (status/overview/models/
sessions/compare/yield/spend/optimize/audit), refuses everything else
(client falls back to a spawn), serializes requests, and converts
process.exit into a caught signal. The app starts the child once at
startup; requests route through it only when warm, cold-start keeps the
spawn path with its progress events, any serve failure falls back to a
spawn, and three child deaths disable serve for the app run.
Measured on a real 17B-token corpus: panel fetches drop from ~7.4s per
spawn to 5-900ms warm (sessions/spend 5ms, status 898ms). One-shot CLI
output verified byte-identical against the pre-branch baseline.
This commit is contained in:
parent
d302846453
commit
d78ab77d96
11 changed files with 548 additions and 7 deletions
|
|
@ -104,6 +104,8 @@ function releaseSlot(): void {
|
|||
/** SIGKILL every in-flight child and cancel anything still queued for a slot.
|
||||
* Wired to Electron's `before-quit`. */
|
||||
export function killAll(): void {
|
||||
serveClient?.destroy()
|
||||
serveClient = null
|
||||
for (const child of activeChildren) child.kill('SIGKILL')
|
||||
activeChildren.clear()
|
||||
// A queued waiter has no child to reap, so releaseSlot never fires for it;
|
||||
|
|
@ -388,6 +390,132 @@ function runCli(spec: SpawnSpec, cmdLabel: string, timeoutMs: number, onStderr?:
|
|||
* Read-only, so concurrent identical calls share one child and a 5s result cache
|
||||
* absorbs same-cadence pollers. Never use this for config-mutating commands.
|
||||
*/
|
||||
// ── Resident serve child ────────────────────────────────────────────────
|
||||
// The heavy read queries (one per panel) each pay seconds of CLI startup on
|
||||
// a large corpus: node boot + a 100MB+ session-cache JSON.parse before any
|
||||
// query work. `codeburn serve` is the same CLI kept warm: requests go over
|
||||
// stdio and the cache stays parsed in the child. Routing rules keep this
|
||||
// strictly an optimization:
|
||||
// - only SERVE_ROUTED commands (the app's JSON panel queries) are eligible;
|
||||
// - requests route through serve only once the child is READY AND WARM, so
|
||||
// the cold-start path keeps its spawn (with its stderr progress events);
|
||||
// - any serve failure falls back to a normal spawn for that call;
|
||||
// - three child deaths permanently disable serve for this app run.
|
||||
const SERVE_ROUTED = new Set(['status', 'models', 'sessions', 'compare', 'yield', 'spend', 'optimize', 'audit'])
|
||||
const SERVE_REQUEST_TIMEOUT_MS = 60_000
|
||||
const SERVE_MAX_RESTARTS = 3
|
||||
|
||||
class ServeClient {
|
||||
private child: ReturnType<typeof spawn> | null = null
|
||||
private pending = new Map<number, { resolve: (v: unknown) => void; reject: (e: Error) => void; timer: NodeJS.Timeout }>()
|
||||
private nextId = 1
|
||||
private ready = false
|
||||
private warm = false
|
||||
private deaths = 0
|
||||
private buffer = ''
|
||||
|
||||
constructor(private readonly spec: SpawnSpec) {}
|
||||
|
||||
isWarmAndReady(): boolean { return this.ready && this.warm && this.child !== null }
|
||||
disabled(): boolean { return this.deaths >= SERVE_MAX_RESTARTS }
|
||||
|
||||
start(): void {
|
||||
if (this.child || this.disabled()) return
|
||||
const child = spawn(this.spec.bin, [...this.spec.args], { shell: false, stdio: ['pipe', 'pipe', 'ignore'], env: this.spec.env })
|
||||
this.child = child
|
||||
child.stdout!.setEncoding('utf8')
|
||||
child.stdout!.on('data', (chunk: string) => this.onData(chunk))
|
||||
const onGone = () => this.onDeath()
|
||||
child.on('exit', onGone)
|
||||
child.on('error', onGone)
|
||||
// Background warm-up: one cheap query makes the child parse the session
|
||||
// cache once; every later panel fetch reuses the in-memory copy.
|
||||
void this.request(['status', '--format', 'menubar-json', '--period', 'today'], SERVE_REQUEST_TIMEOUT_MS)
|
||||
.then(() => { this.warm = true })
|
||||
.catch(() => { /* warm-up failure just leaves routing on the spawn path */ })
|
||||
}
|
||||
|
||||
private onData(chunk: string): void {
|
||||
this.buffer += chunk
|
||||
let idx: number
|
||||
while ((idx = this.buffer.indexOf('\n')) >= 0) {
|
||||
const line = this.buffer.slice(0, idx).trim()
|
||||
this.buffer = this.buffer.slice(idx + 1)
|
||||
if (!line) continue
|
||||
let msg: { id?: number; ready?: boolean; ok?: boolean; refused?: boolean; output?: string; error?: string }
|
||||
try { msg = JSON.parse(line) } catch { continue }
|
||||
if (msg.ready) { this.ready = true; continue }
|
||||
if (typeof msg.id !== 'number') continue
|
||||
const waiter = this.pending.get(msg.id)
|
||||
if (!waiter) continue
|
||||
this.pending.delete(msg.id)
|
||||
clearTimeout(waiter.timer)
|
||||
if (msg.ok && typeof msg.output === 'string') {
|
||||
try { waiter.resolve(JSON.parse(msg.output)) }
|
||||
catch { waiter.reject(new CliError('bad-json', 'codeburn produced output that was not valid JSON')) }
|
||||
} else {
|
||||
waiter.reject(new CliError('nonzero', msg.error ?? 'serve request failed'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private onDeath(): void {
|
||||
const child = this.child
|
||||
this.child = null
|
||||
this.ready = false
|
||||
this.warm = false
|
||||
this.deaths += 1
|
||||
if (child) activeChildren.delete(child as never)
|
||||
for (const [, waiter] of this.pending) {
|
||||
clearTimeout(waiter.timer)
|
||||
waiter.reject(new CliError('nonzero', 'codeburn serve exited'))
|
||||
}
|
||||
this.pending.clear()
|
||||
}
|
||||
|
||||
request(args: string[], timeoutMs: number): Promise<unknown> {
|
||||
const child = this.child
|
||||
if (!child?.stdin) return Promise.reject(new CliError('nonzero', 'serve not running'))
|
||||
const id = this.nextId++
|
||||
return new Promise<unknown>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
// A hung request would block the serialized queue behind it; kill the
|
||||
// child so everything falls back to spawns and a fresh serve restarts.
|
||||
this.pending.delete(id)
|
||||
reject(new CliError('timeout', 'codeburn serve timed out'))
|
||||
child.kill('SIGKILL')
|
||||
}, timeoutMs)
|
||||
this.pending.set(id, { resolve, reject, timer })
|
||||
child.stdin!.write(JSON.stringify({ id, args }) + '\n', (err) => {
|
||||
if (err) {
|
||||
this.pending.delete(id)
|
||||
clearTimeout(timer)
|
||||
reject(new CliError('nonzero', 'serve write failed'))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.deaths = SERVE_MAX_RESTARTS
|
||||
this.child?.kill('SIGKILL')
|
||||
this.onDeath()
|
||||
}
|
||||
}
|
||||
|
||||
let serveClient: ServeClient | null = null
|
||||
|
||||
/** Start the resident serve child and its warm-up query. Called once from app
|
||||
* startup (never from the spawn path, so unit tests of the scheduler and the
|
||||
* cold-start flow are byte-identical without it). Safe to call repeatedly. */
|
||||
export function startServeWarmup(): void {
|
||||
const target = resolveTarget()
|
||||
if (!target) return
|
||||
if (serveClient?.disabled()) return
|
||||
if (!serveClient) serveClient = new ServeClient(spawnSpecFor(target, ['serve', '--stdio']))
|
||||
serveClient.start()
|
||||
}
|
||||
|
||||
export function spawnCli(
|
||||
args: string[],
|
||||
opts: { timeoutMs?: number; onStderr?: (chunk: string) => void; extraEnv?: NodeJS.ProcessEnv; priority?: SpawnPriority } = {},
|
||||
|
|
@ -406,6 +534,21 @@ export function spawnCli(
|
|||
// Coalesce/cache hits settle here, BEFORE queueing, so they never hold a slot.
|
||||
if (existing) return existing
|
||||
|
||||
// Serve fast-path: warm resident child answers the panel query without a
|
||||
// spawn. The child is started once at app startup (startServeWarmup); until
|
||||
// it is warm, every call keeps the plain spawn path.
|
||||
if (SERVE_ROUTED.has(args[0] ?? '') && !opts.extraEnv) {
|
||||
const serve = serveClient
|
||||
if (serve?.isWarmAndReady()) {
|
||||
const flight = serve.request(args, opts.timeoutMs ?? DEFAULT_TIMEOUT_MS)
|
||||
.catch(() => runCli(spec, args[0] ?? '', opts.timeoutMs ?? DEFAULT_TIMEOUT_MS, opts.onStderr))
|
||||
.then(value => { readCache.set(key, { at: Date.now(), value }); return value })
|
||||
.finally(() => { readInflight.delete(key) })
|
||||
readInflight.set(key, flight)
|
||||
return flight
|
||||
}
|
||||
}
|
||||
|
||||
const priority = opts.priority ?? 'interactive'
|
||||
const flight = (async () => {
|
||||
await acquireSlot(priority)
|
||||
|
|
|
|||
|
|
@ -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, type SpawnPriority } from './cli'
|
||||
import { CliError, killAll, resolveCodeburnPath, spawnCli, spawnCliAction, startServeWarmup, type ActionResult, type SpawnPriority } from './cli'
|
||||
import { getQuota, sanitizeError } from './quota'
|
||||
import { Telemetry } from './telemetry'
|
||||
import { createUpdateChecker, type UpdateChecker, type UpdateStatus } from './updates'
|
||||
|
|
@ -564,6 +564,10 @@ function bootstrap(): void {
|
|||
}))
|
||||
|
||||
void app.whenReady().then(() => {
|
||||
// Start the resident serve child early so its warm-up (one cache parse)
|
||||
// finishes during the first panels' cold spawns; every fetch after that
|
||||
// answers from the warm child in milliseconds.
|
||||
startServeWarmup()
|
||||
// Consent-gated anonymous telemetry (desktop only). Nothing transmits until
|
||||
// the onboarding consent screen is completed and the toggle is on; EU/EEA/
|
||||
// UK/CH installs default the toggle off. Dev builds never send.
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
26
src/main.ts
26
src/main.ts
|
|
@ -428,6 +428,12 @@ function assertScope(value: string, allowed: readonly string[], command: string)
|
|||
}
|
||||
}
|
||||
|
||||
// Wrapped in a factory because commander option state is sticky across
|
||||
// parses: `codeburn serve` executes many requests in one process and must
|
||||
// build a FRESH program per request or one request's --period would leak
|
||||
// into the next one's defaults. The normal CLI path builds it exactly once.
|
||||
function buildProgram(): Command {
|
||||
|
||||
async function runJsonReport(period: Period, provider: string, project: string[], exclude: string[]): Promise<void> {
|
||||
await loadPricing()
|
||||
const { range, label } = getDateRange(period)
|
||||
|
|
@ -2339,4 +2345,22 @@ registerActCommands(program)
|
|||
registerGuardCommands(program)
|
||||
registerSyncCommands(program)
|
||||
|
||||
program.parse()
|
||||
program
|
||||
.command('serve')
|
||||
.description('Run a resident query server over stdio (used by the desktop app to avoid per-fetch CLI startup cost)')
|
||||
.option('--stdio', 'Serve JSON requests over stdin/stdout (the only mode)')
|
||||
.action(() => {
|
||||
// Never reached: the serve entry is dispatched before commander parses,
|
||||
// because serving needs the buildProgram factory itself. Registered so
|
||||
// `codeburn serve` appears in help and never falls through to `report`.
|
||||
})
|
||||
|
||||
return program
|
||||
}
|
||||
|
||||
if (process.argv[2] === 'serve') {
|
||||
const { runStdioServe } = await import('./serve.js')
|
||||
await runStdioServe(buildProgram)
|
||||
} else {
|
||||
buildProgram().parse()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3190,7 +3190,35 @@ async function parseProviderSources(
|
|||
|
||||
const CACHE_TTL_MS = 180_000
|
||||
const MAX_CACHE_ENTRIES = 10
|
||||
const sessionCache = new Map<string, { data: ProjectSummary[]; ts: number }>()
|
||||
const sessionCache = new Map<string, { data: ProjectSummary[]; ts: number; startMs?: number; endMs?: number; sig?: string }>()
|
||||
|
||||
// Burst reuse for a resident process (codeburn serve). Every payload command
|
||||
// anchors its range end at its own `new Date()`, so two panel fetches issued
|
||||
// milliseconds apart carry different end timestamps and the exact-key memo
|
||||
// above never hits in real traffic — each fetch re-runs the full discovery +
|
||||
// fingerprint sweep. Within this window, a parse whose range differs ONLY by
|
||||
// a through-now end within the window is served by trimming the previous
|
||||
// parse instead. Staleness is bounded by the window; 0 (the default outside
|
||||
// serve) disables it, so one-shot CLI runs are byte-exact as ever.
|
||||
function parseBurstWindowMs(): number {
|
||||
const raw = Number(process.env['CODEBURN_PARSE_BURST_MS'] ?? '0')
|
||||
return Number.isFinite(raw) && raw > 0 ? Math.min(raw, 60_000) : 0
|
||||
}
|
||||
|
||||
function burstReuse(dateRange: DateRange, sig: string): ProjectSummary[] | null {
|
||||
const windowMs = parseBurstWindowMs()
|
||||
if (windowMs <= 0) return null
|
||||
const now = Date.now()
|
||||
const startMs = dateRange.start.getTime()
|
||||
const endMs = dateRange.end.getTime()
|
||||
for (const entry of sessionCache.values()) {
|
||||
if (entry.sig !== sig || entry.startMs !== startMs || entry.endMs === undefined) continue
|
||||
if (now - entry.ts > windowMs) continue
|
||||
if (endMs < entry.endMs || endMs - entry.endMs > windowMs) continue
|
||||
return filterProjectsByDateRange(entry.data, dateRange)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function cacheKey(dateRange?: DateRange, providerFilter?: string): string {
|
||||
const s = dateRange ? `${dateRange.start.getTime()}:${dateRange.end.getTime()}` : 'none'
|
||||
|
|
@ -3216,7 +3244,15 @@ function cachePut(key: string, data: ProjectSummary[]) {
|
|||
const oldest = [...sessionCache.entries()].sort((a, b) => a[1].ts - b[1].ts)[0]
|
||||
if (oldest) sessionCache.delete(oldest[0])
|
||||
}
|
||||
sessionCache.set(key, { data, ts: now })
|
||||
sessionCache.set(key, { data, ts: now, ...(putMeta ?? {}) })
|
||||
putMeta = null
|
||||
}
|
||||
|
||||
// Range metadata for the entry cachePut is about to store, set by the one
|
||||
// parseAllSessions call path right before it saves its result.
|
||||
let putMeta: { startMs: number; endMs: number; sig: string } | null = null
|
||||
export function setCachePutMeta(meta: { startMs: number; endMs: number; sig: string } | null): void {
|
||||
putMeta = meta
|
||||
}
|
||||
|
||||
export function filterProjectsByName(
|
||||
|
|
@ -3631,6 +3667,13 @@ export async function parseAllSessions(dateRange?: DateRange, providerFilter?: s
|
|||
const key = cacheKey(dateRange, providerFilter)
|
||||
const cached = sessionCache.get(key)
|
||||
if (cached && Date.now() - cached.ts < CACHE_TTL_MS) return cached.data
|
||||
// The signature is the key minus the range: what must match for a burst
|
||||
// reuse (provider, config env, proxy hash) regardless of the now-anchor.
|
||||
const burstSig = cacheKey(undefined, providerFilter)
|
||||
if (dateRange) {
|
||||
const reused = burstReuse(dateRange, burstSig)
|
||||
if (reused) return reused
|
||||
}
|
||||
|
||||
let diskCache = await loadCache()
|
||||
await cleanupOrphanedTempFiles()
|
||||
|
|
@ -3855,6 +3898,7 @@ async function runParse(
|
|||
|
||||
const result = Array.from(mergedMap.values()).sort((a, b) => b.totalCostUSD - a.totalCostUSD)
|
||||
correlateCrossProviderPrSessions(result)
|
||||
if (dateRange) setCachePutMeta({ startMs: dateRange.start.getTime(), endMs: dateRange.end.getTime(), sig: cacheKey(undefined, providerFilter) })
|
||||
cachePut(key, result)
|
||||
return result
|
||||
}
|
||||
|
|
|
|||
134
src/serve.ts
Normal file
134
src/serve.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import { createInterface } from 'readline'
|
||||
|
||||
import type { Command } from 'commander'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// codeburn serve --stdio: a resident query server for the desktop app.
|
||||
//
|
||||
// Every CLI spawn on a large corpus pays seconds of fixed cost before any
|
||||
// work: parsing a 100MB+ session-cache JSON, then re-deriving classification
|
||||
// at query time. The desktop app fetches one payload per panel, so it pays
|
||||
// that cost per fetch. This server is the same CLI kept warm: the app sends
|
||||
// one JSON request per line ({id, args}) and gets the command's stdout back
|
||||
// ({id, ok, output}); the session-cache memo in session-cache.ts makes every
|
||||
// request after the first skip the JSON reload (a stat() revalidates, so a
|
||||
// rewrite by another process still forces a fresh read).
|
||||
//
|
||||
// Correctness stance:
|
||||
// - READ-ONLY allowlist. Only the hot panel queries run in-process; anything
|
||||
// else is rejected and the app falls back to a normal spawn. A rejected
|
||||
// command is a routing decision, not an error.
|
||||
// - A FRESH commander program per request (buildProgram()), because commander
|
||||
// option state is sticky across parses — reusing one program would leak
|
||||
// `--period week` from one request into the defaults of the next.
|
||||
// - Requests are strictly serialized. The parse/refresh pipeline is not
|
||||
// concurrent-safe within one process, and the cross-process refresh lock
|
||||
// already guards between processes.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// First-token allowlist of the app's heavy read queries. Deliberately absent:
|
||||
// every config mutation (currency, model-alias set, budget, price-override,
|
||||
// proxy-path, plan), export (writes files), share/devices (network + pairing
|
||||
// state), menubar/web/mcp/guard/sync/act (process management or writes).
|
||||
const SERVE_COMMANDS = new Set(['status', 'overview', 'models', 'sessions', 'compare', 'yield', 'spend', 'optimize', 'audit'])
|
||||
|
||||
type ServeRequest = { id: string | number; args: string[] }
|
||||
|
||||
function isServeRequest(value: unknown): value is ServeRequest {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
const r = value as Record<string, unknown>
|
||||
const idOk = typeof r['id'] === 'string' || typeof r['id'] === 'number'
|
||||
return idOk && Array.isArray(r['args']) && (r['args'] as unknown[]).every(a => typeof a === 'string')
|
||||
}
|
||||
|
||||
function allowed(args: string[]): boolean {
|
||||
const first = args[0]
|
||||
if (!first || !SERVE_COMMANDS.has(first)) return false
|
||||
// No request may smuggle a second positional that turns a read into
|
||||
// something else; the allowed commands take flags only.
|
||||
return args.slice(1).every((a, i, all) => a.startsWith('-') || (i > 0 && all[i - 1]!.startsWith('--')))
|
||||
}
|
||||
|
||||
class ExitSignal extends Error {
|
||||
constructor(public readonly code: number) { super(`exit ${code}`) }
|
||||
}
|
||||
|
||||
/// Run one argv through a fresh program, capturing everything the command
|
||||
/// writes to stdout. process.exit inside a handler is converted to a thrown
|
||||
/// ExitSignal so a failing request can never take the server down.
|
||||
async function runCaptured(buildProgram: () => Command, args: string[]): Promise<{ output: string; code: number }> {
|
||||
const chunks: string[] = []
|
||||
const originalWrite = process.stdout.write.bind(process.stdout)
|
||||
const originalExit = process.exit.bind(process)
|
||||
|
||||
process.stdout.write = ((chunk: unknown, ...rest: unknown[]) => {
|
||||
chunks.push(typeof chunk === 'string' ? chunk : String(chunk))
|
||||
const last = rest[rest.length - 1]
|
||||
if (typeof last === 'function') (last as () => void)()
|
||||
return true
|
||||
}) as typeof process.stdout.write
|
||||
process.exit = ((code?: number) => { throw new ExitSignal(code ?? 0) }) as typeof process.exit
|
||||
|
||||
try {
|
||||
const program = buildProgram()
|
||||
program.exitOverride()
|
||||
await program.parseAsync(['node', 'codeburn', ...args])
|
||||
return { output: chunks.join(''), code: 0 }
|
||||
} catch (err) {
|
||||
if (err instanceof ExitSignal) return { output: chunks.join(''), code: err.code }
|
||||
throw err
|
||||
} finally {
|
||||
process.stdout.write = originalWrite
|
||||
process.exit = originalExit
|
||||
}
|
||||
}
|
||||
|
||||
export async function runStdioServe(buildProgram: () => Command): Promise<void> {
|
||||
// Panel bursts (the app fetching every panel for one period) reuse a parse
|
||||
// whose through-now range end differs by less than this window, instead of
|
||||
// re-running the discovery sweep per panel. Serve-only: one-shot CLI runs
|
||||
// never set this, so their results stay byte-exact.
|
||||
if (!process.env['CODEBURN_PARSE_BURST_MS']) process.env['CODEBURN_PARSE_BURST_MS'] = '10000'
|
||||
const write = (value: unknown): void => { process.stdout.write(JSON.stringify(value) + '\n') }
|
||||
write({ ready: true, pid: process.pid })
|
||||
|
||||
// Strict serialization: each request chains on the previous one.
|
||||
let queue: Promise<void> = Promise.resolve()
|
||||
|
||||
const rl = createInterface({ input: process.stdin, crlfDelay: Infinity })
|
||||
rl.on('line', (line) => {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) return
|
||||
queue = queue.then(async () => {
|
||||
let request: unknown
|
||||
try {
|
||||
request = JSON.parse(trimmed)
|
||||
} catch {
|
||||
write({ id: null, ok: false, error: 'malformed request line' })
|
||||
return
|
||||
}
|
||||
if (!isServeRequest(request)) {
|
||||
write({ id: (request as { id?: unknown })?.id ?? null, ok: false, error: 'malformed request' })
|
||||
return
|
||||
}
|
||||
if (!allowed(request.args)) {
|
||||
// Routing signal, not a failure: the client falls back to a spawn.
|
||||
write({ id: request.id, ok: false, refused: true, error: 'command not served' })
|
||||
return
|
||||
}
|
||||
try {
|
||||
const { output, code } = await runCaptured(buildProgram, request.args)
|
||||
if (code === 0) write({ id: request.id, ok: true, output })
|
||||
else write({ id: request.id, ok: false, error: `exit ${code}`, output })
|
||||
} catch (err) {
|
||||
write({ id: request.id, ok: false, error: err instanceof Error ? err.message : String(err) })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// The app owns this process: stdin closing means the app is gone.
|
||||
await new Promise<void>((resolve) => {
|
||||
rl.on('close', resolve)
|
||||
process.stdin.on('end', resolve)
|
||||
})
|
||||
}
|
||||
|
|
@ -540,11 +540,30 @@ async function adoptNewestPriorCache(): Promise<SessionCache | null> {
|
|||
return merged
|
||||
}
|
||||
|
||||
// In-process memo of the parsed cache, keyed by the file identity that last
|
||||
// produced it. On a 100MB+ corpus the JSON.parse of the session cache is
|
||||
// seconds of work per load; a resident process (codeburn serve) pays it once
|
||||
// and revalidates with a stat() per request. A rewrite by ANOTHER process
|
||||
// moves mtime/size and forces a reload, so cross-process freshness is
|
||||
// preserved; saveCache updates the memo write-through so the object handed
|
||||
// out stays the canonical one after a refresh.
|
||||
let cacheMemo: { path: string; mtimeMs: number; size: number; cache: SessionCache } | null = null
|
||||
|
||||
export function clearLoadCacheMemo(): void {
|
||||
cacheMemo = null
|
||||
}
|
||||
|
||||
export async function loadCache(): Promise<SessionCache> {
|
||||
const path = getCachePath()
|
||||
try {
|
||||
const raw = await readFile(getCachePath(), 'utf-8')
|
||||
const info = await stat(path)
|
||||
if (cacheMemo && cacheMemo.path === path && cacheMemo.mtimeMs === info.mtimeMs && cacheMemo.size === info.size) {
|
||||
return cacheMemo.cache
|
||||
}
|
||||
const raw = await readFile(path, 'utf-8')
|
||||
const parsed = JSON.parse(raw)
|
||||
if (!validateCache(parsed)) return afterMissingVersionedCache()
|
||||
cacheMemo = { path, mtimeMs: info.mtimeMs, size: info.size, cache: parsed }
|
||||
return parsed
|
||||
} catch {
|
||||
return afterMissingVersionedCache()
|
||||
|
|
@ -614,6 +633,15 @@ export async function saveCache(cache: SessionCache, verifyStillOwner?: () => Pr
|
|||
}
|
||||
}
|
||||
if (!renamed) throw new Error('session cache rename failed')
|
||||
// Write-through: the object just published IS the freshest state; capture
|
||||
// the post-rename file identity so the next loadCache in this process
|
||||
// reuses it instead of re-parsing what it just wrote.
|
||||
try {
|
||||
const info = await stat(finalPath)
|
||||
cacheMemo = { path: finalPath, mtimeMs: info.mtimeMs, size: info.size, cache }
|
||||
} catch {
|
||||
cacheMemo = null
|
||||
}
|
||||
return true
|
||||
} catch (err) {
|
||||
await retryCacheFileMutation(() => unlink(tempPath))
|
||||
|
|
|
|||
|
|
@ -679,3 +679,50 @@ describe('(f) growing resumed CLI session durable merge', () => {
|
|||
expect(second).toEqual({ input: 74463 - 49489 - 24968, cacheRead: 49489, cacheWrite: 24968 })
|
||||
})
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// (q) Burst reuse: a through-now range re-anchored seconds later reuses the
|
||||
// previous parse instead of re-running discovery (serve fast-path)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
describe('(q) parse burst reuse (CODEBURN_PARSE_BURST_MS)', () => {
|
||||
it('serves a re-anchored range from the previous parse inside the window, never outside it', async () => {
|
||||
vi.stubEnv('CODEBURN_PARSE_BURST_MS', '10000')
|
||||
clearSessionCache()
|
||||
const start = new Date(Date.now() - 60 * 60 * 1000)
|
||||
const ts = new Date(Date.now() - 10 * 60 * 1000).toISOString()
|
||||
const synthFile = join(tmpHome, 'synth-burst.txt')
|
||||
await writeFile(synthFile, 'placeholder')
|
||||
_synthSources = [{ path: synthFile, project: 'p', provider: 'test-synthetic' }]
|
||||
_synthYields = [{
|
||||
provider: 'test-synthetic', model: 'synth-model',
|
||||
inputTokens: 1, outputTokens: 5, cacheCreationInputTokens: 0, cacheReadInputTokens: 0,
|
||||
cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0,
|
||||
costUSD: 0, costIsEstimated: false, tools: [], bashCommands: [], skills: [],
|
||||
timestamp: ts, speed: 'standard', deduplicationKey: 'synth-burst-1', userMessage: 'hi', sessionId: 'sb-1',
|
||||
}] as never
|
||||
|
||||
const first = await parseAllSessions({ start, end: new Date() }, 'test-synthetic')
|
||||
expect(totalOutput(first)).toBe(5)
|
||||
|
||||
// The world changes (a second call appears), but a burst-window re-anchor
|
||||
// must serve the PREVIOUS parse: same data, no re-discovery.
|
||||
_synthYields = [..._synthYields, {
|
||||
...( _synthYields[0] as object ), deduplicationKey: 'synth-burst-2', outputTokens: 7,
|
||||
}] as never
|
||||
const second = await parseAllSessions({ start, end: new Date(Date.now() + 1000) }, 'test-synthetic')
|
||||
expect(totalOutput(second)).toBe(5)
|
||||
|
||||
// Outside the window (env cleared = burst disabled), the fresh parse sees
|
||||
// the new call: proof the reuse was the burst path, not staleness. The
|
||||
// source file must actually change, or the fingerprint-keyed disk cache
|
||||
// (correctly) serves the old turns.
|
||||
vi.stubEnv('CODEBURN_PARSE_BURST_MS', '0')
|
||||
await writeFile(synthFile, 'placeholder v2 with a second call')
|
||||
clearSessionCache()
|
||||
const third = await parseAllSessions({ start, end: new Date(Date.now() + 2000) }, 'test-synthetic')
|
||||
expect(totalOutput(third)).toBe(12)
|
||||
vi.unstubAllEnvs()
|
||||
_synthSources = []
|
||||
_synthYields = []
|
||||
})
|
||||
})
|
||||
|
|
|
|||
90
tests/serve-stdio.test.ts
Normal file
90
tests/serve-stdio.test.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
||||
import { spawn, type ChildProcess } from 'child_process'
|
||||
import { join } from 'path'
|
||||
|
||||
// End-to-end protocol test for `codeburn serve --stdio` (the desktop app's
|
||||
// resident query server). Runs the real entry through tsx against the
|
||||
// test-isolated env (env-isolation.ts points every provider at empty dirs),
|
||||
// so requests answer fast and deterministically empty.
|
||||
describe('codeburn serve --stdio', () => {
|
||||
let child: ChildProcess
|
||||
let buffer = ''
|
||||
const waiters = new Map<number, (msg: Record<string, unknown>) => void>()
|
||||
let readyResolve: () => void
|
||||
const ready = new Promise<void>(resolve => { readyResolve = resolve })
|
||||
|
||||
function request(id: number, args: string[]): Promise<Record<string, unknown>> {
|
||||
return new Promise(resolve => {
|
||||
waiters.set(id, resolve)
|
||||
child.stdin!.write(JSON.stringify({ id, args }) + '\n')
|
||||
})
|
||||
}
|
||||
|
||||
function sendRaw(line: string): void {
|
||||
child.stdin!.write(line + '\n')
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
child = spawn(process.execPath, ['--import', 'tsx', join(__dirname, '..', 'src', 'cli.ts'), 'serve', '--stdio'], {
|
||||
stdio: ['pipe', 'pipe', 'ignore'],
|
||||
env: { ...process.env },
|
||||
})
|
||||
child.stdout!.setEncoding('utf8')
|
||||
child.stdout!.on('data', (chunk: string) => {
|
||||
buffer += chunk
|
||||
let idx: number
|
||||
while ((idx = buffer.indexOf('\n')) >= 0) {
|
||||
const line = buffer.slice(0, idx).trim()
|
||||
buffer = buffer.slice(idx + 1)
|
||||
if (!line) continue
|
||||
let msg: Record<string, unknown>
|
||||
try { msg = JSON.parse(line) } catch { continue }
|
||||
if (msg['ready']) { readyResolve(); continue }
|
||||
const waiter = waiters.get(msg['id'] as number)
|
||||
if (waiter) { waiters.delete(msg['id'] as number); waiter(msg) }
|
||||
}
|
||||
})
|
||||
await ready
|
||||
}, 60_000)
|
||||
|
||||
afterAll(() => {
|
||||
child?.kill('SIGKILL')
|
||||
})
|
||||
|
||||
it('answers an allowed query with the command stdout', async () => {
|
||||
const res = await request(1, ['status', '--format', 'menubar-json', '--period', 'today'])
|
||||
expect(res['ok']).toBe(true)
|
||||
const payload = JSON.parse(res['output'] as string) as { current: { label: string } }
|
||||
expect(payload.current.label).toContain('Today')
|
||||
}, 60_000)
|
||||
|
||||
it('isolates option state between requests (no sticky --period)', async () => {
|
||||
// The whole reason serve rebuilds the program per request: commander
|
||||
// option state is sticky, and a leaked --period would mislabel every
|
||||
// later panel.
|
||||
const month = await request(2, ['status', '--format', 'menubar-json', '--period', 'month'])
|
||||
const today = await request(3, ['status', '--format', 'menubar-json', '--period', 'today'])
|
||||
const monthLabel = (JSON.parse(month['output'] as string) as { current: { label: string } }).current.label
|
||||
const todayLabel = (JSON.parse(today['output'] as string) as { current: { label: string } }).current.label
|
||||
expect(monthLabel).not.toBe(todayLabel)
|
||||
expect(todayLabel).toContain('Today')
|
||||
}, 60_000)
|
||||
|
||||
it('refuses commands outside the read allowlist', async () => {
|
||||
const res = await request(4, ['currency', 'EUR'])
|
||||
expect(res['ok']).toBe(false)
|
||||
expect(res['refused']).toBe(true)
|
||||
})
|
||||
|
||||
it('refuses a smuggled positional on an allowed command', async () => {
|
||||
const res = await request(5, ['sessions', 'positional-arg'])
|
||||
expect(res['ok']).toBe(false)
|
||||
expect(res['refused']).toBe(true)
|
||||
})
|
||||
|
||||
it('survives a malformed request line and keeps serving', async () => {
|
||||
sendRaw('this is not json')
|
||||
const res = await request(6, ['status', '--format', 'menubar-json', '--period', 'today'])
|
||||
expect(res['ok']).toBe(true)
|
||||
}, 60_000)
|
||||
})
|
||||
|
|
@ -13,6 +13,7 @@ import {
|
|||
type FileFingerprint,
|
||||
type SessionCache,
|
||||
cleanupOrphanedTempFiles,
|
||||
clearLoadCacheMemo,
|
||||
computeEnvFingerprint,
|
||||
emptyCache,
|
||||
fingerprintFile,
|
||||
|
|
@ -882,3 +883,29 @@ describe('cleanupOrphanedTempFiles', () => {
|
|||
await cleanupOrphanedTempFiles()
|
||||
})
|
||||
})
|
||||
|
||||
// ── loadCache memo (serve fast-path) ─────────────────────────────────────
|
||||
|
||||
describe('loadCache memo', () => {
|
||||
it('returns the identical object while the file is unchanged, reloads on rewrite', async () => {
|
||||
clearLoadCacheMemo()
|
||||
const cache = emptyCache()
|
||||
cache.providers['memo-test'] = { envFingerprint: 'x', files: {} }
|
||||
await saveCache(cache)
|
||||
|
||||
// saveCache write-through: the very object just published is served back.
|
||||
const first = await loadCache()
|
||||
expect(first).toBe(cache)
|
||||
const second = await loadCache()
|
||||
expect(second).toBe(first)
|
||||
|
||||
// An external rewrite (another process) moves mtime/size: fresh parse.
|
||||
const external = emptyCache()
|
||||
external.providers['memo-test-2'] = { envFingerprint: 'y', files: {} }
|
||||
await saveCache(external)
|
||||
const third = await loadCache()
|
||||
expect(third).toBe(external)
|
||||
expect(third).not.toBe(first)
|
||||
clearLoadCacheMemo()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue