mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-20 14:04:25 +00:00
perf(desktop): share cache state and eliminate duplicate cold hydration
This commit is contained in:
parent
c9e6e2ecae
commit
d8d343e83a
29 changed files with 1612 additions and 237 deletions
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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<typeof spawn> | null = null
|
||||
private pending = new Map<number, { resolve: (v: unknown) => void; reject: (e: Error) => void; timer: NodeJS.Timeout }>()
|
||||
private pending = new Map<number, {
|
||||
resolve: (v: unknown) => 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<void> = 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<typeof spawn>, 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<unknown> {
|
||||
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<unknown> {
|
||||
// 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<unknown> {
|
||||
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<unknown>((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<unknown> {
|
||||
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<ActionResult> {
|
||||
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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<T = unknown> = { 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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<Data, Error>] = [:]
|
||||
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<Never, Never>.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<Data, Error>) 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<Data, Error>) {
|
||||
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..<newline)
|
||||
|
|
@ -139,11 +220,21 @@ actor ServeConnection {
|
|||
guard !lineData.isEmpty,
|
||||
let object = try? JSONSerialization.jsonObject(with: lineData) as? [String: Any] else { continue }
|
||||
if object["ready"] as? Bool == true {
|
||||
ready = true
|
||||
continue
|
||||
}
|
||||
guard let id = object["id"] as? Int, let continuation = pending.removeValue(forKey: id) else { continue }
|
||||
if object["ok"] as? Bool == true, let output = object["output"] as? String {
|
||||
guard let id = object["id"] as? Int else { continue }
|
||||
// Desktop asks serve to stream cold-scan stderr as progress frames.
|
||||
// Menubar has no progress UI, but must leave the request pending
|
||||
// until the terminal response arrives if such a frame is emitted.
|
||||
if object["progress"] is String { continue }
|
||||
let succeeded = object["ok"] as? Bool == true
|
||||
// A refused/failed command can finish before any cache hydration.
|
||||
// Only a successful terminal proves the resident is warm. Keep
|
||||
// this before the waiter lookup so a successful orphan response
|
||||
// still records the child as warm without resuming anything.
|
||||
if succeeded { receivedTerminalResponse = true }
|
||||
guard let continuation = pending.removeValue(forKey: id) else { continue }
|
||||
if succeeded, let output = object["output"] as? String {
|
||||
continuation.resume(returning: Data(output.utf8))
|
||||
} else {
|
||||
let message = object["error"] as? String ?? "serve request failed"
|
||||
|
|
@ -152,12 +243,12 @@ actor ServeConnection {
|
|||
}
|
||||
}
|
||||
|
||||
private func childDied() {
|
||||
private func childDied(_ child: Process) {
|
||||
guard process === child else { return }
|
||||
process = nil
|
||||
stdinHandle = nil
|
||||
ready = false
|
||||
warm = false
|
||||
buffer.removeAll()
|
||||
receivedTerminalResponse = false
|
||||
deaths += 1
|
||||
failAllPending()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,13 +14,8 @@ struct SubscriptionSnapshot: Codable, Sendable {
|
|||
private let snapshotFilename = "subscription-snapshots.json"
|
||||
private let pruneOlderThanSeconds: TimeInterval = 30 * 24 * 3600
|
||||
|
||||
private func snapshotsCacheDir() -> 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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
492
mac/Tests/CodeBurnMenubarTests/ServeConnectionTests.swift
Normal file
492
mac/Tests/CodeBurnMenubarTests/ServeConnectionTests.swift
Normal file
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Settings> {
|
||||
|
|
|
|||
13
src/cache-dir.ts
Normal file
13
src/cache-dir.ts
Normal file
|
|
@ -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')
|
||||
}
|
||||
|
|
@ -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<void> {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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<string, FileEntry>
|
||||
}
|
||||
|
||||
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<void> {
|
|||
}
|
||||
}
|
||||
|
||||
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`
|
||||
|
|
|
|||
|
|
@ -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<number> {
|
||||
|
|
@ -111,7 +104,7 @@ async function loadCachedRate(code: string): Promise<number | null> {
|
|||
}
|
||||
|
||||
async function cacheRate(code: string, rate: number): Promise<void> {
|
||||
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<number> {
|
|||
|
||||
export async function loadCurrency(): Promise<void> {
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<DailyCache> {
|
||||
const dir = getCacheDir()
|
||||
const dir = getCodeburnCacheDir()
|
||||
let names: string[] = []
|
||||
try {
|
||||
names = await readdir(dir)
|
||||
|
|
@ -449,7 +446,7 @@ async function adoptOlderDailyCaches(): Promise<DailyCache> {
|
|||
}
|
||||
|
||||
export async function saveDailyCache(cache: DailyCache): Promise<void> {
|
||||
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`
|
||||
|
|
|
|||
|
|
@ -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<string, ModelCosts> {
|
|||
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<Map<string, ModelCosts>> {
|
|||
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),
|
||||
|
|
|
|||
|
|
@ -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<string> {
|
||||
|
|
@ -355,7 +352,7 @@ async function flushCache(liveCascadeIds?: Set<string>): Promise<void> {
|
|||
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' })
|
||||
|
|
|
|||
100
src/serve.ts
100
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<string | null> {
|
||||
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<void>
|
|||
|
||||
// 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<string, { at: number; output: string }>()
|
||||
const outputMemo = new Map<string, { at: number; output: string; configFingerprint: string }>()
|
||||
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<void>
|
|||
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])
|
||||
|
|
|
|||
|
|
@ -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<string, string> = {
|
|||
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<SessionCache | null> {
|
||||
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<SessionCache> {
|
|||
}
|
||||
|
||||
export async function saveCache(cache: SessionCache, verifyStillOwner?: () => Promise<boolean>): Promise<boolean> {
|
||||
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<void> {
|
||||
const dir = getCacheDir()
|
||||
const dir = getCodeburnCacheDir()
|
||||
if (!existsSync(dir)) return
|
||||
|
||||
try {
|
||||
|
|
@ -844,7 +838,7 @@ export type HydrationHandle = { waited: boolean; release: () => Promise<void> }
|
|||
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<LockRecord | null> {
|
|||
|
||||
async function writeOurLock(): Promise<boolean> {
|
||||
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' }) }
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
25
tests/cache-dir.test.ts
Normal file
25
tests/cache-dir.test.ts
Normal file
|
|
@ -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'))
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<number, (msg: Record<string, unknown>) => void>()
|
||||
const progressFrames = new Map<number, Array<Record<string, unknown>>>()
|
||||
let configPath = ''
|
||||
let readyResolve: () => void
|
||||
const ready = new Promise<void>(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<string, unknown>
|
||||
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<string, unknown> | 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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue