mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-21 05:23:19 +00:00
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.
102 lines
3.2 KiB
TypeScript
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`,
|
|
])
|
|
}
|
|
})
|
|
})
|