diff --git a/src/cache-refresh-lock.ts b/src/cache-refresh-lock.ts new file mode 100644 index 00000000..ea101fbd --- /dev/null +++ b/src/cache-refresh-lock.ts @@ -0,0 +1,331 @@ +import { randomBytes } from 'crypto' +import { existsSync } from 'fs' +import { mkdir, open, readFile, stat, unlink, utimes, writeFile } from 'fs/promises' +import { homedir } from 'os' +import { join } from 'path' + +const LOCK_FILE = 'session-refresh.lock' +const TAKEOVER_FILE = `${LOCK_FILE}.takeover` +const DEFAULT_HEARTBEAT_MS = 10_000 +const DEFAULT_STALE_MS = 90_000 +const DEFAULT_WAIT_MS = 30_000 +const DEFAULT_POLL_MS = 100 +const WINDOWS_RETRIES = 3 + +type LockRecord = { pid: number; token: string; at: number } + +export type RefreshLockClock = { + monotonicNow: () => number + wallNow: () => number +} + +export type RefreshLockOptions = { + cacheDir?: string + clock?: RefreshLockClock + heartbeatMs?: number + staleMs?: number + waitMs?: number + pollMs?: number + sleep?: (ms: number) => Promise +} + +export type RefreshLockHandle = { + token: string + release: () => Promise + verifyStillOwner: () => Promise +} + +export type RefreshLockOutcome = + | { outcome: 'acquired'; handle: RefreshLockHandle } + | { outcome: 'completed-by-other' } + | { outcome: 'timed-out' } + | { outcome: 'unavailable' } + +const defaultClock: RefreshLockClock = { + monotonicNow: () => Number(process.hrtime.bigint()) / 1_000_000, + wallNow: () => Date.now(), +} + +function defaultCacheDir(): string { + return process.env['CODEBURN_CACHE_DIR'] ?? join(homedir(), '.cache', 'codeburn') +} + +function delay(ms: number): Promise { + return new Promise(resolve => { setTimeout(resolve, ms) }) +} + +function isBusyError(err: unknown): boolean { + const code = (err as NodeJS.ErrnoException | undefined)?.code + return code === 'EPERM' || code === 'EBUSY' +} + +function isExistsError(err: unknown): boolean { + return (err as NodeJS.ErrnoException | undefined)?.code === 'EEXIST' +} + +function isMissingError(err: unknown): boolean { + return (err as NodeJS.ErrnoException | undefined)?.code === 'ENOENT' +} + +async function retryWindowsMutation(operation: () => Promise, sleep: (ms: number) => Promise): Promise { + for (let attempt = 0; attempt < WINDOWS_RETRIES; attempt++) { + try { + await operation() + return true + } catch (err) { + if (isMissingError(err)) return true + if (!isBusyError(err) || attempt === WINDOWS_RETRIES - 1) return false + await sleep(10 * (attempt + 1)) + } + } + return false +} + +async function createExclusive(path: string, body: string): Promise<'created' | 'exists' | 'unavailable'> { + try { + const handle = await open(path, 'wx', 0o600) + try { await handle.writeFile(body, { encoding: 'utf-8' }) } + finally { await handle.close() } + return 'created' + } catch (err) { + return isExistsError(err) ? 'exists' : 'unavailable' + } +} + +type Observation = { record: LockRecord; mtimeMs: number } +type ObservationResult = Observation | 'missing' | 'changing' | 'unavailable' + +async function observe(path: string): Promise { + // Exclusive create exposes the directory entry just before its small body is + // written, and heartbeat rewrites briefly truncate it. Treat that bounded + // transition as contention, not broken infrastructure. + let sawChange = false + for (let attempt = 0; attempt < 3; attempt++) { + try { + const before = await stat(path) + const raw = await readFile(path, 'utf-8') + const after = await stat(path) + if (before.mtimeMs !== after.mtimeMs || before.size !== after.size) { + sawChange = true + await delay(1) + continue + } + const parsed = JSON.parse(raw) as Partial + if (typeof parsed.pid === 'number' && typeof parsed.token === 'string' && typeof parsed.at === 'number') { + return { record: { pid: parsed.pid, token: parsed.token, at: parsed.at }, mtimeMs: after.mtimeMs } + } + } catch (err) { + if (isMissingError(err)) return 'missing' + const code = (err as NodeJS.ErrnoException | undefined)?.code + if (code === 'EACCES' || code === 'EPERM') return 'unavailable' + } + await delay(1) + } + return sawChange ? 'changing' : 'unavailable' +} + +function sameObservation(a: Observation, b: Observation): boolean { + return a.record.token === b.record.token && a.mtimeMs === b.mtimeMs +} + +let singleFlightTail: Promise = Promise.resolve() + +async function enterSingleFlight(): Promise<() => void> { + const previous = singleFlightTail + let leave!: () => void + singleFlightTail = new Promise(resolve => { leave = resolve }) + await previous + return leave +} + +/** + * Strict gate for the warm session-cache read/reconcile/parse/save transaction. + * Lock ordering, when the daily-cache follow-up lands, is daily → session. + */ +export async function acquireCacheRefreshLock(options: RefreshLockOptions = {}): Promise { + const leaveSingleFlight = await enterSingleFlight() + let ownsSingleFlight = true + const leave = (): void => { + if (!ownsSingleFlight) return + ownsSingleFlight = false + leaveSingleFlight() + } + + const cacheDir = options.cacheDir ?? defaultCacheDir() + const clock = options.clock ?? defaultClock + const heartbeatMs = options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS + const staleMs = options.staleMs ?? DEFAULT_STALE_MS + const waitMs = options.waitMs ?? DEFAULT_WAIT_MS + const pollMs = options.pollMs ?? DEFAULT_POLL_MS + const sleep = options.sleep ?? delay + const lockPath = join(cacheDir, LOCK_FILE) + const takeoverPath = join(cacheDir, TAKEOVER_FILE) + const token = randomBytes(16).toString('hex') + const body = (): string => JSON.stringify({ pid: process.pid, token, at: clock.wallNow() }) + + const acquireTakeoverGuard = async (): Promise<'created' | 'exists' | 'unavailable'> => { + const created = await createExclusive(takeoverPath, body()) + if (created !== 'exists') return created + const staleGuard = await observe(takeoverPath) + if (staleGuard === 'missing') return createExclusive(takeoverPath, body()) + if (staleGuard === 'changing') return 'exists' + if (staleGuard === 'unavailable') return 'unavailable' + if (Math.max(0, clock.wallNow() - staleGuard.mtimeMs) <= staleMs) return 'exists' + const reverified = await observe(takeoverPath) + if (reverified === 'missing') return createExclusive(takeoverPath, body()) + if (reverified === 'changing') return 'exists' + if (reverified === 'unavailable') return 'unavailable' + if (!sameObservation(staleGuard, reverified)) return 'exists' + if (!await retryWindowsMutation(() => unlink(takeoverPath), sleep)) return 'unavailable' + return createExclusive(takeoverPath, body()) + } + + const removeIfOwned = async (): Promise => { + // A contender holds the takeover guard only for milliseconds at a time; + // retry briefly rather than abandoning our lock to 90s stale-timeout, + // which would stall every waiting process for that long. + let guard: 'created' | 'exists' | 'unavailable' = 'exists' + for (let attempt = 0; attempt < 20 && guard !== 'created'; attempt++) { + guard = await acquireTakeoverGuard() + if (guard === 'unavailable') return false + if (guard !== 'created') await sleep(pollMs) + } + if (guard !== 'created') return false + try { + const current = await observe(lockPath) + if (current === 'missing') return true + if (current === 'changing') return false + if (current === 'unavailable') return false + if (current.record.token !== token) return true + return retryWindowsMutation(() => unlink(lockPath), sleep) + } finally { + await retryWindowsMutation(() => unlink(takeoverPath), sleep) + } + } + + const verifyStillOwner = async (): Promise => { + const guard = await acquireTakeoverGuard() + if (guard !== 'created') return false + try { + const current = await observe(lockPath) + return current !== 'missing' && current !== 'changing' && current !== 'unavailable' && current.record.token === token + } finally { + await retryWindowsMutation(() => unlink(takeoverPath), sleep) + } + } + + const makeHandle = (): RefreshLockHandle => { + let released = false + let heartbeatRunning = false + const heartbeat = setInterval(() => { + void (async () => { + if (released || heartbeatRunning) return + heartbeatRunning = true + const guard = await acquireTakeoverGuard() + if (guard !== 'created') { heartbeatRunning = false; return } + try { + const current = await observe(lockPath) + if (current === 'missing' || current === 'changing' || current === 'unavailable' || current.record.token !== token) return + await writeFile(lockPath, body(), { encoding: 'utf-8' }) + const now = new Date(clock.wallNow()) + await utimes(lockPath, now, now) + } catch { /* verify/release will turn displacement or I/O failure into a closed gate */ } + finally { + await retryWindowsMutation(() => unlink(takeoverPath), sleep) + heartbeatRunning = false + } + })() + }, heartbeatMs) + heartbeat.unref() + + return { + token, + verifyStillOwner, + release: async () => { + if (released) return + released = true + clearInterval(heartbeat) + while (heartbeatRunning) await sleep(1) + await removeIfOwned() + leave() + }, + } + } + + const tryCreateOwner = async (): Promise => { + const result = await createExclusive(lockPath, body()) + if (result === 'created') return { outcome: 'acquired', handle: makeHandle() } + if (result === 'unavailable') return { outcome: 'unavailable' } + return null + } + + const tryTakeover = async (stale: Observation): Promise => { + const guard = await acquireTakeoverGuard() + if (guard === 'unavailable') return { outcome: 'unavailable' } + if (guard === 'exists') return null + try { + const current = await observe(lockPath) + if (current === 'unavailable') return { outcome: 'unavailable' } + if (current === 'changing') return null + if (current === 'missing' || !sameObservation(stale, current)) return null + if (Math.max(0, clock.wallNow() - current.mtimeMs) <= staleMs) return null + if (!await retryWindowsMutation(() => unlink(lockPath), sleep)) return { outcome: 'unavailable' } + // Publish the successor while the takeover guard is still canonical. + // Otherwise a waiter can observe neither file and misclassify the narrow + // unlink/create gap as a clean completion by the stale owner. + const successor = await createExclusive(lockPath, body()) + if (successor === 'created') return { outcome: 'acquired', handle: makeHandle() } + if (successor === 'unavailable') return { outcome: 'unavailable' } + return null + } finally { + // Never override the try-block's outcome from here: returning + // 'unavailable' after 'acquired' would abandon a live heartbeating + // handle that then blocks every other process until this one exits. + // A guard file we fail to remove reads as contention to others and is + // replaced once stale. + await retryWindowsMutation(() => unlink(takeoverPath), sleep) + } + } + + try { + if (!existsSync(cacheDir)) await mkdir(cacheDir, { recursive: true }) + const immediate = await tryCreateOwner() + if (immediate) { + if (immediate.outcome !== 'acquired') leave() + return immediate + } + + const deadline = clock.monotonicNow() + waitMs + while (clock.monotonicNow() < deadline) { + const observation = await observe(lockPath) + if (observation === 'unavailable') { leave(); return { outcome: 'unavailable' } } + if (observation === 'changing') { await sleep(pollMs); continue } + if (observation === 'missing') { + // A stale taker removes the primary while holding the guard, then + // exclusively creates its successor. Do not misreport that narrow gap + // as a clean completion by the previous owner. + const guard = await observe(takeoverPath) + if (guard === 'unavailable') { leave(); return { outcome: 'unavailable' } } + if (guard === 'changing') { await sleep(pollMs); continue } + if (guard === 'missing') { leave(); return { outcome: 'completed-by-other' } } + await sleep(pollMs) + continue + } + + const age = Math.max(0, clock.wallNow() - observation.mtimeMs) + if (age > staleMs) { + const takeover = await tryTakeover(observation) + if (takeover) { + if (takeover.outcome !== 'acquired') leave() + return takeover + } + } + await sleep(pollMs) + } + leave() + return { outcome: 'timed-out' } + } catch { + leave() + return { outcome: 'unavailable' } + } +} diff --git a/src/parser.ts b/src/parser.ts index 96c72dda..02a03e5e 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -1,4 +1,5 @@ -import { existsSync } from 'fs' +import { createReadStream, existsSync } from 'fs' +import { createHash } from 'crypto' import { lstat, readFile, readdir, stat } from 'fs/promises' import { basename, dirname, join, resolve, sep } from 'path' import { readSessionLines } from './fs-utils.js' @@ -25,6 +26,7 @@ import { reconcileFile, saveCache, } from './session-cache.js' +import { acquireCacheRefreshLock, type RefreshLockHandle } from './cache-refresh-lock.js' import type { ParsedProviderCall, SessionSource } from './providers/types.js' import type { ApiUsageIteration, @@ -1587,13 +1589,14 @@ async function scanProjectDirs( // caller (parseAllSessions) can persist partial progress. A run killed // mid-scan then resumes from a warm cache instead of re-parsing from zero. onFileParsed?: () => Promise, + readOnly = false, ): Promise { const section = getOrCreateProviderSection(diskCache, 'claude') const allDiscoveredFiles = new Set() type FileInfo = { dirName: string; fp: NonNullable>>; source?: SessionSourceMetadata } const unchangedFiles: Array<{ filePath: string; dirName: string; source?: SessionSourceMetadata; cached: CachedFile }> = [] - const changedFiles: Array<{ filePath: string; info: FileInfo }> = [] + const changedFiles: Array<{ filePath: string; info: FileInfo; cached?: CachedFile }> = [] const discoverProgress = createScanProgress('scanning claude project dirs', dirs.length) let dirsDone = 0 @@ -1604,11 +1607,12 @@ async function scanProjectDirs( const fp = await fingerprintFile(filePath) if (!fp) continue - const action = reconcileFile(fp, section.files[filePath]) - if (action.action === 'unchanged') { + const cached = section.files[filePath] + const action = reconcileFile(fp, cached) + if (cached && (readOnly || action.action === 'unchanged')) { unchangedFiles.push({ filePath, dirName, source, cached: section.files[filePath]! }) - } else { - changedFiles.push({ filePath, info: { dirName, fp, source } }) + } else if (!readOnly) { + changedFiles.push({ filePath, info: { dirName, fp, source }, cached }) } } dirsDone++ @@ -1616,6 +1620,16 @@ async function scanProjectDirs( } discoverProgress.finish() + if (readOnly) { + for (const [filePath, cached] of Object.entries(section.files)) { + if (allDiscoveredFiles.has(filePath)) continue + const dirName = cached.canonicalProjectName + ?? cached.turns[0]?.calls[0]?.project + ?? basename(dirname(filePath)) + unchangedFiles.push({ filePath, dirName, cached }) + } + } + // Pre-seed dedup set from cached (unchanged) files for (const { cached } of unchangedFiles) { for (const turn of cached.turns) { @@ -1629,24 +1643,77 @@ async function scanProjectDirs( const progressTotal = changedFiles.length let filesDone = 0 emitScanProgress({ kind: 'tick', provider: 'claude', done: 0, total: progressTotal }) - for (const { filePath, info } of changedFiles) { + for (const { filePath, info, cached } of changedFiles) { delete section.files[filePath] try { - const tracker = { lastCompleteLineOffset: 0 } - const entries = await parseClaudeEntries(filePath, tracker) - if (!entries) { filesDone++; await parseProgress.tick(filesDone); continue } + let tracker = { lastCompleteLineOffset: 0 } + let parsed: Awaited> | undefined + let retainedTurns: CachedTurn[] = [] + let incremental = false + + if ( + cached?.checkpoint + && info.fp.sizeBytes > cached.fingerprint.sizeBytes + && await matchesClaudeCheckpointPrefix(filePath, cached) + ) { + tracker = { lastCompleteLineOffset: cached.checkpoint.parsedBytes } + const suffix = await parseClaudeEntries(filePath, tracker, cached.checkpoint.parsedBytes) + retainedTurns = cached.checkpoint.parsedBytes === 0 + ? [] + : cached.turns.slice(0, firstOpenTurnMatchesCachedLast(suffix.entries, cached) ? -1 : undefined) + + // A repeated streaming id may update the provisional turn (which was + // deliberately dropped), but an id from a retained turn would make a + // boundary-only replay non-deterministic. Full parse that rare shape. + if (!suffixSupersedesRetainedTurn(suffix.entries, retainedTurns)) { + parsed = suffix + incremental = true + } + } + + if (!parsed) { + tracker = { lastCompleteLineOffset: 0 } + parsed = await parseClaudeEntries(filePath, tracker) + retainedTurns = [] + } + + if (!parsed.hasLines || parsed.entries.length === 0) { + if (!incremental) { filesDone++; await parseProgress.tick(filesDone); continue } + } - const turns = groupIntoTurns(dedupeStreamingMessageIds(entries), seenMsgIds) - const cwd = extractCanonicalCwd(entries) + for (const turn of retainedTurns) { + for (const call of turn.calls) seenMsgIds.add(call.deduplicationKey) + } + const newTurns = groupIntoTurns(dedupeStreamingMessageIds(parsed.entries), seenMsgIds) + const turns = [...retainedTurns, ...newTurns.map(parsedTurnToCachedTurn)] + const cwd = incremental && cached?.canonicalCwd + ? undefined + : extractCanonicalCwd(parsed.entries) const canonical = (cwd && !isCoworkSession(cwd, filePath)) ? await resolveCanonicalProjectPath(cwd) : undefined + const checkpointOffset = parsed.checkpointOffset + // A checkpoint is an optimization, not a correctness requirement. If its + // hash cannot be read, keep the full parse and let the next refresh take + // the normal full-parse path rather than caching a failure marker. + const prefixSha256 = await sha256FilePrefix(filePath, checkpointOffset).catch(() => undefined) + const incrementalInventory = extractMcpInventory(parsed.entries) section.files[filePath] = { fingerprint: info.fp, lastCompleteLineOffset: tracker.lastCompleteLineOffset, - canonicalCwd: canonical?.path, - canonicalProjectName: canonical?.isWorktree ? projectNameFromPath(canonical.path, info.dirName) : undefined, - mcpInventory: extractMcpInventory(entries), - turns: turns.map(parsedTurnToCachedTurn), + checkpoint: prefixSha256 + ? { parsedBytes: checkpointOffset, prefixSha256, version: 1 } + : undefined, + canonicalCwd: incremental ? cached?.canonicalCwd ?? canonical?.path : canonical?.path, + canonicalProjectName: incremental + ? cached?.canonicalProjectName + ?? (canonical?.isWorktree ? projectNameFromPath(canonical.path, info.dirName) : undefined) + : canonical?.isWorktree ? projectNameFromPath(canonical.path, info.dirName) : undefined, + // extractMcpInventory returns sorted names, so a sorted union keeps + // the incremental result deep-equal to a from-scratch parse. + mcpInventory: incremental + ? Array.from(new Set([...(cached?.mcpInventory ?? []), ...incrementalInventory])).sort() + : incrementalInventory, + turns, agentType: await readAgentType(filePath), } ;(diskCache as { _dirty?: boolean })._dirty = true @@ -1671,7 +1738,7 @@ async function scanProjectDirs( } parseProgress.finish() - if (dirs.length > 0) { + if (!readOnly && dirs.length > 0) { for (const cachedPath of Object.keys(section.files)) { if (!allDiscoveredFiles.has(cachedPath)) { delete section.files[cachedPath] @@ -1971,19 +2038,88 @@ function cachedTurnToClassified(turn: CachedTurn): ClassifiedTurn { async function parseClaudeEntries( filePath: string, tracker: { lastCompleteLineOffset: number }, -): Promise { + startByteOffset = 0, +): Promise<{ entries: JournalEntry[]; checkpointOffset: number; hasLines: boolean }> { const entries: JournalEntry[] = [] let hasLines = false + let nextLineStart = startByteOffset + let openTurnStart: number | undefined for await (const line of readSessionLines(filePath, undefined, { largeLineAsBuffer: true, + startByteOffset, byteOffsetTracker: tracker, })) { hasLines = true + // The tracker advances before a yielded complete line. Empty lines are not + // yielded, so using the preceding yielded boundary is conservative: a + // checkpoint may re-read harmless blank lines but can never begin mid-line. + const lineStart = nextLineStart + nextLineStart = tracker.lastCompleteLineOffset const entry = parseJsonlLine(line) - if (entry) entries.push(compactEntry(entry)) + if (!entry) continue + const compacted = compactEntry(entry) + entries.push(compacted) + if (compacted.type === 'user' && getUserMessageText(compacted).trim()) { + openTurnStart = lineStart + } + } + return { entries, checkpointOffset: openTurnStart ?? startByteOffset, hasLines } +} + +async function sha256FilePrefix(filePath: string, byteLength: number): Promise { + const hash = createHash('sha256') + if (byteLength === 0) return hash.digest('hex') + await new Promise((resolvePromise, reject) => { + const stream = createReadStream(filePath, { start: 0, end: byteLength - 1 }) + stream.on('data', chunk => hash.update(chunk)) + stream.on('end', resolvePromise) + stream.on('error', reject) + }) + return hash.digest('hex') +} + +/** Exported for the parity suite's load-bearing-guard assertion. */ +export async function matchesClaudeCheckpointPrefix( + filePath: string, + cached: CachedFile, + validatePrefixHash = true, +): Promise { + const checkpoint = cached.checkpoint + if (!checkpoint || checkpoint.version !== 1) return false + if (!validatePrefixHash) return true + try { + return await sha256FilePrefix(filePath, checkpoint.parsedBytes) === checkpoint.prefixSha256 + } catch { + return false } - if (!hasLines || entries.length === 0) return null - return entries +} + +function firstOpenTurnMatchesCachedLast(entries: JournalEntry[], cached: CachedFile): boolean { + const last = cached.turns.at(-1) + if (!last) return false + const suffixKeys = new Set() + for (const entry of entries) { + const call = parseApiCall(entry) + if (call) suffixKeys.add(call.deduplicationKey) + for (const advisorCall of parseAdvisorCalls(entry)) suffixKeys.add(advisorCall.deduplicationKey) + } + // The checkpoint suffix contains the whole provisional turn. A cached last + // turn belongs to that suffix iff at least one of its calls is re-observed; + // this avoids guessing from timestamps or identical adjacent user messages. + return last.calls.some(call => suffixKeys.has(call.deduplicationKey)) +} + +function suffixSupersedesRetainedTurn(entries: JournalEntry[], retained: CachedTurn[]): boolean { + const retainedKeys = new Set(retained.flatMap(turn => turn.calls.map(call => call.deduplicationKey))) + for (const entry of entries) { + const id = getMessageId(entry) + if (!id) continue + if (retainedKeys.has(id)) return true + for (const key of retainedKeys) { + if (key.startsWith(`${id}:advisor:`)) return true + } + } + return false } function getOrCreateProviderSection(cache: SessionCache, provider: string): ProviderSection { @@ -2151,12 +2287,14 @@ async function parseProviderSources( seenKeys: Set, diskCache: SessionCache, dateRange?: DateRange, + readOnly = false, ): Promise { const provider = await getProvider(providerName) if (!provider) return [] const section = getOrCreateProviderSection(diskCache, providerName) const allDiscoveredFiles = new Set() + const servedSources = [...sources] type SourceInfo = { source: SessionSource; fp: NonNullable>> } const unchangedSources: Array<{ source: SessionSource; cached: CachedFile }> = [] @@ -2169,7 +2307,7 @@ async function parseProviderSources( // comes from a live API fetch in createSessionParser. There's nothing to // fingerprint or incrementally cache, so re-fetch every run with a synthetic // fingerprint (mtime=now so the date-range filter below never excludes it). - if (provider.network) { + if (provider.network && !readOnly) { changedSources.push({ source, fp: { dev: 0, ino: 0, mtimeMs: Date.now(), sizeBytes: 0 } }) continue } @@ -2182,13 +2320,26 @@ async function parseProviderSources( // A cached parse failure at this same fingerprint stays skipped — don't // re-read a file that already threw and hasn't changed. It re-parses only // when the file changes (then `reconcileFile` reports non-'unchanged'). - if (action.action === 'unchanged' && cached && (cached.failed || !cachedFileNeedsProviderReparse(providerName, source.path, cached))) { + if (cached && (readOnly || (action.action === 'unchanged' && (cached.failed || !cachedFileNeedsProviderReparse(providerName, source.path, cached))))) { unchangedSources.push({ source, cached }) - } else { + } else if (!readOnly) { changedSources.push({ source, fp }) } } + if (readOnly) { + for (const [path, cached] of Object.entries(section.files)) { + if (allDiscoveredFiles.has(path)) continue + servedSources.push({ + provider: providerName, + path, + project: cached.turns[0]?.calls[0]?.project ?? providerName, + }) + allDiscoveredFiles.add(path) + unchangedSources.push({ source: servedSources[servedSources.length - 1]!, cached }) + } + } + // Parser dedup: cross-provider keys + cached file keys. // Separate from seenKeys so parsing doesn't suppress query-time output. const parserDedup = new Set(seenKeys) @@ -2292,12 +2443,12 @@ async function parseProviderSources( // Stamp the durable flag into the cache section so the orphan-bootstrap in // parseAllSessions can fast-check without a getProvider() round-trip. - if (provider.durableSources && !section.durable) { + if (!readOnly && provider.durableSources && !section.durable) { section.durable = true ;(diskCache as { _dirty?: boolean })._dirty = true } - if (sources.length > 0 && !provider.durableSources) { + if (!readOnly && sources.length > 0 && !provider.durableSources) { for (const cachedPath of Object.keys(section.files)) { if (!allDiscoveredFiles.has(cachedPath)) { delete section.files[cachedPath] @@ -2308,7 +2459,7 @@ async function parseProviderSources( // 90-day age-out for durable providers: remove entries whose newest call is // older than 90 days so the cache doesn't grow unboundedly over time. - if (provider.durableSources) { + if (!readOnly && provider.durableSources) { const cutoffMs = Date.now() - 90 * 24 * 60 * 60 * 1000 for (const [cachedPath, cachedFile] of Object.entries(section.files)) { const newestTs = cachedFile.turns @@ -2327,7 +2478,7 @@ async function parseProviderSources( // Uses seenKeys (shared across providers) for cross-provider dedup. const sessionMap = new Map() - for (const source of sources) { + for (const source of servedSources) { const cachedFile = section.files[source.path] if (!cachedFile) continue @@ -2595,21 +2746,59 @@ export async function parseAllSessions(dateRange?: DateRange, providerFilter?: s // If another live process is already hydrating, wait for it, then reload the // now-warm cache instead of double-parsing. Never a correctness gate: on any // doubt it proceeds unlocked. - const hydration = await beginColdHydration(!isCacheComplete(diskCache)) - if (hydration.waited) diskCache = await loadCache() + if (!isCacheComplete(diskCache)) { + const hydration = await beginColdHydration(true) + if (hydration.waited) diskCache = await loadCache() + const isCold = !isCacheComplete(diskCache) + try { + return await runParse(key, diskCache, dateRange, providerFilter, { isCold }) + } finally { + await hydration.release() + } + } + + // A complete cache refresh is a strict read/reconcile/parse/save transaction. + // Keep the snapshot loaded before acquisition: timeout/unavailable paths serve + // exactly this complete snapshot and never mutate or invalidate the holder. + const priorSnapshot = diskCache + const refresh = await acquireCacheRefreshLock() + if (refresh.outcome === 'timed-out' || refresh.outcome === 'unavailable') { + return runParse(key, priorSnapshot, dateRange, providerFilter, { readOnly: true }) + } + if (refresh.outcome === 'completed-by-other') { + return runParse(key, await loadCache(), dateRange, providerFilter, { readOnly: true }) + } - // Cold = this run finishes a genuine (possibly resumed) full parse. A warm run - // reconciles an already-complete corpus. Drives the splash's cold-only reveal - // and the daily backfill's "don't finalize partial history" guard. - const isCold = !isCacheComplete(diskCache) try { - return await runParse(key, diskCache, dateRange, providerFilter, isCold) + // Reload only after ownership is canonical; this closes the lost-update + // window between the pre-gate read and the holder's completed publication. + diskCache = await loadCache() + return await runParse(key, diskCache, dateRange, providerFilter, { refreshLock: refresh.handle }) + } catch (err) { + if (!(err instanceof RefreshFenceLostError) && !(err instanceof RefreshPublicationUnavailableError)) throw err + return runParse(key, await loadCache(), dateRange, providerFilter, { readOnly: true }) } finally { - await hydration.release() + await refresh.handle.release() } } -async function runParse(key: string, diskCache: SessionCache, dateRange?: DateRange, providerFilter?: string, isCold = false): Promise { +class RefreshFenceLostError extends Error {} +class RefreshPublicationUnavailableError extends Error {} + +type RunParseOptions = { + isCold?: boolean + readOnly?: boolean + refreshLock?: RefreshLockHandle +} + +async function runParse( + key: string, + diskCache: SessionCache, + dateRange?: DateRange, + providerFilter?: string, + options: RunParseOptions = {}, +): Promise { + const { isCold = false, readOnly = false, refreshLock } = options const seenMsgIds = new Set() const seenKeys = new Set() const allSources = await discoverAllSessions(providerFilter) @@ -2630,6 +2819,7 @@ async function runParse(key: string, diskCache: SessionCache, dateRange?: DateRa // never races the final save below. let lastSaveAt = Date.now() const saveProgress = async (): Promise => { + if (!isCold || readOnly) return if (!(diskCache as { _dirty?: boolean })._dirty) return if (Date.now() - lastSaveAt < PROGRESS_SAVE_THROTTLE_MS) return lastSaveAt = Date.now() @@ -2651,7 +2841,7 @@ async function runParse(key: string, diskCache: SessionCache, dateRange?: DateRa if (claudeSources.length > 0) emitScanProgress({ kind: 'provider', provider: 'claude', state: 'start' }) let claudeProjects: ProjectSummary[] = [] try { - claudeProjects = await scanProjectDirs(claudeDirs, seenMsgIds, diskCache, dateRange, saveProgress) + claudeProjects = await scanProjectDirs(claudeDirs, seenMsgIds, diskCache, dateRange, saveProgress, readOnly) if (claudeSources.length > 0) emitScanProgress({ kind: 'provider', provider: 'claude', state: 'done', files: claudeSources.length }) } catch (err) { if (!isPermissionError(err)) throw err @@ -2663,7 +2853,7 @@ async function runParse(key: string, diskCache: SessionCache, dateRange?: DateRa for (const [providerName, sources] of providerGroups) { emitScanProgress({ kind: 'provider', provider: providerName, state: 'start' }) try { - const projects = await parseProviderSources(providerName, sources, seenKeys, diskCache, dateRange) + const projects = await parseProviderSources(providerName, sources, seenKeys, diskCache, dateRange, readOnly) emitScanProgress({ kind: 'provider', provider: providerName, state: 'done', files: sources.length }) otherProjects.push(...projects) } catch (err) { @@ -2692,7 +2882,7 @@ async function runParse(key: string, diskCache: SessionCache, dateRange?: DateRa // constant — both checks are O(1) and avoid a getProvider() dynamic-import // round-trip for every unprocessed provider in the disk cache. if (!section.durable && !DURABLE_PROVIDER_NAMES.has(providerName)) continue - const projects = await parseProviderSources(providerName, [], seenKeys, diskCache, dateRange) + const projects = await parseProviderSources(providerName, [], seenKeys, diskCache, dateRange, readOnly) otherProjects.push(...projects) } @@ -2703,9 +2893,15 @@ async function runParse(key: string, diskCache: SessionCache, dateRange?: DateRa // on is durable. A run killed before here never reaches this, so its throttled // partial saves keep `complete: false` and the next launch resumes cold. const wasComplete = isCacheComplete(diskCache) - if (!wasComplete) diskCache.complete = true - if ((diskCache as { _dirty?: boolean })._dirty || !wasComplete) { - try { await saveCache(diskCache) } catch {} + if (!readOnly && !wasComplete) diskCache.complete = true + if (!readOnly && ((diskCache as { _dirty?: boolean })._dirty || !wasComplete)) { + try { + const published = await saveCache(diskCache, refreshLock?.verifyStillOwner) + if (!published) throw new RefreshFenceLostError() + } catch (err) { + if (err instanceof RefreshFenceLostError) throw err + if (refreshLock) throw new RefreshPublicationUnavailableError() + } } sessionHydrationComplete = true diff --git a/src/session-cache.ts b/src/session-cache.ts index d189251a..e99d7f41 100644 --- a/src/session-cache.ts +++ b/src/session-cache.ts @@ -53,9 +53,27 @@ export type FileFingerprint = { sizeBytes: number } +export type ClaudeParseCheckpoint = { + /** + * Byte boundary immediately before Claude's current (therefore still + * mutable) turn. `groupIntoTurns` closes a turn only when the next non-empty + * user record arrives, while `dedupeStreamingMessageIds` lets later records + * replace an assistant message by id. Re-reading from this boundary therefore + * includes every record that can still change the cached result; if the + * suffix references an id in an earlier retained turn, the parser falls back + * to a full parse instead of trying to serialize parser/dedup state. + */ + parsedBytes: number + prefixSha256: string + version: 1 +} + export type CachedFile = { fingerprint: FileFingerprint lastCompleteLineOffset?: number + // Claude scanProjectDirs entries only. Other providers intentionally retain + // their existing cache and refresh behaviour. + checkpoint?: ClaudeParseCheckpoint canonicalCwd?: string canonicalProjectName?: string mcpInventory: string[] @@ -94,11 +112,14 @@ export type SessionCache = { // ── Constants ────────────────────────────────────────────────────────── +// v6: Claude JSONL entries carry a prefix-validated turn-boundary checkpoint. +// Older caches take one full parse before incremental refresh is available. +// // v5: kiro joined the costUSD pass-through allowlist (credit-based pricing). // Cached kiro entries from v4 carry costUSD: undefined and would keep being // re-priced from estimated tokens forever, since historical session files // never change. Bump forces a one-time re-parse so metered credit costs land. -export const CACHE_VERSION = 5 +export const CACHE_VERSION = 6 // The cache filename is version-suffixed so different binaries (e.g. an old // launchd menubar on a prior release and a newer desktop app) each own a @@ -243,6 +264,16 @@ function validateFingerprint(fp: unknown): fp is FileFingerprint { return isNum(f['dev']) && isNum(f['ino']) && isNum(f['mtimeMs']) && isNum(f['sizeBytes']) } +function validateClaudeParseCheckpoint(checkpoint: unknown): checkpoint is ClaudeParseCheckpoint { + if (!checkpoint || typeof checkpoint !== 'object') return false + const c = checkpoint as Record + return c['version'] === 1 + && isNum(c['parsedBytes']) + && (c['parsedBytes'] as number) >= 0 + && typeof c['prefixSha256'] === 'string' + && /^[0-9a-f]{64}$/.test(c['prefixSha256'] as string) +} + function validateUsage(u: unknown): u is CachedUsage { if (!u || typeof u !== 'object') return false const o = u as Record @@ -287,6 +318,7 @@ function validateCachedFile(f: unknown): f is CachedFile { const o = f as Record return validateFingerprint(o['fingerprint']) && isOptionalNum(o['lastCompleteLineOffset']) + && (o['checkpoint'] === undefined || validateClaudeParseCheckpoint(o['checkpoint'])) && isOptionalString(o['canonicalCwd']) && isOptionalString(o['canonicalProjectName']) && isStringArray(o['mcpInventory']) @@ -337,7 +369,7 @@ async function adoptLegacyCache(): Promise { } } -export async function saveCache(cache: SessionCache): Promise { +export async function saveCache(cache: SessionCache, verifyStillOwner?: () => Promise): Promise { const dir = getCacheDir() if (!existsSync(dir)) await mkdir(dir, { recursive: true }) @@ -355,13 +387,48 @@ export async function saveCache(cache: SessionCache): Promise { } try { - await rename(tempPath, finalPath) + // The warm refresh transaction passes an ownership fence. It must be the + // final operation before publication so a displaced writer cannot replace + // the canonical cache with its stale snapshot. + if (verifyStillOwner && !await verifyStillOwner()) { + await retryCacheFileMutation(() => unlink(tempPath)) + return false + } + let renamed = false + for (let attempt = 0; attempt < 3; attempt++) { + try { + await rename(tempPath, finalPath) + renamed = true + break + } catch (err) { + const code = (err as NodeJS.ErrnoException).code + if ((code !== 'EPERM' && code !== 'EBUSY') || attempt === 2) throw err + await new Promise(resolve => { setTimeout(resolve, 10 * (attempt + 1)) }) + } + } + if (!renamed) throw new Error('session cache rename failed') + return true } catch (err) { - try { await unlink(tempPath) } catch {} + await retryCacheFileMutation(() => unlink(tempPath)) throw err } } +async function retryCacheFileMutation(operation: () => Promise): Promise { + for (let attempt = 0; attempt < 3; attempt++) { + try { + await operation() + return true + } catch (err) { + const code = (err as NodeJS.ErrnoException).code + if (code === 'ENOENT') return true + if ((code !== 'EPERM' && code !== 'EBUSY') || attempt === 2) return false + await new Promise(resolve => { setTimeout(resolve, 10 * (attempt + 1)) }) + } + } + return false +} + // ── File Fingerprinting ──────────────────────────────────────────────── // // Fingerprints cover the source's transcript file only. Providers that keep diff --git a/tests/cache-refresh-lock-process.test.ts b/tests/cache-refresh-lock-process.test.ts new file mode 100644 index 00000000..cf907c31 --- /dev/null +++ b/tests/cache-refresh-lock-process.test.ts @@ -0,0 +1,167 @@ +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[] = [] + +async function waitFor(path: string, timeoutMs = 5_000): Promise { + 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 { + 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 { + 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 { + return spawn(process.execPath, ['--import', 'tsx', join(process.cwd(), 'tests/fixtures/cache-refresh-worker.ts'), cacheDir, barriers, id, source, String(bypass)], { + cwd: process.cwd(), + stdio: ['ignore', 'ignore', 'pipe'], + }) +} + +afterEach(async () => { + delete process.env['CODEBURN_CACHE_DIR'] + 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('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]) + }) +}) diff --git a/tests/cache-refresh-lock.test.ts b/tests/cache-refresh-lock.test.ts new file mode 100644 index 00000000..4f52d415 --- /dev/null +++ b/tests/cache-refresh-lock.test.ts @@ -0,0 +1,206 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { mkdir, mkdtemp, readFile, rm, stat, unlink, utimes, writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' + +import { + acquireCacheRefreshLock, + type RefreshLockClock, +} from '../src/cache-refresh-lock.js' +import { emptyCache, loadCache, saveCache, sessionCachePath } from '../src/session-cache.js' + +const dirs: string[] = [] + +async function tempDir(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'cb-refresh-lock-')) + dirs.push(dir) + return dir +} + +function lockPath(dir: string): string { + return join(dir, 'session-refresh.lock') +} + +function fakeClock(start = 1_000): RefreshLockClock & { advance: (ms: number) => void } { + let wall = start + let monotonic = start + return { + wallNow: () => wall, + monotonicNow: () => monotonic, + advance: ms => { wall += ms; monotonic += ms }, + } +} + +afterEach(async () => { + delete process.env['CODEBURN_CACHE_DIR'] + await Promise.all(dirs.splice(0).map(dir => rm(dir, { recursive: true, force: true }))) +}) + +describe('warm session-cache refresh lock', () => { + it('returns acquired and releases its own token', async () => { + const dir = await tempDir() + const result = await acquireCacheRefreshLock({ cacheDir: dir }) + expect(result.outcome).toBe('acquired') + if (result.outcome !== 'acquired') return + + const record = JSON.parse(await readFile(lockPath(dir), 'utf-8')) + expect(record).toMatchObject({ pid: process.pid, token: result.handle.token }) + expect(typeof record.at).toBe('number') + await result.handle.release() + await expect(stat(lockPath(dir))).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('reports completed-by-other after a clean release', async () => { + const dir = await tempDir() + const path = lockPath(dir) + await writeFile(path, JSON.stringify({ pid: 1, token: 'holder', at: Date.now() })) + let polls = 0 + const result = await acquireCacheRefreshLock({ + cacheDir: dir, + waitMs: 100, + pollMs: 1, + sleep: async () => { + if (++polls === 1) await unlink(path) + }, + }) + expect(result).toEqual({ outcome: 'completed-by-other' }) + }) + + it('uses a monotonic deadline and times out without invalidating the holder', async () => { + const dir = await tempDir() + const clock = fakeClock() + const path = lockPath(dir) + await writeFile(path, JSON.stringify({ pid: 1, token: 'holder', at: clock.wallNow() })) + const now = new Date(clock.wallNow()) + await utimes(path, now, now) + + const result = await acquireCacheRefreshLock({ + cacheDir: dir, + clock, + waitMs: 30, + staleMs: 90, + pollMs: 10, + sleep: async ms => { clock.advance(ms) }, + }) + expect(result).toEqual({ outcome: 'timed-out' }) + expect(JSON.parse(await readFile(path, 'utf-8')).token).toBe('holder') + }) + + it('reports unavailable when lock infrastructure is unusable', async () => { + const dir = await tempDir() + const notDirectory = join(dir, 'file') + await writeFile(notDirectory, 'x') + expect(await acquireCacheRefreshLock({ cacheDir: notDirectory })).toEqual({ outcome: 'unavailable' }) + }) + + it('serializes same-process acquisitions before touching the filesystem lock', async () => { + const dir = await tempDir() + const first = await acquireCacheRefreshLock({ cacheDir: dir }) + expect(first.outcome).toBe('acquired') + if (first.outcome !== 'acquired') return + + let settled = false + const secondPromise = acquireCacheRefreshLock({ cacheDir: dir }).then(result => { + settled = true + return result + }) + await new Promise(resolve => { setTimeout(resolve, 20) }) + expect(settled).toBe(false) + + await first.handle.release() + const second = await secondPromise + expect(second.outcome).toBe('acquired') + if (second.outcome === 'acquired') { + expect(second.handle.token).not.toBe(first.handle.token) + await second.handle.release() + } + }) + + it('does not take over a heartbeating owner', async () => { + const dir = await tempDir() + const path = lockPath(dir) + await writeFile(path, JSON.stringify({ pid: 1, token: 'holder', at: Date.now() })) + const heartbeat = setInterval(() => { + const now = new Date() + void utimes(path, now, now) + }, 5) + try { + const result = await acquireCacheRefreshLock({ cacheDir: dir, staleMs: 20, waitMs: 60, pollMs: 5 }) + expect(result).toEqual({ outcome: 'timed-out' }) + expect(JSON.parse(await readFile(path, 'utf-8')).token).toBe('holder') + } finally { + clearInterval(heartbeat) + } + }) + + it('heartbeats its own lock body and mtime with the injected clock', async () => { + const dir = await tempDir() + const clock = fakeClock(10_000) + const result = await acquireCacheRefreshLock({ cacheDir: dir, clock, heartbeatMs: 5 }) + expect(result.outcome).toBe('acquired') + if (result.outcome !== 'acquired') return + try { + const before = (await stat(lockPath(dir))).mtimeMs + clock.advance(1_000) + await new Promise(resolve => { setTimeout(resolve, 100) }) + const record = JSON.parse(await readFile(lockPath(dir), 'utf-8')) + expect(record.at).toBe(clock.wallNow()) + expect((await stat(lockPath(dir))).mtimeMs).not.toBe(before) + } finally { + await result.handle.release() + } + }) + + it('takes over only after re-verifying a stale token and mtime', async () => { + const dir = await tempDir() + const clock = fakeClock(100_000) + const path = lockPath(dir) + await writeFile(path, JSON.stringify({ pid: 1, token: 'stale', at: 1 })) + const old = new Date(1) + await utimes(path, old, old) + + const result = await acquireCacheRefreshLock({ cacheDir: dir, clock, staleMs: 90, waitMs: 100 }) + expect(result.outcome).toBe('acquired') + if (result.outcome !== 'acquired') return + expect(JSON.parse(await readFile(path, 'utf-8')).token).toBe(result.handle.token) + await result.handle.release() + await expect(stat(join(dir, 'session-refresh.lock.takeover'))).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('reclaims an abandoned stale takeover guard', async () => { + const dir = await tempDir() + const clock = fakeClock(100_000) + await writeFile(lockPath(dir), JSON.stringify({ pid: 1, token: 'stale', at: 1 })) + await writeFile(join(dir, 'session-refresh.lock.takeover'), JSON.stringify({ pid: 2, token: 'stale-guard', at: 1 })) + const old = new Date(1) + await utimes(lockPath(dir), old, old) + await utimes(join(dir, 'session-refresh.lock.takeover'), old, old) + + const result = await acquireCacheRefreshLock({ cacheDir: dir, clock, staleMs: 90, waitMs: 100 }) + expect(result.outcome).toBe('acquired') + if (result.outcome === 'acquired') await result.handle.release() + }) + + it('fences publication and release removes only its own token', async () => { + const dir = await tempDir() + process.env['CODEBURN_CACHE_DIR'] = dir + const original = emptyCache() + original.complete = true + await saveCache(original) + + const result = await acquireCacheRefreshLock({ cacheDir: dir, heartbeatMs: 60_000 }) + expect(result.outcome).toBe('acquired') + if (result.outcome !== 'acquired') return + + await writeFile(lockPath(dir), JSON.stringify({ pid: 999, token: 'successor', at: Date.now() })) + const changed = emptyCache() + changed.complete = true + changed.providers['claude'] = { parseVersion: 'test', envFingerprint: 'test', files: {} } + expect(await saveCache(changed, result.handle.verifyStillOwner)).toBe(false) + expect((await loadCache()).providers['claude']).toBeUndefined() + + await result.handle.release() + expect(JSON.parse(await readFile(lockPath(dir), 'utf-8')).token).toBe('successor') + expect(sessionCachePath()).toContain(dir) + }) +}) diff --git a/tests/fixtures/cache-refresh-worker.ts b/tests/fixtures/cache-refresh-worker.ts new file mode 100644 index 00000000..a2807df1 --- /dev/null +++ b/tests/fixtures/cache-refresh-worker.ts @@ -0,0 +1,43 @@ +import { existsSync } from 'fs' +import { mkdir, readFile, writeFile } from 'fs/promises' +import { join } from 'path' + +import { acquireCacheRefreshLock } from '../../src/cache-refresh-lock.js' +import { loadCache, saveCache } from '../../src/session-cache.js' + +const [cacheDir, barrierDir, id, sourcePath, bypass = 'false'] = process.argv.slice(2) +if (!cacheDir || !barrierDir || !id || !sourcePath) throw new Error('missing worker argument') + +async function waitFor(name: string): Promise { + const path = join(barrierDir!, name) + while (!existsSync(path)) await new Promise(resolve => { setTimeout(resolve, 5) }) +} + +process.env['CODEBURN_CACHE_DIR'] = cacheDir +await mkdir(barrierDir, { recursive: true }) + +const refresh = bypass === 'true' ? null : await acquireCacheRefreshLock({ cacheDir, waitMs: 2_000, pollMs: 5 }) +if (refresh && refresh.outcome !== 'acquired') { + await writeFile(join(barrierDir, `${id}.${refresh.outcome}`), '') + process.exit(0) +} + +try { + const cache = await loadCache() + // Parsing is deliberately tiny; the files and barrier make the transaction + // interleaving deterministic rather than relying on parser runtime variance. + const parsed = JSON.parse(await readFile(sourcePath, 'utf-8')) as { output: number } + cache.providers['regression'] ??= { parseVersion: 'test', envFingerprint: 'test', files: {} } + cache.providers['regression'].files[sourcePath] = { + fingerprint: { dev: 1, ino: parsed.output, mtimeMs: parsed.output, sizeBytes: parsed.output }, + mcpInventory: [], + turns: [], + } + ;(cache as { _dirty?: boolean })._dirty = true + await writeFile(join(barrierDir, `${id}.parsed`), '') + await waitFor(`${id}.save`) + const published = await saveCache(cache, refresh?.handle.verifyStillOwner) + await writeFile(join(barrierDir, `${id}.${published ? 'published' : 'fenced'}`), '') +} finally { + await refresh?.handle.release() +} diff --git a/tests/parser-cache-refresh-timeout.test.ts b/tests/parser-cache-refresh-timeout.test.ts new file mode 100644 index 00000000..92415775 --- /dev/null +++ b/tests/parser-cache-refresh-timeout.test.ts @@ -0,0 +1,62 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' + +vi.mock('../src/cache-refresh-lock.js', () => ({ + acquireCacheRefreshLock: async () => ({ outcome: 'timed-out' as const }), +})) + +import { clearSessionCache, parseAllSessions } from '../src/parser.js' +import { sessionCachePath } from '../src/session-cache.js' + +let root: string +let sessionPath: string + +function output(projects: Awaited>): number { + return projects.flatMap(p => p.sessions).flatMap(s => s.turns) + .flatMap(t => t.assistantCalls).reduce((sum, call) => sum + call.usage.outputTokens, 0) +} + +async function writeSession(value: number): Promise { + await writeFile(sessionPath, JSON.stringify({ + type: 'assistant', + sessionId: 'sess', + timestamp: '2026-05-15T10:00:00Z', + cwd: '/tmp/proj', + message: { + id: `msg-${value}`, type: 'message', role: 'assistant', model: 'claude-sonnet-4-5', + content: [], usage: { input_tokens: 100, output_tokens: value }, + }, + }) + '\n') +} + +beforeEach(async () => { + clearSessionCache() + root = await mkdtemp(join(tmpdir(), 'cb-refresh-timeout-')) + const home = join(root, 'home') + const project = join(home, 'projects', 'proj') + await mkdir(project, { recursive: true }) + sessionPath = join(project, 'sess.jsonl') + process.env['CLAUDE_CONFIG_DIR'] = home + process.env['CODEBURN_CACHE_DIR'] = join(root, 'cache') + process.env['CODEBURN_DESKTOP_SESSIONS_DIR'] = join(home, 'desktop-sessions') +}) + +afterEach(async () => { + clearSessionCache() + await rm(root, { recursive: true, force: true }) +}) + +describe('parseAllSessions warm refresh timeout', () => { + it('serves the prior complete snapshot and leaves the holder cache untouched', async () => { + await writeSession(50) + expect(output(await parseAllSessions(undefined, 'claude'))).toBe(50) + const before = await readFile(sessionCachePath(), 'utf-8') + + await writeSession(5000) + clearSessionCache() + expect(output(await parseAllSessions(undefined, 'claude'))).toBe(50) + expect(await readFile(sessionCachePath(), 'utf-8')).toBe(before) + }) +}) diff --git a/tests/parser-claude-incremental-parity.test.ts b/tests/parser-claude-incremental-parity.test.ts new file mode 100644 index 00000000..48c8160a --- /dev/null +++ b/tests/parser-claude-incremental-parity.test.ts @@ -0,0 +1,208 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { appendFile, mkdir, mkdtemp, readFile, rm, truncate, utimes, writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' + +import { + clearSessionCache, + matchesClaudeCheckpointPrefix, + parseAllSessions, +} from '../src/parser.js' +import { loadCache } from '../src/session-cache.js' + +const user = (n: number): string => JSON.stringify({ + type: 'user', + sessionId: 'incremental-parity', + timestamp: `2026-05-01T00:00:${String(n * 2).padStart(2, '0')}Z`, + cwd: '/repo/incremental-parity', + message: { role: 'user', content: `turn ${n}` }, +}) + +const assistant = (n: number, messageId = `msg-${n}`, outputTokens = 10): string => JSON.stringify({ + type: 'assistant', + sessionId: 'incremental-parity', + timestamp: `2026-05-01T00:00:${String(n * 2 + 1).padStart(2, '0')}Z`, + cwd: '/repo/incremental-parity', + message: { + id: messageId, + type: 'message', + role: 'assistant', + model: 'claude-sonnet-4-5', + content: [], + usage: { input_tokens: 100, output_tokens: outputTokens }, + }, +}) + +const jsonl = (...lines: string[]): string => `${lines.join('\n')}\n` + +const mcpDelta = (n: number, ...names: string[]): string => JSON.stringify({ + type: 'user', + sessionId: 'incremental-parity', + timestamp: `2026-05-01T00:01:${String(n).padStart(2, '0')}Z`, + cwd: '/repo/incremental-parity', + message: { role: 'user', content: '' }, + attachment: { type: 'deferred_tools_delta', addedNames: names }, +}) + +describe('Claude checkpoint incremental parse parity', () => { + let root: string + let cacheDir: string + let configDir: string + let sessionFile: string + let oldCacheDir: string | undefined + let oldConfigDirs: string | undefined + let oldDesktopDir: string | undefined + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'codeburn-claude-incremental-')) + cacheDir = join(root, 'cache') + configDir = join(root, 'claude') + sessionFile = join(configDir, 'projects', '-repo-incremental-parity', 'session.jsonl') + await mkdir(join(configDir, 'projects', '-repo-incremental-parity'), { recursive: true }) + await mkdir(cacheDir, { recursive: true }) + + oldCacheDir = process.env['CODEBURN_CACHE_DIR'] + oldConfigDirs = process.env['CLAUDE_CONFIG_DIRS'] + oldDesktopDir = process.env['CODEBURN_DESKTOP_SESSIONS_DIR'] + process.env['CODEBURN_CACHE_DIR'] = cacheDir + process.env['CLAUDE_CONFIG_DIRS'] = configDir + process.env['CODEBURN_DESKTOP_SESSIONS_DIR'] = join(root, 'no-desktop-sessions') + clearSessionCache() + }) + + afterEach(async () => { + clearSessionCache() + if (oldCacheDir === undefined) delete process.env['CODEBURN_CACHE_DIR'] + else process.env['CODEBURN_CACHE_DIR'] = oldCacheDir + if (oldConfigDirs === undefined) delete process.env['CLAUDE_CONFIG_DIRS'] + else process.env['CLAUDE_CONFIG_DIRS'] = oldConfigDirs + if (oldDesktopDir === undefined) delete process.env['CODEBURN_DESKTOP_SESSIONS_DIR'] + else process.env['CODEBURN_DESKTOP_SESSIONS_DIR'] = oldDesktopDir + await rm(root, { recursive: true, force: true }) + }) + + async function parseFresh(): Promise>> { + await rm(cacheDir, { recursive: true, force: true }) + await mkdir(cacheDir, { recursive: true }) + clearSessionCache() + return parseAllSessions(undefined, 'claude') + } + + async function expectWarmRefreshMatchesFresh( + initial: string, + update: () => Promise, + ): Promise { + await writeFile(sessionFile, initial) + await parseFresh() + await update() + clearSessionCache() + const incremental = await parseAllSessions(undefined, 'claude') + const full = await parseFresh() + expect(incremental).toEqual(full) + } + + it('matches full parsing when complete turns are appended', async () => { + await expectWarmRefreshMatchesFresh( + jsonl(user(1), assistant(1), user(2), assistant(2)), + () => appendFile(sessionFile, jsonl(user(3), assistant(3), user(4))), + ) + }) + + it('re-parses an open turn from its beginning', async () => { + await expectWarmRefreshMatchesFresh( + jsonl(user(1)), + () => appendFile(sessionFile, jsonl(assistant(1), user(2), assistant(2))), + ) + }) + + it('keeps last-wins streaming-id updates identical to a full parse', async () => { + await expectWarmRefreshMatchesFresh( + jsonl(user(1), assistant(1, 'streaming-id', 10)), + () => appendFile(sessionFile, jsonl(assistant(1, 'streaming-id', 20), user(2), assistant(2))), + ) + }) + + it('full-parses when a later turn supersedes an id in a retained turn', async () => { + await expectWarmRefreshMatchesFresh( + jsonl( + user(1), assistant(1, 'old-turn-id', 10), + user(2), assistant(2, 'current-turn-id', 10), + ), + () => appendFile(sessionFile, jsonl( + assistant(2, 'old-turn-id', 20), + user(3), assistant(3), + )), + ) + }) + + it('full-parses a same-length mutation', async () => { + const initial = jsonl(user(1), assistant(1, 'msg-1', 10)) + await expectWarmRefreshMatchesFresh(initial, async () => { + const mutated = (await readFile(sessionFile, 'utf8')).replace('"output_tokens":10', '"output_tokens":20') + expect(Buffer.byteLength(mutated)).toBe(Buffer.byteLength(initial)) + await writeFile(sessionFile, mutated) + }) + }) + + it('full-parses a truncation', async () => { + const retained = jsonl(user(1), assistant(1)) + await expectWarmRefreshMatchesFresh( + `${retained}${jsonl(user(2), assistant(2))}`, + () => truncate(sessionFile, Buffer.byteLength(retained)), + ) + }) + + it('prefix hash is load-bearing after a same-size rewrite followed by append', async () => { + const initial = jsonl( + user(1), assistant(1, 'msg-1', 10), + user(2), assistant(2, 'msg-2', 10), + ) + await writeFile(sessionFile, initial) + await parseFresh() + const cached = (await loadCache()).providers['claude']!.files[sessionFile]! + + const rewritten = initial.replace('"output_tokens":10', '"output_tokens":20') + expect(Buffer.byteLength(rewritten)).toBe(Buffer.byteLength(initial)) + await writeFile(sessionFile, rewritten) + await appendFile(sessionFile, jsonl(user(3), assistant(3))) + + // This pair is the red-first proof: bypassing validation accepts the + // corrupted prefix, while the committed guarded path rejects it. + expect(await matchesClaudeCheckpointPrefix(sessionFile, cached, false)).toBe(true) + expect(await matchesClaudeCheckpointPrefix(sessionFile, cached)).toBe(false) + + clearSessionCache() + const guarded = await parseAllSessions(undefined, 'claude') + const full = await parseFresh() + expect(guarded).toEqual(full) + }) + + it('keeps mcpInventory identical to a full parse across an incremental append', async () => { + // Names deliberately arrive in non-alphabetical order: extractMcpInventory + // sorts, so the incremental union must sort too or parity breaks. + await writeFile(sessionFile, jsonl( + mcpDelta(1, 'mcp__zeta__tool', 'mcp__alpha__tool'), + user(1), assistant(1), + )) + await parseFresh() + await appendFile(sessionFile, jsonl( + mcpDelta(2, 'mcp__mid__tool'), + user(2), assistant(2), + )) + clearSessionCache() + await parseAllSessions(undefined, 'claude') + const incrementalInventory = (await loadCache()).providers['claude']?.files[sessionFile]?.mcpInventory + await parseFresh() + const fullInventory = (await loadCache()).providers['claude']?.files[sessionFile]?.mcpInventory + expect(incrementalInventory).toEqual(fullInventory) + expect(fullInventory).toEqual(['mcp__alpha__tool', 'mcp__mid__tool', 'mcp__zeta__tool']) + }) + + it('keeps exact results for an empty append (mtime-only touch)', async () => { + const initial = jsonl(user(1), assistant(1)) + await expectWarmRefreshMatchesFresh(initial, async () => { + const future = new Date(Date.now() + 2_000) + await utimes(sessionFile, future, future) + }) + }) +})