codeburn/packages/cli/tests/cache-refresh-lock-process.test.ts
iamtoruk 0fee325b24 Merge branch 'feat/core-extraction' into fix/port-misc
Conflict resolution rules applied:

- packages/cli/src/daily-cache.ts (the only textual conflict): kept the BASE
  side of the version constants and their coordination comment verbatim
  (DAILY_CACHE_VERSION / MIN_SUPPORTED_VERSION stay at 26 — this change moves
  when the watermark advances, not what any cached day contains, so no
  re-derive is owed). Kept the BASE widened re-derive window
  (DAILY_CACHE_RETENTION_DAYS, with its straddle/tz comment) and layered this
  branch's completeness logic on top of it: capture the parse result
  (freshProjects) so sessionComplete() can read the tag off the exact array,
  priorWatermark hold-back on a partial parse, complete: parseWasComplete, and
  the watermarkTrusted stamp.

- packages/cli/src/parser.ts: auto-merged; #929's per-call slicing and #930's
  discovery changes are intact, and this branch's read-only-path additions
  (readOnlyServedStale on the stale/missing-cache-entry arms, network providers
  served from cache instead of re-fetched, hydration completeness tagged onto
  the result array) attach to the post-#929 code paths. Re-read end to end
  after the merge.

- packages/cli/tests/cache-refresh-lock*.test.ts, vitest.config.ts,
  package.json: kept the base config values (testTimeout 30s, retry 2, the
  lock-quarantine test/test:locks scripts, #1068's inode fix, #921's retries)
  and added this branch's corrupt-lock arms on top. The new describe's retry
  is 3, not 6, per review. Added the new corrupt-body file to test:locks:
  the base `test` script excludes tests/cache-refresh-lock*, so the new file
  would otherwise run in no suite.

- Everything else auto-merged as a union; no hunk from either side dropped.
2026-08-21 10:34:13 -07:00

234 lines
11 KiB
TypeScript

import { afterEach, describe, expect, it } from 'vitest'
import { spawn, type ChildProcess } from 'child_process'
import { existsSync } from 'fs'
import { mkdir, mkdtemp, readdir, rm, stat, utimes, writeFile } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { emptyCache, loadCache, saveCache } from '../src/session-cache.js'
const roots: string[] = []
const children: ChildProcess[] = []
async function killChild(child: ChildProcess): Promise<void> {
// Robust to a child that already exited: exitCode is set once the process
// is gone, so the early return skips it and the kill below is a no-op.
if (child.exitCode !== null) return
// A test that failed mid-flight can still hold pending waitForExit promises
// on this child; detach them so the SIGTERM exit does not reject a promise
// nobody is awaiting (unhandled-rejection noise on top of the real failure).
child.removeAllListeners('exit')
child.removeAllListeners('error')
const exited = new Promise<void>(resolve => child.once('exit', () => resolve()))
if (child.exitCode !== null) return // exited between the check and the listener
if (!child.kill()) return
await Promise.race([exited, new Promise(resolve => { setTimeout(resolve, 1_000) })])
if (child.exitCode === null) child.kill('SIGKILL')
await Promise.race([exited, new Promise(resolve => { setTimeout(resolve, 1_000) })])
}
async function waitFor(path: string, timeoutMs = 5_000): Promise<void> {
const deadline = Date.now() + timeoutMs
while (!existsSync(path)) {
if (Date.now() >= deadline) throw new Error(`timed out waiting for ${path}`)
await new Promise(resolve => { setTimeout(resolve, 5) })
}
}
async function waitForAny(dir: string, names: string[], timeoutMs = 5_000): Promise<string> {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
for (const name of names) if (existsSync(join(dir, name))) return name
await new Promise(resolve => { setTimeout(resolve, 5) })
}
throw new Error(`timed out waiting for ${names.join(', ')}; saw ${(await readdir(dir)).join(', ')}`)
}
function waitForExit(child: ChildProcess): Promise<void> {
if (child.exitCode !== null) return child.exitCode === 0 ? Promise.resolve() : Promise.reject(new Error(`worker exited ${child.exitCode}`))
return new Promise((resolve, reject) => {
let stderr = ''
child.stderr?.on('data', chunk => { stderr += String(chunk) })
child.once('error', reject)
child.once('exit', code => code === 0 ? resolve() : reject(new Error(`worker exited ${code}: ${stderr}`)))
})
}
function worker(cacheDir: string, barriers: string, id: string, source: string, bypass = false): ChildProcess {
// Resolve the fixture relative to this file, not process.cwd(): vitest keeps
// the shell's cwd (e.g. the repo root under `--root packages/cli`), where
// `tests/fixtures/` does not exist and every child dies on ENOENT before
// touching a barrier.
const child = spawn(process.execPath, ['--import', 'tsx', join(import.meta.dirname, 'fixtures/cache-refresh-worker.ts'), cacheDir, barriers, id, source, String(bypass)], {
cwd: process.cwd(),
stdio: ['ignore', 'ignore', 'pipe'],
})
children.push(child)
return child
}
afterEach(async () => {
delete process.env['CODEBURN_CACHE_DIR']
// A failed assertion (or a waitFor timeout) can leave a worker blocked on
// its barrier file indefinitely; with the global retry the next attempt
// spawns a fresh pair on top of the leaked ones. Kill everything we
// spawned and wait for it to die before tearing the temp roots down.
// Upstream 2a4b8f2 has the identical leaky afterEach, so this is not a
// regression the port introduces — the retry just makes the latent leak
// reachable.
await Promise.all(children.splice(0).map(killChild))
await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true })))
})
describe('warm refresh child-process regression', () => {
it('gives exactly one contender ownership of a stale lock', async () => {
const root = await mkdtemp(join(tmpdir(), 'cb-refresh-stale-'))
roots.push(root)
const cacheDir = join(root, 'cache')
const barriers = join(root, 'barriers')
await mkdir(cacheDir, { recursive: true })
await mkdir(barriers, { recursive: true })
process.env['CODEBURN_CACHE_DIR'] = cacheDir
const initial = emptyCache()
initial.complete = true
await saveCache(initial)
const stalePath = join(cacheDir, 'session-refresh.lock')
await writeFile(stalePath, JSON.stringify({ pid: 1, token: 'abandoned', at: 1 }))
await utimes(stalePath, new Date(1), new Date(1))
const source = join(root, 'changed.json')
await writeFile(source, JSON.stringify({ output: 303 }))
const a = worker(cacheDir, barriers, 'a', source)
const b = worker(cacheDir, barriers, 'b', source)
const winner = await Promise.race([
waitFor(join(barriers, 'a.parsed')).then(() => 'a'),
waitFor(join(barriers, 'b.parsed')).then(() => 'b'),
])
const loser = winner === 'a' ? 'b' : 'a'
expect(Number(existsSync(join(barriers, 'a.parsed'))) + Number(existsSync(join(barriers, 'b.parsed')))).toBe(1)
// Keep the winner alive through the loser's full waiter budget: a second
// stale contender must never publish or steal a heartbeating successor.
const loserOutcome = await waitForAny(barriers, [
`${loser}.timed-out`, `${loser}.parsed`, `${loser}.completed-by-other`, `${loser}.unavailable`,
])
expect(loserOutcome, (await readdir(barriers)).join(',')).toBe(`${loser}.timed-out`)
await writeFile(join(barriers, `${winner}.save`), '')
await Promise.all([waitForExit(a), waitForExit(b)])
await expect(stat(join(cacheDir, 'session-refresh.lock.takeover'))).rejects.toMatchObject({ code: 'ENOENT' })
})
it('gives exactly one contender ownership of a stale zero-byte lock', async () => {
const root = await mkdtemp(join(tmpdir(), 'cb-refresh-corrupt-'))
roots.push(root)
const cacheDir = join(root, 'cache')
const barriers = join(root, 'barriers')
await mkdir(cacheDir, { recursive: true })
await mkdir(barriers, { recursive: true })
process.env['CODEBURN_CACHE_DIR'] = cacheDir
const initial = emptyCache()
initial.complete = true
await saveCache(initial)
const corruptPath = join(cacheDir, 'session-refresh.lock')
await writeFile(corruptPath, '')
await utimes(corruptPath, new Date(1), new Date(1))
const source = join(root, 'changed.json')
await writeFile(source, JSON.stringify({ output: 404 }))
const a = worker(cacheDir, barriers, 'a', source)
const b = worker(cacheDir, barriers, 'b', source)
const winner = await Promise.race([
waitFor(join(barriers, 'a.parsed')).then(() => 'a'),
waitFor(join(barriers, 'b.parsed')).then(() => 'b'),
])
const loser = winner === 'a' ? 'b' : 'a'
expect(Number(existsSync(join(barriers, 'a.parsed'))) + Number(existsSync(join(barriers, 'b.parsed')))).toBe(1)
const loserOutcome = await waitForAny(barriers, [
`${loser}.timed-out`, `${loser}.parsed`, `${loser}.completed-by-other`, `${loser}.unavailable`,
])
expect(loserOutcome, (await readdir(barriers)).join(',')).toBe(`${loser}.timed-out`)
await writeFile(join(barriers, `${winner}.save`), '')
await Promise.all([waitForExit(a), waitForExit(b)])
await expect(stat(join(cacheDir, 'session-refresh.lock.takeover'))).rejects.toMatchObject({ code: 'ENOENT' })
expect(Object.keys((await loadCache()).providers['regression']?.files ?? {})).toEqual([source])
})
it('serializes disjoint parsed updates so the later publication cannot drop the first', async () => {
const root = await mkdtemp(join(tmpdir(), 'cb-refresh-process-'))
roots.push(root)
const cacheDir = join(root, 'cache')
const barriers = join(root, 'barriers')
await mkdir(cacheDir, { recursive: true })
await mkdir(barriers, { recursive: true })
process.env['CODEBURN_CACHE_DIR'] = cacheDir
const initial = emptyCache()
initial.complete = true
await saveCache(initial)
const sourceA = join(root, 'changed-a.json')
const sourceB = join(root, 'changed-b.json')
await writeFile(sourceA, JSON.stringify({ output: 101 }))
await writeFile(sourceB, JSON.stringify({ output: 202 }))
const a = worker(cacheDir, barriers, 'a', sourceA)
const b = worker(cacheDir, barriers, 'b', sourceB)
// Exactly one child can cross the acquisition barrier. Let it publish and
// release; only then can the other reload the first child's update.
let retry: ChildProcess | undefined
await Promise.race([
waitFor(join(barriers, 'a.parsed')).then(() => 'a'),
waitFor(join(barriers, 'b.parsed')).then(() => 'b'),
]).then(async first => {
const second = first === 'a' ? 'b' : 'a'
const secondSource = second === 'a' ? sourceA : sourceB
await writeFile(join(barriers, `${first}.save`), '')
await waitFor(join(barriers, `${first}.published`))
// A waiter that observes the clean release correctly serves the holder's
// fresh snapshot instead of mutating. A subsequent refresh of that
// process then acquires normally and applies its independently visible
// source change on top of the holder's publication.
const outcome = await waitForAny(barriers, [`${second}.completed-by-other`, `${second}.parsed`])
if (outcome === `${second}.parsed`) {
await writeFile(join(barriers, `${second}.save`), '')
} else {
await waitForExit(second === 'a' ? a : b)
retry = worker(cacheDir, barriers, `${second}-retry`, secondSource)
await waitFor(join(barriers, `${second}-retry.parsed`))
await writeFile(join(barriers, `${second}-retry.save`), '')
}
})
await Promise.all([waitForExit(a), waitForExit(b), ...(retry ? [waitForExit(retry)] : [])])
const files = (await loadCache()).providers['regression']?.files ?? {}
expect(Object.keys(files).sort()).toEqual([sourceA, sourceB].sort())
})
it('proves the barrier reproducer loses one update when the transaction gate is bypassed', async () => {
const root = await mkdtemp(join(tmpdir(), 'cb-refresh-control-'))
roots.push(root)
const cacheDir = join(root, 'cache')
const barriers = join(root, 'barriers')
await mkdir(cacheDir, { recursive: true })
await mkdir(barriers, { recursive: true })
process.env['CODEBURN_CACHE_DIR'] = cacheDir
const initial = emptyCache()
initial.complete = true
await saveCache(initial)
const sourceA = join(root, 'changed-a.json')
const sourceB = join(root, 'changed-b.json')
await writeFile(sourceA, JSON.stringify({ output: 101 }))
await writeFile(sourceB, JSON.stringify({ output: 202 }))
const a = worker(cacheDir, barriers, 'a', sourceA, true)
const b = worker(cacheDir, barriers, 'b', sourceB, true)
await Promise.all([waitFor(join(barriers, 'a.parsed')), waitFor(join(barriers, 'b.parsed'))])
await writeFile(join(barriers, 'a.save'), '')
await waitFor(join(barriers, 'a.published'))
await writeFile(join(barriers, 'b.save'), '')
await Promise.all([waitForExit(a), waitForExit(b)])
const files = (await loadCache()).providers['regression']?.files ?? {}
expect(Object.keys(files)).toEqual([sourceB])
})
})