diff --git a/app/electron/cli.test.ts b/app/electron/cli.test.ts index e95bc93d..b9f90015 100644 --- a/app/electron/cli.test.ts +++ b/app/electron/cli.test.ts @@ -4,7 +4,7 @@ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, chmodSync import { tmpdir } from 'node:os' import { dirname, join, isAbsolute, relative, win32, posix } from 'node:path' -import { spawnCli, spawnCliAction, spawnEnvFor, spawnSpecFor, killAll, CliError, nodeManagerDirs, notFoundStage, resolveCodeburnPath, resolveTarget } from './cli' +import { spawnCli, spawnCliAction, spawnEnvFor, spawnSpecFor, startServe, killAll, shutdownAll, CliError, nodeManagerDirs, notFoundStage, resolveCodeburnPath, resolveTarget } from './cli' let dir: string const originalBin = process.env.CODEBURN_BIN @@ -23,6 +23,53 @@ function fakeBin(name: string, body: string): string { return p } +function readMaybe(path: string): string { + try { return readFileSync(path, 'utf8') } catch { return '' } +} + +/** A protocol-faithful fake CLI whose serve child accepts requests before its + * delayed ready frame. Files expose process starts and heavy request executions + * without relying on timing or private ServeClient internals. */ +function fakeResidentBin(): { + startsFile: string + heavyFile: string + oneShotsFile: string + actionsFile: string + serveEnvFile: string +} { + const startsFile = join(dir, 'serve-starts') + const heavyFile = join(dir, 'heavy-requests') + const oneShotsFile = join(dir, 'one-shot-reads') + const actionsFile = join(dir, 'actions') + const serveEnvFile = join(dir, 'serve-progress-env') + fakeBin( + 'resident.js', + `const fs = require('node:fs'); const readline = require('node:readline'); + const command = process.argv[2]; + if (command === 'serve') { + fs.appendFileSync(${JSON.stringify(startsFile)}, 's'); + const generation = fs.readFileSync(${JSON.stringify(startsFile)}, 'utf8').length; + fs.writeFileSync(${JSON.stringify(serveEnvFile)}, process.env.CODEBURN_PROGRESS || ''); + const rl = readline.createInterface({ input: process.stdin }); + rl.on('line', line => { + const request = JSON.parse(line); + fs.appendFileSync(${JSON.stringify(heavyFile)}, 'h'); + const progress = 'CODEBURN_PROGRESS ' + JSON.stringify({ kind: 'provider', provider: 'claude', state: 'start', generation }) + '\\n'; + process.stdout.write(JSON.stringify({ id: request.id, progress }) + '\\n'); + process.stdout.write(JSON.stringify({ id: request.id, ok: true, output: JSON.stringify({ via: 'serve', generation, args: request.args }) }) + '\\n'); + }); + setTimeout(() => process.stdout.write(JSON.stringify({ ready: true, pid: process.pid }) + '\\n'), 100); + } else if (command === 'currency') { + fs.appendFileSync(${JSON.stringify(actionsFile)}, 'a'); + process.stdout.write('currency updated'); + } else { + fs.appendFileSync(${JSON.stringify(oneShotsFile)}, 'o'); + process.stdout.write(JSON.stringify({ via: 'spawn', command })); + }`, + ) + return { startsFile, heavyFile, oneShotsFile, actionsFile, serveEnvFile } +} + /** Writes the repo CLI under this test's isolated dev-root override. */ function fakeDevRepoCli(): string { const repoRoot = join(dir, 'dev-repo') @@ -39,6 +86,7 @@ beforeEach(() => { }) afterEach(() => { + killAll() if (originalBin === undefined) delete process.env.CODEBURN_BIN else process.env.CODEBURN_BIN = originalBin if (originalPathDirs === undefined) delete process.env.CODEBURN_PATH_DIRS @@ -389,6 +437,287 @@ describe('spawnCli coalescing (read-only)', () => { }) }) +describe('resident serve single-flight', () => { + it('startServe is idempotent and creates only one resident child', async () => { + const files = fakeResidentBin() + startServe() + startServe() + + const result = await spawnCli(['status', '--double-start'], { timeoutMs: 5_000 }) as { generation: number } + + expect(result.generation).toBe(1) + expect(readMaybe(files.startsFile)).toBe('s') + expect(readMaybe(files.heavyFile)).toBe('h') + }) + + it('lazily starts a new resident after an unexpected death and one-shot fallback', async () => { + const startsFile = join(dir, 'serve-starts') + const oneShotsFile = join(dir, 'one-shot-reads') + fakeBin( + 'dies-once-resident.js', + `const fs = require('node:fs'); const readline = require('node:readline'); + const command = process.argv[2]; + if (command === 'serve') { + fs.appendFileSync(${JSON.stringify(startsFile)}, 's'); + const generation = fs.readFileSync(${JSON.stringify(startsFile)}, 'utf8').length; + const rl = readline.createInterface({ input: process.stdin }); + rl.once('line', line => { + const request = JSON.parse(line); + if (generation === 1) process.exit(1); + process.stdout.write(JSON.stringify({ id: request.id, ok: true, output: JSON.stringify({ via: 'serve', generation }) }) + '\\n'); + }); + } else { + fs.appendFileSync(${JSON.stringify(oneShotsFile)}, 'o'); + process.stdout.write(JSON.stringify({ via: 'spawn' })); + }`, + ) + startServe() + + await expect(spawnCli(['status', '--first'], { timeoutMs: 5_000 })) + .resolves.toEqual({ via: 'spawn' }) + await expect(spawnCli(['models', '--second'], { timeoutMs: 5_000 })) + .resolves.toEqual({ via: 'serve', generation: 2 }) + + expect(readMaybe(startsFile)).toBe('ss') + expect(readMaybe(oneShotsFile)).toBe('o') + }) + + it('gives the first resident status request the power-user cold timeout floor', async () => { + fakeBin( + 'slow-cold-resident.js', + `const readline = require('node:readline'); + if (process.argv[2] === 'serve') { + const rl = readline.createInterface({ input: process.stdin }); + rl.on('line', line => { + const request = JSON.parse(line); + setTimeout(() => process.stdout.write(JSON.stringify({ id: request.id, ok: true, output: JSON.stringify({ via: 'serve' }) }) + '\\n'), 80); + }); + } else { + process.stdout.write(JSON.stringify({ via: 'spawn' })); + }`, + ) + startServe() + + await expect(spawnCli(['status', '--cold-floor'], { timeoutMs: 20 })) + .resolves.toEqual({ via: 'serve' }) + }) + + it('starts a queued resident timeout only after the request ahead settles', async () => { + fakeBin( + 'serial-resident.js', + `const readline = require('node:readline'); + if (process.argv[2] === 'serve') { + const rl = readline.createInterface({ input: process.stdin }); + (async () => { + for await (const line of rl) { + const request = JSON.parse(line); + if (request.args.includes('--slow')) await new Promise(resolve => setTimeout(resolve, 400)); + process.stdout.write(JSON.stringify({ id: request.id, ok: true, output: JSON.stringify({ via: 'serve', args: request.args }) }) + '\\n'); + } + })(); + } else { + process.stdout.write(JSON.stringify({ via: 'spawn' })); + }`, + ) + startServe() + await expect(spawnCli(['status', '--warm'], { timeoutMs: 5_000 })) + .resolves.toMatchObject({ via: 'serve' }) + + const slow = spawnCli(['sessions', '--slow'], { timeoutMs: 1_000 }) + const queued = spawnCli(['models', '--queued'], { timeoutMs: 200 }) + const [slowResult, queuedResult] = await Promise.all([slow, queued]) + + expect(slowResult).toMatchObject({ via: 'serve' }) + expect(queuedResult).toMatchObject({ via: 'serve' }) + }) + + it('uses the first real request as the only heavy execution, even before ready', async () => { + const files = fakeResidentBin() + startServe() + + const result = await spawnCli(['status', '--format', 'menubar-json'], { + timeoutMs: 5_000, + extraEnv: { CODEBURN_PROGRESS: '1' }, + }) as { via: string; generation: number } + + expect(result).toMatchObject({ via: 'serve', generation: 1 }) + expect(readMaybe(files.startsFile)).toBe('s') + expect(readMaybe(files.heavyFile)).toBe('h') + expect(readMaybe(files.oneShotsFile)).toBe('') + expect(readMaybe(files.serveEnvFile)).toBe('1') + }) + + it('forwards serve progress frames through the read onStderr callback', async () => { + fakeResidentBin() + startServe() + const chunks: string[] = [] + + await spawnCli(['status'], { + timeoutMs: 5_000, + extraEnv: { CODEBURN_PROGRESS: '1' }, + onStderr: chunk => { chunks.push(chunk) }, + }) + + expect(chunks.join('')).toBe('CODEBURN_PROGRESS {"kind":"provider","provider":"claude","state":"start","generation":1}\n') + }) + + it('keeps requests with any non-progress env override on the one-shot path', async () => { + const files = fakeResidentBin() + startServe() + + const result = await spawnCli(['status'], { + timeoutMs: 5_000, + extraEnv: { CODEBURN_PROGRESS: '1', CODEBURN_TEST_MODE: 'isolated' }, + }) as { via: string } + + expect(result.via).toBe('spawn') + expect(readMaybe(files.heavyFile)).toBe('') + expect(readMaybe(files.oneShotsFile)).toBe('o') + }) + + it('treats empty and undefined-only env overrides as serve-compatible', async () => { + const files = fakeResidentBin() + startServe() + + const empty = await spawnCli(['status', '--empty-env'], { + timeoutMs: 5_000, + extraEnv: {}, + }) as { via: string } + const undefinedOnly = await spawnCli(['models', '--undefined-env'], { + timeoutMs: 5_000, + extraEnv: { CODEBURN_PROGRESS: undefined }, + }) as { via: string } + + expect(empty.via).toBe('serve') + expect(undefinedOnly.via).toBe('serve') + expect(readMaybe(files.heavyFile)).toBe('hh') + expect(readMaybe(files.oneShotsFile)).toBe('') + }) + + it('restarts the resident child after a successful config mutation', async () => { + const files = fakeResidentBin() + startServe() + + const before = await spawnCli(['status'], { timeoutMs: 5_000 }) as { generation: number } + const action = await spawnCliAction(['currency', 'EUR'], { timeoutMs: 5_000 }) + const after = await spawnCli(['status'], { timeoutMs: 5_000 }) as { generation: number } + + expect(action).toMatchObject({ ok: true, stdout: 'currency updated', code: 0 }) + expect(before.generation).toBe(1) + expect(after.generation).toBe(2) + expect(readMaybe(files.startsFile)).toBe('ss') + expect(readMaybe(files.heavyFile)).toBe('hh') + expect(readMaybe(files.actionsFile)).toBe('a') + }) + + it('preserves the unexpected-death budget across mutation restarts', async () => { + const startsFile = join(dir, 'serve-starts') + const oneShotsFile = join(dir, 'one-shot-reads') + fakeBin( + 'crashing-resident.js', + `const fs = require('node:fs'); const readline = require('node:readline'); + const command = process.argv[2]; + if (command === 'serve') { + fs.appendFileSync(${JSON.stringify(startsFile)}, 's'); + const rl = readline.createInterface({ input: process.stdin }); + rl.once('line', () => process.exit(1)); + } else if (command === 'currency') { + process.stdout.write('currency updated'); + } else { + fs.appendFileSync(${JSON.stringify(oneShotsFile)}, 'o'); + process.stdout.write(JSON.stringify({ via: 'spawn' })); + }`, + ) + startServe() + + for (let attempt = 0; attempt < 3; attempt += 1) { + await expect(spawnCli(['status', '--attempt', String(attempt)], { timeoutMs: 5_000 })) + .resolves.toEqual({ via: 'spawn' }) + await expect(spawnCliAction(['currency', attempt % 2 === 0 ? 'EUR' : 'USD'], { timeoutMs: 5_000 })) + .resolves.toMatchObject({ ok: true }) + } + + // A mutation may replace a healthy child, but it must not erase real crash + // history and resurrect serve after the third unexpected death. + expect(readMaybe(startsFile)).toBe('sss') + await expect(spawnCli(['status', '--after-budget'], { timeoutMs: 5_000 })) + .resolves.toEqual({ via: 'spawn' }) + expect(readMaybe(startsFile)).toBe('sss') + expect(readMaybe(oneShotsFile)).toBe('oooo') + }) + + it('stops lazy crash recovery after three consecutive resident deaths', async () => { + const startsFile = join(dir, 'serve-starts') + const oneShotsFile = join(dir, 'one-shot-reads') + fakeBin( + 'always-crashing-resident.js', + `const fs = require('node:fs'); const readline = require('node:readline'); + if (process.argv[2] === 'serve') { + fs.appendFileSync(${JSON.stringify(startsFile)}, 's'); + const rl = readline.createInterface({ input: process.stdin }); + rl.once('line', () => process.exit(1)); + } else { + fs.appendFileSync(${JSON.stringify(oneShotsFile)}, 'o'); + process.stdout.write(JSON.stringify({ via: 'spawn' })); + }`, + ) + startServe() + + for (let attempt = 0; attempt < 4; attempt += 1) { + await expect(spawnCli(['status', '--lazy-crash', String(attempt)], { timeoutMs: 5_000 })) + .resolves.toEqual({ via: 'spawn' }) + } + + expect(readMaybe(startsFile)).toBe('sss') + expect(readMaybe(oneShotsFile)).toBe('oooo') + }) + + it('does not spawn a one-shot fallback after killAll destroys serve', async () => { + const requestSeenFile = join(dir, 'request-seen') + const oneShotsFile = join(dir, 'one-shot-reads') + fakeBin( + 'shutdown-resident.js', + `const fs = require('node:fs'); const readline = require('node:readline'); + if (process.argv[2] === 'serve') { + const rl = readline.createInterface({ input: process.stdin }); + rl.once('line', () => { fs.writeFileSync(${JSON.stringify(requestSeenFile)}, '1'); }); + } else { + fs.appendFileSync(${JSON.stringify(oneShotsFile)}, 'o'); + process.stdout.write('{}'); + }`, + ) + startServe() + const pending = spawnCli(['status', '--shutdown'], { timeoutMs: 60_000 }) + for (let attempt = 0; attempt < 400 && !readMaybe(requestSeenFile); attempt += 1) { + await new Promise(resolve => setTimeout(resolve, 5)) + } + const requestSeen = readMaybe(requestSeenFile) + killAll() + + expect(requestSeen).toBe('1') + await expect(pending).rejects.toMatchObject({ kind: 'nonzero' }) + await new Promise(resolve => setTimeout(resolve, 25)) + expect(readMaybe(oneShotsFile)).toBe('') + }) + + it('keeps the warm resident child after a successful export', async () => { + const files = fakeResidentBin() + startServe() + + const before = await spawnCli(['status'], { timeoutMs: 5_000 }) as { generation: number } + const action = await spawnCliAction(['export', '-f', 'json', '-o', join(dir, 'usage.json')], { timeoutMs: 5_000 }) + // Different argv bypasses the 5s result cache and proves which resident + // generation actually handled the next served read. + const after = await spawnCli(['models', '--format', 'json'], { timeoutMs: 5_000 }) as { generation: number } + + expect(action.ok).toBe(true) + expect(before.generation).toBe(1) + expect(after.generation).toBe(1) + expect(readMaybe(files.startsFile)).toBe('s') + expect(readMaybe(files.heavyFile)).toBe('hh') + }) +}) + describe('killAll', () => { it('reaps an in-flight child so its promise settles', async () => { fakeBin('hang-kill.js', 'setInterval(() => {}, 1000)') @@ -398,6 +727,23 @@ describe('killAll', () => { killAll() await expect(pending).rejects.toMatchObject({ kind: 'nonzero' }) }) + + it('terminal shutdown rejects new read and action races without spawning', async () => { + const startsFile = join(dir, 'starts') + fakeBin( + 'shutdown-guard.js', + `require('node:fs').appendFileSync(${JSON.stringify(startsFile)}, 'x'); process.stdout.write('{}')`, + ) + + shutdownAll() + startServe() + + await expect(spawnCli(['status', '--after-shutdown'])) + .rejects.toMatchObject({ kind: 'nonzero' }) + await expect(spawnCliAction(['currency', 'EUR'])) + .resolves.toMatchObject({ ok: false, code: null }) + expect(readMaybe(startsFile)).toBe('') + }) }) describe('spawnCli concurrency scheduler', () => { diff --git a/app/electron/cli.ts b/app/electron/cli.ts index ad63a8ec..938f3796 100644 --- a/app/electron/cli.ts +++ b/app/electron/cli.ts @@ -54,6 +54,10 @@ export class CliError extends Error { } const DEFAULT_TIMEOUT_MS = 45_000 +// The first status query may hydrate a power-user cache from scratch. Every +// resident request admitted before that succeeds shares this floor so a later +// short request cannot kill the child while it waits behind the cold scan. +export const DESKTOP_COLD_TIMEOUT_MS = 10 * 60_000 // A runaway CLI (or a compromised binary) must not exhaust main-process memory. const MAX_OUTPUT_BYTES = 16 * 1024 * 1024 // Same-cadence pollers fire near-identical read spawns; share one child and hold @@ -76,6 +80,7 @@ type SlotWaiter = { resolve: () => void; reject: (err: unknown) => void } let running = 0 const interactiveQueue: SlotWaiter[] = [] const backgroundQueue: SlotWaiter[] = [] +let shuttingDown = false /** Grant free slots to queued waiters, interactive first, up to the cap. */ function pumpSlots(): void { @@ -101,9 +106,8 @@ function releaseSlot(): void { pumpSlots() } -/** SIGKILL every in-flight child and cancel anything still queued for a slot. - * Wired to Electron's `before-quit`. */ -export function killAll(): void { +/** Reap every child and cancel anything still queued for a slot. */ +function reapAll(): void { serveClient?.destroy() serveClient = null for (const child of activeChildren) child.kill('SIGKILL') @@ -117,6 +121,19 @@ export function killAll(): void { for (const waiter of waiting) waiter.reject(new CliError('nonzero', 'codeburn cancelled')) } +/** Test/dev cleanup that permits a later fresh start in this same process. */ +export function killAll(): void { + shuttingDown = false + reapAll() +} + +/** Terminal app shutdown: reap current work and reject any IPC race that arrives + * while Electron is still flushing telemetry before the final quit pass. */ +export function shutdownAll(): void { + shuttingDown = true + reapAll() +} + // Homebrew + common Node version managers, mirroring mac/CodeburnCLI.swift so a // GUI-launched app (minimal PATH) still finds a globally-installed `codeburn`. export function nodeManagerDirs(): string[] { @@ -397,42 +414,50 @@ function runCli(spec: SpawnSpec, cmdLabel: string, timeoutMs: number, onStderr?: // 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); +// - the first real panel request is also the cache warm-up, so startup never +// runs an artificial warm-up query beside a duplicate one-shot child; +// - progress frames from serve are forwarded through the same onStderr hook +// used by a one-shot cold start; // - 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 | null = null - private pending = new Map void; reject: (e: Error) => void; timer: NodeJS.Timeout }>() + private pending = new Map void + reject: (e: Error) => void + timer: NodeJS.Timeout + warmsServe: boolean + onStderr?: (chunk: string) => void + }>() private nextId = 1 - private ready = false - private warm = false private deaths = 0 private buffer = '' + private warmed = false + private destroyed = false + private requestTail: Promise = Promise.resolve() constructor(private readonly spec: SpawnSpec) {} - isWarmAndReady(): boolean { return this.ready && this.warm && this.child !== null } + isRunning(): boolean { return this.child !== null } disabled(): boolean { return this.deaths >= SERVE_MAX_RESTARTS } + isDestroyed(): boolean { return this.destroyed } start(): void { - if (this.child || this.disabled()) return + if (this.child || this.disabled() || this.destroyed) 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.stdout!.on('data', (chunk: string) => { + // A replaced child's stream can drain after its exit callback. Never let + // those stale bytes repopulate the shared line buffer for the new child. + if (this.child === child) this.onData(chunk) + }) + const onGone = () => this.onDeath(child) 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 { @@ -442,15 +467,22 @@ class ServeClient { 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 } + let msg: { id?: number; ready?: boolean; progress?: string; ok?: boolean; refused?: boolean; output?: string; error?: string } try { msg = JSON.parse(line) } catch { continue } - if (msg.ready) { this.ready = true; continue } + if (msg.ready) continue if (typeof msg.id !== 'number') continue const waiter = this.pending.get(msg.id) if (!waiter) continue + if (typeof msg.progress === 'string') { + if (waiter.onStderr) { + try { waiter.onStderr(msg.progress) } catch { /* progress consumers never own the request */ } + } + continue + } this.pending.delete(msg.id) clearTimeout(waiter.timer) if (msg.ok && typeof msg.output === 'string') { + if (waiter.warmsServe) this.warmed = true try { waiter.resolve(JSON.parse(msg.output)) } catch { waiter.reject(new CliError('bad-json', 'codeburn produced output that was not valid JSON')) } } else { @@ -459,13 +491,16 @@ class ServeClient { } } - private onDeath(): void { - const child = this.child + private onDeath(child: ReturnType, countsTowardBudget = true): void { + // Both `error` and `exit` can fire for one child, and destroy() performs the + // same cleanup synchronously. Only the currently-owned child may transition + // this client or reject its pending requests. + if (this.child !== child) return this.child = null - this.ready = false - this.warm = false - this.deaths += 1 - if (child) activeChildren.delete(child as never) + this.buffer = '' + this.warmed = false + if (countsTowardBudget) this.deaths += 1 + activeChildren.delete(child as never) for (const [, waiter] of this.pending) { clearTimeout(waiter.timer) waiter.reject(new CliError('nonzero', 'codeburn serve exited')) @@ -473,10 +508,32 @@ class ServeClient { this.pending.clear() } - request(args: string[], timeoutMs: number): Promise { + restartAfterMutation(): void { + const child = this.child + if (child) { + // This is an intentional replacement, not a crash. Detach first so the + // later exit event cannot consume the unexpected-death budget. + this.onDeath(child, false) + child.kill('SIGKILL') + } + this.start() + } + + request(args: string[], timeoutMs: number, onStderr?: (chunk: string) => void): Promise { + // The stdio server is deliberately serial. Mirror that contract client-side + // so queued calls do not start their timers while a cold request is still + // hydrating the cache in front of them. + const run = () => this.requestNow(args, timeoutMs, onStderr) + const result = this.requestTail.then(run, run) + this.requestTail = result.then(() => undefined, () => undefined) + return result + } + + private requestNow(args: string[], timeoutMs: number, onStderr?: (chunk: string) => void): Promise { const child = this.child if (!child?.stdin) return Promise.reject(new CliError('nonzero', 'serve not running')) const id = this.nextId++ + const effectiveTimeoutMs = this.warmed ? timeoutMs : Math.max(timeoutMs, DESKTOP_COLD_TIMEOUT_MS) return new Promise((resolve, reject) => { const timer = setTimeout(() => { // A hung request would block the serialized queue behind it; kill the @@ -484,8 +541,14 @@ class ServeClient { this.pending.delete(id) reject(new CliError('timeout', 'codeburn serve timed out')) child.kill('SIGKILL') - }, timeoutMs) - this.pending.set(id, { resolve, reject, timer }) + }, effectiveTimeoutMs) + this.pending.set(id, { + resolve, + reject, + timer, + warmsServe: args[0] === 'status', + ...(onStderr ? { onStderr } : {}), + }) child.stdin!.write(JSON.stringify({ id, args }) + '\n', (err) => { if (err) { this.pending.delete(id) @@ -497,29 +560,61 @@ class ServeClient { } destroy(): void { + this.destroyed = true this.deaths = SERVE_MAX_RESTARTS - this.child?.kill('SIGKILL') - this.onDeath() + const child = this.child + if (!child) return + this.onDeath(child, false) + child.kill('SIGKILL') } } 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 { +/** Start the resident serve child without issuing a query. The first real panel + * request is accepted immediately (even before the ready frame) and performs + * the one cold-cache hydration while streaming progress back to the splash. */ +export function startServe(): void { + if (shuttingDown) return const target = resolveTarget() if (!target) return if (serveClient?.disabled()) return - if (!serveClient) serveClient = new ServeClient(spawnSpecFor(target, ['serve', '--stdio'])) + if (!serveClient) { + const spec = spawnSpecFor(target, ['serve', '--stdio']) + spec.env = { ...spec.env, CODEBURN_PROGRESS: '1' } + serveClient = new ServeClient(spec) + } serveClient.start() } +function restartServeAfterMutation(): void { + // CLI-only consumers never started serve, so do not create a surprise daemon + // for them. In Electron, replace the resident child immediately so its parser + // and output memos cannot survive a successful config mutation. Reusing the + // client preserves its app-lifetime budget of unexpected child deaths. + if (!serveClient) return + serveClient.restartAfterMutation() +} + +function actionInvalidatesServe(args: string[]): boolean { + // Export only writes the caller-selected artifact. Every other current + // Electron action changes config or device state, and future actions restart + // by default until they are explicitly proven state-preserving. + return args[0] !== 'export' +} + +function isServeCompatibleEnv(extraEnv?: NodeJS.ProcessEnv): boolean { + if (!extraEnv) return true + const entries = Object.entries(extraEnv).filter(([, value]) => value !== undefined) + if (entries.length === 0) return true + return entries.length === 1 && entries[0]![0] === 'CODEBURN_PROGRESS' && entries[0]![1] === '1' +} + export function spawnCli( args: string[], opts: { timeoutMs?: number; onStderr?: (chunk: string) => void; extraEnv?: NodeJS.ProcessEnv; priority?: SpawnPriority } = {}, ): Promise { + if (shuttingDown) return Promise.reject(new CliError('nonzero', 'codeburn is shutting down')) const target = resolveTarget() if (!target) return Promise.reject(new CliError('not-found', 'codeburn CLI not found', notFoundStage())) const spec = spawnSpecFor(target, args) @@ -534,14 +629,24 @@ 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) { + // Serve fast-path: the child is started once at app startup. It accepts the + // first real query before its ready frame, making that request the single + // cache warm-up. CODEBURN_PROGRESS is compatible because startServe sets it + // on the resident child; any other per-call env needs an isolated one-shot. + if (SERVE_ROUTED.has(args[0] ?? '') && isServeCompatibleEnv(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)) + // Recover lazily from an unexpected child death. start() is synchronous and + // idempotent, and the client's lifetime death budget prevents an endlessly + // crashing binary from being respawned on every poll. + if (serve && !serve.isRunning() && !serve.disabled()) serve.start() + if (serve?.isRunning()) { + const flight = serve.request(args, opts.timeoutMs ?? DEFAULT_TIMEOUT_MS, opts.onStderr) + .catch(err => { + // App shutdown is terminal: never turn rejected resident requests + // into brand-new one-shot children after killAll() has reaped them. + if (serve.isDestroyed()) throw err + return 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) @@ -568,6 +673,7 @@ export function spawnCli( * 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 { + if (shuttingDown) return Promise.resolve({ ok: false, stdout: '', stderr: 'codeburn is shutting down', code: null }) const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS const target = resolveTarget() if (!target) return Promise.resolve({ ok: false, stdout: '', stderr: 'codeburn CLI not found', code: null }) @@ -603,6 +709,7 @@ function runAction(spec: SpawnSpec, args: string[], timeoutMs: number): Promise< // The action may have changed config the read cache still reflects; a // Settings refetch fires immediately after, so serve it fresh data. readCache.clear() + if (result.ok && actionInvalidatesServe(args)) restartServeAfterMutation() resolve(result) } diff --git a/app/electron/main.ts b/app/electron/main.ts index 7d2c2bb4..d3676474 100644 --- a/app/electron/main.ts +++ b/app/electron/main.ts @@ -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, startServeWarmup, type ActionResult, type SpawnPriority } from './cli' +import { CliError, DESKTOP_COLD_TIMEOUT_MS, resolveCodeburnPath, shutdownAll, spawnCli, spawnCliAction, startServe, type ActionResult, type SpawnPriority } from './cli' import { getQuota, sanitizeError } from './quota' import { Telemetry } from './telemetry' import { createUpdateChecker, type UpdateChecker, type UpdateStatus } from './updates' @@ -77,7 +77,7 @@ export type Envelope = { ok: true; value: T } | { ok: false; error: // slowness. Give the first (cold) overview a long window; revert to the default // once it succeeds. Sections gate their own first poll on this one resolving so // the cold hydration runs ONCE, not once per section in parallel. -const WARMUP_TIMEOUT_MS = 10 * 60_000 +const WARMUP_TIMEOUT_MS = DESKTOP_COLD_TIMEOUT_MS // Wire marker for CLI scan-progress lines (src/parser.ts: PROGRESS_LINE_PREFIX). const PROGRESS_LINE_PREFIX = 'CODEBURN_PROGRESS ' // IPC channel carrying cold-start scan-progress events to the splash. @@ -564,15 +564,15 @@ function bootstrap(): void { app.on('before-quit', createBeforeQuitHandler({ getTelemetry: () => telemetryInstance, - killAll, + killAll: shutdownAll, quit: () => app.quit(), })) 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() + // Start the resident child early, but issue no artificial warm-up query: + // the first real overview request is the single cache hydration and streams + // its progress through serve. Every later panel reuses that parsed cache. + startServe() // 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. diff --git a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift index e6366baa..63144f76 100644 --- a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift +++ b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift @@ -127,9 +127,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM // interaction (popover open, wake) refreshes immediately. restorePersistedCurrency() - // Resident serve child: payload fetches answer from a warm CLI once - // its warm-up completes; until then (and on any failure) fetches keep - // the spawn path. See ServeConnection. + // Start the resident CLI early without an artificial query. The first + // real status refresh becomes its only cold warm-up. See ServeConnection. Task { await ServeConnection.shared.ensureStarted() } // #868 experiment: restore only the activation half of the #147 fix. // Packaged builds ship LSUIElement=true, so the policy is .accessory diff --git a/mac/Sources/CodeBurnMenubar/CurrencyState.swift b/mac/Sources/CodeBurnMenubar/CurrencyState.swift index def6cf32..1c9f3d12 100644 --- a/mac/Sources/CodeBurnMenubar/CurrencyState.swift +++ b/mac/Sources/CodeBurnMenubar/CurrencyState.swift @@ -77,11 +77,7 @@ actor FXRateCache { private var loaded = false private var cacheFilePath: String { - let base = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] - return base - .appendingPathComponent("codeburn-mac", isDirectory: true) - .appendingPathComponent("fx-rates.json") - .path + return (CodeBurnCacheDirectory.resolve() as NSString).appendingPathComponent("fx-rates.json") } private func loadIfNeeded() { diff --git a/mac/Sources/CodeBurnMenubar/Data/CodeBurnCacheDirectory.swift b/mac/Sources/CodeBurnMenubar/Data/CodeBurnCacheDirectory.swift new file mode 100644 index 00000000..d5e31b82 --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/Data/CodeBurnCacheDirectory.swift @@ -0,0 +1,18 @@ +import Foundation + +/// Resolves the on-disk directory shared by the CLI, desktop app and menubar. +enum CodeBurnCacheDirectory { + static func resolve( + environment: [String: String] = ProcessInfo.processInfo.environment, + homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser + ) -> String { + if let override = environment["CODEBURN_CACHE_DIR"], + !override.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return override + } + return homeDirectory + .appendingPathComponent(".cache", isDirectory: true) + .appendingPathComponent("codeburn", isDirectory: true) + .path + } +} diff --git a/mac/Sources/CodeBurnMenubar/Data/DataClient.swift b/mac/Sources/CodeBurnMenubar/Data/DataClient.swift index cafe0449..0a2c24ad 100644 --- a/mac/Sources/CodeBurnMenubar/Data/DataClient.swift +++ b/mac/Sources/CodeBurnMenubar/Data/DataClient.swift @@ -123,13 +123,24 @@ struct DataClient { subcommand: [String], qualityOfService: QualityOfService = .userInitiated ) async throws -> ProcessResult { - // Serve fast path: a warm resident `codeburn serve` child answers the - // status payload without a spawn (no node boot, no session-cache - // reload). Any serve failure falls back to the spawn path below, so - // this is strictly an optimization; it also takes no spawn slot. + // Serve path: the first real status payload warms the resident child, + // then later payloads reuse it (no node boot or session-cache reload). + // Any serve failure falls back to the spawn path below, so this remains + // strictly an optimization and takes no spawn slot. if ServeConnection.isEligible(subcommand) { - if let stdout = try? await ServeConnection.shared.requestIfWarm(args: subcommand) { + do { + let stdout = try await ServeConnection.shared.request(args: subcommand) return ProcessResult(stdout: stdout, stderr: "", exitCode: 0) + } catch let error as CancellationError { + // Cancellation is control flow from the refresh owner. Starting + // a fallback process here would turn cancelled work into a new + // expensive cold parse and delay task teardown. + throw error + } catch { + // Resident serve is only an optimization. Protocol, child, and + // timeout failures retain the established one-shot fallback, + // unless a sibling teardown raced this task's cancellation. + try Task.checkCancellation() } } await spawnLimiter.acquire() diff --git a/mac/Sources/CodeBurnMenubar/Data/MenubarStatusCache.swift b/mac/Sources/CodeBurnMenubar/Data/MenubarStatusCache.swift index ef5f217a..72a8dfb7 100644 --- a/mac/Sources/CodeBurnMenubar/Data/MenubarStatusCache.swift +++ b/mac/Sources/CodeBurnMenubar/Data/MenubarStatusCache.swift @@ -9,8 +9,9 @@ struct MenubarStatusCache { /// Default location under `~/.cache/codeburn/`. static func standard() -> MenubarStatusCache { - let home = FileManager.default.homeDirectoryForCurrentUser.path - return MenubarStatusCache(statusPath: "\(home)/.cache/codeburn/menubar-status.json") + let cacheDir = CodeBurnCacheDirectory.resolve() + let path = (cacheDir as NSString).appendingPathComponent("menubar-status.json") + return MenubarStatusCache(statusPath: path) } struct BadgeRead { diff --git a/mac/Sources/CodeBurnMenubar/Data/ServeConnection.swift b/mac/Sources/CodeBurnMenubar/Data/ServeConnection.swift index 0eb13578..25f36388 100644 --- a/mac/Sources/CodeBurnMenubar/Data/ServeConnection.swift +++ b/mac/Sources/CodeBurnMenubar/Data/ServeConnection.swift @@ -1,3 +1,4 @@ +import Darwin import Foundation /// A resident `codeburn serve --stdio` child, held so payload fetches skip the @@ -6,8 +7,9 @@ import Foundation /// replies are `{id, ok, output}`. Mirrors the desktop app's client contract: /// /// - Only `status` payload queries route here; anything else spawns as before. -/// - Requests route through serve only once the child is READY and WARM (one -/// completed query), so cold start behaves exactly as today. +/// - The first real status request is also the warm-up. It may be written +/// before the child announces READY; the pipe buffers it until serve reads +/// stdin, avoiding a second one-shot process that parses the same cache. /// - Any failure falls back to the spawn path for that call; three child /// deaths disable serve for this app run. /// - The child's stdin closing (app quit, even SIGKILL) ends the server loop @@ -15,43 +17,69 @@ import Foundation actor ServeConnection { static let shared = ServeConnection() + typealias ProcessFactory = ([String], QualityOfService) -> Process + typealias TimeoutSleep = @Sendable (UInt64) async throws -> Void + private var process: Process? private var stdinHandle: FileHandle? private var nextId = 1 private var pending: [Int: CheckedContinuation] = [:] - private var ready = false - private var warm = false private var deaths = 0 private var buffer = Data() + private var receivedTerminalResponse = false + private let makeProcess: ProcessFactory + private let timeoutSleep: TimeoutSleep private static let maxDeaths = 3 - private static let requestTimeoutSeconds: UInt64 = 60 + private static let coldRequestTimeoutNanoseconds: UInt64 = 10 * 60 * 1_000_000_000 + private static let warmRequestTimeoutNanoseconds: UInt64 = 60 * 1_000_000_000 struct ServeUnavailable: Error {} struct ServeRequestFailed: Error { let message: String } + init( + makeProcess: @escaping ProcessFactory = CodeburnCLI.makeProcess, + timeoutSleep: @escaping TimeoutSleep = { nanoseconds in + try await Task.sleep(nanoseconds: nanoseconds) + } + ) { + self.makeProcess = makeProcess + self.timeoutSleep = timeoutSleep + } + static func isEligible(_ subcommand: [String]) -> Bool { subcommand.first == "status" } - /// Kick the child off (idempotent). Called from app startup; fetches keep - /// spawning until the warm-up completes. + /// Kick the child off (idempotent). Called from app startup and again by + /// the first request in case the startup task has not run yet. func ensureStarted() { guard process == nil, deaths < Self.maxDeaths else { return } - let child = CodeburnCLI.makeProcess(subcommand: ["serve", "--stdio"], qualityOfService: .utility) + // This single resident serves both background and user-visible status + // requests. Its cold hydration replaces the old interactive one-shot, + // so keep the child at the same user-initiated QoS as visible fetches. + let child = makeProcess(["serve", "--stdio"], .userInitiated) let stdinPipe = Pipe() + let stdinWriter = stdinPipe.fileHandleForWriting + // Suppress SIGPIPE only for this connection's write end. A process-wide + // SIG_IGN leaks into unrelated libraries and children; F_SETNOSIGPIPE + // keeps a closed child stdin on the normal throwable EPIPE path. + guard Darwin.fcntl(stdinWriter.fileDescriptor, F_SETNOSIGPIPE, 1) == 0 else { + deaths = Self.maxDeaths + return + } let stdoutPipe = Pipe() child.standardInput = stdinPipe child.standardOutput = stdoutPipe child.standardError = FileHandle.nullDevice - stdoutPipe.fileHandleForReading.readabilityHandler = { handle in + stdoutPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in let data = handle.availableData guard !data.isEmpty else { return } - Task { await ServeConnection.shared.consume(data) } + Task { await self?.consume(data, from: child) } } - child.terminationHandler = { _ in + child.terminationHandler = { [weak self] terminatedChild in stdoutPipe.fileHandleForReading.readabilityHandler = nil - Task { await ServeConnection.shared.childDied() } + Task { await self?.childDied(terminatedChild) } } do { try child.run() @@ -60,20 +88,19 @@ actor ServeConnection { return } process = child - stdinHandle = stdinPipe.fileHandleForWriting - Task { - // Warm-up: one cheap query makes the child parse the session cache - // once; every later payload answers from the warm in-memory copy. - _ = try? await self.send(args: ["status", "--format", "menubar-json", "--period", "today", "--no-optimize"]) - await self.markWarm() - } + stdinHandle = stdinWriter } - /// The fast path `runCLI` consults: throws ServeUnavailable unless the - /// child is warm, so callers can fall back to a spawn without waiting. - func requestIfWarm(args: [String]) async throws -> Data { - guard ready, warm, process != nil else { throw ServeUnavailable() } - return try await send(args: args) + /// Send the first real payload through the resident child. A request does + /// not need to wait for the READY frame: stdin is safe to write as soon as + /// Process.run() succeeds, and serve serializes it after initialization. + func request(args: [String]) async throws -> Data { + try Task.checkCancellation() + ensureStarted() + guard process != nil else { throw ServeUnavailable() } + let response = try await send(args: args) + try Task.checkCancellation() + return response } func shutdown() { @@ -82,37 +109,44 @@ actor ServeConnection { failAllPending() process = nil stdinHandle = nil + receivedTerminalResponse = false } // MARK: - internals - private func markWarm() { - if process != nil { warm = true } - } - private func send(args: [String]) async throws -> Data { guard let stdinHandle, let child = process else { throw ServeUnavailable() } let id = nextId nextId += 1 let request: [String: Any] = ["id": id, "args": args] let line = try JSONSerialization.data(withJSONObject: request) + // Every request admitted before the first terminal response is a cold + // request, including concurrent startup fetches. Once any terminal + // frame arrives the resident child is hydrated and later requests use + // the ordinary one-minute guard. + let timeoutNanoseconds = receivedTerminalResponse + ? Self.warmRequestTimeoutNanoseconds + : Self.coldRequestTimeoutNanoseconds + let sleep = timeoutSleep return try await withThrowingTaskGroup(of: Data.self) { group in group.addTask { - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - Task { await self.registerPending(id: id, continuation: continuation) } - do { - try stdinHandle.write(contentsOf: line + Data("\n".utf8)) - } catch { - Task { await self.rejectPending(id: id, error: ServeRequestFailed(message: "stdin write failed")) } - } - } + try await self.registerAndWrite( + id: id, + line: line, + stdinHandle: stdinHandle, + child: child + ) } group.addTask { - try await Task.sleep(nanoseconds: Self.requestTimeoutSeconds * 1_000_000_000) + try await sleep(timeoutNanoseconds) // A hung request would block the serialized queue behind it: // kill the child so everything falls back to spawns. - await self.rejectPending(id: id, error: ServeRequestFailed(message: "serve timeout")) - child.terminate() + await self.cancelPendingRequest( + id: id, + child: child, + error: ServeRequestFailed(message: "serve timeout"), + countsAsDeath: true + ) throw ServeRequestFailed(message: "serve timeout") } let result = try await group.next()! @@ -121,17 +155,64 @@ actor ServeConnection { } } - private func registerPending(id: Int, continuation: CheckedContinuation) { - pending[id] = continuation - } - - private func rejectPending(id: Int, error: Error) { - if let continuation = pending.removeValue(forKey: id) { - continuation.resume(throwing: error) + private func registerAndWrite( + id: Int, + line: Data, + stdinHandle: FileHandle, + child: Process + ) async throws -> Data { + try Task.checkCancellation() + return try await withTaskCancellationHandler { + let response = try await withCheckedThrowingContinuation { continuation in + // Register synchronously on the actor before writing. A tiny fake + // server (and occasionally a hot real child) can answer faster + // than a separately scheduled registration Task would run. + pending[id] = continuation + do { + try stdinHandle.write(contentsOf: line + Data("\n".utf8)) + } catch { + pending.removeValue(forKey: id) + continuation.resume(throwing: ServeRequestFailed(message: "stdin write failed")) + } + } + try Task.checkCancellation() + return response + } onCancel: { + Task { + await self.cancelPendingRequest( + id: id, + child: child, + error: CancellationError(), + countsAsDeath: false + ) + } } } - private func consume(_ data: Data) { + private func cancelPendingRequest( + id: Int, + child: Process, + error: Error, + countsAsDeath: Bool + ) { + guard let continuation = pending.removeValue(forKey: id) else { return } + continuation.resume(throwing: error) + // Caller cancellation abandons only this response. The serialized serve + // child may still be doing the expensive first hydration, and killing it + // here lets tab switches and UI watchdogs restart that work indefinitely. + // A real request timeout still kills the exact child that owns the hung + // request; its termination callback consumes the death budget normally. + guard countsAsDeath, process === child, child.isRunning else { return } + child.terminate() + } + + // Internal so the generation guard can be exercised deterministically by + // tests without relying on Foundation callback scheduling at process exit. + func consume(_ data: Data, from child: Process) { + // A readability callback can already have queued its actor Task when the + // old process exits. If a replacement starts first, those late bytes must + // not repopulate the shared line buffer or mark the new child as warm. + guard process === child else { return } buffer.append(data) while let newline = buffer.firstIndex(of: UInt8(ascii: "\n")) { let lineData = buffer.subdata(in: buffer.startIndex.. String { - return ProcessInfo.processInfo.environment["CODEBURN_CACHE_DIR"] - ?? (NSHomeDirectory() as NSString).appendingPathComponent(".cache/codeburn") -} - private func snapshotsPath() -> String { - return (snapshotsCacheDir() as NSString).appendingPathComponent(snapshotFilename) + return (CodeBurnCacheDirectory.resolve() as NSString).appendingPathComponent(snapshotFilename) } private actor SnapshotLock { diff --git a/mac/Sources/CodeBurnMenubar/Security/SafeFile.swift b/mac/Sources/CodeBurnMenubar/Security/SafeFile.swift index 3d6bda57..3b3dea29 100644 --- a/mac/Sources/CodeBurnMenubar/Security/SafeFile.swift +++ b/mac/Sources/CodeBurnMenubar/Security/SafeFile.swift @@ -2,7 +2,7 @@ import Foundation /// Symlink-safe file I/O with atomic writes and optional cross-process flock. /// -/// Every cache file we touch (`~/Library/Caches/codeburn-mac/fx-rates.json`, +/// Every cache file we touch (`~/.cache/codeburn/fx-rates.json`, /// `~/.cache/codeburn/subscription-snapshots.json`, `~/.config/codeburn/config.json`) is a /// legitimate target for a local-symlink attack: if an attacker plants a symlink from one of /// those paths to, say, `~/.ssh/config`, a naive `Data.write(to:)` blindly follows the link and diff --git a/mac/Tests/CodeBurnMenubarTests/CodeBurnCacheDirectoryTests.swift b/mac/Tests/CodeBurnMenubarTests/CodeBurnCacheDirectoryTests.swift new file mode 100644 index 00000000..cb217ed5 --- /dev/null +++ b/mac/Tests/CodeBurnMenubarTests/CodeBurnCacheDirectoryTests.swift @@ -0,0 +1,36 @@ +import Foundation +import Testing +@testable import CodeBurnMenubar + +@Suite("CodeBurnCacheDirectory") +struct CodeBurnCacheDirectoryTests { + @Test("honors CODEBURN_CACHE_DIR override") + func honorsOverride() { + let resolved = CodeBurnCacheDirectory.resolve( + environment: ["CODEBURN_CACHE_DIR": "/tmp/codeburn-shared-cache"], + homeDirectory: URL(fileURLWithPath: "/Users/test") + ) + + #expect(resolved == "/tmp/codeburn-shared-cache") + } + + @Test("falls back to the user's standard cache directory") + func fallsBackToStandardDirectory() { + let resolved = CodeBurnCacheDirectory.resolve( + environment: [:], + homeDirectory: URL(fileURLWithPath: "/Users/test", isDirectory: true) + ) + + #expect(resolved == "/Users/test/.cache/codeburn") + } + + @Test("ignores an empty cache override") + func ignoresEmptyOverride() { + let resolved = CodeBurnCacheDirectory.resolve( + environment: ["CODEBURN_CACHE_DIR": " \n"], + homeDirectory: URL(fileURLWithPath: "/Users/test", isDirectory: true) + ) + + #expect(resolved == "/Users/test/.cache/codeburn") + } +} diff --git a/mac/Tests/CodeBurnMenubarTests/ServeConnectionTests.swift b/mac/Tests/CodeBurnMenubarTests/ServeConnectionTests.swift new file mode 100644 index 00000000..ca74ca8c --- /dev/null +++ b/mac/Tests/CodeBurnMenubarTests/ServeConnectionTests.swift @@ -0,0 +1,492 @@ +import Darwin +import Foundation +import Testing +@testable import CodeBurnMenubar + +private let ignoredSIGPIPEHandlerBits = unsafeBitCast(SIG_IGN, to: UInt.self) +private let coldTimeoutNanoseconds: UInt64 = 10 * 60 * 1_000_000_000 +private let warmTimeoutNanoseconds: UInt64 = 60 * 1_000_000_000 + +private func currentSIGPIPEHandlerBits() -> UInt { + var action = sigaction() + _ = sigaction(SIGPIPE, nil, &action) + return unsafeBitCast(action.__sigaction_u.__sa_handler, to: UInt.self) +} + +private actor TimeoutRecorder { + private var values: [UInt64] = [] + + func recordAndSleep(_ nanoseconds: UInt64) async throws { + values.append(nanoseconds) + // Cold timers stay pending until the fake child replies and the task + // group cancels them. The warm timer returns immediately to exercise + // the timeout path without a real one-minute wait. + if nanoseconds == warmTimeoutNanoseconds { return } + try await Task.sleep(nanoseconds: 5 * 1_000_000_000) + } + + func recordAndWait(_ nanoseconds: UInt64) async throws { + values.append(nanoseconds) + // This recorder verifies timeout selection without firing the timeout. + // The response must deterministically win, then cancel this sleeper. + try await Task.sleep(nanoseconds: 5 * 1_000_000_000) + } + + func snapshot() -> [UInt64] { values } +} + +private final class QualityOfServiceRecorder: @unchecked Sendable { + private let lock = NSLock() + private var values: [QualityOfService] = [] + + func record(_ value: QualityOfService) { + lock.lock() + values.append(value) + lock.unlock() + } + + func snapshot() -> [QualityOfService] { + lock.lock() + defer { lock.unlock() } + return values + } +} + +@Suite("ServeConnection", .serialized) +struct ServeConnectionTests { + @Test("the resident child starts at user-initiated QoS") + func residentChildUsesInteractiveQoS() async { + let recorder = QualityOfServiceRecorder() + let connection = ServeConnection { _, qualityOfService in + recorder.record(qualityOfService) + let child = Process() + child.executableURL = URL(fileURLWithPath: "/bin/sh") + child.arguments = ["-c", "while IFS= read -r line; do :; done"] + child.qualityOfService = qualityOfService + return child + } + + await connection.ensureStarted() + + #expect(recorder.snapshot() == [.userInitiated]) + await connection.shutdown() + } + + @Test("cancelling a hung request returns promptly") + func cancellationUnblocksPendingContinuation() async throws { + let dir = NSTemporaryDirectory() + "serve-connection-cancel-test-" + UUID().uuidString + try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(atPath: dir) } + let requestMarker = dir + "/request-read" + + let connection = ServeConnection { _, qualityOfService in + let child = Process() + child.executableURL = URL(fileURLWithPath: "/bin/sh") + child.arguments = ["-c", "IFS= read -r line; : > \"$1\"; sleep 1", "serve-fixture", requestMarker] + child.qualityOfService = qualityOfService + return child + } + + let request = Task { + try await connection.request(args: ["status", "--format", "menubar-json"]) + } + for _ in 0..<200 where !FileManager.default.fileExists(atPath: requestMarker) { + try await Task.sleep(nanoseconds: 10_000_000) + } + #expect(FileManager.default.fileExists(atPath: requestMarker)) + + let clock = ContinuousClock() + let started = clock.now + request.cancel() + do { + _ = try await request.value + #expect(Bool(false), "cancelled request unexpectedly succeeded") + } catch { + #expect(error is CancellationError) + } + let elapsed = started.duration(to: clock.now) + #expect(elapsed < .milliseconds(500)) + await connection.shutdown() + } + + @Test("a request queued during cancelled hydration completes on the same child") + func cancellationKeepsQueuedRequestOnResidentChild() async throws { + let dir = NSTemporaryDirectory() + "serve-connection-cancel-overlap-test-" + UUID().uuidString + try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(atPath: dir) } + let pidsFile = dir + "/pids" + let eventsFile = dir + "/events" + let releaseMarker = dir + "/release-first" + let recorder = TimeoutRecorder() + + let connection = ServeConnection( + makeProcess: { _, qualityOfService in + let child = Process() + child.executableURL = URL(fileURLWithPath: "/bin/sh") + child.arguments = ["-c", """ + printf '%s\n' "$$" >> "$1" + IFS= read -r first + first_id=$(printf '%s' "$first" | sed -E 's/.*"id":([0-9]+).*/\\1/') + printf 'first-read\n' >> "$2" + while [ ! -f "$3" ]; do sleep 0.01; done + printf '{"id":%s,"ok":true,"output":"late-%s"}\n' "$first_id" "$first_id" + printf 'late-first\n' >> "$2" + IFS= read -r second + second_id=$(printf '%s' "$second" | sed -E 's/.*"id":([0-9]+).*/\\1/') + printf 'second-read\n' >> "$2" + printf '{"id":%s,"ok":true,"output":"live-%s"}\n' "$second_id" "$second_id" + printf 'second-replied\n' >> "$2" + """, "serve-fixture", pidsFile, eventsFile, releaseMarker] + child.qualityOfService = qualityOfService + return child + }, + timeoutSleep: { nanoseconds in + try await recorder.recordAndSleep(nanoseconds) + } + ) + + let first = Task { + try await connection.request(args: ["status", "--request", "first"]) + } + for _ in 0..<200 { + let events = (try? String(contentsOfFile: eventsFile, encoding: .utf8)) ?? "" + if events.contains("first-read\n") { break } + try await Task.sleep(nanoseconds: 10_000_000) + } + #expect(try String(contentsOfFile: eventsFile, encoding: .utf8) == "first-read\n") + + first.cancel() + do { + _ = try await first.value + #expect(Bool(false), "cancelled request unexpectedly succeeded") + } catch { + #expect(error is CancellationError) + } + + // Submit the next request while the child is still blocked hydrating + // the cancelled first one. Two timeout selections prove both requests + // reached send() before the fake is released to emit either response. + let second = Task { + try await connection.request(args: ["status", "--request", "second"]) + } + for _ in 0..<200 { + if await recorder.snapshot().count >= 2 { break } + try await Task.sleep(nanoseconds: 10_000_000) + } + #expect(await recorder.snapshot().count == 2) + #expect(try String(contentsOfFile: eventsFile, encoding: .utf8) == "first-read\n") + + _ = FileManager.default.createFile(atPath: releaseMarker, contents: Data()) + let secondPayload = try await second.value + + #expect(String(decoding: secondPayload, as: UTF8.self) == "live-2") + let pids = try String(contentsOfFile: pidsFile, encoding: .utf8) + .split(separator: "\n") + #expect(pids.count == 1) + let events = try String(contentsOfFile: eventsFile, encoding: .utf8) + .split(separator: "\n") + #expect(events == ["first-read", "late-first", "second-read", "second-replied"]) + await connection.shutdown() + } + + @Test("external cancellations keep one child and safely discard late replies") + func cancellationsKeepResidentChildAlive() async throws { + let dir = NSTemporaryDirectory() + "serve-connection-cancel-reuse-test-" + UUID().uuidString + try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(atPath: dir) } + let pidsFile = dir + "/pids" + let requestsFile = dir + "/requests" + let lateRepliesFile = dir + "/late-replies" + + let connection = ServeConnection { _, qualityOfService in + let child = Process() + child.executableURL = URL(fileURLWithPath: "/bin/sh") + child.arguments = ["-c", """ + printf '%s\n' "$$" >> "$1" + while IFS= read -r line; do + printf r >> "$2" + id=$(printf '%s' "$line" | sed -E 's/.*"id":([0-9]+).*/\\1/') + if [ "$id" -le 3 ]; then + sleep 0.05 + printf '{"id":%s,"ok":true,"output":"late-%s"}\n' "$id" "$id" + printf l >> "$3" + else + printf '{"id":%s,"ok":true,"output":"live-%s"}\n' "$id" "$id" + fi + done + """, "serve-fixture", pidsFile, requestsFile, lateRepliesFile] + child.qualityOfService = qualityOfService + return child + } + + for attempt in 0..<3 { + let request = Task { + try await connection.request(args: ["status", "--attempt", String(attempt)]) + } + for _ in 0..<200 { + let reads = (try? String(contentsOfFile: requestsFile, encoding: .utf8).count) ?? 0 + if reads >= attempt + 1 { break } + try await Task.sleep(nanoseconds: 10_000_000) + } + request.cancel() + do { + _ = try await request.value + #expect(Bool(false), "cancelled request unexpectedly succeeded") + } catch { + #expect(error is CancellationError) + } + + // The fake child deliberately emits the now-orphaned response after + // cancellation. It must be ignored without double-resuming anything, + // and the same resident child must remain available for the next id. + for _ in 0..<200 { + let replies = (try? String(contentsOfFile: lateRepliesFile, encoding: .utf8).count) ?? 0 + if replies >= attempt + 1 { break } + try await Task.sleep(nanoseconds: 10_000_000) + } + let replies = (try? String(contentsOfFile: lateRepliesFile, encoding: .utf8).count) ?? 0 + #expect(replies == attempt + 1) + } + + let finalPayload = try await connection.request(args: ["status", "--attempt", "final"]) + #expect(String(decoding: finalPayload, as: UTF8.self) == "live-4") + let pids = try String(contentsOfFile: pidsFile, encoding: .utf8) + .split(separator: "\n") + #expect(pids.count == 1) + #expect(try String(contentsOfFile: requestsFile, encoding: .utf8) == "rrrr") + #expect(try String(contentsOfFile: lateRepliesFile, encoding: .utf8) == "lll") + await connection.shutdown() + } + + @Test("late stdout from a replaced child cannot corrupt or warm its replacement") + func staleGenerationStdoutIsDiscarded() async throws { + let oldChild = Process() + oldChild.executableURL = URL(fileURLWithPath: "/bin/sh") + oldChild.arguments = ["-c", "IFS= read -r line; sleep 0.1; exit 1"] + + let newChild = Process() + newChild.executableURL = URL(fileURLWithPath: "/bin/sh") + newChild.arguments = ["-c", """ + while IFS= read -r line; do + id=$(printf '%s' "$line" | sed -E 's/.*"id":([0-9]+).*/\\1/') + printf '{"id":%s,"ok":true,"output":"new-%s"}\n' "$id" "$id" + done + """] + + var children = [oldChild, newChild] + let recorder = TimeoutRecorder() + let connection = ServeConnection( + makeProcess: { _, qualityOfService in + let child = children.removeFirst() + child.qualityOfService = qualityOfService + return child + }, + timeoutSleep: { nanoseconds in + try await recorder.recordAndSleep(nanoseconds) + } + ) + + do { + _ = try await connection.request(args: ["status", "--generation", "old"]) + #expect(Bool(false), "old child unexpectedly answered") + } catch { + #expect(error is ServeConnection.ServeRequestFailed) + } + + await connection.ensureStarted() + + // Model both harmful trailing shapes after the replacement owns the + // connection: a complete terminal would incorrectly select the warm + // timeout, while a fragment would corrupt the replacement's first line. + await connection.consume( + Data("{\"id\":1,\"ok\":true,\"output\":\"late-old\"}\n".utf8), + from: oldChild + ) + await connection.consume(Data("{\"id\":1".utf8), from: oldChild) + + let payload = try await connection.request(args: ["status", "--generation", "new"]) + + #expect(String(decoding: payload, as: UTF8.self) == "new-2") + #expect(await recorder.snapshot() == [coldTimeoutNanoseconds, coldTimeoutNanoseconds]) + #expect(children.isEmpty) + await connection.shutdown() + } + + @Test("all concurrent cold requests get ten minutes, then warm requests get one minute") + func coldAndWarmTimeoutSelection() async throws { + let dir = NSTemporaryDirectory() + "serve-connection-timeout-test-" + UUID().uuidString + try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(atPath: dir) } + let releaseMarker = dir + "/release-cold-responses" + let recorder = TimeoutRecorder() + + let connection = ServeConnection( + makeProcess: { _, qualityOfService in + let child = Process() + child.executableURL = URL(fileURLWithPath: "/bin/sh") + child.arguments = ["-c", """ + IFS= read -r first + IFS= read -r second + while [ ! -f "$1" ]; do sleep 0.01; done + for line in "$first" "$second"; do + id=$(printf '%s' "$line" | sed -E 's/.*"id":([0-9]+).*/\\1/') + printf '{"id":%s,"ok":true,"output":"served"}\\n' "$id" + done + IFS= read -r third + sleep 2 + """, "serve-fixture", releaseMarker] + child.qualityOfService = qualityOfService + return child + }, + timeoutSleep: { nanoseconds in + try await recorder.recordAndSleep(nanoseconds) + } + ) + + let first = Task { try await connection.request(args: ["status", "--request", "one"]) } + let second = Task { try await connection.request(args: ["status", "--request", "two"]) } + for _ in 0..<200 { + if await recorder.snapshot().count >= 2 { break } + try await Task.sleep(nanoseconds: 10_000_000) + } + let coldSelections = await recorder.snapshot() + #expect(coldSelections.count == 2) + #expect(coldSelections.allSatisfy { $0 == coldTimeoutNanoseconds }) + + _ = FileManager.default.createFile(atPath: releaseMarker, contents: Data()) + let firstPayload = try await first.value + let secondPayload = try await second.value + #expect(String(decoding: firstPayload, as: UTF8.self) == "served") + #expect(String(decoding: secondPayload, as: UTF8.self) == "served") + + do { + _ = try await connection.request(args: ["status", "--request", "three"]) + #expect(Bool(false), "warm request unexpectedly escaped its timeout") + } catch { + #expect(error is ServeConnection.ServeRequestFailed) + } + let allSelections = await recorder.snapshot() + #expect(allSelections == [ + coldTimeoutNanoseconds, + coldTimeoutNanoseconds, + warmTimeoutNanoseconds, + ]) + await connection.shutdown() + } + + @Test("a failed terminal response does not mark the resident child warm") + func failedTerminalResponseKeepsColdTimeout() async throws { + let recorder = TimeoutRecorder() + let connection = ServeConnection( + makeProcess: { _, qualityOfService in + let child = Process() + child.executableURL = URL(fileURLWithPath: "/bin/sh") + child.arguments = ["-c", """ + count=0 + while IFS= read -r line; do + count=$((count + 1)) + id=$(printf '%s' "$line" | sed -E 's/.*"id":([0-9]+).*/\\1/') + if [ "$count" -eq 1 ]; then + printf '{"id":%s,"ok":false,"error":"cold failure"}\\n' "$id" + else + printf '{"id":%s,"ok":true,"output":"served-%s"}\\n' "$id" "$count" + fi + done + """] + child.qualityOfService = qualityOfService + return child + }, + timeoutSleep: { nanoseconds in + try await recorder.recordAndWait(nanoseconds) + } + ) + + do { + _ = try await connection.request(args: ["status", "--request", "failed"]) + #expect(Bool(false), "failed response unexpectedly succeeded") + } catch { + #expect(error is ServeConnection.ServeRequestFailed) + } + + let second = try await connection.request(args: ["status", "--request", "cold-success"]) + let third = try await connection.request(args: ["status", "--request", "warm-success"]) + + #expect(String(decoding: second, as: UTF8.self) == "served-2") + #expect(String(decoding: third, as: UTF8.self) == "served-3") + #expect(await recorder.snapshot() == [ + coldTimeoutNanoseconds, + coldTimeoutNanoseconds, + warmTimeoutNanoseconds, + ]) + await connection.shutdown() + } + + @Test("the first real request is the only cold-start query") + func firstRequestIsTheWarmup() async throws { + let dir = NSTemporaryDirectory() + "serve-connection-test-" + UUID().uuidString + try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(atPath: dir) } + let requestLog = dir + "/requests.log" + + let connection = ServeConnection { _, qualityOfService in + let child = Process() + child.executableURL = URL(fileURLWithPath: "/bin/sh") + child.arguments = ["-c", """ + while IFS= read -r line; do + printf 'request\\n' >> "$1" + id=$(printf '%s' "$line" | sed -E 's/.*\"id\":([0-9]+).*/\\1/') + printf '{\"id\":%s,\"progress\":\"scanning\"}\\n' "$id" + printf '{\"id\":%s,\"ok\":true,\"output\":\"served\"}\\n' "$id" + # Emit READY after the terminal response. The client must + # register and complete the first real request without it. + printf '{\"ready\":true,\"pid\":1}\\n' + done + """, "serve-fixture", requestLog] + child.qualityOfService = qualityOfService + return child + } + + await connection.ensureStarted() + let payload = try await connection.request(args: ["status", "--format", "menubar-json"]) + + #expect(String(decoding: payload, as: UTF8.self) == "served") + let requests = try String(contentsOfFile: requestLog, encoding: .utf8) + .split(separator: "\n") + #expect(requests.count == 1) + await connection.shutdown() + } + + @Test("a child that closes stdin fails the request without terminating the app") + func closedChildStdinDoesNotRaiseSIGPIPE() async throws { + let dir = NSTemporaryDirectory() + "serve-connection-sigpipe-test-" + UUID().uuidString + try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(atPath: dir) } + let closedMarker = dir + "/stdin-closed" + let sigpipeHandlerBefore = currentSIGPIPEHandlerBits() + + let connection = ServeConnection { _, qualityOfService in + let child = Process() + child.executableURL = URL(fileURLWithPath: "/bin/sh") + child.arguments = ["-c", "exec 0<&-; : > \"$1\"; sleep 2", "serve-fixture", closedMarker] + child.qualityOfService = qualityOfService + return child + } + + await connection.ensureStarted() + #expect(currentSIGPIPEHandlerBits() == sigpipeHandlerBefore) + #expect(currentSIGPIPEHandlerBits() != ignoredSIGPIPEHandlerBits) + for _ in 0..<200 where !FileManager.default.fileExists(atPath: closedMarker) { + try await Task.sleep(nanoseconds: 10_000_000) + } + #expect(FileManager.default.fileExists(atPath: closedMarker)) + + var requestFailed = false + do { + _ = try await connection.request(args: ["status", "--format", "menubar-json"]) + } catch { + requestFailed = true + } + #expect(requestFailed) + await connection.shutdown() + } +} diff --git a/src/antigravity-statusline.ts b/src/antigravity-statusline.ts index 15f49093..27b24067 100644 --- a/src/antigravity-statusline.ts +++ b/src/antigravity-statusline.ts @@ -3,6 +3,7 @@ import { randomBytes } from 'crypto' import { dirname, join } from 'path' import { homedir } from 'os' +import { getCodeburnCacheDir } from './cache-dir.js' import { recordAntigravityStatusLinePayload, snapshotAntigravityStatusLinePayload, @@ -54,12 +55,8 @@ function settingsPath(): string { ?? join(homedir(), '.gemini', 'antigravity-cli', 'settings.json') } -function codeburnCacheDir(): string { - return process.env['CODEBURN_CACHE_DIR'] ?? join(homedir(), '.cache', 'codeburn') -} - function previousStatusLinePath(): string { - return join(codeburnCacheDir(), 'antigravity-statusline-previous.json') + return join(getCodeburnCacheDir(), 'antigravity-statusline-previous.json') } async function readSettings(): Promise { diff --git a/src/cache-dir.ts b/src/cache-dir.ts new file mode 100644 index 00000000..a202be05 --- /dev/null +++ b/src/cache-dir.ts @@ -0,0 +1,13 @@ +import { homedir } from 'os' +import { join } from 'path' + +/** + * Resolve CodeBurn's shared cache directory at call time. + * + * Reading the environment on every call matters for embedded consumers and + * tests that change CODEBURN_CACHE_DIR after importing the CLI modules. + */ +export function getCodeburnCacheDir(): string { + const override = process.env['CODEBURN_CACHE_DIR'] + return override?.trim() ? override : join(homedir(), '.cache', 'codeburn') +} diff --git a/src/cache-refresh-lock.ts b/src/cache-refresh-lock.ts index 58faf281..f467886f 100644 --- a/src/cache-refresh-lock.ts +++ b/src/cache-refresh-lock.ts @@ -1,9 +1,10 @@ import { createHash, randomBytes } from 'crypto' import { existsSync } from 'fs' import { mkdir, open, readFile, stat, unlink, utimes, writeFile } from 'fs/promises' -import { homedir } from 'os' import { join } from 'path' +import { getCodeburnCacheDir } from './cache-dir.js' + const LOCK_FILE = 'session-refresh.lock' const TAKEOVER_FILE = `${LOCK_FILE}.takeover` const DEFAULT_HEARTBEAT_MS = 10_000 @@ -46,10 +47,6 @@ const defaultClock: RefreshLockClock = { wallNow: () => Date.now(), } -function defaultCacheDir(): string { - return process.env['CODEBURN_CACHE_DIR'] ?? join(homedir(), '.cache', 'codeburn') -} - function delay(ms: number): Promise { return new Promise(resolve => { setTimeout(resolve, ms) }) } @@ -197,7 +194,7 @@ export async function acquireCacheRefreshLock(options: RefreshLockOptions = {}): leaveSingleFlight() } - const cacheDir = options.cacheDir ?? defaultCacheDir() + const cacheDir = options.cacheDir ?? getCodeburnCacheDir() const clock = options.clock ?? defaultClock const heartbeatMs = options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS const staleMs = options.staleMs ?? DEFAULT_STALE_MS diff --git a/src/codex-cache.ts b/src/codex-cache.ts index 6146e8e9..7676dc7b 100644 --- a/src/codex-cache.ts +++ b/src/codex-cache.ts @@ -2,8 +2,8 @@ import { readFile, mkdir, stat, open, rename, unlink } from 'fs/promises' import { existsSync } from 'fs' import { randomBytes } from 'crypto' import { join } from 'path' -import { homedir } from 'os' +import { getCodeburnCacheDir } from './cache-dir.js' import type { ParsedProviderCall } from './providers/types.js' // v4: attribute MCP calls emitted as event_msg/mcp_tool_call_end (issue #478). @@ -31,12 +31,8 @@ type ResultCache = { files: Record } -function getCacheDir(): string { - return process.env['CODEBURN_CACHE_DIR'] ?? join(homedir(), '.cache', 'codeburn') -} - function getCachePath(): string { - return join(getCacheDir(), CACHE_FILE) + return join(getCodeburnCacheDir(), CACHE_FILE) } let memCache: ResultCache | null = null @@ -129,7 +125,7 @@ export async function flushCodexCache(): Promise { } } - const dir = getCacheDir() + const dir = getCodeburnCacheDir() if (!existsSync(dir)) await mkdir(dir, { recursive: true }) const finalPath = getCachePath() const tempPath = `${finalPath}.${randomBytes(8).toString('hex')}.tmp` diff --git a/src/currency.ts b/src/currency.ts index 228c7c5a..1e9fae1d 100644 --- a/src/currency.ts +++ b/src/currency.ts @@ -1,7 +1,7 @@ import { readFile, writeFile, mkdir } from 'fs/promises' import { join } from 'path' -import { homedir } from 'os' +import { getCodeburnCacheDir } from './cache-dir.js' import { readConfig } from './config.js' import { fetchWithTimeout } from './fetch-utils.js' @@ -72,15 +72,8 @@ export function roundForActiveCurrency(value: number): number { return Math.round(value * factor) / factor } -function getCacheDir(): string { - // Honor the same relocation override every other cache module uses - // (session-cache, daily-cache, codex-cache, models); this was the one - // straggler still hardcoding the default path. - return process.env['CODEBURN_CACHE_DIR'] ?? join(homedir(), '.cache', 'codeburn') -} - function getRateCachePath(): string { - return join(getCacheDir(), 'exchange-rate.json') + return join(getCodeburnCacheDir(), 'exchange-rate.json') } async function fetchRate(code: string): Promise { @@ -111,7 +104,7 @@ async function loadCachedRate(code: string): Promise { } async function cacheRate(code: string, rate: number): Promise { - await mkdir(getCacheDir(), { recursive: true }) + await mkdir(getCodeburnCacheDir(), { recursive: true }) await writeFile(getRateCachePath(), JSON.stringify({ timestamp: Date.now(), code, rate })) } @@ -138,7 +131,13 @@ async function getExchangeRate(code: string): Promise { export async function loadCurrency(): Promise { const config = await readConfig() - if (!config.currency) return + if (!config.currency) { + // A long-lived `serve` process may previously have loaded a non-USD + // currency. Removing the config entry is the USD reset contract, so reset + // the module state as well as letting the output memo invalidate. + active = USD + return + } const code = config.currency.code.toUpperCase() const rate = await getExchangeRate(code) diff --git a/src/cursor-cache.ts b/src/cursor-cache.ts index 28a7820f..d48ca33c 100644 --- a/src/cursor-cache.ts +++ b/src/cursor-cache.ts @@ -1,8 +1,8 @@ import { readFile, writeFile, mkdir, rename, stat, unlink } from 'fs/promises' import { join } from 'path' -import { homedir } from 'os' import { randomBytes } from 'crypto' +import { getCodeburnCacheDir } from './cache-dir.js' import type { ParsedProviderCall } from './providers/types.js' // Bumped to 3 for the workspace-aware breakdown change: the cursor parser @@ -31,12 +31,8 @@ type ResultCache = { const CACHE_FILE = 'cursor-results.json' -function getCacheDir(): string { - return join(homedir(), '.cache', 'codeburn') -} - function getCachePath(): string { - return join(getCacheDir(), CACHE_FILE) + return join(getCodeburnCacheDir(), CACHE_FILE) } async function getDbFingerprint(dbPath: string): Promise<{ mtimeMs: number; size: number } | null> { @@ -86,7 +82,7 @@ export async function writeCachedResults( const fp = await getDbFingerprint(dbPath) if (!fp) return - const dir = getCacheDir() + const dir = getCodeburnCacheDir() await mkdir(dir, { recursive: true }).catch(() => {}) const cache: ResultCache = { version: CURSOR_CACHE_VERSION, diff --git a/src/daily-cache.ts b/src/daily-cache.ts index 7abb445c..76e787f0 100644 --- a/src/daily-cache.ts +++ b/src/daily-cache.ts @@ -1,8 +1,9 @@ import { randomBytes } from 'crypto' import { existsSync } from 'fs' import { mkdir, open, readdir, readFile, rename, stat, unlink } from 'fs/promises' -import { homedir } from 'os' import { join } from 'path' + +import { getCodeburnCacheDir } from './cache-dir.js' import type { DateRange, ProjectSummary } from './types.js' // Bumped to 17: copilot CLI sessions were misclassified as VS Code transcripts @@ -176,10 +177,6 @@ export type DailyCache = { watermarkTrusted?: boolean } -function getCacheDir(): string { - return process.env['CODEBURN_CACHE_DIR'] ?? join(homedir(), '.cache', 'codeburn') -} - /** IANA name of the current local timezone (respects the TZ env var). Days are * bucketed by local midnight, so this tags the cache for TZ-change invalidation. */ export function currentTzKey(): string { @@ -187,7 +184,7 @@ export function currentTzKey(): string { } function getCachePath(): string { - return join(getCacheDir(), DAILY_CACHE_FILENAME) + return join(getCodeburnCacheDir(), DAILY_CACHE_FILENAME) } /** Absolute path of the active (version-suffixed) daily cache file. */ @@ -379,7 +376,7 @@ function isAdoptableCache(parsed: unknown): parsed is AdoptableCache { /// bump lossless: the new version starts from the union of everything every /// previous version ever recorded, then re-derives what sources still support. async function adoptOlderDailyCaches(): Promise { - const dir = getCacheDir() + const dir = getCodeburnCacheDir() let names: string[] = [] try { names = await readdir(dir) @@ -449,7 +446,7 @@ async function adoptOlderDailyCaches(): Promise { } export async function saveDailyCache(cache: DailyCache): Promise { - const dir = getCacheDir() + const dir = getCodeburnCacheDir() if (!existsSync(dir)) await mkdir(dir, { recursive: true }) const finalPath = getCachePath() const tempPath = `${finalPath}.${randomBytes(8).toString('hex')}.tmp` diff --git a/src/models.ts b/src/models.ts index f1d9ad0b..9e061193 100644 --- a/src/models.ts +++ b/src/models.ts @@ -1,6 +1,7 @@ import { readFile, writeFile, mkdir } from 'fs/promises' import { join } from 'path' -import { homedir } from 'os' + +import { getCodeburnCacheDir } from './cache-dir.js' import snapshotData from './data/litellm-snapshot.json' import fallbackData from './data/pricing-fallback.json' import { fetchWithTimeout } from './fetch-utils.js' @@ -143,13 +144,8 @@ function getLowercasePricingIndex(): Map { return lowercasePricingIndex } -function getCacheDir(): string { - if (process.env['CODEBURN_CACHE_DIR']) return process.env['CODEBURN_CACHE_DIR'] - return join(homedir(), '.cache', 'codeburn') -} - function getCachePath(): string { - return join(getCacheDir(), 'litellm-pricing.json') + return join(getCodeburnCacheDir(), 'litellm-pricing.json') } /// Clamp a per-token rate to a sane non-negative value. Defense in depth @@ -202,7 +198,7 @@ async function fetchAndCachePricing(): Promise> { if (stripped !== name && !pricing.has(stripped)) pricing.set(stripped, costs) } - await mkdir(getCacheDir(), { recursive: true }) + await mkdir(getCodeburnCacheDir(), { recursive: true }) await writeFile(getCachePath(), JSON.stringify({ timestamp: Date.now(), data: Object.fromEntries(pricing), diff --git a/src/providers/antigravity.ts b/src/providers/antigravity.ts index 31449567..49eb295d 100644 --- a/src/providers/antigravity.ts +++ b/src/providers/antigravity.ts @@ -6,6 +6,7 @@ import { homedir } from 'os' import { fileURLToPath } from 'url' import https from 'https' +import { getCodeburnCacheDir } from '../cache-dir.js' import { calculateCost } from '../models.js' import { isSqliteAvailable, isSqliteBusyError, openDatabase } from '../sqlite.js' import type { ProbeRoot, Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js' @@ -175,16 +176,12 @@ function getAgent(): https.Agent { return httpsAgent } -function getCacheDir(): string { - return process.env['CODEBURN_CACHE_DIR'] ?? join(homedir(), '.cache', 'codeburn') -} - function getCachePath(): string { - return join(getCacheDir(), 'antigravity-results.json') + return join(getCodeburnCacheDir(), 'antigravity-results.json') } export function getAntigravityStatusLineEventsPath(): string { - return join(getCacheDir(), 'antigravity-statusline.jsonl') + return join(getCodeburnCacheDir(), 'antigravity-statusline.jsonl') } function execFileText(command: string, args: string[], timeout = 3000): Promise { @@ -355,7 +352,7 @@ async function flushCache(liveCascadeIds?: Set): Promise { if (!cacheDirty) return try { - const dir = getCacheDir() + const dir = getCodeburnCacheDir() await mkdir(dir, { recursive: true }) const finalPath = getCachePath() const tempPath = `${finalPath}.${randomBytes(8).toString('hex')}.tmp` @@ -1009,7 +1006,7 @@ export async function recordAntigravityStatusLinePayload(input: unknown): Promis if (!event) return false const path = getAntigravityStatusLineEventsPath() - await mkdir(getCacheDir(), { recursive: true, mode: 0o700 }) + await mkdir(getCodeburnCacheDir(), { recursive: true, mode: 0o700 }) const fd = await open(path, 'a', 0o600) try { await fd.appendFile(`${JSON.stringify(event)}\n`, { encoding: 'utf-8' }) diff --git a/src/serve.ts b/src/serve.ts index dc80e391..5444464b 100644 --- a/src/serve.ts +++ b/src/serve.ts @@ -1,8 +1,10 @@ import { watch, type FSWatcher } from 'fs' -import { stat } from 'fs/promises' +import { readFile, stat } from 'fs/promises' +import { createHash } from 'crypto' import { createInterface } from 'readline' import type { Command } from 'commander' +import { getConfigFilePath } from './config.js' // --------------------------------------------------------------------------- // codeburn serve --stdio: a resident query server for the desktop app. @@ -60,20 +62,44 @@ 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 }> { +function chunkToString(chunk: unknown, encoding: unknown): string { + if (typeof chunk === 'string') return chunk + if (chunk instanceof Uint8Array) { + return Buffer.from(chunk).toString(typeof encoding === 'string' ? encoding as BufferEncoding : 'utf8') + } + return String(chunk) +} + +function finishWrite(rest: unknown[]): void { + const callback = rest[rest.length - 1] + if (typeof callback === 'function') (callback as () => void)() +} + +/// Run one argv through a fresh program, capturing command stdout for the +/// final response and forwarding command stderr as progress. 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[], + onProgress: (progress: string) => void, +): Promise<{ output: string; code: number }> { const chunks: string[] = [] const originalWrite = process.stdout.write.bind(process.stdout) + const originalErrorWrite = process.stderr.write.bind(process.stderr) 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)() + chunks.push(chunkToString(chunk, rest[0])) + finishWrite(rest) return true }) as typeof process.stdout.write + process.stderr.write = ((chunk: unknown, ...rest: unknown[]) => { + const progress = chunkToString(chunk, rest[0]) + if (progress) onProgress(progress) + finishWrite(rest) + return true + }) as typeof process.stderr.write process.exit = ((code?: number) => { throw new ExitSignal(code ?? 0) }) as typeof process.exit try { @@ -86,10 +112,28 @@ async function runCaptured(buildProgram: () => Command, args: string[]): Promise throw err } finally { process.stdout.write = originalWrite + process.stderr.write = originalErrorWrite process.exit = originalExit } } +/// A cheap per-request fingerprint for the configuration that affects query +/// rendering and aggregation. Hashing the small config file tracks effective +/// content rather than filesystem churn: a byte-identical rewrite keeps the +/// memo hot, while any real change invalidates immediately. A missing config +/// is a stable state; every other read failure fails closed (no memo reuse). +async function getConfigFingerprint(): Promise { + const path = getConfigFilePath() + try { + const content = await readFile(path) + const digest = createHash('sha256').update(content).digest('hex') + return `${path}\u0000sha256:${digest}` + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return `${path}\u0000missing` + return null + } +} + /// Watch every provider's probe roots (the same paths codeburn doctor reports /// as "where discovery looks") so the parse-reuse validator can answer "did /// any session data change since T?" without a stat sweep. macOS fs.watch @@ -149,14 +193,20 @@ export async function runStdioServe(buildProgram: () => Command): Promise // Output-level memo: an identical panel query while the roots are quiet // returns the previous stdout verbatim - the aggregation work is skipped - // too, not just the parse. Invalidation is the same event-or-cap rule the - // parse reuse uses. + // too, not just the parse. Session data uses the same event-or-cap rule as + // parse reuse; config.json is fingerprinted on every request because it can + // change rendering without touching a provider root. const OUTPUT_MEMO_CAP_MS = 5 * 60 * 1000 - const outputMemo = new Map() + const outputMemo = new Map() + let observedConfigFingerprint: string | null | undefined if (process.stdin.isTTY) { process.stderr.write('codeburn serve speaks JSON over stdio and exists for the desktop app to hold warm.\nNothing interactive happens here; press Ctrl+C to exit.\n') } - const write = (value: unknown): void => { process.stdout.write(JSON.stringify(value) + '\n') } + // Keep the protocol transport anchored to the real stdout. runCaptured() + // temporarily replaces process.stdout.write to collect command output; a + // dynamic lookup here would swallow progress frames into the final payload. + const protocolWrite = process.stdout.write.bind(process.stdout) + const write = (value: unknown): void => { protocolWrite(JSON.stringify(value) + '\n') } write({ ready: true, pid: process.pid }) // Strict serialization: each request chains on the previous one. @@ -183,16 +233,36 @@ export async function runStdioServe(buildProgram: () => Command): Promise write({ id: request.id, ok: false, refused: true, error: 'command not served' }) return } + const configFingerprint = await getConfigFingerprint() + if (observedConfigFingerprint !== undefined && configFingerprint !== observedConfigFingerprint) { + outputMemo.clear() + } + observedConfigFingerprint = configFingerprint + // A permission or transient read failure must shorten reuse, never make + // an old result look current. + if (configFingerprint === null) outputMemo.clear() + const memoKey = request.args.join('\u0000') const memoHit = outputMemo.get(memoKey) - if (memoHit && Date.now() - memoHit.at < OUTPUT_MEMO_CAP_MS && rootsQuietSince?.(memoHit.at)) { + if ( + configFingerprint !== null + && memoHit?.configFingerprint === configFingerprint + && Date.now() - memoHit.at < OUTPUT_MEMO_CAP_MS + && rootsQuietSince?.(memoHit.at) + ) { write({ id: request.id, ok: true, output: memoHit.output }) return } try { - const { output, code } = await runCaptured(buildProgram, request.args) + const { output, code } = await runCaptured( + buildProgram, + request.args, + progress => write({ id: request.id, progress }), + ) if (code === 0) { - outputMemo.set(memoKey, { at: Date.now(), output }) + if (configFingerprint !== null) { + outputMemo.set(memoKey, { at: Date.now(), output, configFingerprint }) + } if (outputMemo.size > 32) { const oldest = [...outputMemo.entries()].sort((a, b) => a[1].at - b[1].at)[0] if (oldest) outputMemo.delete(oldest[0]) diff --git a/src/session-cache.ts b/src/session-cache.ts index 2759520f..410d7143 100644 --- a/src/session-cache.ts +++ b/src/session-cache.ts @@ -2,8 +2,8 @@ import { readFile, stat, open, rename, unlink, readdir, mkdir } from 'fs/promise import { existsSync, readFileSync, unlinkSync } from 'fs' import { createHash, randomBytes } from 'crypto' import { join } from 'path' -import { homedir } from 'os' +import { getCodeburnCacheDir } from './cache-dir.js' import type { ToolCall } from './types.js' // ── Types ────────────────────────────────────────────────────────────── @@ -279,18 +279,12 @@ export const PROVIDER_PARSE_VERSIONS: Record = { antigravity: 'worktree-project-grouping-v5', } -// ── Cache Dir ────────────────────────────────────────────────────────── - -function getCacheDir(): string { - return process.env['CODEBURN_CACHE_DIR'] ?? join(homedir(), '.cache', 'codeburn') -} - function getCachePath(): string { - return join(getCacheDir(), CACHE_FILE) + return join(getCodeburnCacheDir(), CACHE_FILE) } function getLegacyCachePath(): string { - return join(getCacheDir(), LEGACY_CACHE_FILE) + return join(getCodeburnCacheDir(), LEGACY_CACHE_FILE) } /** Absolute path of the active (version-suffixed) session cache file. */ @@ -490,7 +484,7 @@ function isCacheEnvelope(raw: unknown, version: number): raw is { version: numbe // sources. The daily cache (durable cost history) is not touched. async function adoptPriorCache(version: number): Promise { try { - const raw = await readFile(join(getCacheDir(), priorCacheFile(version)), 'utf-8') + const raw = await readFile(join(getCodeburnCacheDir(), priorCacheFile(version)), 'utf-8') const parsed = JSON.parse(raw) if (!isCacheEnvelope(parsed, version)) return null const migrated: SessionCache = { version: CACHE_VERSION, providers: {}, complete: false } @@ -596,7 +590,7 @@ async function adoptLegacyCache(): Promise { } export async function saveCache(cache: SessionCache, verifyStillOwner?: () => Promise): Promise { - const dir = getCacheDir() + const dir = getCodeburnCacheDir() if (!existsSync(dir)) await mkdir(dir, { recursive: true }) const finalPath = getCachePath() @@ -800,7 +794,7 @@ export function mergeCallByDedupKey( // ── Temp Cleanup ─────────────────────────────────────────────────────── export async function cleanupOrphanedTempFiles(): Promise { - const dir = getCacheDir() + const dir = getCodeburnCacheDir() if (!existsSync(dir)) return try { @@ -844,7 +838,7 @@ export type HydrationHandle = { waited: boolean; release: () => Promise } const NOOP_HANDLE: HydrationHandle = { waited: false, release: async () => {} } function lockPath(): string { - return join(getCacheDir(), HYDRATION_LOCK_FILE) + return join(getCodeburnCacheDir(), HYDRATION_LOCK_FILE) } // Our own pid never counts as a foreign holder: a same-process lock is either @@ -867,7 +861,7 @@ async function readLockRecord(): Promise { async function writeOurLock(): Promise { try { - const dir = getCacheDir() + const dir = getCodeburnCacheDir() if (!existsSync(dir)) await mkdir(dir, { recursive: true }) const handle = await open(lockPath(), 'wx', 0o600) try { await handle.writeFile(JSON.stringify({ pid: process.pid, at: Date.now() }), { encoding: 'utf-8' }) } diff --git a/src/sync/ledger.ts b/src/sync/ledger.ts index 99ef8560..b33f3588 100644 --- a/src/sync/ledger.ts +++ b/src/sync/ledger.ts @@ -7,7 +7,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, renameSync } from 'fs' import { join } from 'path' -import { homedir } from 'os' +import { getCodeburnCacheDir } from '../cache-dir.js' export interface LedgerEntry { key: string // deduplicationKey @@ -16,15 +16,21 @@ export interface LedgerEntry { const SIX_MONTHS_MS = 180 * 24 * 60 * 60 * 1000 -function cacheDir(): string { - // Honor XDG_CACHE_HOME — the ledger is reconstructible state, not config +function ledgerCacheDir(): string { + const explicit = process.env.CODEBURN_CACHE_DIR + if (explicit?.trim()) return explicit + + // The sync ledger historically honored XDG_CACHE_HOME. Preserve that path + // so upgrades do not forget 180 days of sent keys and re-upload old calls; + // the ordinary CLI/desktop cache still shares the resolver below. const xdg = process.env.XDG_CACHE_HOME - const base = xdg && xdg.trim() ? xdg : join(homedir(), '.cache') - return join(base, 'codeburn') + if (xdg?.trim()) return join(xdg, 'codeburn') + + return getCodeburnCacheDir() } function ledgerPath(): string { - return join(cacheDir(), 'sync-ledger.json') + return join(ledgerCacheDir(), 'sync-ledger.json') } export function readLedger(): LedgerEntry[] { @@ -43,7 +49,7 @@ export function readLedger(): LedgerEntry[] { } export function writeLedger(entries: LedgerEntry[]): void { - const dir = cacheDir() + const dir = ledgerCacheDir() mkdirSync(dir, { recursive: true }) // Atomic write: a crash mid-write must not corrupt the ledger — a corrupt // ledger reads as empty and the next push re-sends the whole window. diff --git a/tests/cache-dir.test.ts b/tests/cache-dir.test.ts new file mode 100644 index 00000000..eaef6c74 --- /dev/null +++ b/tests/cache-dir.test.ts @@ -0,0 +1,25 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { join } from 'path' +import { homedir } from 'os' +import { getCodeburnCacheDir } from '../src/cache-dir.js' + +describe('getCodeburnCacheDir', () => { + const original = process.env['CODEBURN_CACHE_DIR'] + + afterEach(() => { + if (original === undefined) delete process.env['CODEBURN_CACHE_DIR'] + else process.env['CODEBURN_CACHE_DIR'] = original + }) + + it('resolves an explicit override at call time', () => { + process.env['CODEBURN_CACHE_DIR'] = '/tmp/codeburn-one' + expect(getCodeburnCacheDir()).toBe('/tmp/codeburn-one') + process.env['CODEBURN_CACHE_DIR'] = '/tmp/codeburn-two' + expect(getCodeburnCacheDir()).toBe('/tmp/codeburn-two') + }) + + it.each(['', ' ', '\n\t'])('treats a blank override as absent (%j)', value => { + process.env['CODEBURN_CACHE_DIR'] = value + expect(getCodeburnCacheDir()).toBe(join(homedir(), '.cache', 'codeburn')) + }) +}) diff --git a/tests/providers/cursor.test.ts b/tests/providers/cursor.test.ts index 61151a6f..34c28d88 100644 --- a/tests/providers/cursor.test.ts +++ b/tests/providers/cursor.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest' -import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises' +import { mkdtemp, mkdir, readFile, writeFile, rm } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' import { createRequire } from 'node:module' @@ -87,6 +87,37 @@ describe('cursor cache', () => { const result = await readCachedResults('/nonexistent/path.db', new Date(0).toISOString()) expect(result).toBeNull() }) + + it('honors CODEBURN_CACHE_DIR at call time', async () => { + const root = await mkdtemp(join(tmpdir(), 'cursor-cache-override-')) + const previousCacheDir = process.env['CODEBURN_CACHE_DIR'] + const dbPath = join(root, 'state.vscdb') + const firstCacheDir = join(root, 'cache-a') + const secondCacheDir = join(root, 'cache-b') + const firstFloor = '2026-01-01T00:00:00.000Z' + const secondFloor = '2026-02-01T00:00:00.000Z' + await writeFile(dbPath, 'cursor-db-fixture') + + try { + const { writeCachedResults } = await import('../../src/cursor-cache.js') + process.env['CODEBURN_CACHE_DIR'] = firstCacheDir + await writeCachedResults(dbPath, [], firstFloor) + + process.env['CODEBURN_CACHE_DIR'] = secondCacheDir + await writeCachedResults(dbPath, [], secondFloor) + + const firstPath = join(firstCacheDir, 'cursor-results.json') + const secondPath = join(secondCacheDir, 'cursor-results.json') + const first = JSON.parse(await readFile(firstPath, 'utf-8')) as { lookbackFloor: string } + const second = JSON.parse(await readFile(secondPath, 'utf-8')) as { lookbackFloor: string } + expect(first.lookbackFloor).toBe(firstFloor) + expect(second.lookbackFloor).toBe(secondFloor) + } finally { + if (previousCacheDir === undefined) delete process.env['CODEBURN_CACHE_DIR'] + else process.env['CODEBURN_CACHE_DIR'] = previousCacheDir + await rm(root, { recursive: true, force: true }) + } + }) }) // Regression: Cursor renamed the per-workspace composer list key from diff --git a/tests/serve-stdio.test.ts b/tests/serve-stdio.test.ts index 2f0af775..427b0950 100644 --- a/tests/serve-stdio.test.ts +++ b/tests/serve-stdio.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest' import { spawn, type ChildProcess } from 'child_process' +import { mkdir, writeFile } from 'fs/promises' import { join } from 'path' // End-to-end protocol test for `codeburn serve --stdio` (the desktop app's @@ -10,6 +11,8 @@ describe('codeburn serve --stdio', () => { let child: ChildProcess let buffer = '' const waiters = new Map) => void>() + const progressFrames = new Map>>() + let configPath = '' let readyResolve: () => void const ready = new Promise(resolve => { readyResolve = resolve }) @@ -25,6 +28,20 @@ describe('codeburn serve --stdio', () => { } beforeAll(async () => { + const home = process.env['HOME']! + configPath = join(home, '.config', 'codeburn', 'config.json') + await mkdir(join(home, '.config', 'codeburn'), { recursive: true }) + await writeFile(configPath, JSON.stringify({ currency: { code: 'USD' } }), 'utf8') + + // Keep the EUR half of the config-freshness regression fully offline. + const cacheDir = join(home, '.cache', 'codeburn') + await mkdir(cacheDir, { recursive: true }) + await writeFile(join(cacheDir, 'exchange-rate.json'), JSON.stringify({ + timestamp: Date.now(), + code: 'EUR', + rate: 0.9, + }), 'utf8') + child = spawn(process.execPath, ['--import', 'tsx', join(__dirname, '..', 'src', 'cli.ts'), 'serve', '--stdio'], { stdio: ['pipe', 'pipe', 'ignore'], env: { ...process.env }, @@ -40,6 +57,13 @@ describe('codeburn serve --stdio', () => { let msg: Record try { msg = JSON.parse(line) } catch { continue } if (msg['ready']) { readyResolve(); continue } + if (typeof msg['progress'] === 'string' && !('ok' in msg)) { + const id = msg['id'] as number + const frames = progressFrames.get(id) ?? [] + frames.push(msg) + progressFrames.set(id, frames) + continue + } const waiter = waiters.get(msg['id'] as number) if (waiter) { waiters.delete(msg['id'] as number); waiter(msg) } } @@ -87,4 +111,65 @@ describe('codeburn serve --stdio', () => { const res = await request(6, ['status', '--format', 'menubar-json', '--period', 'today']) expect(res['ok']).toBe(true) }, 60_000) + + it('streams captured command stderr as protocol progress frames', async () => { + const res = await request(7, ['status', '--definitely-not-a-real-option']) + expect(res['ok']).toBe(false) + + const frames = progressFrames.get(7) ?? [] + expect(frames.length).toBeGreaterThan(0) + expect(frames.every(frame => Object.keys(frame).sort().join(',') === 'id,progress')).toBe(true) + expect(frames.map(frame => frame['progress']).join('')).toContain('unknown option') + }, 60_000) + + it('invalidates identical-argv output memo immediately when config.json changes', async () => { + const args = ['status', '--format', 'menubar-json', '--period', 'week', '--no-optimize', '--no-timeline'] + const usdConfig = JSON.stringify({ currency: { code: 'USD' } }) + await writeFile(configPath, usdConfig, 'utf8') + + let previous = await request(8, args) + expect(previous['ok']).toBe(true) + expect((JSON.parse(previous['output'] as string) as { currency: { code: string } }).currency.code).toBe('USD') + + // Prove this argv is actually hitting the output memo before testing its + // invalidation. The root watchers arm asynchronously at serve startup, so + // allow a few requests until two byte-identical generated payloads arrive. + let memoized: Record | null = null + for (let id = 9; id < 110; id++) { + await new Promise(resolve => setTimeout(resolve, 20)) + const next = await request(id, args) + if (next['output'] === previous['output']) { + memoized = next + break + } + previous = next + } + expect(memoized).not.toBeNull() + + // A byte-identical rewrite changes filesystem metadata but not effective + // configuration. The memo must survive it and return the exact generated + // payload, including the original volatile `generated` timestamp. + await new Promise(resolve => setTimeout(resolve, 20)) + await writeFile(configPath, usdConfig, 'utf8') + const sameBytes = await request(110, args) + expect(sameBytes['ok']).toBe(true) + expect(sameBytes['output']).toBe(memoized!['output']) + + // Same byte length as USD: a size-only fingerprint would miss this. + await writeFile(configPath, JSON.stringify({ currency: { code: 'EUR' } }), 'utf8') + const fresh = await request(111, args) + expect(fresh['ok']).toBe(true) + expect((JSON.parse(fresh['output'] as string) as { currency: { code: string } }).currency.code).toBe('EUR') + expect(fresh['output']).not.toBe(memoized!['output']) + + // Removing the configured currency is the USD reset contract. The serve + // process must reset its module-level currency state as well as invalidate + // the output memo, otherwise a long-lived child keeps rendering EUR. + await writeFile(configPath, '{}', 'utf8') + const reset = await request(112, args) + expect(reset['ok']).toBe(true) + expect((JSON.parse(reset['output'] as string) as { + currency: { code: string; rate: number } + }).currency).toMatchObject({ code: 'USD', rate: 1 }) + }, 60_000) }) diff --git a/tests/sync-ledger-otlp.test.ts b/tests/sync-ledger-otlp.test.ts index 0b32f80d..08de4ac5 100644 --- a/tests/sync-ledger-otlp.test.ts +++ b/tests/sync-ledger-otlp.test.ts @@ -233,17 +233,21 @@ describe('batchCalls', () => { describe('ledger', () => { let tmpDir: string const originalHome = process.env.HOME + const originalCacheDir = process.env.CODEBURN_CACHE_DIR + const originalXdgCacheDir = process.env.XDG_CACHE_HOME beforeEach(async () => { tmpDir = await mkdtemp(join(tmpdir(), 'codeburn-ledger-')) process.env.HOME = tmpDir - // env-isolation.ts redirects XDG_CACHE_HOME to a per-worker sandbox shared - // across tests — the ledger honors XDG, so point it at the per-test dir. - process.env.XDG_CACHE_HOME = join(tmpDir, '.cache') + process.env.CODEBURN_CACHE_DIR = join(tmpDir, '.cache', 'codeburn') }) afterEach(async () => { process.env.HOME = originalHome + if (originalCacheDir === undefined) delete process.env.CODEBURN_CACHE_DIR + else process.env.CODEBURN_CACHE_DIR = originalCacheDir + if (originalXdgCacheDir === undefined) delete process.env.XDG_CACHE_HOME + else process.env.XDG_CACHE_HOME = originalXdgCacheDir await rm(tmpDir, { recursive: true, force: true }) }) @@ -328,21 +332,101 @@ describe('ledger', () => { expect(existsSync(join(dir, 'sync-ledger.json.tmp'))).toBe(false) }) - it('honors XDG_CACHE_HOME when set', async () => { + it('honors CODEBURN_CACHE_DIR at call time', async () => { const { writeLedger, readLedger } = await import('../src/sync/ledger.js') const { existsSync } = await import('fs') const { join } = await import('path') - const xdgDir = join(process.env.HOME!, 'xdg-cache') - const original = process.env.XDG_CACHE_HOME + const firstDir = join(tmpDir, 'cache-a') + const secondDir = join(tmpDir, 'cache-b') + + process.env.CODEBURN_CACHE_DIR = firstDir + writeLedger([{ key: 'first', ts: '2026-07-01T00:00:00Z' }]) + process.env.CODEBURN_CACHE_DIR = secondDir + writeLedger([{ key: 'second', ts: '2026-07-02T00:00:00Z' }]) + + expect(existsSync(join(firstDir, 'sync-ledger.json'))).toBe(true) + expect(existsSync(join(secondDir, 'sync-ledger.json'))).toBe(true) + expect(readLedger().map(e => e.key)).toEqual(['second']) + + process.env.CODEBURN_CACHE_DIR = firstDir + expect(readLedger().map(e => e.key)).toEqual(['first']) + }) + + it('uses the shared default when both overrides are explicitly absent', async () => { + const { writeLedger } = await import('../src/sync/ledger.js') + const { existsSync } = await import('fs') + const { join } = await import('path') + + delete process.env.CODEBURN_CACHE_DIR + delete process.env.XDG_CACHE_HOME + writeLedger([{ key: 'default', ts: '2026-07-01T00:00:00Z' }]) + + expect(existsSync(join(tmpDir, '.cache', 'codeburn', 'sync-ledger.json'))).toBe(true) + }) + + it('uses XDG_CACHE_HOME/codeburn when the explicit override is absent', async () => { + const { writeLedger } = await import('../src/sync/ledger.js') + const { existsSync } = await import('fs') + const { join } = await import('path') + const xdgDir = join(tmpDir, 'xdg-cache') + + delete process.env.CODEBURN_CACHE_DIR process.env.XDG_CACHE_HOME = xdgDir - try { - writeLedger([{ key: 'xdg-entry', ts: '2026-07-01T00:00:00Z' }]) - expect(existsSync(join(xdgDir, 'codeburn', 'sync-ledger.json'))).toBe(true) - expect(readLedger().map(e => e.key)).toEqual(['xdg-entry']) - } finally { - if (original === undefined) delete process.env.XDG_CACHE_HOME - else process.env.XDG_CACHE_HOME = original - } + writeLedger([{ key: 'xdg', ts: '2026-07-01T00:00:00Z' }]) + + expect(existsSync(join(xdgDir, 'codeburn', 'sync-ledger.json'))).toBe(true) + }) + + it('prefers non-empty CODEBURN_CACHE_DIR over XDG_CACHE_HOME', async () => { + const { writeLedger } = await import('../src/sync/ledger.js') + const { existsSync } = await import('fs') + const { join } = await import('path') + const explicitDir = join(tmpDir, 'explicit-cache') + const xdgDir = join(tmpDir, 'xdg-cache') + + process.env.CODEBURN_CACHE_DIR = explicitDir + process.env.XDG_CACHE_HOME = xdgDir + writeLedger([{ key: 'explicit', ts: '2026-07-01T00:00:00Z' }]) + + expect(existsSync(join(explicitDir, 'sync-ledger.json'))).toBe(true) + expect(existsSync(join(xdgDir, 'codeburn', 'sync-ledger.json'))).toBe(false) + }) + + it.each(['', ' '])('ignores empty CODEBURN_CACHE_DIR %j and falls back to XDG_CACHE_HOME', async explicit => { + const { writeLedger } = await import('../src/sync/ledger.js') + const { existsSync } = await import('fs') + const { join } = await import('path') + const xdgDir = join(tmpDir, `xdg-cache-${explicit.length}`) + + process.env.CODEBURN_CACHE_DIR = explicit + process.env.XDG_CACHE_HOME = xdgDir + writeLedger([{ key: 'xdg-fallback', ts: '2026-07-01T00:00:00Z' }]) + + expect(existsSync(join(xdgDir, 'codeburn', 'sync-ledger.json'))).toBe(true) + }) + + it.each(['', ' '])('ignores empty XDG_CACHE_HOME %j and uses the shared default', async xdg => { + const { writeLedger } = await import('../src/sync/ledger.js') + const { existsSync } = await import('fs') + const { join } = await import('path') + + delete process.env.CODEBURN_CACHE_DIR + process.env.XDG_CACHE_HOME = xdg + writeLedger([{ key: 'default-fallback', ts: '2026-07-01T00:00:00Z' }]) + + expect(existsSync(join(tmpDir, '.cache', 'codeburn', 'sync-ledger.json'))).toBe(true) + }) + + it.each(['', ' '])('uses the shared default when both overrides are empty and CODEBURN is %j', async explicit => { + const { writeLedger } = await import('../src/sync/ledger.js') + const { existsSync } = await import('fs') + const { join } = await import('path') + + process.env.CODEBURN_CACHE_DIR = explicit + process.env.XDG_CACHE_HOME = ' ' + writeLedger([{ key: 'default-fallback', ts: '2026-07-01T00:00:00Z' }]) + + expect(existsSync(join(tmpDir, '.cache', 'codeburn', 'sync-ledger.json'))).toBe(true) }) })