From 047c9542196aa474e848dbe79f4ef80cfc1c0f2a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 22 Jul 2026 13:09:20 -0700 Subject: [PATCH] fix(terminal): bound cold restore replay --- .../daemon/cold-restore-payload-cache.test.ts | 41 +++++ src/main/daemon/cold-restore-payload-cache.ts | 77 +++++++++ src/main/daemon/daemon-pty-adapter.test.ts | 5 + src/main/daemon/daemon-pty-adapter.ts | 32 ++-- .../hibernation-cold-restore-repro.test.ts | 8 +- src/main/daemon/history-reader.test.ts | 64 ++++---- src/main/daemon/history-reader.ts | 147 ++++++++++-------- ...rminal-history-incremental-restore.test.ts | 62 ++++++-- 8 files changed, 306 insertions(+), 130 deletions(-) create mode 100644 src/main/daemon/cold-restore-payload-cache.test.ts create mode 100644 src/main/daemon/cold-restore-payload-cache.ts diff --git a/src/main/daemon/cold-restore-payload-cache.test.ts b/src/main/daemon/cold-restore-payload-cache.test.ts new file mode 100644 index 00000000000..a8ca35859ac --- /dev/null +++ b/src/main/daemon/cold-restore-payload-cache.test.ts @@ -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) + }) +}) diff --git a/src/main/daemon/cold-restore-payload-cache.ts b/src/main/daemon/cold-restore-payload-cache.ts new file mode 100644 index 00000000000..3d4649eb7bd --- /dev/null +++ b/src/main/daemon/cold-restore-payload-cache.ts @@ -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() + 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 + } +} diff --git a/src/main/daemon/daemon-pty-adapter.test.ts b/src/main/daemon/daemon-pty-adapter.test.ts index 73c21f42f5d..e0c8e3336e9 100644 --- a/src/main/daemon/daemon-pty-adapter.test.ts +++ b/src/main/daemon/daemon-pty-adapter.test.ts @@ -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() }) diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index fc20ae5e110..ed53e269b0d 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -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 @@ -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() // 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() private sleepRestoreSessionIds = new Set() + private coldRestoreCache = new ColdRestorePayloadCache(undefined, (sessionId) => { + this.sleepRestoreSessionIds.delete(sessionId) + }) private activeSessionIds = new Set() private sessionIncarnations = new Map() private pendingSpawnOperationsBySessionId = new Map>() @@ -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 => { 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) } diff --git a/src/main/daemon/hibernation-cold-restore-repro.test.ts b/src/main/daemon/hibernation-cold-restore-repro.test.ts index e80fb6f8a80..561c7de261e 100644 --- a/src/main/daemon/hibernation-cold-restore-repro.test.ts +++ b/src/main/daemon/hibernation-cold-restore-repro.test.ts @@ -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) diff --git a/src/main/daemon/history-reader.test.ts b/src/main/daemon/history-reader.test.ts index 4330b0e2c9e..87fd1195e89 100644 --- a/src/main/daemon/history-reader.test.ts +++ b/src/main/daemon/history-reader.test.ts @@ -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') }) }) diff --git a/src/main/daemon/history-reader.ts b/src/main/daemon/history-reader.ts index e219d58cfc4..c47f432af08 100644 --- a/src/main/daemon/history-reader.ts +++ b/src/main/daemon/history-reader.ts @@ -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 { + 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 { 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 { + 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 { 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, diff --git a/src/main/daemon/terminal-history-incremental-restore.test.ts b/src/main/daemon/terminal-history-incremental-restore.test.ts index 544ff2a4c75..9411342976e 100644 --- a/src/main/daemon/terminal-history-incremental-restore.test.ts +++ b/src/main/daemon/terminal-history-incremental-restore.test.ts @@ -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() + } + }) })