mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-21 22:44:31 +00:00
Clear the per-directory Codex and Antigravity memo maps in the resident RSS guard; document the single cache-dir rule (XDG_CACHE_HOME no longer consulted, ledger migrated); stop output-overflow terminations from spending the resident's unexpected-death budget.
1130 lines
49 KiB
TypeScript
1130 lines
49 KiB
TypeScript
// @vitest-environment node
|
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, chmodSync } from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import { dirname, join, isAbsolute, relative, win32, posix } from 'node:path'
|
|
|
|
import { spawnCli, spawnCliAction, spawnEnvFor, spawnSpecFor, startServe, killAll, shutdownAll, CliError, nodeManagerDirs, notFoundStage, resolveCodeburnPath, resolveTarget } from './cli'
|
|
|
|
let dir: string
|
|
const originalBin = process.env.CODEBURN_BIN
|
|
const originalPathDirs = process.env.CODEBURN_PATH_DIRS
|
|
const originalPathFile = process.env.CODEBURN_CLI_PATH_FILE
|
|
const originalViteUrl = process.env.VITE_DEV_SERVER_URL
|
|
const originalBundled = process.env.CODEBURN_BUNDLED_CLI
|
|
const originalDevRepoRoot = process.env.CODEBURN_DEV_REPO_ROOT
|
|
|
|
/** Writes an executable node script and points CODEBURN_BIN at it. */
|
|
function fakeBin(name: string, body: string): string {
|
|
const p = join(dir, name)
|
|
writeFileSync(p, `#!/usr/bin/env node\n${body}\n`, { mode: 0o755 })
|
|
chmodSync(p, 0o755)
|
|
process.env.CODEBURN_BIN = p
|
|
return p
|
|
}
|
|
|
|
function readMaybe(path: string): string {
|
|
try { return readFileSync(path, 'utf8') } catch { return '' }
|
|
}
|
|
|
|
async function waitFor(condition: () => boolean, timeoutMs = 3_000): Promise<void> {
|
|
const deadline = Date.now() + timeoutMs
|
|
while (!condition()) {
|
|
if (Date.now() > deadline) throw new Error('waitFor timed out')
|
|
await new Promise(resolve => setTimeout(resolve, 10))
|
|
}
|
|
}
|
|
|
|
/** 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')
|
|
const p = join(repoRoot, 'dist', 'cli.js')
|
|
mkdirSync(dirname(p), { recursive: true })
|
|
writeFileSync(p, '#!/usr/bin/env node\n', { mode: 0o755 })
|
|
chmodSync(p, 0o755)
|
|
process.env.CODEBURN_DEV_REPO_ROOT = repoRoot
|
|
return p
|
|
}
|
|
|
|
beforeEach(() => {
|
|
dir = mkdtempSync(join(tmpdir(), 'codeburn-cli-'))
|
|
})
|
|
|
|
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
|
|
else process.env.CODEBURN_PATH_DIRS = originalPathDirs
|
|
if (originalPathFile === undefined) delete process.env.CODEBURN_CLI_PATH_FILE
|
|
else process.env.CODEBURN_CLI_PATH_FILE = originalPathFile
|
|
if (originalViteUrl === undefined) delete process.env.VITE_DEV_SERVER_URL
|
|
else process.env.VITE_DEV_SERVER_URL = originalViteUrl
|
|
if (originalBundled === undefined) delete process.env.CODEBURN_BUNDLED_CLI
|
|
else process.env.CODEBURN_BUNDLED_CLI = originalBundled
|
|
if (originalDevRepoRoot === undefined) delete process.env.CODEBURN_DEV_REPO_ROOT
|
|
else process.env.CODEBURN_DEV_REPO_ROOT = originalDevRepoRoot
|
|
rmSync(dir, { recursive: true, force: true })
|
|
})
|
|
|
|
describe('resolveCodeburnPath (Vite development)', () => {
|
|
it('prefers the executable repo dist/cli.js when the Vite dev server is set', () => {
|
|
delete process.env.CODEBURN_BIN
|
|
process.env.CODEBURN_PATH_DIRS = ''
|
|
process.env.CODEBURN_CLI_PATH_FILE = join(dir, 'no-persisted-path')
|
|
process.env.VITE_DEV_SERVER_URL = 'http://localhost:5173'
|
|
const devBin = fakeDevRepoCli()
|
|
|
|
expect(resolveCodeburnPath()).toBe(devBin)
|
|
})
|
|
|
|
it('prefers the repo dev CLI over a persisted-path file (stale global) in dev', () => {
|
|
// A persisted global (e.g. an older Homebrew codeburn) must NOT shadow the
|
|
// repo build in dev, or newly-added commands break. Regression: 0.9.15
|
|
// lacked `sessions`, so the persisted path produced a CLI error.
|
|
delete process.env.CODEBURN_BIN
|
|
process.env.CODEBURN_PATH_DIRS = ''
|
|
const persistedTarget = join(dir, 'stale-codeburn')
|
|
writeFileSync(persistedTarget, '#!/usr/bin/env node\n', { mode: 0o755 })
|
|
chmodSync(persistedTarget, 0o755)
|
|
const persistedFile = join(dir, 'cli-path.v1')
|
|
writeFileSync(persistedFile, persistedTarget)
|
|
process.env.CODEBURN_CLI_PATH_FILE = persistedFile
|
|
process.env.VITE_DEV_SERVER_URL = 'http://localhost:5173'
|
|
const devBin = fakeDevRepoCli()
|
|
|
|
const resolved = resolveCodeburnPath()
|
|
expect(resolved).toBe(devBin)
|
|
expect(resolved).not.toBe(persistedTarget)
|
|
})
|
|
|
|
it('does not return the repo dev CLI outside the Vite dev server', () => {
|
|
delete process.env.CODEBURN_BIN
|
|
process.env.CODEBURN_PATH_DIRS = ''
|
|
process.env.CODEBURN_CLI_PATH_FILE = join(dir, 'no-persisted-path')
|
|
delete process.env.VITE_DEV_SERVER_URL
|
|
|
|
expect(resolveCodeburnPath()).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('resolveTarget (bundled CLI in the packaged app)', () => {
|
|
/** An existing (not necessarily executable) file to stand in for the bundle. */
|
|
function bundledEntry(name = 'cli.js'): string {
|
|
const p = join(dir, name)
|
|
writeFileSync(p, '// bundled cli\n')
|
|
return p
|
|
}
|
|
|
|
it('resolves the bundled CLI, beating a persisted path and PATH', () => {
|
|
delete process.env.CODEBURN_BIN
|
|
delete process.env.VITE_DEV_SERVER_URL
|
|
process.env.CODEBURN_PATH_DIRS = ''
|
|
// A persisted global that should NOT win once a bundled CLI is present.
|
|
const persistedTarget = join(dir, 'stale-global')
|
|
writeFileSync(persistedTarget, '#!/usr/bin/env node\n', { mode: 0o755 })
|
|
chmodSync(persistedTarget, 0o755)
|
|
const persistedFile = join(dir, 'cli-path.v1')
|
|
writeFileSync(persistedFile, persistedTarget)
|
|
process.env.CODEBURN_CLI_PATH_FILE = persistedFile
|
|
|
|
const entry = bundledEntry()
|
|
process.env.CODEBURN_BUNDLED_CLI = entry
|
|
|
|
expect(resolveTarget()).toEqual({ kind: 'bundled', entry })
|
|
expect(resolveCodeburnPath()).toBe(entry)
|
|
})
|
|
|
|
it('CODEBURN_BIN still overrides the bundled CLI', () => {
|
|
const override = fakeBin('override.js', 'process.stdout.write("{}")') // sets CODEBURN_BIN
|
|
process.env.CODEBURN_BUNDLED_CLI = bundledEntry('bundled.js')
|
|
delete process.env.VITE_DEV_SERVER_URL
|
|
|
|
expect(resolveTarget()).toEqual({ kind: 'external', bin: override })
|
|
})
|
|
|
|
it('the dev repo CLI beats the bundled CLI in Vite development', () => {
|
|
delete process.env.CODEBURN_BIN
|
|
process.env.CODEBURN_PATH_DIRS = ''
|
|
process.env.CODEBURN_CLI_PATH_FILE = join(dir, 'no-persisted-path')
|
|
process.env.CODEBURN_BUNDLED_CLI = bundledEntry('bundled.js')
|
|
process.env.VITE_DEV_SERVER_URL = 'http://localhost:5173'
|
|
const devBin = fakeDevRepoCli()
|
|
|
|
const target = resolveTarget()
|
|
expect(target).toEqual({ kind: 'external', bin: devBin })
|
|
})
|
|
|
|
it('falls through when CODEBURN_BUNDLED_CLI points at a missing file', () => {
|
|
delete process.env.CODEBURN_BIN
|
|
delete process.env.VITE_DEV_SERVER_URL
|
|
process.env.CODEBURN_PATH_DIRS = '' // force an empty PATH search space
|
|
process.env.CODEBURN_CLI_PATH_FILE = join(dir, 'no-persisted-path')
|
|
process.env.CODEBURN_BUNDLED_CLI = join(dir, 'does-not-exist', 'cli.js')
|
|
|
|
expect(resolveTarget()).toBeNull()
|
|
expect(resolveCodeburnPath()).toBeNull()
|
|
})
|
|
|
|
// The Windows P0: the packaged app set CODEBURN_BUNDLED_CLI to a C:\ path, but
|
|
// the guard was `startsWith('/')` (POSIX-only), so the bundled CLI was skipped
|
|
// and resolution fell through to a PATH search that finds nothing → not-found
|
|
// on 100% of Windows installs. The guard is now `path.isAbsolute`, which is
|
|
// the platform variant (win32 on Windows). These tests pin both the intent
|
|
// (relative paths are still rejected as a safety guard) and the Windows fix.
|
|
it('resolves an absolute CODEBURN_BUNDLED_CLI (as the packaged app sets it)', () => {
|
|
delete process.env.CODEBURN_BIN
|
|
delete process.env.VITE_DEV_SERVER_URL
|
|
process.env.CODEBURN_PATH_DIRS = ''
|
|
process.env.CODEBURN_CLI_PATH_FILE = join(dir, 'no-persisted-path')
|
|
const entry = bundledEntry('launch.js')
|
|
expect(isAbsolute(entry)).toBe(true)
|
|
process.env.CODEBURN_BUNDLED_CLI = entry
|
|
expect(resolveTarget()).toEqual({ kind: 'bundled', entry })
|
|
})
|
|
|
|
it('rejects a relative CODEBURN_BUNDLED_CLI even when the file exists (guards relative injection)', () => {
|
|
delete process.env.CODEBURN_BIN
|
|
delete process.env.VITE_DEV_SERVER_URL
|
|
process.env.CODEBURN_PATH_DIRS = ''
|
|
process.env.CODEBURN_CLI_PATH_FILE = join(dir, 'no-persisted-path')
|
|
const entry = bundledEntry('rel-launch.js')
|
|
const rel = relative(process.cwd(), entry) // resolvable via cwd, but NOT absolute
|
|
expect(isAbsolute(rel)).toBe(false)
|
|
process.env.CODEBURN_BUNDLED_CLI = rel
|
|
// File exists (isFile true), so only the isAbsolute guard can reject it.
|
|
expect(resolveTarget()).toBeNull()
|
|
})
|
|
|
|
it('rejects a relative CODEBURN_BIN override even when the file exists', () => {
|
|
delete process.env.VITE_DEV_SERVER_URL
|
|
process.env.CODEBURN_PATH_DIRS = ''
|
|
process.env.CODEBURN_CLI_PATH_FILE = join(dir, 'no-persisted-path')
|
|
delete process.env.CODEBURN_BUNDLED_CLI
|
|
const bin = join(dir, 'rel-codeburn.js')
|
|
writeFileSync(bin, '#!/usr/bin/env node\n', { mode: 0o755 })
|
|
chmodSync(bin, 0o755)
|
|
const rel = relative(process.cwd(), bin)
|
|
expect(isAbsolute(rel)).toBe(false)
|
|
process.env.CODEBURN_BIN = rel
|
|
expect(resolveTarget()).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('absolute-path guard is cross-platform (path.isAbsolute, not startsWith("/"))', () => {
|
|
// On the POSIX CI host `isAbsolute` is path.posix.isAbsolute, so these assert
|
|
// the per-platform variants directly to encode the Windows intent regardless
|
|
// of where the suite runs.
|
|
const winPath = 'C:\\Users\\x\\resources\\cli\\dist\\launch.js'
|
|
|
|
it('accepts a Windows absolute bundled path where the old startsWith("/") guard rejected it', () => {
|
|
expect(winPath.startsWith('/')).toBe(false) // old guard: dropped it → the P0
|
|
expect(win32.isAbsolute(winPath)).toBe(true) // new guard on Windows: accepted
|
|
expect(win32.isAbsolute('cli\\dist\\launch.js')).toBe(false) // relative still rejected
|
|
})
|
|
|
|
it('accepts a POSIX absolute path and rejects a relative one (macOS/Linux unchanged)', () => {
|
|
expect(posix.isAbsolute('/res/cli/dist/launch.js')).toBe(true)
|
|
expect(posix.isAbsolute('cli/dist/launch.js')).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('notFoundStage (non-sensitive telemetry enum for a not-found)', () => {
|
|
it('reports bundled-not-absolute for a relative CODEBURN_BUNDLED_CLI', () => {
|
|
delete process.env.CODEBURN_BIN
|
|
process.env.CODEBURN_BUNDLED_CLI = 'cli/dist/launch.js'
|
|
expect(notFoundStage()).toBe('bundled-not-absolute')
|
|
})
|
|
|
|
it('reports bundled-missing for an absolute CODEBURN_BUNDLED_CLI whose file is absent', () => {
|
|
delete process.env.CODEBURN_BIN
|
|
process.env.CODEBURN_BUNDLED_CLI = join(dir, 'nope', 'cli.js')
|
|
expect(notFoundStage()).toBe('bundled-missing')
|
|
})
|
|
|
|
it('reports bin-not-absolute for a relative CODEBURN_BIN override', () => {
|
|
process.env.CODEBURN_BIN = 'relative/codeburn'
|
|
delete process.env.CODEBURN_BUNDLED_CLI
|
|
expect(notFoundStage()).toBe('bin-not-absolute')
|
|
})
|
|
|
|
it('reports no-path-match when nothing is configured', () => {
|
|
delete process.env.CODEBURN_BIN
|
|
delete process.env.CODEBURN_BUNDLED_CLI
|
|
expect(notFoundStage()).toBe('no-path-match')
|
|
})
|
|
|
|
it('spawnCli rejects not-found carrying the resolution stage as detail', async () => {
|
|
delete process.env.CODEBURN_BIN
|
|
delete process.env.VITE_DEV_SERVER_URL
|
|
process.env.CODEBURN_PATH_DIRS = ''
|
|
process.env.CODEBURN_CLI_PATH_FILE = join(dir, 'no-persisted-path')
|
|
process.env.CODEBURN_BUNDLED_CLI = 'cli/dist/launch.js' // relative → bundled-not-absolute
|
|
await expect(spawnCli(['status'])).rejects.toMatchObject({ kind: 'not-found', detail: 'bundled-not-absolute' })
|
|
})
|
|
})
|
|
|
|
describe('spawnSpecFor (bundled CLI runs via Electron-as-node)', () => {
|
|
it('spawns process.execPath with the bundle as argv[0] and ELECTRON_RUN_AS_NODE set', () => {
|
|
const spec = spawnSpecFor({ kind: 'bundled', entry: '/res/cli/dist/cli.js' }, ['status', '--period', 'today'])
|
|
expect(spec.bin).toBe(process.execPath)
|
|
expect(spec.args).toEqual(['/res/cli/dist/cli.js', 'status', '--period', 'today'])
|
|
expect(spec.env.ELECTRON_RUN_AS_NODE).toBe('1')
|
|
// PATH is still augmented (the bundle's own dir leads), harmless for a CLI
|
|
// that itself shells out during pairing/sync.
|
|
expect((spec.env.PATH ?? '').split(':')[0]).toBe('/res/cli/dist')
|
|
})
|
|
|
|
it('spawns an external CLI directly, with no run-as-node flag', () => {
|
|
const spec = spawnSpecFor({ kind: 'external', bin: '/some/bin/codeburn' }, ['status'])
|
|
expect(spec.bin).toBe('/some/bin/codeburn')
|
|
expect(spec.args).toEqual(['status'])
|
|
expect(spec.env.ELECTRON_RUN_AS_NODE).toBeUndefined()
|
|
expect((spec.env.PATH ?? '').split(':')[0]).toBe('/some/bin')
|
|
})
|
|
|
|
it('spawnCli runs the bundled entry end-to-end as Node', async () => {
|
|
delete process.env.CODEBURN_BIN
|
|
delete process.env.VITE_DEV_SERVER_URL
|
|
process.env.CODEBURN_PATH_DIRS = ''
|
|
process.env.CODEBURN_CLI_PATH_FILE = join(dir, 'no-persisted-path')
|
|
// process.execPath is the Node running vitest here; a real packaged app uses
|
|
// Electron's binary, but the spawn shape (execPath + entry + args + env) is
|
|
// identical, so this exercises the whole bundled path.
|
|
const entry = join(dir, 'bundled-cli.js')
|
|
writeFileSync(
|
|
entry,
|
|
'process.stdout.write(JSON.stringify({ ranAsNode: process.env.ELECTRON_RUN_AS_NODE === "1", firstArg: process.argv[2] }))\n',
|
|
)
|
|
process.env.CODEBURN_BUNDLED_CLI = entry
|
|
|
|
const result = (await spawnCli(['status'])) as { ranAsNode: boolean; firstArg: string }
|
|
expect(result).toEqual({ ranAsNode: true, firstArg: 'status' })
|
|
})
|
|
})
|
|
|
|
describe('spawnCli', () => {
|
|
it('resolves parsed JSON on success', async () => {
|
|
fakeBin('ok.js', 'process.stdout.write(JSON.stringify({ ok: 1 }))')
|
|
await expect(spawnCli(['status'])).resolves.toEqual({ ok: 1 })
|
|
})
|
|
|
|
it('rejects with kind "nonzero" on a non-zero exit', async () => {
|
|
fakeBin('fail.js', 'process.stderr.write("boom"); process.exit(2)')
|
|
await expect(spawnCli(['status'])).rejects.toMatchObject({ kind: 'nonzero' } satisfies Partial<CliError>)
|
|
})
|
|
|
|
it('rejects with kind "bad-json" on non-JSON stdout', async () => {
|
|
fakeBin('garbage.js', 'process.stdout.write("not json at all")')
|
|
await expect(spawnCli(['status'])).rejects.toMatchObject({ kind: 'bad-json' })
|
|
})
|
|
|
|
it('rejects with kind "timeout" when the binary hangs', async () => {
|
|
fakeBin('hang.js', 'setInterval(() => {}, 1000)')
|
|
await expect(spawnCli(['status'], { timeoutMs: 150 })).rejects.toMatchObject({ kind: 'timeout' })
|
|
})
|
|
|
|
it('rejects with kind "not-found" when no binary resolves', async () => {
|
|
delete process.env.CODEBURN_BIN
|
|
process.env.CODEBURN_PATH_DIRS = '' // force an empty search space
|
|
process.env.CODEBURN_CLI_PATH_FILE = join(dir, 'no-such-persisted-path')
|
|
try {
|
|
await expect(spawnCli(['status'])).rejects.toMatchObject({ kind: 'not-found' })
|
|
} finally {
|
|
delete process.env.CODEBURN_PATH_DIRS
|
|
delete process.env.CODEBURN_CLI_PATH_FILE
|
|
}
|
|
})
|
|
|
|
it('rejects with kind "too-large" and kills a binary that floods stdout', async () => {
|
|
fakeBin('flood.js', "const s='x'.repeat(1024*1024); for(let i=0;i<20;i++) process.stdout.write(s); setInterval(()=>{},1000)")
|
|
await expect(spawnCli(['status'])).rejects.toMatchObject({ kind: 'too-large' } satisfies Partial<CliError>)
|
|
})
|
|
})
|
|
|
|
describe('spawn PATH augmentation (GUI-launched apps have a minimal PATH)', () => {
|
|
it("prepends the resolved binary's own directory so its env-shebang finds node", async () => {
|
|
const bin = fakeBin('path-echo.js', 'process.stdout.write(JSON.stringify({ path: process.env.PATH }))')
|
|
const result = await spawnCli(['status']) as { path: string }
|
|
expect(result.path.split(':')[0]).toBe(dirname(bin))
|
|
})
|
|
|
|
it('spawnEnvFor dedupes and keeps the original PATH entries', () => {
|
|
const env = spawnEnvFor('/some/tool/bin/codeburn')
|
|
const parts = (env.PATH ?? '').split(':')
|
|
expect(parts[0]).toBe('/some/tool/bin')
|
|
expect(new Set(parts).size).toBe(parts.length)
|
|
for (const original of (process.env.PATH ?? '').split(':').filter(Boolean)) {
|
|
expect(parts).toContain(original)
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('spawnCli coalescing (read-only)', () => {
|
|
it('shares one child between two concurrent identical calls', async () => {
|
|
const countFile = join(dir, 'spawns')
|
|
fakeBin('counter.js', `require('fs').appendFileSync(${JSON.stringify(countFile)},'x'); process.stdout.write(JSON.stringify({ok:1}))`)
|
|
const [a, b] = await Promise.all([spawnCli(['status']), spawnCli(['status'])])
|
|
expect(a).toEqual({ ok: 1 })
|
|
expect(b).toEqual({ ok: 1 })
|
|
expect(readFileSync(countFile, 'utf8')).toBe('x') // exactly one spawn
|
|
})
|
|
|
|
it('reflects an external config change on the next same-argv read', async () => {
|
|
const configFile = join(dir, 'external-config')
|
|
const countFile = join(dir, 'spawns')
|
|
writeFileSync(configFile, 'before')
|
|
fakeBin(
|
|
'external-config.js',
|
|
`const fs = require('node:fs'); fs.appendFileSync(${JSON.stringify(countFile)}, 'x'); process.stdout.write(JSON.stringify({ value: fs.readFileSync(${JSON.stringify(configFile)}, 'utf8') }))`,
|
|
)
|
|
|
|
await expect(spawnCli(['model-alias', '--list'])).resolves.toEqual({ value: 'before' })
|
|
writeFileSync(configFile, 'after')
|
|
await expect(spawnCli(['model-alias', '--list'])).resolves.toEqual({ value: 'after' })
|
|
expect(readFileSync(countFile, 'utf8')).toBe('xx')
|
|
})
|
|
|
|
it('never coalesces config-mutating action calls', async () => {
|
|
const countFile = join(dir, 'spawns')
|
|
fakeBin('action-counter.js', `require('fs').appendFileSync(${JSON.stringify(countFile)},'x'); process.stdout.write('done')`)
|
|
await Promise.all([spawnCliAction(['currency', 'EUR']), spawnCliAction(['currency', 'EUR'])])
|
|
expect(readFileSync(countFile, 'utf8')).toBe('xx') // two independent spawns
|
|
})
|
|
|
|
it('runs a fresh read after a config-mutating action', async () => {
|
|
const countFile = join(dir, 'spawns')
|
|
fakeBin('mixed.js', `require('fs').appendFileSync(${JSON.stringify(countFile)},'x'); process.stdout.write(JSON.stringify({ok:1}))`)
|
|
await spawnCli(['model-alias', '--list'])
|
|
await spawnCliAction(['model-alias', 'a', 'b'])
|
|
await spawnCli(['model-alias', '--list'])
|
|
expect(readFileSync(countFile, 'utf8')).toBe('xxx')
|
|
})
|
|
|
|
it('fences old in-flight reads across a mutation without deleting the new flight', async () => {
|
|
const configFile = join(dir, 'generation-config')
|
|
const startsFile = join(dir, 'generation-read-starts')
|
|
const releaseDir = join(dir, 'generation-release')
|
|
mkdirSync(releaseDir)
|
|
writeFileSync(configFile, 'old')
|
|
fakeBin(
|
|
'generation-fence.js',
|
|
`const fs = require('node:fs'); const path = require('node:path');
|
|
if (process.argv[3] === '--list') {
|
|
const value = fs.readFileSync(${JSON.stringify(configFile)}, 'utf8');
|
|
fs.appendFileSync(${JSON.stringify(startsFile)}, 'r');
|
|
const generation = fs.readFileSync(${JSON.stringify(startsFile)}, 'utf8').length;
|
|
const release = path.join(${JSON.stringify(releaseDir)}, String(generation));
|
|
const timer = setInterval(() => {
|
|
if (!fs.existsSync(release)) return;
|
|
clearInterval(timer);
|
|
process.stdout.write(JSON.stringify({ value, generation }));
|
|
}, 5);
|
|
} else {
|
|
fs.writeFileSync(${JSON.stringify(configFile)}, 'new');
|
|
process.stdout.write('updated');
|
|
}`,
|
|
)
|
|
|
|
const oldRead = spawnCli(['model-alias', '--list'])
|
|
await waitFor(() => readMaybe(startsFile) === 'r')
|
|
await expect(spawnCliAction(['model-alias', 'alias', 'model']))
|
|
.resolves.toMatchObject({ ok: true })
|
|
|
|
const newRead = spawnCli(['model-alias', '--list'])
|
|
await waitFor(() => readMaybe(startsFile) === 'rr')
|
|
writeFileSync(join(releaseDir, '1'), '')
|
|
await expect(oldRead).resolves.toEqual({ value: 'old', generation: 1 })
|
|
|
|
// Settling the superseded flight must not remove the current generation's
|
|
// entry: this identical call still shares read #2 instead of spawning #3.
|
|
const coalescedNewRead = spawnCli(['model-alias', '--list'])
|
|
await new Promise(resolve => setTimeout(resolve, 100))
|
|
expect(readMaybe(startsFile)).toBe('rr')
|
|
|
|
writeFileSync(join(releaseDir, '2'), '')
|
|
await expect(Promise.all([newRead, coalescedNewRead])).resolves.toEqual([
|
|
{ value: 'new', generation: 2 },
|
|
{ value: 'new', generation: 2 },
|
|
])
|
|
})
|
|
})
|
|
|
|
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('rejects and terminates a resident that emits an oversized valid JSON frame', async () => {
|
|
const startsFile = join(dir, 'oversized-frame-starts')
|
|
const oneShotsFile = join(dir, 'oversized-frame-one-shots')
|
|
fakeBin(
|
|
'oversized-frame-resident.js',
|
|
`const fs = require('node:fs'); const readline = require('node:readline');
|
|
if (process.argv[2] === '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);
|
|
const output = generation === 1
|
|
? JSON.stringify({ value: 'x'.repeat(16 * 1024 * 1024 + 1024) })
|
|
: JSON.stringify({ generation });
|
|
process.stdout.write(JSON.stringify({ id: request.id, ok: true, output }) + '\\n');
|
|
});
|
|
setInterval(() => {}, 1000);
|
|
} else {
|
|
fs.appendFileSync(${JSON.stringify(oneShotsFile)}, 'o');
|
|
process.stdout.write('{}');
|
|
}`,
|
|
)
|
|
startServe()
|
|
|
|
await expect(spawnCli(['status', '--oversized-frame'], { timeoutMs: 5_000 }))
|
|
.rejects.toMatchObject({ kind: 'too-large' } satisfies Partial<CliError>)
|
|
await expect(spawnCli(['status', '--after-oversized-frame'], { timeoutMs: 5_000 }))
|
|
.resolves.toEqual({ generation: 2 })
|
|
expect(readMaybe(startsFile)).toBe('ss')
|
|
expect(readMaybe(oneShotsFile)).toBe('')
|
|
})
|
|
|
|
it('keeps serve enabled after more overflows than the resident death budget', async () => {
|
|
const startsFile = join(dir, 'overflow-budget-starts')
|
|
const oneShotsFile = join(dir, 'overflow-budget-one-shots')
|
|
fakeBin(
|
|
'always-oversized-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', line => {
|
|
const request = JSON.parse(line);
|
|
const output = JSON.stringify({ value: 'x'.repeat(16 * 1024 * 1024 + 1024) });
|
|
process.stdout.write(JSON.stringify({ id: request.id, ok: true, output }) + '\\n');
|
|
});
|
|
setInterval(() => {}, 1000);
|
|
} else {
|
|
fs.appendFileSync(${JSON.stringify(oneShotsFile)}, 'o');
|
|
process.stdout.write('{}');
|
|
}`,
|
|
)
|
|
startServe()
|
|
|
|
for (let attempt = 0; attempt < 4; attempt += 1) {
|
|
await expect(spawnCli(['status', '--overflow', String(attempt)], { timeoutMs: 5_000 }))
|
|
.rejects.toMatchObject({ kind: 'too-large' } satisfies Partial<CliError>)
|
|
}
|
|
|
|
// An overflow kill is deliberate, so it never spends the unexpected-death
|
|
// budget: the fourth request still reaches a resident, not a one-shot.
|
|
expect(readMaybe(startsFile)).toBe('ssss')
|
|
expect(readMaybe(oneShotsFile)).toBe('')
|
|
})
|
|
|
|
it('rejects and terminates a resident whose protocol line never terminates', async () => {
|
|
const startsFile = join(dir, 'unterminated-line-starts')
|
|
const oneShotsFile = join(dir, 'unterminated-line-one-shots')
|
|
fakeBin(
|
|
'unterminated-line-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.stdout.write('x'.repeat(16 * 1024 * 1024 + 1024)));
|
|
setInterval(() => {}, 1000);
|
|
} else {
|
|
fs.appendFileSync(${JSON.stringify(oneShotsFile)}, 'o');
|
|
process.stdout.write('{}');
|
|
}`,
|
|
)
|
|
startServe()
|
|
|
|
await expect(spawnCli(['status', '--unterminated-line'], { timeoutMs: 5_000 }))
|
|
.rejects.toMatchObject({ kind: 'too-large' } satisfies Partial<CliError>)
|
|
expect(readMaybe(startsFile)).toBe('s')
|
|
expect(readMaybe(oneShotsFile)).toBe('')
|
|
})
|
|
|
|
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 })
|
|
// A different panel query proves which resident generation handled the
|
|
// next served read without relying on same-request coalescing.
|
|
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)')
|
|
const pending = spawnCli(['status'], { timeoutMs: 60_000 })
|
|
// Let the child spawn before reaping.
|
|
await new Promise(resolve => setTimeout(resolve, 50))
|
|
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('')
|
|
})
|
|
|
|
it('terminal shutdown cancels read and action slots admitted before their spawn microtask', async () => {
|
|
const startsFile = join(dir, 'starts-after-admission')
|
|
fakeBin(
|
|
'shutdown-after-admission.js',
|
|
`require('node:fs').appendFileSync(${JSON.stringify(startsFile)}, process.argv[2] + '\\n'); if (process.argv[2] === 'status') process.stdout.write('{}'); else process.stdout.write('updated')`,
|
|
)
|
|
|
|
// Both calls synchronously acquire the two free scheduler slots. Their
|
|
// actual spawn resumes in a microtask, which is exactly the before-quit race.
|
|
const read = spawnCli(['status', '--admitted'])
|
|
const action = spawnCliAction(['currency', 'EUR'])
|
|
shutdownAll()
|
|
|
|
await Promise.all([
|
|
expect(read).rejects.toMatchObject({ kind: 'nonzero' }),
|
|
expect(action).resolves.toMatchObject({ ok: false, code: null }),
|
|
])
|
|
expect(readMaybe(startsFile)).toBe('')
|
|
})
|
|
})
|
|
|
|
describe('spawnCli concurrency scheduler', () => {
|
|
// A fake CLI that records each spawn (by subcommand) and then blocks until a
|
|
// release file named after that subcommand appears, so the test controls
|
|
// exactly when each child exits and can observe how many run at once.
|
|
function schedulerBin(startedFile: string, releaseDir: string): void {
|
|
fakeBin(
|
|
'sched.js',
|
|
`const fs = require('fs'); const path = require('path');
|
|
const cmd = process.argv[2];
|
|
fs.appendFileSync(${JSON.stringify(startedFile)}, cmd + '\\n');
|
|
const rel = path.join(${JSON.stringify(releaseDir)}, cmd);
|
|
const t = setInterval(() => {
|
|
if (fs.existsSync(rel)) { clearInterval(t); process.stdout.write('{}'); process.exit(0); }
|
|
}, 5);`,
|
|
)
|
|
}
|
|
function startedList(startedFile: string): string[] {
|
|
try { return readFileSync(startedFile, 'utf8').split('\n').filter(Boolean) } catch { return [] }
|
|
}
|
|
function release(releaseDir: string, cmd: string): void { writeFileSync(join(releaseDir, cmd), '') }
|
|
async function waitUntil(cond: () => boolean, timeoutMs = 3000): Promise<void> {
|
|
const deadline = Date.now() + timeoutMs
|
|
while (!cond()) {
|
|
if (Date.now() > deadline) throw new Error('waitUntil timed out')
|
|
await new Promise(r => setTimeout(r, 10))
|
|
}
|
|
}
|
|
const delay = (ms: number) => new Promise(r => setTimeout(r, ms))
|
|
|
|
// Reset scheduler state so a leaked running slot can never starve the next test.
|
|
afterEach(() => { killAll() })
|
|
|
|
it('runs at most two children at once; a third waits for a freed slot', async () => {
|
|
const startedFile = join(dir, 'started')
|
|
const releaseDir = join(dir, 'release'); mkdirSync(releaseDir)
|
|
schedulerBin(startedFile, releaseDir)
|
|
|
|
const p1 = spawnCli(['status'])
|
|
const p2 = spawnCli(['models'])
|
|
const p3 = spawnCli(['sessions'])
|
|
await waitUntil(() => startedList(startedFile).length === 2)
|
|
await delay(100) // the cap must keep the third from sneaking in
|
|
expect(startedList(startedFile).sort()).toEqual(['models', 'status'])
|
|
|
|
release(releaseDir, 'status') // free one slot
|
|
await waitUntil(() => startedList(startedFile).includes('sessions'))
|
|
|
|
release(releaseDir, 'models'); release(releaseDir, 'sessions')
|
|
await Promise.all([p1, p2, p3])
|
|
})
|
|
|
|
it('lets a later interactive spawn preempt an earlier queued background one', async () => {
|
|
const startedFile = join(dir, 'started')
|
|
const releaseDir = join(dir, 'release'); mkdirSync(releaseDir)
|
|
schedulerBin(startedFile, releaseDir)
|
|
|
|
// Fill both slots so the next two calls must queue.
|
|
const p1 = spawnCli(['fill1'], { priority: 'background' })
|
|
const p2 = spawnCli(['fill2'], { priority: 'background' })
|
|
await waitUntil(() => startedList(startedFile).length === 2)
|
|
|
|
// Queue a background first, then an interactive.
|
|
const pbg = spawnCli(['bg'], { priority: 'background' })
|
|
const pint = spawnCli(['inter'], { priority: 'interactive' })
|
|
await delay(50)
|
|
expect(startedList(startedFile)).not.toContain('bg')
|
|
expect(startedList(startedFile)).not.toContain('inter')
|
|
|
|
// Free exactly one slot: the interactive must take it despite queueing later.
|
|
release(releaseDir, 'fill1')
|
|
await waitUntil(() => startedList(startedFile).length === 3)
|
|
expect(startedList(startedFile)).toContain('inter')
|
|
expect(startedList(startedFile)).not.toContain('bg')
|
|
|
|
release(releaseDir, 'fill2'); release(releaseDir, 'inter'); release(releaseDir, 'bg')
|
|
await Promise.all([p1, p2, pbg, pint])
|
|
})
|
|
|
|
it('does not spend a slot on a coalesced (same-argv) call', async () => {
|
|
const startedFile = join(dir, 'started')
|
|
const releaseDir = join(dir, 'release'); mkdirSync(releaseDir)
|
|
schedulerBin(startedFile, releaseDir)
|
|
|
|
const p1 = spawnCli(['status'])
|
|
const p2 = spawnCli(['models'])
|
|
await waitUntil(() => startedList(startedFile).length === 2)
|
|
|
|
const p1b = spawnCli(['status']) // coalesces onto p1's child — no new slot
|
|
const p3 = spawnCli(['sessions']) // genuinely new — queued behind the cap
|
|
await delay(100)
|
|
expect(startedList(startedFile).length).toBe(2) // still just the two originals
|
|
|
|
release(releaseDir, 'status') // frees the slot the coalesced pair shared
|
|
await waitUntil(() => startedList(startedFile).includes('sessions'))
|
|
|
|
release(releaseDir, 'models'); release(releaseDir, 'sessions')
|
|
const [a, b] = await Promise.all([p1, p1b])
|
|
expect(a).toEqual(b) // one child served both callers
|
|
await Promise.all([p2, p3])
|
|
})
|
|
|
|
it('cancels a queued spawn on killAll instead of letting it spawn later', async () => {
|
|
const startedFile = join(dir, 'started')
|
|
const releaseDir = join(dir, 'release'); mkdirSync(releaseDir)
|
|
schedulerBin(startedFile, releaseDir)
|
|
|
|
const p1 = spawnCli(['status'])
|
|
const p2 = spawnCli(['models'])
|
|
await waitUntil(() => startedList(startedFile).length === 2)
|
|
const p3 = spawnCli(['sessions']) // queued behind the cap, no child yet
|
|
await delay(50)
|
|
expect(startedList(startedFile)).not.toContain('sessions')
|
|
|
|
killAll() // reaps the two running AND cancels the queued third
|
|
// Attach all three rejection handlers synchronously: the queued spawn rejects
|
|
// at once, so it must not sit unhandled while the killed children settle.
|
|
await Promise.all([
|
|
expect(p1).rejects.toMatchObject({ kind: 'nonzero' }),
|
|
expect(p2).rejects.toMatchObject({ kind: 'nonzero' }),
|
|
expect(p3).rejects.toMatchObject({ kind: 'nonzero' }),
|
|
])
|
|
|
|
await delay(50)
|
|
expect(startedList(startedFile)).not.toContain('sessions') // never spawned
|
|
})
|
|
|
|
it('limits six simultaneous resident-failure fallbacks to two one-shot children', async () => {
|
|
const startedFile = join(dir, 'fallback-started')
|
|
const activeDir = join(dir, 'fallback-active'); mkdirSync(activeDir)
|
|
const activeCountsFile = join(dir, 'fallback-active-counts')
|
|
const releaseDir = join(dir, 'fallback-release'); mkdirSync(releaseDir)
|
|
fakeBin(
|
|
'failing-resident-with-blocked-fallbacks.js',
|
|
`const fs = require('node:fs'); const path = require('node:path'); 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);
|
|
process.stdout.write(JSON.stringify({ id: request.id, ok: false, error: 'resident failed' }) + '\\n');
|
|
});
|
|
} else {
|
|
const id = process.argv[3];
|
|
fs.appendFileSync(${JSON.stringify(startedFile)}, id + '\\n');
|
|
const activeFile = path.join(${JSON.stringify(activeDir)}, String(process.pid));
|
|
fs.writeFileSync(activeFile, '');
|
|
fs.appendFileSync(${JSON.stringify(activeCountsFile)}, fs.readdirSync(${JSON.stringify(activeDir)}).length + '\\n');
|
|
const releaseFile = path.join(${JSON.stringify(releaseDir)}, id);
|
|
const timer = setInterval(() => {
|
|
if (!fs.existsSync(releaseFile)) return;
|
|
clearInterval(timer);
|
|
fs.unlinkSync(activeFile);
|
|
process.stdout.write(JSON.stringify({ via: 'spawn', id }));
|
|
}, 5);
|
|
}`,
|
|
)
|
|
startServe()
|
|
|
|
const requests = Array.from({ length: 6 }, (_, index) =>
|
|
spawnCli(['status', `fallback-${index}`], { timeoutMs: 5_000 }),
|
|
)
|
|
await waitUntil(() => startedList(startedFile).length >= 2)
|
|
await delay(150)
|
|
const admittedBeforeRelease = startedList(startedFile)
|
|
|
|
for (let index = 0; index < 6; index += 1) release(releaseDir, `fallback-${index}`)
|
|
await Promise.all(requests)
|
|
|
|
const activeCounts = startedList(activeCountsFile).map(Number)
|
|
expect(admittedBeforeRelease).toHaveLength(2)
|
|
expect(Math.max(...activeCounts)).toBeLessThanOrEqual(2)
|
|
})
|
|
})
|
|
|
|
describe('spawnCliAction', () => {
|
|
it('returns stdout and ok:true on success', async () => {
|
|
fakeBin('action-ok.js', 'process.stdout.write("currency updated")')
|
|
await expect(spawnCliAction(['currency', 'EUR'])).resolves.toEqual({ ok: true, stdout: 'currency updated', stderr: '', code: 0 })
|
|
})
|
|
|
|
it('returns stderr and ok:false on a non-zero exit', async () => {
|
|
fakeBin('action-fail.js', 'process.stderr.write("invalid alias"); process.exit(3)')
|
|
await expect(spawnCliAction(['model-alias', 'a', 'b'])).resolves.toEqual({ ok: false, stdout: '', stderr: 'invalid alias', code: 3 })
|
|
})
|
|
})
|
|
|
|
describe('nodeManagerDirs (nvm resolution)', () => {
|
|
const savedNvm = process.env.NVM_DIR
|
|
afterEach(() => {
|
|
if (savedNvm === undefined) delete process.env.NVM_DIR
|
|
else process.env.NVM_DIR = savedNvm
|
|
})
|
|
|
|
it('scans nvm version dirs newest-first and takes the first that holds codeburn', () => {
|
|
// Two versions; the lexicographically-"newest" (v9.0.0 > v22.0.0 as strings)
|
|
// has NO codeburn, while the real newer v22.0.0 does. The old `sort().reverse()[0]`
|
|
// would pick v9.0.0's bin and miss the CLI entirely.
|
|
const nvm = mkdtempSync(join(tmpdir(), 'codeburn-nvm-'))
|
|
try {
|
|
const versions = join(nvm, 'versions', 'node')
|
|
const v9bin = join(versions, 'v9.0.0', 'bin')
|
|
const v22bin = join(versions, 'v22.0.0', 'bin')
|
|
mkdirSync(v9bin, { recursive: true })
|
|
mkdirSync(v22bin, { recursive: true })
|
|
const codeburn = join(v22bin, 'codeburn')
|
|
writeFileSync(codeburn, '#!/bin/sh\n', { mode: 0o755 })
|
|
chmodSync(codeburn, 0o755)
|
|
|
|
process.env.NVM_DIR = nvm
|
|
const dirs = nodeManagerDirs()
|
|
expect(dirs).toContain(v22bin)
|
|
expect(dirs).not.toContain(v9bin)
|
|
} finally {
|
|
rmSync(nvm, { recursive: true, force: true })
|
|
}
|
|
})
|
|
})
|