cache: strict cross-process gate for the warm session-cache refresh transaction (#743)

* cache: strict cross-process gate for the warm session-cache refresh transaction (#645)

* lock: serialize the fence against the owner's own heartbeat

verifyStillOwner and the heartbeat tick both take the takeover guard;
without in-process serialization the fence could observe its own
heartbeat's guard file, read it as displacement, and abort a legitimate
publication. Fail-safe, but it discarded the parse the lock exists to
protect (~6% of verifies at a 1ms heartbeat in the repro). Owner-side
guard operations now run through one serializer; cross-process guard
semantics are unchanged. Regression test at heartbeatMs 1, mutation-
verified against the unserialized code.

---------

Co-authored-by: Aditya Vikram Singh <247195684+avs-io@users.noreply.github.com>
Co-authored-by: reviewer <review@local>
This commit is contained in:
Aditya Vikram Singh 2026-07-20 21:36:35 +05:30 committed by GitHub
parent 17a23ead40
commit e27fe36bd5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 982 additions and 35 deletions

344
src/cache-refresh-lock.ts Normal file
View file

@ -0,0 +1,344 @@
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<void>
}
export type RefreshLockHandle = {
token: string
release: () => Promise<void>
verifyStillOwner: () => Promise<boolean>
}
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<void> {
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<void>, sleep: (ms: number) => Promise<void>): Promise<boolean> {
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<ObservationResult> {
// 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<LockRecord>
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<void> = Promise.resolve()
async function enterSingleFlight(): Promise<() => void> {
const previous = singleFlightTail
let leave!: () => void
singleFlightTail = new Promise<void>(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<RefreshLockOutcome> {
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() })
// In-process serializer for every operation that takes the takeover guard on
// behalf of THIS owner (heartbeat tick, publication fence). Without it the
// fence can observe its own heartbeat's guard file and read "guard held" as
// "displaced", aborting a legitimate publication — fail-safe but it throws
// away the parse the lock exists to protect. Cross-process semantics are
// untouched: the guard file still arbitrates between processes.
let ownerOpTail: Promise<unknown> = Promise.resolve()
const serializeOwnerOp = <T>(fn: () => Promise<T>): Promise<T> => {
const next = ownerOpTail.then(fn)
ownerOpTail = next.catch(() => undefined)
return next
}
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<boolean> => {
// 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 = (): Promise<boolean> => serializeOwnerOp(async () => {
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 serializeOwnerOp(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<RefreshLockOutcome | null> => {
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<RefreshLockOutcome | null> => {
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' }
}
}

View file

@ -25,6 +25,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,
@ -1801,6 +1802,7 @@ 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<void>,
readOnly = false,
): Promise<ProjectSummary[]> {
const section = getOrCreateProviderSection(diskCache, 'claude')
const allDiscoveredFiles = new Set<string>()
@ -1818,16 +1820,19 @@ 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 if (action.action === 'appended') {
changedFiles.push({
filePath,
info: { dirName, fp, source },
append: { cached: section.files[filePath]!, readFromOffset: action.readFromOffset },
})
} else {
} else if (!readOnly) {
if (action.action === 'appended') {
changedFiles.push({
filePath,
info: { dirName, fp, source },
append: { cached: section.files[filePath]!, readFromOffset: action.readFromOffset },
})
continue
}
changedFiles.push({ filePath, info: { dirName, fp, source } })
}
}
@ -1836,6 +1841,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) {
@ -1975,7 +1990,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]
@ -2523,12 +2538,14 @@ async function parseProviderSources(
seenKeys: Set<string>,
diskCache: SessionCache,
dateRange?: DateRange,
readOnly = false,
): Promise<ProjectSummary[]> {
const provider = await getProvider(providerName)
if (!provider) return []
const section = getOrCreateProviderSection(diskCache, providerName)
const allDiscoveredFiles = new Set<string>()
const servedSources = [...sources]
type SourceInfo = { source: SessionSource; fp: NonNullable<Awaited<ReturnType<typeof fingerprintFile>>> }
const unchangedSources: Array<{ source: SessionSource; cached: CachedFile }> = []
@ -2541,7 +2558,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
}
@ -2554,13 +2571,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)
@ -2664,12 +2694,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]
@ -2680,7 +2710,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
@ -2699,7 +2729,7 @@ async function parseProviderSources(
// Uses seenKeys (shared across providers) for cross-provider dedup.
const sessionMap = new Map<string, { project: string; projectPath?: string; turns: ClassifiedTurn[] }>()
for (const source of sources) {
for (const source of servedSources) {
const cachedFile = section.files[source.path]
if (!cachedFile) continue
@ -2967,21 +2997,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<ProjectSummary[]> {
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<ProjectSummary[]> {
const { isCold = false, readOnly = false, refreshLock } = options
const seenMsgIds = new Set<string>()
const seenKeys = new Set<string>()
const allSources = await discoverAllSessions(providerFilter)
@ -3002,6 +3070,7 @@ async function runParse(key: string, diskCache: SessionCache, dateRange?: DateRa
// never races the final save below.
let lastSaveAt = Date.now()
const saveProgress = async (): Promise<void> => {
if (!isCold || readOnly) return
if (!(diskCache as { _dirty?: boolean })._dirty) return
if (Date.now() - lastSaveAt < PROGRESS_SAVE_THROTTLE_MS) return
lastSaveAt = Date.now()
@ -3023,7 +3092,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
@ -3035,7 +3104,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) {
@ -3064,7 +3133,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)
}
@ -3075,9 +3144,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

View file

@ -383,7 +383,7 @@ async function adoptLegacyCache(): Promise<SessionCache> {
}
}
export async function saveCache(cache: SessionCache): Promise<void> {
export async function saveCache(cache: SessionCache, verifyStillOwner?: () => Promise<boolean>): Promise<boolean> {
const dir = getCacheDir()
if (!existsSync(dir)) await mkdir(dir, { recursive: true })
@ -401,13 +401,48 @@ export async function saveCache(cache: SessionCache): Promise<void> {
}
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<void>): Promise<boolean> {
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