mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 08:03:13 +00:00
fix(terminal): bound cold restore replay
This commit is contained in:
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user