diff --git a/src/main/daemon/history-reader-memory.test.ts b/src/main/daemon/history-reader-memory.test.ts index 36e86f5f413..9a96e4e7cf8 100644 --- a/src/main/daemon/history-reader-memory.test.ts +++ b/src/main/daemon/history-reader-memory.test.ts @@ -22,6 +22,8 @@ import { TERMINAL_HISTORY_JSON_MAX_STRUCTURAL_TOKENS } from './terminal-history-file-reader' +const RETIRED_CHECKPOINT_READ_CAP_BYTES = 16 * 1024 * 1024 + const directories: string[] = [] function createSession(sessionId: string): { basePath: string; sessionPath: string } { @@ -49,7 +51,7 @@ function createSparseFile(path: string, bytes: number): void { closeSync(descriptor) } -function checkpoint(): string { +function checkpoint(overrides?: Record): string { return JSON.stringify({ snapshotAnsi: 'safe checkpoint', scrollbackAnsi: '', @@ -62,7 +64,8 @@ function checkpoint(): string { mouseTracking: false, applicationCursor: false, alternateScreen: false - } + }, + ...overrides }) } @@ -82,12 +85,27 @@ describe('terminal history restore memory limits', () => { expect(restore?.snapshotAnsi).toBe('safe checkpoint') }) + // Why: the read cap once sat at 16MiB while the writer stayed unbounded, so a big-scrollback + // session cold-restored to an empty terminal — checkpoint nulled, and every fallback collapsed. + it('restores a checkpoint larger than the retired 16MiB read cap', async () => { + const { basePath, sessionPath } = createSession('large-checkpoint') + const scrollbackAnsi = 'x'.repeat(RETIRED_CHECKPOINT_READ_CAP_BYTES + 1) + writeFileSync(join(sessionPath, 'checkpoint.json'), checkpoint({ scrollbackAnsi })) + + const restore = await new HistoryReader(basePath).detectColdRestore('large-checkpoint') + expect(restore?.scrollbackAnsi).toBe(scrollbackAnsi) + }) + + // Why the cap survives at all: it bounds a corrupt/runaway file, well above legitimate output. it('ignores oversized checkpoint and metadata files before parsing', async () => { const checkpointSession = createSession('oversized-checkpoint') - createSparseFile( - join(checkpointSession.sessionPath, 'checkpoint.json'), - TERMINAL_HISTORY_CHECKPOINT_MAX_BYTES + 1 - ) + const oversizedCheckpoint = join(checkpointSession.sessionPath, 'checkpoint.json') + createSparseFile(oversizedCheckpoint, TERMINAL_HISTORY_CHECKPOINT_MAX_BYTES + 1) + // Why assert the reader directly too: detectColdRestore returns null for any unparseable + // checkpoint, so it would stay green with the byte cap removed entirely. + expect(() => + readTerminalHistoryJson(oversizedCheckpoint, TERMINAL_HISTORY_CHECKPOINT_MAX_BYTES) + ).toThrow(/File too large/) expect( await new HistoryReader(checkpointSession.basePath).detectColdRestore('oversized-checkpoint') ).toBeNull() diff --git a/src/main/daemon/terminal-history-file-limits.ts b/src/main/daemon/terminal-history-file-limits.ts index 3a8e4a1891e..5fc4523d785 100644 --- a/src/main/daemon/terminal-history-file-limits.ts +++ b/src/main/daemon/terminal-history-file-limits.ts @@ -1,4 +1,8 @@ export const TERMINAL_HISTORY_META_MAX_BYTES = 64 * 1024 export const TERMINAL_HISTORY_LOG_MAX_BYTES = 5 * 1024 * 1024 -export const TERMINAL_HISTORY_CHECKPOINT_MAX_BYTES = 16 * 1024 * 1024 +// Why deliberately generous: the checkpoint writer is unbounded, and a read cap under what it +// can emit silently drops ALL scrollback on cold restore. This guards a corrupt/runaway file, +// not retention — a 50k-row max preset of ordinary text serializes to ~14MB, ~15x under. Output +// colored per cell can still exceed this; trimming the snapshot writer-side is the real fix. +export const TERMINAL_HISTORY_CHECKPOINT_MAX_BYTES = 200_000_000 export const TERMINAL_HISTORY_LEGACY_SCROLLBACK_MAX_BYTES = 16 * 1024 * 1024