mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-25 16:19:39 +00:00
os.homedir() reads USERPROFILE on Windows and never HOME, so the isolation setup and every test that moved home were still resolving the developer's real home directory. Fixtures came back empty, real sessions leaked into counts, and the code under test wrote plans, receipts and a sync ledger into the real ~/.config/codeburn during a run. USERPROFILE now joins the redirected variables and a shared setHome() helper moves HOME and USERPROFILE together; every spawn env carries both. The remaining Windows-only failures were POSIX-shaped fixtures: chmod tricks that only toggle a read-only attribute on NTFS, pid 1 as an always-alive lock holder, splitting paths on '/', bare tar, mode-bit expectations, and per-file timeouts too short for the cost of a tsx spawn. Each gets a platform-aware equivalent so nothing is skipped on Windows and the POSIX branches stay byte-for-byte what they were. Full run on Windows: 267 files passed, 2 skipped, 3592 tests passed.
166 lines
6.8 KiB
TypeScript
166 lines
6.8 KiB
TypeScript
// Advisory hydration-lock coordination in parseAllSessions. When the on-disk
|
|
// cache is cold and another LIVE process already holds a fresh lock, a second
|
|
// process waits for release then reads the now-warm cache instead of re-parsing.
|
|
// Stale / dead-pid locks are ignored and replaced. The lock is an optimization,
|
|
// never a correctness gate.
|
|
|
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
import { existsSync } from 'fs'
|
|
import { mkdir, mkdtemp, rm, unlink, writeFile } from 'fs/promises'
|
|
import { tmpdir } from 'os'
|
|
import { join } from 'path'
|
|
|
|
import { clearSessionCache, parseAllSessions } from '../src/parser.js'
|
|
import { sessionCacheDir } from '../src/session-cache.js'
|
|
import { readCacheOnDisk, writeCacheOnDisk } from './fixtures/session-cache-io.js'
|
|
|
|
let tmpHome: string
|
|
let cacheDir: string
|
|
|
|
// A pid that is alive and is not us. pid 1 is init on POSIX but does not exist
|
|
// on Windows, where probing it reports ESRCH and the lock would read as dead.
|
|
const LIVE_FOREIGN_PID = process.ppid
|
|
|
|
function lockPath(): string {
|
|
return join(cacheDir, 'hydrating.lock')
|
|
}
|
|
|
|
function delay(ms: number): Promise<void> {
|
|
return new Promise(resolve => { setTimeout(resolve, ms) })
|
|
}
|
|
|
|
function totalOutput(projects: Awaited<ReturnType<typeof parseAllSessions>>): number {
|
|
return projects
|
|
.flatMap(p => p.sessions)
|
|
.flatMap(s => s.turns)
|
|
.flatMap(t => t.assistantCalls)
|
|
.reduce((sum, c) => sum + c.usage.outputTokens, 0)
|
|
}
|
|
|
|
async function writeClaudeSession(output: number): Promise<void> {
|
|
const dir = join(tmpHome, 'projects', 'proj')
|
|
await mkdir(dir, { recursive: true })
|
|
await writeFile(join(dir, 'sess.jsonl'), JSON.stringify({
|
|
type: 'assistant',
|
|
sessionId: 'sess',
|
|
timestamp: '2026-05-15T10:00:00Z',
|
|
cwd: '/tmp/proj',
|
|
message: {
|
|
id: 'msg-1', type: 'message', role: 'assistant', model: 'claude-sonnet-4-5',
|
|
content: [], usage: { input_tokens: 100, output_tokens: output },
|
|
},
|
|
}) + '\n')
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
clearSessionCache()
|
|
tmpHome = await mkdtemp(join(tmpdir(), 'cb-hydlock-home-'))
|
|
cacheDir = await mkdtemp(join(tmpdir(), 'cb-hydlock-cache-'))
|
|
process.env['CLAUDE_CONFIG_DIR'] = tmpHome
|
|
process.env['CODEBURN_CACHE_DIR'] = cacheDir
|
|
process.env['CODEBURN_DESKTOP_SESSIONS_DIR'] = join(tmpHome, 'desktop-sessions')
|
|
})
|
|
|
|
afterEach(async () => {
|
|
clearSessionCache()
|
|
await rm(tmpHome, { recursive: true, force: true })
|
|
await rm(cacheDir, { recursive: true, force: true })
|
|
})
|
|
|
|
describe('parseAllSessions hydration lock', () => {
|
|
it('waits for a live foreign lock, then serves the warm cache instead of re-parsing', async () => {
|
|
// Warm the cache once from a real on-disk session (output 50), capture the
|
|
// exact cache structure, then tamper the cached output to a sentinel (999)
|
|
// that could ONLY come from reading the cache — a fresh parse of the
|
|
// unchanged source file would yield 50.
|
|
await writeClaudeSession(50)
|
|
expect(totalOutput(await parseAllSessions(undefined, 'claude'))).toBe(50)
|
|
|
|
const tampered = await readCacheOnDisk()
|
|
for (const section of Object.values(tampered.providers)) {
|
|
for (const file of Object.values(section.files)) {
|
|
for (const turn of file.turns) for (const call of turn.calls) call.usage.outputTokens = 999
|
|
}
|
|
}
|
|
|
|
// Go cold: remove the versioned cache and drop the in-memory cache so the
|
|
// next parse genuinely cold-starts and consults the lock.
|
|
await rm(sessionCacheDir(), { recursive: true })
|
|
clearSessionCache()
|
|
|
|
// A fresh lock held by another live process. The cold parse must block on it.
|
|
await writeFile(lockPath(), JSON.stringify({ pid: LIVE_FOREIGN_PID, at: Date.now() }))
|
|
|
|
let resolved = false
|
|
const promise = parseAllSessions(undefined, 'claude').then(r => { resolved = true; return r })
|
|
|
|
await delay(50)
|
|
// Still waiting on the foreign lock — it has not re-parsed and returned.
|
|
expect(resolved).toBe(false)
|
|
|
|
// The "first process" finishes: it leaves the warm (tampered) cache behind
|
|
// and releases the lock. The waiter wakes, reloads, and serves the cache.
|
|
await writeCacheOnDisk(tampered)
|
|
await unlink(lockPath())
|
|
|
|
const result = await promise
|
|
expect(resolved).toBe(true)
|
|
expect(totalOutput(result)).toBe(999)
|
|
// The waiter never held the lock, so nothing to clean up on its side.
|
|
expect(existsSync(lockPath())).toBe(false)
|
|
})
|
|
|
|
it('ignores and replaces a stale lock, parsing normally and releasing in a finally', async () => {
|
|
await writeClaudeSession(50)
|
|
|
|
// A stale lock (timestamp well past the freshness window) must be ignored,
|
|
// replaced, and the parse proceeds normally.
|
|
await mkdir(cacheDir, { recursive: true })
|
|
await writeFile(lockPath(), JSON.stringify({ pid: LIVE_FOREIGN_PID, at: Date.now() - 20 * 60_000 }))
|
|
|
|
const result = await parseAllSessions(undefined, 'claude')
|
|
expect(totalOutput(result)).toBe(50)
|
|
// Lock released in the finally.
|
|
expect(existsSync(lockPath())).toBe(false)
|
|
// The parse warmed the versioned cache: the envelope is what publishes it,
|
|
// so the directory merely existing proves nothing.
|
|
expect(existsSync(join(sessionCacheDir(), 'envelope.json'))).toBe(true)
|
|
})
|
|
|
|
it('ignores a fresh lock whose pid is dead', async () => {
|
|
await writeClaudeSession(50)
|
|
|
|
// Fresh timestamp but a pid that does not exist (ESRCH): treated as dead,
|
|
// ignored, replaced, and the parse proceeds normally.
|
|
await mkdir(cacheDir, { recursive: true })
|
|
await writeFile(lockPath(), JSON.stringify({ pid: 2_147_483_646, at: Date.now() }))
|
|
|
|
const result = await parseAllSessions(undefined, 'claude')
|
|
expect(totalOutput(result)).toBe(50)
|
|
expect(existsSync(lockPath())).toBe(false)
|
|
})
|
|
|
|
it('takes over a lock whose live holder dies mid-wait, then cleans it up', async () => {
|
|
await writeClaudeSession(50)
|
|
await mkdir(cacheDir, { recursive: true })
|
|
|
|
// A fresh lock held by a live process: the cold parse starts waiting.
|
|
await writeFile(lockPath(), JSON.stringify({ pid: LIVE_FOREIGN_PID, at: Date.now() }))
|
|
let resolved = false
|
|
const promise = parseAllSessions(undefined, 'claude').then(r => { resolved = true; return r })
|
|
await delay(60)
|
|
expect(resolved).toBe(false) // still blocked on the live foreign lock
|
|
|
|
// The holder is SIGKILLed mid-scan: its pid goes dead and its lock is left
|
|
// behind (no clean release). The waiter must detect the dead pid, take over
|
|
// (re-parse under its own lock), and remove the leftover on release.
|
|
await writeFile(lockPath(), JSON.stringify({ pid: 2_147_483_646, at: Date.now() }))
|
|
|
|
const result = await promise
|
|
expect(resolved).toBe(true)
|
|
// Took over and re-parsed the source (50) rather than trusting a partial cache.
|
|
expect(totalOutput(result)).toBe(50)
|
|
// No leftover lock: the takeover acquired it and released it in the finally.
|
|
expect(existsSync(lockPath())).toBe(false)
|
|
})
|
|
})
|