fix(terminal): bound cold restore replay

This commit is contained in:
Brennan Benson
2026-07-22 13:09:20 -07:00
parent 0121f571e4
commit 047c954219
8 changed files with 306 additions and 130 deletions
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest'
import {
ColdRestorePayloadCache,
getColdRestorePayloadBytes,
type ColdRestorePayload
} from './cold-restore-payload-cache'
function payload(scrollback: string): ColdRestorePayload {
return { scrollback, cwd: '/tmp', cols: 80, rows: 24 }
}
describe('ColdRestorePayloadCache', () => {
it('evicts least-recently-used payloads to stay under its byte bound', () => {
const first = payload('a'.repeat(100))
const second = payload('b'.repeat(100))
const third = payload('c'.repeat(100))
const maxBytes = getColdRestorePayloadBytes(first) * 2
const evicted: string[] = []
const cache = new ColdRestorePayloadCache(maxBytes, (sessionId) => evicted.push(sessionId))
cache.set('first', first)
cache.set('second', second)
expect(cache.get('first')).toBe(first)
cache.set('third', third)
expect(cache.has('first')).toBe(true)
expect(cache.has('second')).toBe(false)
expect(cache.has('third')).toBe(true)
expect(cache.byteSize).toBeLessThanOrEqual(maxBytes)
expect(evicted).toEqual(['second'])
})
it('does not retain one payload larger than the entire cache budget', () => {
const cache = new ColdRestorePayloadCache(32)
cache.set('oversized', payload('x'.repeat(100)))
expect(cache.has('oversized')).toBe(false)
expect(cache.byteSize).toBe(0)
})
})
@@ -0,0 +1,77 @@
import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges'
export type ColdRestorePayload = {
scrollback: string
cwd: string
cols: number
rows: number
oscLinks?: TerminalOscLinkRange[]
}
// Why: restore payloads remain sticky only for remount safety; cap their aggregate main-process footprint.
export const MAX_COLD_RESTORE_CACHE_BYTES = 16 * 1024 * 1024
export function getColdRestorePayloadBytes(payload: ColdRestorePayload): number {
const oscLinkBytes =
payload.oscLinks?.reduce((bytes, link) => bytes + Buffer.byteLength(link.uri) + 24, 0) ?? 0
return Buffer.byteLength(payload.scrollback) + Buffer.byteLength(payload.cwd) + oscLinkBytes + 16
}
export class ColdRestorePayloadCache {
private entries = new Map<string, { payload: ColdRestorePayload; bytes: number }>()
private totalBytes = 0
constructor(
private readonly maxBytes = MAX_COLD_RESTORE_CACHE_BYTES,
private readonly onEvict?: (sessionId: string) => void
) {}
get byteSize(): number {
return this.totalBytes
}
get(sessionId: string): ColdRestorePayload | undefined {
const entry = this.entries.get(sessionId)
if (!entry) {
return undefined
}
this.entries.delete(sessionId)
this.entries.set(sessionId, entry)
return entry.payload
}
has(sessionId: string): boolean {
return this.entries.has(sessionId)
}
set(sessionId: string, payload: ColdRestorePayload): void {
this.delete(sessionId)
const bytes = getColdRestorePayloadBytes(payload)
this.entries.set(sessionId, { payload, bytes })
this.totalBytes += bytes
while (this.totalBytes > this.maxBytes) {
const oldestSessionId = this.entries.keys().next().value
if (oldestSessionId === undefined) {
break
}
this.delete(oldestSessionId)
this.onEvict?.(oldestSessionId)
}
}
delete(sessionId: string): boolean {
const entry = this.entries.get(sessionId)
if (!entry) {
return false
}
this.entries.delete(sessionId)
this.totalBytes -= entry.bytes
return true
}
clear(): void {
this.entries.clear()
this.totalBytes = 0
}
}
@@ -1994,9 +1994,13 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
writeFileSync(join(sessionDir, 'scrollback.bin'), 'cached output')
historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir })
const internals = historyAdapter as unknown as {
coldRestoreCache: { byteSize: number }
}
const first = await historyAdapter.spawn({ cols: 80, rows: 24, sessionId })
expect(first.coldRestore).toBeDefined()
expect(internals.coldRestoreCache.byteSize).toBeGreaterThan(0)
// Second call (StrictMode remount) should get cached data
const second = await historyAdapter.spawn({ cols: 80, rows: 24, sessionId })
@@ -2005,6 +2009,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
// After ack, cold restore should not be returned
historyAdapter.ackColdRestore(sessionId)
expect(internals.coldRestoreCache.byteSize).toBe(0)
const third = await historyAdapter.spawn({ cols: 80, rows: 24, sessionId })
expect(third.coldRestore).toBeUndefined()
})
+15 -17
View File
@@ -40,17 +40,9 @@ import { resolveWslSessionContext } from './wsl-session-context'
import { normalizeWslColdRestoreCwd } from './wsl-cold-restore-cwd'
import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition'
import { shouldUseShellReadyStartupDelivery } from '../../shared/codex-startup-delivery'
import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges'
import type { PtyIncarnationId } from '../../shared/pty-incarnation'
import { resolveSafePtyDefaultCwd } from '../providers/pty-default-cwd'
type ColdRestorePayload = {
scrollback: string
cwd: string
cols: number
rows: number
oscLinks?: TerminalOscLinkRange[]
}
import { ColdRestorePayloadCache, type ColdRestorePayload } from './cold-restore-payload-cache'
type PendingDaemonSpawnOperation = {
exitsBySessionId: Map<string, { incarnationId?: string }[]>
@@ -134,8 +126,10 @@ export class DaemonPtyAdapter implements IPtyProvider {
// Why: StrictMode/re-render remounts can call createOrAttach for a just-killed session; tombstones stop the daemon resurrecting it (Map evicts oldest-first, per terminal-host.ts).
private killedSessionTombstones = new Map<string, number>()
// Why: React StrictMode double-mounts; this sticky cache returns the same cold restore data on remount until the renderer acknowledges it.
private coldRestoreCache = new Map<string, ColdRestorePayload>()
private sleepRestoreSessionIds = new Set<string>()
private coldRestoreCache = new ColdRestorePayloadCache(undefined, (sessionId) => {
this.sleepRestoreSessionIds.delete(sessionId)
})
private activeSessionIds = new Set<string>()
private sessionIncarnations = new Map<string, string>()
private pendingSpawnOperationsBySessionId = new Map<string, Set<PendingDaemonSpawnOperation>>()
@@ -254,9 +248,11 @@ export class DaemonPtyAdapter implements IPtyProvider {
shellOverride: opts.shellOverride,
terminalWindowsWslDistro: opts.terminalWindowsWslDistro
})?.distro
const detectColdRestore = (options?: { ignoreCleanEnd?: boolean }): ColdRestoreInfo | null => {
const detectColdRestore = async (options?: {
ignoreCleanEnd?: boolean
}): Promise<ColdRestoreInfo | null> => {
const restoreInfo =
this.historyReader?.detectColdRestore(sessionId, { ...options, wslDistro }) ?? null
(await this.historyReader?.detectColdRestore(sessionId, { ...options, wslDistro })) ?? null
if (!restoreInfo) {
return null
}
@@ -293,7 +289,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
if ((await this.getAppliedSize(sessionId)) !== null) {
restoreSkippedForLiveSession = true
} else {
restoreInfo = detectColdRestore()
restoreInfo = await detectColdRestore()
}
}
let effectiveCwd = restoreInfo?.cwd ?? opts.cwd
@@ -403,7 +399,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
// Why: the probe→createOrAttach gap is racy — the session can exit in between, so re-detect to match the unprobed restore path.
// Why ignoreCleanEnd: the raced exit event can write endedAt before the reply; nulling the restore here would delete the checkpoint instead of restoring it.
if (result.isNew && restoreSkippedForLiveSession) {
restoreInfo = detectColdRestore({ ignoreCleanEnd: true })
restoreInfo = await detectColdRestore({ ignoreCleanEnd: true })
scrollback = restoreInfo ? getRecoveredHistorySeed(restoreInfo) : null
if (restoreInfo && scrollback) {
// Why: the aliveness probe raced with session death, so the first
@@ -435,7 +431,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
this.initialCwds.set(sessionId, effectiveCwd)
}
} else if (!result.isNew && result.historySeeded === false) {
restoreInfo = detectColdRestore()
restoreInfo = await detectColdRestore()
scrollback = restoreInfo ? getRecoveredHistorySeed(restoreInfo) : null
}
@@ -655,7 +651,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
}
await this.checkpointSessions([id], { final: true, teardown: true })
const wslDistro = this.wslDistrosBySessionId.get(id)
const detected = this.historyReader?.detectColdRestore(id, { wslDistro }) ?? null
const detected = (await this.historyReader?.detectColdRestore(id, { wslDistro })) ?? null
const restoreInfo = detected
? {
...detected,
@@ -670,7 +666,9 @@ export class DaemonPtyAdapter implements IPtyProvider {
const coldRestore = restoreInfo ? this.buildColdRestorePayload(restoreInfo) : null
if (coldRestore) {
this.coldRestoreCache.set(id, coldRestore)
this.sleepRestoreSessionIds.add(id)
if (this.coldRestoreCache.has(id)) {
this.sleepRestoreSessionIds.add(id)
}
// Why: physical exit must not mark intentional sleep as a clean end; the final checkpoint stays the wake-time recovery authority.
this.historyManager?.suspendSession(id)
}
@@ -37,7 +37,7 @@ describe('agent hibernation cold-restore (alt-screen TUI)', () => {
await manager.checkpoint(sessionId, em.getSnapshot())
em.dispose()
const info = reader.detectColdRestore(sessionId)
const info = await reader.detectColdRestore(sessionId)
expect(info).not.toBeNull()
// Adapter uses rehydrateSequences + snapshotAnsi for non-alt-screen → non-empty.
expect(info!.modes.alternateScreen).toBe(false)
@@ -59,7 +59,7 @@ describe('agent hibernation cold-restore (alt-screen TUI)', () => {
await manager.checkpoint(sessionId, em.getSnapshot())
em.dispose()
const info = reader.detectColdRestore(sessionId)
const info = await reader.detectColdRestore(sessionId)
// Session is eligible (endedAt null) and the agent's snapshot is intact.
expect(info).not.toBeNull()
expect(info!.modes.alternateScreen).toBe(true)
@@ -88,7 +88,7 @@ describe('agent hibernation cold-restore (alt-screen TUI)', () => {
await manager.checkpoint(sessionId, em.getSnapshot())
em.dispose()
const info = reader.detectColdRestore(sessionId)
const info = await reader.detectColdRestore(sessionId)
const adapterScrollback = info!.modes.alternateScreen
? info!.scrollbackAnsi || info!.snapshotAnsi || null
: info!.rehydrateSequences + info!.snapshotAnsi
@@ -118,7 +118,7 @@ describe('agent hibernation cold-restore (alt-screen TUI)', () => {
await manager.checkpoint(sessionId, em.getSnapshot())
em.dispose()
const info = reader.detectColdRestore(sessionId)
const info = await reader.detectColdRestore(sessionId)
expect(info!.modes.alternateScreen).toBe(true)
const adapterScrollback = info!.scrollbackAnsi || info!.snapshotAnsi || null
expect(adapterScrollback).not.toContain(ALT_SCREEN_ON)
+32 -32
View File
@@ -81,10 +81,10 @@ describe('HistoryReader', () => {
})
describe('detectColdRestore — checkpoint.json', () => {
it('returns restore info from checkpoint.json for unclean shutdown', () => {
it('returns restore info from checkpoint.json for unclean shutdown', async () => {
writeSessionWithCheckpoint(dir, 'sess-1', makeMeta(), makeCheckpoint())
const info = reader.detectColdRestore('sess-1')
const info = await reader.detectColdRestore('sess-1')
expect(info).not.toBeNull()
expect(info!.cwd).toBe('/home/user/project')
expect(info!.cols).toBe(80)
@@ -93,7 +93,7 @@ describe('HistoryReader', () => {
expect(info!.rehydrateSequences).toBe('')
})
it('restores terminal modes from checkpoint', () => {
it('restores terminal modes from checkpoint', async () => {
const modes = {
bracketedPaste: true,
mouseTracking: false,
@@ -102,12 +102,12 @@ describe('HistoryReader', () => {
}
writeSessionWithCheckpoint(dir, 'sess-1', makeMeta(), makeCheckpoint({ modes }))
const info = reader.detectColdRestore('sess-1')
const info = await reader.detectColdRestore('sess-1')
expect(info!.modes.bracketedPaste).toBe(true)
expect(info!.modes.applicationCursor).toBe(true)
})
it('restores rehydrateSequences from checkpoint', () => {
it('restores rehydrateSequences from checkpoint', async () => {
writeSessionWithCheckpoint(
dir,
'sess-1',
@@ -115,19 +115,19 @@ describe('HistoryReader', () => {
makeCheckpoint({ rehydrateSequences: '\x1b[?2004h' })
)
const info = reader.detectColdRestore('sess-1')
const info = await reader.detectColdRestore('sess-1')
expect(info!.rehydrateSequences).toBe('\x1b[?2004h')
})
it('restores OSC link ranges from checkpoint', () => {
it('restores OSC link ranges from checkpoint', async () => {
const oscLinks = [{ row: 0, startCol: 6, endCol: 11, uri: 'https://example.com/issue/1234' }]
writeSessionWithCheckpoint(dir, 'sess-1', makeMeta(), makeCheckpoint({ oscLinks }))
const info = reader.detectColdRestore('sess-1')
const info = await reader.detectColdRestore('sess-1')
expect(info!.oscLinks).toEqual(oscLinks)
})
it('returns null for clean shutdown (endedAt is set)', () => {
it('returns null for clean shutdown (endedAt is set)', async () => {
writeSessionWithCheckpoint(
dir,
'sess-1',
@@ -135,37 +135,37 @@ describe('HistoryReader', () => {
makeCheckpoint()
)
expect(reader.detectColdRestore('sess-1')).toBeNull()
expect(await reader.detectColdRestore('sess-1')).toBeNull()
})
it('returns null for nonexistent session', () => {
expect(reader.detectColdRestore('nonexistent')).toBeNull()
it('returns null for nonexistent session', async () => {
expect(await reader.detectColdRestore('nonexistent')).toBeNull()
})
it('returns null for corrupt meta.json', () => {
it('returns null for corrupt meta.json', async () => {
const sessionDir = join(dir, getHistorySessionDirName('corrupt'))
mkdirSync(sessionDir, { recursive: true })
writeFileSync(join(sessionDir, 'meta.json'), 'not json')
writeFileSync(join(sessionDir, 'checkpoint.json'), JSON.stringify(makeCheckpoint()))
expect(reader.detectColdRestore('corrupt')).toBeNull()
expect(await reader.detectColdRestore('corrupt')).toBeNull()
})
it('falls back to scrollback.bin when checkpoint.json is corrupt', () => {
it('falls back to scrollback.bin when checkpoint.json is corrupt', async () => {
const sessionDir = join(dir, getHistorySessionDirName('bad-cp'))
mkdirSync(sessionDir, { recursive: true })
writeFileSync(join(sessionDir, 'meta.json'), JSON.stringify(makeMeta()))
writeFileSync(join(sessionDir, 'checkpoint.json'), 'not json')
writeFileSync(join(sessionDir, 'scrollback.bin'), 'fallback data\r\n')
const info = reader.detectColdRestore('bad-cp')
const info = await reader.detectColdRestore('bad-cp')
expect(info).not.toBeNull()
expect(info!.snapshotAnsi).toBe('fallback data\r\n')
expect(info!.rehydrateSequences).toBe('')
})
})
it('replays incremental hostname OSC-7 with the same WSL context', () => {
it('replays incremental hostname OSC-7 with the same WSL context', async () => {
writeSessionWithCheckpoint(dir, 'wsl-log', makeMeta(), makeCheckpoint({ generation: 7 }))
const sessionDir = join(dir, getHistorySessionDirName('wsl-log'))
writeFileSync(
@@ -178,16 +178,16 @@ describe('HistoryReader', () => {
])
)
const info = reader.detectColdRestore('wsl-log', { wslDistro: 'Ubuntu' })
const info = await reader.detectColdRestore('wsl-log', { wslDistro: 'Ubuntu' })
expect(info?.cwd).toBe('\\\\wsl.localhost\\Ubuntu\\home\\user\\project')
})
describe('detectColdRestore — scrollback.bin fallback (backward compatibility)', () => {
it('restores from scrollback.bin when checkpoint.json is absent', () => {
it('restores from scrollback.bin when checkpoint.json is absent', async () => {
writeSessionWithScrollback(dir, 'old-sess', makeMeta(), 'old format data\r\n')
const info = reader.detectColdRestore('old-sess')
const info = await reader.detectColdRestore('old-sess')
expect(info).not.toBeNull()
expect(info!.snapshotAnsi).toContain('old format data')
expect(info!.rehydrateSequences).toBe('')
@@ -195,20 +195,20 @@ describe('HistoryReader', () => {
expect(info!.modes.alternateScreen).toBe(false)
})
it('returns null when neither checkpoint.json nor scrollback.bin exist', () => {
it('returns null when neither checkpoint.json nor scrollback.bin exist', async () => {
const sessionDir = join(dir, getHistorySessionDirName('no-data'))
mkdirSync(sessionDir, { recursive: true })
writeFileSync(join(sessionDir, 'meta.json'), JSON.stringify(makeMeta()))
expect(reader.detectColdRestore('no-data')).toBeNull()
expect(await reader.detectColdRestore('no-data')).toBeNull()
})
it('truncates alt-screen from scrollback.bin fallback', () => {
it('truncates alt-screen from scrollback.bin fallback', async () => {
const scrollback = ['normal output\r\n', '\x1b[?1049h', 'vim content here'].join('')
writeSessionWithScrollback(dir, 'tui-sess', makeMeta(), scrollback)
const info = reader.detectColdRestore('tui-sess')
const info = await reader.detectColdRestore('tui-sess')
expect(info).not.toBeNull()
expect(info!.snapshotAnsi).toContain('normal output')
expect(info!.snapshotAnsi).not.toContain('vim content')
@@ -216,7 +216,7 @@ describe('HistoryReader', () => {
})
describe('TUI truncation (scrollback.bin fallback path)', () => {
it('preserves content when alt-screen is properly closed', () => {
it('preserves content when alt-screen is properly closed', async () => {
const scrollback = [
'before vim\r\n',
'\x1b[?1049h',
@@ -227,13 +227,13 @@ describe('HistoryReader', () => {
writeSessionWithScrollback(dir, 'closed-tui', makeMeta(), scrollback)
const info = reader.detectColdRestore('closed-tui')
const info = await reader.detectColdRestore('closed-tui')
expect(info).not.toBeNull()
expect(info!.snapshotAnsi).toContain('before vim')
expect(info!.snapshotAnsi).toContain('after vim')
})
it('handles multiple alt-screen cycles with last one unclosed', () => {
it('handles multiple alt-screen cycles with last one unclosed', async () => {
const scrollback = [
'line1\r\n',
'\x1b[?1049h',
@@ -246,14 +246,14 @@ describe('HistoryReader', () => {
writeSessionWithScrollback(dir, 'multi-tui', makeMeta(), scrollback)
const info = reader.detectColdRestore('multi-tui')
const info = await reader.detectColdRestore('multi-tui')
expect(info).not.toBeNull()
expect(info!.snapshotAnsi).toContain('line1')
expect(info!.snapshotAnsi).toContain('line2')
expect(info!.snapshotAnsi).not.toContain('vim2-still-running')
})
it('truncates at outermost unmatched alt-screen-on for nested sessions', () => {
it('truncates at outermost unmatched alt-screen-on for nested sessions', async () => {
const scrollback = [
'normal output\r\n',
'\x1b[?1049h',
@@ -264,17 +264,17 @@ describe('HistoryReader', () => {
writeSessionWithScrollback(dir, 'nested-tui', makeMeta(), scrollback)
const info = reader.detectColdRestore('nested-tui')
const info = await reader.detectColdRestore('nested-tui')
expect(info).not.toBeNull()
expect(info!.snapshotAnsi).toContain('normal output')
expect(info!.snapshotAnsi).not.toContain('tmux content')
expect(info!.snapshotAnsi).not.toContain('vim inside tmux')
})
it('returns full content when no alt-screen sequences', () => {
it('returns full content when no alt-screen sequences', async () => {
writeSessionWithScrollback(dir, 'plain', makeMeta(), 'just normal shell output\r\n')
const info = reader.detectColdRestore('plain')
const info = await reader.detectColdRestore('plain')
expect(info!.snapshotAnsi).toBe('just normal shell output\r\n')
})
})
+84 -63
View File
@@ -1,11 +1,13 @@
import { join } from 'node:path'
import { readFileSync, existsSync, readdirSync } from 'node:fs'
import { readFile } from 'node:fs/promises'
import type { SessionMeta } from './history-manager'
import type { TerminalCheckpointFile, TerminalModes } from './types'
import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges'
import { getHistorySessionDirName } from './history-paths'
import { decodeTerminalHistoryLog } from './terminal-history-log'
import { HeadlessEmulator } from './headless-emulator'
import { PrioritySemaphore } from './priority-semaphore'
export type ColdRestoreInfo = {
snapshotAnsi: string
@@ -20,6 +22,12 @@ export type ColdRestoreInfo = {
const ALT_SCREEN_ON = '\x1b[?1049h'
const ALT_SCREEN_OFF = '\x1b[?1049l'
// Why: parallel pane mounts should interleave with main-process work without multiplying replay slices per turn.
const coldRestoreReplaySemaphore = new PrioritySemaphore(1)
function yieldToEventLoop(): Promise<void> {
return new Promise((resolve) => setImmediate(resolve))
}
export class HistoryReader {
private basePath: string
@@ -37,10 +45,10 @@ export class HistoryReader {
return meta !== null && meta.endedAt === null
}
detectColdRestore(
async detectColdRestore(
sessionId: string,
opts?: { ignoreCleanEnd?: boolean; wslDistro?: string }
): ColdRestoreInfo | null {
): Promise<ColdRestoreInfo | null> {
const meta = this.readMeta(sessionId)
if (!meta) {
return null
@@ -59,7 +67,7 @@ export class HistoryReader {
let checkpoint: TerminalCheckpointFile | null = null
if (checkpointExists) {
try {
checkpoint = JSON.parse(readFileSync(checkpointPath, 'utf-8'))
checkpoint = JSON.parse(await readFile(checkpointPath, 'utf-8'))
} catch {
checkpoint = null
}
@@ -69,7 +77,12 @@ export class HistoryReader {
// byte-exact output up to ~5s before the crash (up to the full-snapshot
// cooldown, ~45s, for a streaming session mid-deferral), while the
// checkpoint can be a full log-cap (~5MB of output) stale.
const logRestore = this.restoreFromIncrementalLog(sessionDir, meta, checkpoint, opts?.wslDistro)
const logRestore = await this.restoreFromIncrementalLog(
sessionDir,
meta,
checkpoint,
opts?.wslDistro
)
if (logRestore) {
return logRestore
}
@@ -77,7 +90,7 @@ export class HistoryReader {
if (!checkpoint) {
// Why: backward compatibility with pre-checkpoint sessions, and corrupt
// checkpoints — the old scrollback.bin is the best remaining data.
return this.detectColdRestoreFromScrollback(sessionId, meta)
return await this.detectColdRestoreFromScrollback(sessionId, meta)
}
return this.coldRestoreInfoFromSnapshot(checkpoint, checkpoint.cwd, meta)
@@ -119,77 +132,85 @@ export class HistoryReader {
// emulator the daemon used reproduces the exact terminal state at the last
// appended batch — including alt-screen and mode handling — and reuses
// getSnapshot()'s normalization instead of string-level reconstruction.
private restoreFromIncrementalLog(
private async restoreFromIncrementalLog(
sessionDir: string,
meta: SessionMeta,
checkpoint: TerminalCheckpointFile | null,
wslDistro?: string
): ColdRestoreInfo | null {
let logBuffer: Buffer
): Promise<ColdRestoreInfo | null> {
const release = await coldRestoreReplaySemaphore.acquire(0)
try {
logBuffer = readFileSync(join(sessionDir, 'output.log'))
} catch {
return null
}
const log = decodeTerminalHistoryLog(logBuffer)
if (!log || log.batches.length === 0) {
return null
}
// Generation mismatch means the log does not continue this checkpoint
// (e.g. crash between checkpoint rename and log reset, or a pre-log
// checkpoint without a generation field). Replaying it would duplicate or
// garble content; the checkpoint alone is consistent.
if (checkpoint) {
if (typeof checkpoint.generation !== 'number' || log.generation !== checkpoint.generation) {
let logBuffer: Buffer
try {
logBuffer = await readFile(join(sessionDir, 'output.log'))
} catch {
return null
}
} else if (log.generation !== 0) {
return null
}
const emulator = new HeadlessEmulator({
cols: checkpoint?.cols ?? meta.cols,
rows: checkpoint?.rows ?? meta.rows,
wslDistro
})
try {
const log = decodeTerminalHistoryLog(logBuffer)
if (!log || log.batches.length === 0) {
return null
}
// Generation mismatch means the log does not continue this checkpoint
// (e.g. crash between checkpoint rename and log reset, or a pre-log
// checkpoint without a generation field). Replaying it would duplicate or
// garble content; the checkpoint alone is consistent.
if (checkpoint) {
if (
!emulator.writeSync(
(checkpoint.scrollbackAnsi ?? '') +
checkpoint.rehydrateSequences +
checkpoint.snapshotAnsi
)
) {
if (typeof checkpoint.generation !== 'number' || log.generation !== checkpoint.generation) {
return null
}
emulator.setRestoredOscLinks(checkpoint.oscLinks)
} else if (log.generation !== 0) {
return null
}
for (const batch of log.batches) {
for (const record of batch.records) {
if (record.kind === 'output') {
if (!emulator.writeSync(record.data)) {
return null
const emulator = new HeadlessEmulator({
cols: checkpoint?.cols ?? meta.cols,
rows: checkpoint?.rows ?? meta.rows,
wslDistro
})
try {
if (checkpoint) {
if (
!emulator.writeSync(
(checkpoint.scrollbackAnsi ?? '') +
checkpoint.rehydrateSequences +
checkpoint.snapshotAnsi
)
) {
return null
}
emulator.setRestoredOscLinks(checkpoint.oscLinks)
}
for (const [batchIndex, batch] of log.batches.entries()) {
for (const record of batch.records) {
if (record.kind === 'output') {
if (!emulator.writeSync(record.data)) {
return null
}
} else if (record.kind === 'resize') {
emulator.resize(record.cols, record.rows)
} else {
emulator.clearScrollback()
}
} else if (record.kind === 'resize') {
emulator.resize(record.cols, record.rows)
} else {
emulator.clearScrollback()
}
if (batchIndex < log.batches.length - 1) {
await yieldToEventLoop()
}
}
const snapshot = emulator.getSnapshot()
return this.coldRestoreInfoFromSnapshot(
snapshot,
snapshot.cwd ?? checkpoint?.cwd ?? meta.cwd,
meta
)
} catch {
// Why: a replay failure must degrade to checkpoint-only restore, never
// surface as a failed spawn.
return null
} finally {
emulator.dispose()
}
const snapshot = emulator.getSnapshot()
return this.coldRestoreInfoFromSnapshot(
snapshot,
snapshot.cwd ?? checkpoint?.cwd ?? meta.cwd,
meta
)
} catch {
// Why: a replay failure must degrade to checkpoint-only restore, never
// surface as a failed spawn.
return null
} finally {
emulator.dispose()
release()
}
}
@@ -236,10 +257,10 @@ export class HistoryReader {
// Why: handles the upgrade transition where sessions created before the
// checkpoint migration still have scrollback.bin but no checkpoint.json.
private detectColdRestoreFromScrollback(
private async detectColdRestoreFromScrollback(
sessionId: string,
meta: SessionMeta
): ColdRestoreInfo | null {
): Promise<ColdRestoreInfo | null> {
const scrollbackPath = join(
this.basePath,
getHistorySessionDirName(sessionId),
@@ -249,7 +270,7 @@ export class HistoryReader {
return null
}
try {
const scrollback = readFileSync(scrollbackPath, 'utf-8')
const scrollback = await readFile(scrollbackPath, 'utf-8')
const truncated = this.truncateAltScreen(scrollback)
return {
snapshotAnsi: truncated,
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { mkdtempSync, rmSync, readFileSync, writeFileSync, existsSync, truncateSync } from 'node:fs'
@@ -53,7 +53,7 @@ describe('incremental terminal history restore', () => {
{ kind: 'output', data: 'second line\r\n' }
])
const restore = reader.detectColdRestore(SESSION_ID)
const restore = await reader.detectColdRestore(SESSION_ID)
expect(restore).not.toBeNull()
expect(restore!.scrollbackAnsi).toContain('first line')
expect(restore!.scrollbackAnsi).toContain('second line')
@@ -66,7 +66,7 @@ describe('incremental terminal history restore', () => {
{ kind: 'output', data: 'from tail after checkpoint\r\n' }
])
const restore = reader.detectColdRestore(SESSION_ID)
const restore = await reader.detectColdRestore(SESSION_ID)
expect(restore).not.toBeNull()
expect(restore!.scrollbackAnsi).toContain('from base')
expect(restore!.scrollbackAnsi).toContain('from tail after checkpoint')
@@ -79,7 +79,7 @@ describe('incremental terminal history restore', () => {
)
await manager.appendIncrements(SESSION_ID, 1, [{ kind: 'output', data: 'tail\r\n' }])
const restore = reader.detectColdRestore(SESSION_ID)
const restore = await reader.detectColdRestore(SESSION_ID)
expect(restore).not.toBeNull()
expect(restore!.oscLinks).toContainEqual({
row: 0,
@@ -99,7 +99,7 @@ describe('incremental terminal history restore', () => {
JSON.stringify({ ...checkpoint, cwd: '/home/user', generation: 1 })
)
const restore = reader.detectColdRestore(SESSION_ID)
const restore = await reader.detectColdRestore(SESSION_ID)
expect(restore).not.toBeNull()
expect(restore!.scrollbackAnsi).toContain('base content')
expect(restore!.scrollbackAnsi).not.toContain('stale tail')
@@ -119,7 +119,7 @@ describe('incremental terminal history restore', () => {
])
)
const restore = reader.detectColdRestore(SESSION_ID)
const restore = await reader.detectColdRestore(SESSION_ID)
expect(restore).not.toBeNull()
expect(restore!.scrollbackAnsi).toContain('old format base')
expect(restore!.scrollbackAnsi).not.toContain('orphan tail')
@@ -137,7 +137,7 @@ describe('incremental terminal history restore', () => {
])
)
const restore = reader.detectColdRestore(SESSION_ID)
const restore = await reader.detectColdRestore(SESSION_ID)
expect(restore).not.toBeNull()
expect(restore!.scrollbackAnsi).toContain('safe base')
expect(restore!.scrollbackAnsi).not.toContain('kept')
@@ -150,7 +150,7 @@ describe('incremental terminal history restore', () => {
const logPath = sessionFile('output.log')
truncateSync(logPath, readFileSync(logPath).length - 5)
const restore = reader.detectColdRestore(SESSION_ID)
const restore = await reader.detectColdRestore(SESSION_ID)
expect(restore).not.toBeNull()
expect(restore!.scrollbackAnsi).toContain('complete batch')
expect(restore!.scrollbackAnsi).not.toContain('torn batch')
@@ -163,7 +163,7 @@ describe('incremental terminal history restore', () => {
{ kind: 'output', data: 'after resize\r\n' }
])
const restore = reader.detectColdRestore(SESSION_ID)
const restore = await reader.detectColdRestore(SESSION_ID)
expect(restore).not.toBeNull()
expect(restore!.cols).toBe(132)
expect(restore!.rows).toBe(40)
@@ -177,7 +177,7 @@ describe('incremental terminal history restore', () => {
{ kind: 'output', data: 'survives clear\r\n' }
])
const restore = reader.detectColdRestore(SESSION_ID)
const restore = await reader.detectColdRestore(SESSION_ID)
expect(restore).not.toBeNull()
expect(restore!.scrollbackAnsi).toContain('survives clear')
expect(restore!.scrollbackAnsi).not.toContain('cleared away')
@@ -188,7 +188,7 @@ describe('incremental terminal history restore', () => {
{ kind: 'output', data: 'normal output\r\n\x1b[?1049halt screen content' }
])
const restore = reader.detectColdRestore(SESSION_ID)
const restore = await reader.detectColdRestore(SESSION_ID)
expect(restore).not.toBeNull()
expect(restore!.modes.alternateScreen).toBe(true)
expect(restore!.scrollbackAnsi).toContain('normal output')
@@ -200,7 +200,7 @@ describe('incremental terminal history restore', () => {
await manager.checkpoint(SESSION_ID, snapshotOf(['pre-checkpoint\r\n']))
await manager.appendIncrements(SESSION_ID, 2, [{ kind: 'output', data: 'post-checkpoint\r\n' }])
const restore = reader.detectColdRestore(SESSION_ID)
const restore = await reader.detectColdRestore(SESSION_ID)
expect(restore).not.toBeNull()
const occurrences = restore!.scrollbackAnsi.split('pre-checkpoint').length - 1
expect(occurrences).toBe(1)
@@ -222,7 +222,7 @@ describe('incremental terminal history restore', () => {
await manager.appendIncrements(SESSION_ID, 4, [{ kind: 'output', data: 'fresh\r\n' }])
).toBe('ok')
const restore = reader.detectColdRestore(SESSION_ID)
const restore = await reader.detectColdRestore(SESSION_ID)
expect(restore).not.toBeNull()
expect(restore!.scrollbackAnsi).toContain('compacted')
expect(restore!.scrollbackAnsi).toContain('fresh')
@@ -246,9 +246,43 @@ describe('incremental terminal history restore', () => {
{ kind: 'output', data: 'after relaunch\r\n' }
])
const restore = reader.detectColdRestore(SESSION_ID)
const restore = await reader.detectColdRestore(SESSION_ID)
expect(restore).not.toBeNull()
expect(restore!.scrollbackAnsi).toContain('before relaunch')
expect(restore!.scrollbackAnsi).toContain('after relaunch')
})
it('yields between batches and admits only one replay at a time', async () => {
const secondSessionId = `${SESSION_ID}-second`
await manager.openSession(secondSessionId, { cwd: '/home/user', cols: 80, rows: 24 })
for (const sessionId of [SESSION_ID, secondSessionId]) {
await manager.appendIncrements(sessionId, 1, [{ kind: 'output', data: 'first\r\n' }])
await manager.appendIncrements(sessionId, 2, [{ kind: 'output', data: 'second\r\n' }])
}
const pendingYields: (() => void)[] = []
const immediateSpy = vi.spyOn(globalThis, 'setImmediate').mockImplementation(((
callback: (...args: unknown[]) => void,
...args: unknown[]
) => {
pendingYields.push(() => callback(...args))
return {} as NodeJS.Immediate
}) as typeof setImmediate)
try {
const firstReplay = reader.detectColdRestore(SESSION_ID)
const secondReplay = reader.detectColdRestore(secondSessionId)
await vi.waitFor(() => expect(pendingYields).toHaveLength(1))
pendingYields.shift()!()
expect((await firstReplay)?.scrollbackAnsi).toContain('second')
await vi.waitFor(() => expect(pendingYields).toHaveLength(1))
pendingYields.shift()!()
expect((await secondReplay)?.scrollbackAnsi).toContain('second')
expect(pendingYields).toHaveLength(0)
} finally {
immediateSpy.mockRestore()
}
})
})