codeburn/tests/save-cache-yields.test.ts
Resham Joshi 1d375ccf79
fix(tui): keep the dashboard responsive during the cold-start fill and exit on Ctrl+C (#1142)
The progressive cold start introduced by #1109 paints the dashboard from
recent files, then runs a background fill that parses the deferred
history and writes the per-file session cache. On a real 21k-file corpus
that fill's cache save publishes one JSON.stringify + fsync per provider
shard in a tight loop, and the whole save held the event loop long
enough that Ink's stdin reader never ran - a q or Ctrl+C pressed in the
post-paint window was swallowed, not buffered, and the user saw a
frozen terminal (#1139, #1141).

saveCache now yields to the event loop between every shard write, so
the Ink input handler fires between publishes. The yield sits BETWEEN
shards, never inside one, so each shard's temp+rename atomicity and the
lost-fence cleanup that owns the canonical envelope are preserved;
nothing marked seen without being parsed, the kill-safe resume
invariant from #1109 is intact, and the floored-parse memo key suffix
keeps warm reads byte-identical. The fill still parses every deferred
file, so money totals are unchanged.

The dashboard's useInput now exits on Ctrl+C through the same path as
q, so a raw 0x03 from the terminal lands as soon as the loop breathes.

Pinning tests:
- tests/save-cache-yields.test.ts: a setImmediate probe must interleave
  with saveCache's shard writes.
- tests/dashboard-exit.test.ts: a raw \x03 exits the dashboard the
  same way q does.
- tests/parser.test.ts: the copilot validated-clean re-parse test
  tightens its fake-timer scope to only Date, so the new event-loop
  yield inside saveCache still resolves under vitest's fake timers.

The #1109 cold-start one-shots (report, sessions, models, json/csv/
markdown, the desktop and menubar payloads) are unchanged.
2026-08-25 06:08:39 -07:00

102 lines
3.2 KiB
TypeScript

import { mkdtemp, rm } from 'fs/promises'
import { join } from 'path'
import { tmpdir } from 'os'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
CACHE_VERSION,
clearLoadCacheMemo,
computeEnvFingerprint,
loadCache,
markCacheDirty,
saveCache,
type CachedFile,
type ProviderSection,
type SessionCache,
} from '../src/session-cache.js'
let tmpDir: string
beforeEach(async () => {
clearLoadCacheMemo()
tmpDir = await mkdtemp(join(tmpdir(), 'save-cache-yields-'))
process.env['CODEBURN_CACHE_DIR'] = join(tmpDir, 'cache')
})
afterEach(async () => {
clearLoadCacheMemo()
delete process.env['CODEBURN_CACHE_DIR']
await rm(tmpDir, { recursive: true, force: true })
})
function makeFile(timestamp: string): CachedFile {
return {
fingerprint: { dev: 0, ino: 0, mtimeMs: 0, sizeBytes: 0 },
mcpInventory: [],
turns: [{ timestamp, sessionId: 'fixture', userMessage: '', calls: [] }],
}
}
function makeCache(providers: string[]): SessionCache {
const sections: Record<string, ProviderSection> = {}
for (const provider of providers) {
sections[provider] = {
envFingerprint: computeEnvFingerprint(provider),
files: {
[`/tmp/${provider}/a.jsonl`]: makeFile('2026-05-15T10:00:00Z'),
[`/tmp/${provider}/b.jsonl`]: makeFile('2026-05-15T11:00:00Z'),
},
}
}
return {
version: CACHE_VERSION,
providers: sections,
complete: false,
}
}
describe('saveCache yields to the event loop between shard writes (#1141)', () => {
it('surrenders the event loop between every shard write so Ink stdin stays live during a long fill', async () => {
// Background fill: many dirty providers, each with two files in the same
// bucket. Every shard in phase one goes through writeShard + yieldToEventLoop;
// a probe setImmediate scheduled before saveCache must interleave, not block.
const cache = makeCache(['claude', 'codex', 'copilot', 'kimi', 'grok'])
for (const provider of Object.keys(cache.providers)) markCacheDirty(cache, provider)
let probeHits = 0
let probeAttached = true
// Attach BEFORE saveCache: any yield inside saveCache lets this handler
// run. Re-schedule from inside the handler so we count every event-loop
// turn, not just one.
const tick = () => {
if (!probeAttached) return
probeHits++
setImmediate(tick)
}
setImmediate(tick)
try {
await saveCache(cache)
} finally {
probeAttached = false
}
// Each provider has ONE bucket, so we expect at least as many yields as
// providers — the yield is between shards, not inside one, so the atomic
// temp+rename per shard is preserved and every probe pass is a real
// setImmediate boundary (#1141).
expect(probeHits).toBeGreaterThanOrEqual(Object.keys(cache.providers).length)
// The cache is still byte-correct: every provider's file round-trips.
clearLoadCacheMemo()
const reloaded = await loadCache()
for (const provider of Object.keys(cache.providers)) {
const cachedPaths = Object.keys(reloaded.providers[provider]?.files ?? {}).sort()
expect(cachedPaths).toEqual([
`/tmp/${provider}/a.jsonl`,
`/tmp/${provider}/b.jsonl`,
])
}
})
})