From 0d33053881c3d4dd80343004eb872408c119dfdb Mon Sep 17 00:00:00 2001 From: Aditya Vikram Singh <247195684+avs-io@users.noreply.github.com> Date: Mon, 7 Sep 2026 17:00:14 +0530 Subject: [PATCH 1/5] test(electron): stop chatty watchdog smoke racing Node boot Independent default app npmtest killed the chatty optimize child at 614ms because the 600ms idle timer started at spawn and the first progress byte waited on Node boot plus setInterval. Emit the first stderr byte immediately after boot, then ten 400ms ticks (result ticks 11) under a 2s silence budget. Node boot still counts; the larger smoke window absorbs it. Work still outlives a fixed total-runtime cap. Production already resets on every byte; timeout constants unchanged. --- app/electron/cli.test.ts | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/app/electron/cli.test.ts b/app/electron/cli.test.ts index 71556b6c..459d471e 100644 --- a/app/electron/cli.test.ts +++ b/app/electron/cli.test.ts @@ -473,25 +473,31 @@ describe('spawnCli', () => { }) describe('no-output watchdog (timeoutMs bounds SILENCE, not total runtime)', () => { - /** Emits one progress byte every `everyMs` for `ticks` ticks, then the payload. */ - function chattyBin(everyMs: number, ticks: number): void { + /** First stderr write is immediate after Node boot: that only removes the extra + * setInterval delay. The spawn-time silence timer still includes Node boot; + * the larger smoke window absorbs startup. Further ticks keep the process + * alive past `timeoutMs`. Not a production timeout change. */ + function chattyBin(everyMs: number, extraTicks: number): void { fakeBin( 'chatty.js', - `let n = 0; + `let n = 1; + process.stderr.write('CODEBURN_PROGRESS {"kind":"tick","provider":"claude","done":0,"total":${extraTicks + 1}}\\n'); const t = setInterval(() => { - process.stderr.write('CODEBURN_PROGRESS {"kind":"tick","provider":"claude","done":' + n + ',"total":${ticks}}\\n'); - if (++n >= ${ticks}) { clearInterval(t); process.stdout.write(JSON.stringify({ ok: 1, ticks: n })); } + process.stderr.write('CODEBURN_PROGRESS {"kind":"tick","provider":"claude","done":' + n + ',"total":${extraTicks + 1}}\\n'); + if (++n > ${extraTicks}) { clearInterval(t); process.stdout.write(JSON.stringify({ ok: 1, ticks: n })); } }, ${everyMs});`, ) } - // The 0.9.20 failure class: a warm `optimize` measured 52.5s against a fixed - // 45s cap and was SIGKILLed even though the parse was making steady progress. - // Scaled down here — total runtime is 5x the window, so the OLD fixed-timeout - // code fails this test and only a resetting watchdog passes. + // Production already restarts the idle window on every stdout/stderr byte; + // this is not a product fix. The fixture used to wait one setInterval before + // the first byte, so a 600ms smoke window raced Node boot (independent 614ms + // fail). Immediate first stderr write removes only that delay — boot still + // counts. Extra ticks run ~4s under a 2s silence window, so a fixed + // total-runtime cap still fails this test. Not a production timeout change. it('never kills a child that keeps producing output past the window', async () => { - chattyBin(100, 15) // ~1.5s of work under a 600ms window - await expect(spawnCli(['optimize'], { timeoutMs: 600 })).resolves.toEqual({ ok: 1, ticks: 15 }) + chattyBin(400, 10) + await expect(spawnCli(['optimize'], { timeoutMs: 2_000 })).resolves.toEqual({ ok: 1, ticks: 11 }) }) it('kills a child that goes silent, measured from its LAST byte', async () => { From 98258bbc645232fde4db47b392e1ef8d6fe51c51 Mon Sep 17 00:00:00 2001 From: Aditya Vikram Singh <247195684+avs-io@users.noreply.github.com> Date: Mon, 7 Sep 2026 15:03:59 +0530 Subject: [PATCH 2/5] test(cli): pin color off for unpriced hostile-ID source-safety spawn FORCE_COLOR=1 on agent/CI runners makes chalk paint table chrome with ESC. The C0/C1 assertion is a model-ID sanitization check, not a color check. --- tests/cli-models-unpriced.test.ts | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/tests/cli-models-unpriced.test.ts b/tests/cli-models-unpriced.test.ts index 6069d079..5296d4c1 100644 --- a/tests/cli-models-unpriced.test.ts +++ b/tests/cli-models-unpriced.test.ts @@ -6,17 +6,25 @@ import { join } from 'node:path' import { describe, expect, it } from 'vitest' function runCli(args: string[], home: string, locale?: string) { + // Agent/CI runners often set FORCE_COLOR=1 even when NO_COLOR=1. Chalk then + // paints table chrome with ESC (U+001B). The hostile-ID assertion below is + // a source-safety check on model IDs, not on chalk headers, so pin color + // off in the child. Do not drop the C0/C1 regex. + const env = { ...process.env } + delete env.FORCE_COLOR + env.NO_COLOR = '1' + env.HOME = home + env.USERPROFILE = home + env.CLAUDE_CONFIG_DIR = join(home, '.claude') + env.CODEBURN_CACHE_DIR = join(home, '.cache', 'codeburn') + env.TZ = 'UTC' + if (locale) { + env.LANG = locale + env.LC_ALL = locale + } return spawnSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', ...args], { cwd: process.cwd(), - env: { - ...process.env, - HOME: home, - USERPROFILE: home, - CLAUDE_CONFIG_DIR: join(home, '.claude'), - CODEBURN_CACHE_DIR: join(home, '.cache', 'codeburn'), - TZ: 'UTC', - ...(locale ? { LANG: locale, LC_ALL: locale } : {}), - }, + env, encoding: 'utf-8', timeout: 30_000, }) From 1baa56090117d0d7bb40a4571b88525395535763 Mon Sep 17 00:00:00 2001 From: Aditya Vikram Singh <247195684+avs-io@users.noreply.github.com> Date: Tue, 8 Sep 2026 00:08:16 +0530 Subject: [PATCH 3/5] test: handshake lock-degraded snapshot poll on serial test:locks Move the cross-process status-snapshot gate off the parallel pool. Wait for the child's first session-refresh.lock EEXIST via a test-only --import probe that syncs ESM builtin exports, then assert degraded stale=true/calls=1/no snapshot and a clean calls=2 persisted snapshot. --- ...cache-refresh-lock-status-snapshot.test.ts | 226 +++++++++++++++++- .../cache-refresh-lock-wait-probe.mjs | 31 +++ tests/session-cache-status-snapshot.test.ts | 215 +---------------- 3 files changed, 261 insertions(+), 211 deletions(-) create mode 100644 tests/fixtures/cache-refresh-lock-wait-probe.mjs diff --git a/tests/cache-refresh-lock-status-snapshot.test.ts b/tests/cache-refresh-lock-status-snapshot.test.ts index d36eef79..4d2e2e7a 100644 --- a/tests/cache-refresh-lock-status-snapshot.test.ts +++ b/tests/cache-refresh-lock-status-snapshot.test.ts @@ -1,13 +1,17 @@ import { afterEach, describe, expect, it } from 'vitest' -import { spawn, type ChildProcess } from 'child_process' +import { spawn, spawnSync, type ChildProcess } from 'child_process' import { createHash } from 'crypto' import { existsSync } from 'fs' import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'fs/promises' import { tmpdir } from 'os' -import { join } from 'path' +import { delimiter, join } from 'path' +import { pathToFileURL } from 'url' +import { acquireCacheRefreshLock } from '../src/cache-refresh-lock.js' import { saveStatusSnapshot } from '../src/session-cache.js' +const LOCK_WAIT_PROBE = pathToFileURL(join(process.cwd(), 'tests/fixtures/cache-refresh-lock-wait-probe.mjs')).href + const roots: string[] = [] async function waitFor(path: string, timeoutMs = 10_000): Promise { @@ -102,3 +106,221 @@ describe('status snapshot child-process write lock', () => { expect((await readdir(cacheDir)).filter(name => name.endsWith('.tmp') || name.endsWith('.lock'))).toEqual([]) }) }) + +// Post-merge review of PR #999: a `status --format menubar-json --no-optimize` +// poll that runs while ANOTHER process holds `session-refresh.lock` parses +// read-only and serves a degraded corpus (payload.stale === true), and a later +// in-process parse — the payload builder's own history re-parse, running after +// the holder releases — flips the module-level hydration global back to +// complete before the save point. A save gate consulting only that global +// persists the under-reported payload under the CURRENT corpus fingerprint, +// poisoning every future poll. This case spawns the real CLI against a live +// cross-process refresh lock, so it lives in the serial `test:locks` suite +// rather than the full parallel `npm test` pool. +const SNAPSHOT_FILE_RE = /^status-snapshot\.[0-9a-f]+\.json$/ +async function snapshotFileNames(cacheDir: string): Promise { + if (!existsSync(cacheDir)) return [] + return (await readdir(cacheDir)).filter(f => SNAPSHOT_FILE_RE.test(f)) +} + +function cliEnv(home: string, extraEnv: Record = {}): NodeJS.ProcessEnv { + return { + ...process.env, + CLAUDE_CONFIG_DIR: join(home, '.claude'), + CODEBURN_CACHE_DIR: join(home, '.cache', 'codeburn'), + HOME: home, USERPROFILE: home, + TZ: 'UTC', + ...extraEnv, + } +} + +type CliResult = { status: number | null, stdout: string, stderr: string } + +function runCli(args: string[], home: string, extraEnv: Record = {}): CliResult { + const result = spawnSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', ...args], { + cwd: process.cwd(), + env: cliEnv(home, extraEnv), + encoding: 'utf-8', + timeout: 60_000, + }) + return { status: result.status, stdout: result.stdout ?? '', stderr: result.stderr ?? '' } +} + +function runCliAsync( + args: string[], + home: string, + extraEnv: Record = {}, + opts: { lockWaitReady?: string } = {}, +): { child: ChildProcess, promise: Promise } { + const nodeArgs = ['--import', 'tsx'] + if (opts.lockWaitReady) nodeArgs.push('--import', LOCK_WAIT_PROBE) + const child = spawn(process.execPath, [...nodeArgs, 'src/cli.ts', ...args], { + cwd: process.cwd(), + env: cliEnv(home, opts.lockWaitReady + ? { ...extraEnv, CODEBURN_LOCK_WAIT_READY: opts.lockWaitReady } + : extraEnv), + }) + const promise = new Promise((resolve, reject) => { + let stdout = '' + let stderr = '' + child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString('utf-8') }) + child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf-8') }) + child.once('error', reject) + child.once('close', status => { resolve({ status, stdout, stderr }) }) + }) + return { child, promise } +} + +function userLine(sessionId: string, timestamp: string): string { + return JSON.stringify({ + type: 'user', + sessionId, + timestamp, + message: { role: 'user', content: 'do the thing' }, + }) +} + +function assistantLine(sessionId: string, timestamp: string, messageId: string): string { + return JSON.stringify({ + type: 'assistant', + sessionId, + timestamp, + message: { + id: messageId, + type: 'message', + role: 'assistant', + model: 'claude-sonnet-4-5', + content: [{ type: 'text', text: 'done' }], + usage: { input_tokens: 500, output_tokens: 50 }, + }, + }) +} + +describe('degraded read-only parse is never persisted as a status snapshot', () => { + it('writes no snapshot for a lock-degraded poll, then resumes persisting on the clean pass', { timeout: 120_000 }, async () => { + const home = await mkdtemp(join(tmpdir(), 'codeburn-snapshot-degraded-')) + roots.push(home) + const cacheDir = join(home, '.cache', 'codeburn') + // Two Claude config roots: the query below is scoped to one of them via + // --claude-config-source. The scoped path is what makes the PR #999 + // sequence reachable in a one-shot process: the payload builder captures + // the hydration verdict right after the (degraded) primary parse, then + // runs its OWN history re-parse over a wider range — a second, real + // parse that re-acquires the lock once the holder releases and flips the + // module-level hydration global back to complete before the save point. + const work = join(home, 'claude-work') + const personal = join(home, 'claude-personal') + await mkdir(join(work, 'projects', 'app'), { recursive: true }) + await mkdir(join(personal, 'projects', 'app'), { recursive: true }) + + // Two hours back, clamped inside the current UTC day (cliEnv pins + // TZ=UTC), so every session falls inside the 'today' query. + const now = new Date() + const todayUtcMidnight = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()) + const base = new Date(Math.max(todayUtcMidnight, now.getTime() - 2 * 3600_000)) + const ts = (offset: number) => new Date(base.getTime() + offset).toISOString().replace(/\.\d+Z$/, 'Z') + + await writeFile( + join(work, 'projects', 'app', 'w1.jsonl'), + [userLine('w1', ts(0)), assistantLine('w1', ts(60_000), 'msg-w1')].join('\n') + '\n', + ) + await writeFile( + join(personal, 'projects', 'app', 'p1.jsonl'), + [userLine('p1', ts(30_000)), assistantLine('p1', ts(90_000), 'msg-p1')].join('\n') + '\n', + ) + const env = { CLAUDE_CONFIG_DIRS: [work, personal].join(delimiter) } + + // Warm the session cache through the DEFAULT optimize path: it parses + // and persists the corpus but never reads or writes the status snapshot + // (main.ts: useSnapshot = !optimize). Also discovers the config-source + // id the scoped queries below select. + const warm = runCli(['status', '--format', 'menubar-json', '--period', 'today', '--provider', 'all'], home, env) + expect(warm.status, `stderr: ${warm.stderr}`).toBe(0) + const warmPayload = JSON.parse(warm.stdout) as { + current: { calls: number } + claudeConfigs: { options: Array<{ id: string, label: string }> } + } + expect(warmPayload.current.calls).toBe(2) + const workSourceId = warmPayload.claudeConfigs.options.find(o => o.label === 'claude-work')?.id + expect(workSourceId).toBeTruthy() + expect(await snapshotFileNames(cacheDir)).toEqual([]) + + // New activity in the SELECTED root that the warm cache has never seen. + // A read-only parse has no cache entry for it and must skip it, + // under-reporting the totals. + await writeFile( + join(work, 'projects', 'app', 'w2.jsonl'), + [userLine('w2', ts(120_000)), assistantLine('w2', ts(180_000), 'msg-w2')].join('\n') + '\n', + ) + + const args = [ + 'status', '--format', 'menubar-json', '--period', 'today', '--provider', 'all', + '--claude-config-source', workSourceId!, + '--no-optimize', + ] + + // A live, heartbeating owner holding the refresh lock, exactly as in + // cache-refresh-lock.test.ts: the pid answers signal 0 and the mtime + // stays fresh, so the child's primary parse can neither acquire nor + // take over — it parks in the wait loop. + const held = await acquireCacheRefreshLock({ cacheDir }) + expect(held.outcome).toBe('acquired') + if (held.outcome !== 'acquired') return + + let degraded + const readyPath = join(home, 'lock-wait.ready') + const running = runCliAsync(args, home, env, { lockWaitReady: readyPath }) + try { + // Release only once the child has failed its first exclusive create on + // session-refresh.lock. The wait-probe writes readyPath on that EEXIST, + // which is the observable that the first acquire did not succeed. + // Releasing earlier would let that FIRST acquire succeed and turn this + // into a clean run; releasing is what reports 'completed-by-other' and + // sends the primary parse down the read-only path while leaving the + // lock free for the payload builder's later history re-parse — the + // exact PR #999 sequence. + const parked = waitFor(readyPath, 60_000).then(() => 'parked' as const) + const first = await Promise.race([parked, running.promise.then(result => ({ result }))]) + if (first !== 'parked') { + throw new Error( + `CLI exited before lock wait: status=${first.result.status} stderr=${first.result.stderr}`, + ) + } + await held.handle.release() + degraded = await running.promise + } finally { + if (running.child.exitCode === null && running.child.signalCode === null) { + running.child.kill('SIGTERM') + } + await running.promise.catch(() => undefined) + await held.handle.release() + } + + expect(degraded.status, `stderr: ${degraded.stderr}`).toBe(0) + const degradedPayload = JSON.parse(degraded.stdout) as { stale?: boolean, current: { calls: number } } + // The primary parse went read-only behind the held lock and served the + // warm cache: w2 is missing from the totals and the payload says so. + expect(degradedPayload.stale).toBe(true) + expect(degradedPayload.current.calls).toBe(1) + // The gate: no snapshot may be persisted from this degraded payload, + // even though the history re-parse after the release flipped the + // hydration global back to complete before the save point. + expect(await snapshotFileNames(cacheDir)).toEqual([]) + + // The gate reopens: the identical query on a clean pass recomputes and + // persists a complete snapshot. + const clean = runCli(args, home, env) + expect(clean.status, `stderr: ${clean.stderr}`).toBe(0) + const cleanPayload = JSON.parse(clean.stdout) as { stale?: boolean, current: { calls: number } } + expect(cleanPayload.stale).toBeUndefined() + expect(cleanPayload.current.calls).toBe(2) + + const snapshots = await snapshotFileNames(cacheDir) + expect(snapshots).toHaveLength(1) + const record = JSON.parse(await readFile(join(cacheDir, snapshots[0]!), 'utf-8')) as { + payload: { stale?: boolean, current: { calls: number } } + } + expect(record.payload.stale).toBeUndefined() + expect(record.payload.current.calls).toBe(2) + }) +}) diff --git a/tests/fixtures/cache-refresh-lock-wait-probe.mjs b/tests/fixtures/cache-refresh-lock-wait-probe.mjs new file mode 100644 index 00000000..c6ca1758 --- /dev/null +++ b/tests/fixtures/cache-refresh-lock-wait-probe.mjs @@ -0,0 +1,31 @@ +import fs from 'node:fs' +import { syncBuiltinESMExports } from 'node:module' +import { basename } from 'node:path' + +// Test-only --import: the real CLI has no "I am parked in lock wait" seam. +// createExclusive uses the named ESM export `open` from 'fs/promises'. +// Patching fs.promises.open alone does not update that live binding (tsx +// already imported the builtin before this probe runs), so the child never +// writes readyPath. syncBuiltinESMExports() republishes the CJS patch to +// the ESM named export the lock code actually calls. +const readyPath = process.env.CODEBURN_LOCK_WAIT_READY +if (readyPath) { + const origOpen = fs.promises.open.bind(fs.promises) + fs.promises.open = async function open(path, flags, mode) { + try { + return await origOpen(path, flags, mode) + } catch (err) { + if ( + err && + /** @type {NodeJS.ErrnoException} */ (err).code === 'EEXIST' && + flags === 'wx' && + typeof path === 'string' && + basename(path) === 'session-refresh.lock' + ) { + try { fs.writeFileSync(readyPath, '') } catch { /* already written */ } + } + throw err + } + } + syncBuiltinESMExports() +} diff --git a/tests/session-cache-status-snapshot.test.ts b/tests/session-cache-status-snapshot.test.ts index 04b59abe..b93df04f 100644 --- a/tests/session-cache-status-snapshot.test.ts +++ b/tests/session-cache-status-snapshot.test.ts @@ -9,15 +9,13 @@ // exercises the locked publication fix for finding B-G1 directly: a slower, // older-corpus write must not clobber a faster, newer one, and two distinct queryKeys // must not evict each other. -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { chmod, mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'fs/promises' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { chmod, mkdir, readdir, readFile, rm } from 'fs/promises' import { existsSync } from 'fs' import { createHash } from 'crypto' -import { spawn, spawnSync } from 'child_process' import { tmpdir } from 'os' -import { delimiter, join } from 'path' +import { join } from 'path' -import { acquireCacheRefreshLock } from '../src/cache-refresh-lock.js' import { loadStatusSnapshot, saveStatusSnapshot } from '../src/session-cache.js' let TMP_DIR: string @@ -221,207 +219,6 @@ describe('load-time re-validation mirrors the save gate', () => { }) }) -// Post-merge review of PR #999: a `status --format menubar-json --no-optimize` -// poll that runs while ANOTHER process holds `session-refresh.lock` parses -// read-only and serves a degraded corpus (payload.stale === true), and a later -// in-process parse — the payload builder's own history re-parse, running after -// the holder releases — flips the module-level hydration global back to -// complete before the save point. A save gate consulting only that global -// persists the under-reported payload under the CURRENT corpus fingerprint, -// poisoning every future poll. These cases spawn the real CLI; each one does -// genuine parse work plus a lock-hold window, so they get the same generous -// timeout as the CLI-spawning suites. -vi.setConfig({ testTimeout: 120_000 }) - -const SNAPSHOT_FILE_RE = /^status-snapshot\.[0-9a-f]+\.json$/ -async function snapshotFileNames(cacheDir: string): Promise { - if (!existsSync(cacheDir)) return [] - return (await readdir(cacheDir)).filter(f => SNAPSHOT_FILE_RE.test(f)) -} - -function cliEnv(home: string, extraEnv: Record = {}): NodeJS.ProcessEnv { - return { - ...process.env, - CLAUDE_CONFIG_DIR: join(home, '.claude'), - CODEBURN_CACHE_DIR: join(home, '.cache', 'codeburn'), - HOME: home, USERPROFILE: home, - TZ: 'UTC', - ...extraEnv, - } -} - -function runCli(args: string[], home: string, extraEnv: Record = {}): { status: number | null, stdout: string, stderr: string } { - const result = spawnSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', ...args], { - cwd: process.cwd(), - env: cliEnv(home, extraEnv), - encoding: 'utf-8', - timeout: 60_000, - }) - return { status: result.status, stdout: result.stdout ?? '', stderr: result.stderr ?? '' } -} - -function runCliAsync(args: string[], home: string, extraEnv: Record = {}): Promise<{ status: number | null, stdout: string, stderr: string }> { - return new Promise((resolve, reject) => { - const child = spawn(process.execPath, ['--import', 'tsx', 'src/cli.ts', ...args], { - cwd: process.cwd(), - env: cliEnv(home, extraEnv), - }) - let stdout = '' - let stderr = '' - child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString('utf-8') }) - child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf-8') }) - child.once('error', reject) - child.once('close', status => { resolve({ status, stdout, stderr }) }) - }) -} - -function userLine(sessionId: string, timestamp: string): string { - return JSON.stringify({ - type: 'user', - sessionId, - timestamp, - message: { role: 'user', content: 'do the thing' }, - }) -} - -function assistantLine(sessionId: string, timestamp: string, messageId: string): string { - return JSON.stringify({ - type: 'assistant', - sessionId, - timestamp, - message: { - id: messageId, - type: 'message', - role: 'assistant', - model: 'claude-sonnet-4-5', - content: [{ type: 'text', text: 'done' }], - usage: { input_tokens: 500, output_tokens: 50 }, - }, - }) -} - -const delay = (ms: number): Promise => new Promise(resolve => { setTimeout(resolve, ms) }) - -describe('degraded read-only parse is never persisted as a status snapshot', () => { - it('writes no snapshot for a lock-degraded poll, then resumes persisting on the clean pass', async () => { - const home = await mkdtemp(join(tmpdir(), 'codeburn-snapshot-degraded-')) - const cacheDir = join(home, '.cache', 'codeburn') - try { - // Two Claude config roots: the query below is scoped to one of them via - // --claude-config-source. The scoped path is what makes the PR #999 - // sequence reachable in a one-shot process: the payload builder captures - // the hydration verdict right after the (degraded) primary parse, then - // runs its OWN history re-parse over a wider range — a second, real - // parse that re-acquires the lock once the holder releases and flips the - // module-level hydration global back to complete before the save point. - const work = join(home, 'claude-work') - const personal = join(home, 'claude-personal') - await mkdir(join(work, 'projects', 'app'), { recursive: true }) - await mkdir(join(personal, 'projects', 'app'), { recursive: true }) - - // Two hours back, clamped inside the current UTC day (cliEnv pins - // TZ=UTC), so every session falls inside the 'today' query. - const now = new Date() - const todayUtcMidnight = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()) - const base = new Date(Math.max(todayUtcMidnight, now.getTime() - 2 * 3600_000)) - const ts = (offset: number) => new Date(base.getTime() + offset).toISOString().replace(/\.\d+Z$/, 'Z') - - await writeFile( - join(work, 'projects', 'app', 'w1.jsonl'), - [userLine('w1', ts(0)), assistantLine('w1', ts(60_000), 'msg-w1')].join('\n') + '\n', - ) - await writeFile( - join(personal, 'projects', 'app', 'p1.jsonl'), - [userLine('p1', ts(30_000)), assistantLine('p1', ts(90_000), 'msg-p1')].join('\n') + '\n', - ) - const env = { CLAUDE_CONFIG_DIRS: [work, personal].join(delimiter) } - - // Warm the session cache through the DEFAULT optimize path: it parses - // and persists the corpus but never reads or writes the status snapshot - // (main.ts: useSnapshot = !optimize). Also discovers the config-source - // id the scoped queries below select. - const warmStart = Date.now() - const warm = runCli(['status', '--format', 'menubar-json', '--period', 'today', '--provider', 'all'], home, env) - const warmElapsedMs = Date.now() - warmStart - expect(warm.status, `stderr: ${warm.stderr}`).toBe(0) - const warmPayload = JSON.parse(warm.stdout) as { - current: { calls: number } - claudeConfigs: { options: Array<{ id: string, label: string }> } - } - expect(warmPayload.current.calls).toBe(2) - const workSourceId = warmPayload.claudeConfigs.options.find(o => o.label === 'claude-work')?.id - expect(workSourceId).toBeTruthy() - expect(await snapshotFileNames(cacheDir)).toEqual([]) - - // New activity in the SELECTED root that the warm cache has never seen. - // A read-only parse has no cache entry for it and must skip it, - // under-reporting the totals. - await writeFile( - join(work, 'projects', 'app', 'w2.jsonl'), - [userLine('w2', ts(120_000)), assistantLine('w2', ts(180_000), 'msg-w2')].join('\n') + '\n', - ) - - const args = [ - 'status', '--format', 'menubar-json', '--period', 'today', '--provider', 'all', - '--claude-config-source', workSourceId!, - '--no-optimize', - ] - - // A live, heartbeating owner holding the refresh lock, exactly as in - // cache-refresh-lock.test.ts: the pid answers signal 0 and the mtime - // stays fresh, so the child's primary parse can neither acquire nor - // take over — it parks in the wait loop. - const held = await acquireCacheRefreshLock({ cacheDir }) - expect(held.outcome).toBe('acquired') - if (held.outcome !== 'acquired') return - - let degraded - try { - const running = runCliAsync(args, home, env) - // Release only once the child is certainly parked in its lock wait: - // the warm run just did the same startup (tsx boot, pricing load, - // corpus fingerprint) the child repeats before it ever touches the - // lock, so its acquire attempt lands well inside this window. - // Releasing earlier would let that FIRST acquire succeed and turn - // this into a clean run; releasing is what reports - // 'completed-by-other' and sends the primary parse down the read-only - // path while leaving the lock free for the payload builder's later - // history re-parse — the exact PR #999 sequence. - await delay(warmElapsedMs + 1_500) - await held.handle.release() - degraded = await running - } finally { - await held.handle.release() - } - - expect(degraded.status, `stderr: ${degraded.stderr}`).toBe(0) - const degradedPayload = JSON.parse(degraded.stdout) as { stale?: boolean, current: { calls: number } } - // The primary parse went read-only behind the held lock and served the - // warm cache: w2 is missing from the totals and the payload says so. - expect(degradedPayload.stale).toBe(true) - expect(degradedPayload.current.calls).toBe(1) - // The gate: no snapshot may be persisted from this degraded payload, - // even though the history re-parse after the release flipped the - // hydration global back to complete before the save point. - expect(await snapshotFileNames(cacheDir)).toEqual([]) - - // The gate reopens: the identical query on a clean pass recomputes and - // persists a complete snapshot. - const clean = runCli(args, home, env) - expect(clean.status, `stderr: ${clean.stderr}`).toBe(0) - const cleanPayload = JSON.parse(clean.stdout) as { stale?: boolean, current: { calls: number } } - expect(cleanPayload.stale).toBeUndefined() - expect(cleanPayload.current.calls).toBe(2) - - const snapshots = await snapshotFileNames(cacheDir) - expect(snapshots).toHaveLength(1) - const record = JSON.parse(await readFile(join(cacheDir, snapshots[0]!), 'utf-8')) as { - payload: { stale?: boolean, current: { calls: number } } - } - expect(record.payload.stale).toBeUndefined() - expect(record.payload.current.calls).toBe(2) - } finally { - await rm(home, { recursive: true, force: true }) - } - }) -}) +// The cross-process lock-degraded snapshot gate (PR #999: stale payload must +// not persist, then a clean pass writes the snapshot) lives in +// cache-refresh-lock-status-snapshot.test.ts so it runs serially via test:locks. From 68ec84a2c668f086700eb09c1bd1aa8405052166 Mon Sep 17 00:00:00 2001 From: Aditya Vikram Singh <247195684+avs-io@users.noreply.github.com> Date: Tue, 8 Sep 2026 00:14:37 +0530 Subject: [PATCH 4/5] test: bound the lock-degraded CLI child with SIGTERM then SIGKILL Give runCliAsync the same 60s wall as spawnSync. A hang SIGTERMs, then SIGKILLs after 1s; both timers clear on a normal close. After lock-wait ready, a non-zero/signaled child fails as stuck rather than awaiting unbounded. --- ...cache-refresh-lock-status-snapshot.test.ts | 69 ++++++++++++++++--- 1 file changed, 61 insertions(+), 8 deletions(-) diff --git a/tests/cache-refresh-lock-status-snapshot.test.ts b/tests/cache-refresh-lock-status-snapshot.test.ts index 4d2e2e7a..8c8ab352 100644 --- a/tests/cache-refresh-lock-status-snapshot.test.ts +++ b/tests/cache-refresh-lock-status-snapshot.test.ts @@ -134,16 +134,44 @@ function cliEnv(home: string, extraEnv: Record = {}): NodeJS.Pro } } -type CliResult = { status: number | null, stdout: string, stderr: string } +type CliResult = { status: number | null, stdout: string, stderr: string, signal: NodeJS.Signals | null } + +// Same wall bound as runCli's spawnSync timeout — not a longer wait. SIGTERM +// grace is only the hang-escalation path, not the happy path. +const CLI_CHILD_MS = 60_000 +const TERM_GRACE_MS = 1_000 + +function stillRunning(child: ChildProcess): boolean { + return child.exitCode === null && child.signalCode === null +} function runCli(args: string[], home: string, extraEnv: Record = {}): CliResult { const result = spawnSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', ...args], { cwd: process.cwd(), env: cliEnv(home, extraEnv), encoding: 'utf-8', - timeout: 60_000, + timeout: CLI_CHILD_MS, + }) + return { status: result.status, stdout: result.stdout ?? '', stderr: result.stderr ?? '', signal: result.signal } +} + +async function stopCliChild(child: ChildProcess): Promise { + if (!stillRunning(child)) return + await new Promise(resolve => { + let settled = false + const done = (): void => { + if (settled) return + settled = true + clearTimeout(grace) + resolve() + } + const grace = setTimeout(() => { + if (stillRunning(child)) child.kill('SIGKILL') + }, TERM_GRACE_MS) + child.once('exit', done) + child.kill('SIGTERM') + if (!stillRunning(child)) done() }) - return { status: result.status, stdout: result.stdout ?? '', stderr: result.stderr ?? '' } } function runCliAsync( @@ -163,10 +191,32 @@ function runCliAsync( const promise = new Promise((resolve, reject) => { let stdout = '' let stderr = '' + let settled = false + let termTimer: ReturnType | undefined + let killTimer: ReturnType | undefined + const clearTimers = (): void => { + if (termTimer !== undefined) clearTimeout(termTimer) + if (killTimer !== undefined) clearTimeout(killTimer) + termTimer = undefined + killTimer = undefined + } + const settle = (fn: () => void): void => { + if (settled) return + settled = true + clearTimers() + fn() + } child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString('utf-8') }) child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf-8') }) - child.once('error', reject) - child.once('close', status => { resolve({ status, stdout, stderr }) }) + child.once('error', err => { settle(() => reject(err)) }) + child.once('close', (status, signal) => { settle(() => resolve({ status, stdout, stderr, signal })) }) + termTimer = setTimeout(() => { + if (!stillRunning(child)) return + child.kill('SIGTERM') + killTimer = setTimeout(() => { + if (stillRunning(child)) child.kill('SIGKILL') + }, TERM_GRACE_MS) + }, CLI_CHILD_MS) }) return { child, promise } } @@ -289,13 +339,16 @@ describe('degraded read-only parse is never persisted as a status snapshot', () await held.handle.release() degraded = await running.promise } finally { - if (running.child.exitCode === null && running.child.signalCode === null) { - running.child.kill('SIGTERM') - } + await stopCliChild(running.child) await running.promise.catch(() => undefined) await held.handle.release() } + if (degraded.status !== 0) { + throw new Error( + `CLI stuck after lock-wait ready: status=${degraded.status} signal=${degraded.signal} stderr=${degraded.stderr}`, + ) + } expect(degraded.status, `stderr: ${degraded.stderr}`).toBe(0) const degradedPayload = JSON.parse(degraded.stdout) as { stale?: boolean, current: { calls: number } } // The primary parse went read-only behind the held lock and served the From c5f4d23b39e8497623c63c1374242f8f0b57a459 Mon Sep 17 00:00:00 2001 From: Aditya Vikram Singh <247195684+avs-io@users.noreply.github.com> Date: Tue, 8 Sep 2026 00:29:48 +0530 Subject: [PATCH 5/5] test: stop owned snapshot children before removing fixtures --- .../cache-refresh-lock-status-snapshot.test.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/tests/cache-refresh-lock-status-snapshot.test.ts b/tests/cache-refresh-lock-status-snapshot.test.ts index 8c8ab352..fdc434f6 100644 --- a/tests/cache-refresh-lock-status-snapshot.test.ts +++ b/tests/cache-refresh-lock-status-snapshot.test.ts @@ -13,6 +13,12 @@ import { saveStatusSnapshot } from '../src/session-cache.js' const LOCK_WAIT_PROBE = pathToFileURL(join(process.cwd(), 'tests/fixtures/cache-refresh-lock-wait-probe.mjs')).href const roots: string[] = [] +const asyncCli: { child: ChildProcess, promise: Promise }[] = [] + +function forgetAsyncCli(child: ChildProcess): void { + const i = asyncCli.findIndex(entry => entry.child === child) + if (i >= 0) asyncCli.splice(i, 1) +} async function waitFor(path: string, timeoutMs = 10_000): Promise { const deadline = Date.now() + timeoutMs @@ -53,7 +59,15 @@ function recordPath(cacheDir: string, queryKey: string): string { afterEach(async () => { delete process.env['CODEBURN_CACHE_DIR'] - await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) + const owned = asyncCli.splice(0) + try { + await Promise.all(owned.map(async ({ child, promise }) => { + try { await stopCliChild(child) } catch { /* still remove roots */ } + try { await promise } catch { /* spawn error or already rejected */ } + })) + } finally { + await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) + } }) describe('status snapshot child-process write lock', () => { @@ -204,6 +218,7 @@ function runCliAsync( if (settled) return settled = true clearTimers() + forgetAsyncCli(child) fn() } child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString('utf-8') }) @@ -218,6 +233,7 @@ function runCliAsync( }, TERM_GRACE_MS) }, CLI_CHILD_MS) }) + asyncCli.push({ child, promise }) return { child, promise } }