fix(daemon): bound durable snapshot replay

This commit is contained in:
Brennan Benson
2026-08-13 01:09:45 -07:00
parent 516c9970c3
commit 77bd4f8302
3 changed files with 42 additions and 9 deletions
@@ -23,7 +23,7 @@ export function terminalSnapshotFromColdRestore(
): TerminalSnapshot {
return {
snapshotAnsi: info.snapshotAnsi,
scrollbackAnsi: info.scrollbackAnsi,
scrollbackAnsi: info.modes.alternateScreen ? info.scrollbackAnsi : '',
oscLinks: info.oscLinks,
rehydrateSequences: info.rehydrateSequences,
...(info.pendingEscapeTailAnsi ? { pendingEscapeTailAnsi: info.pendingEscapeTailAnsi } : {}),
@@ -42,12 +42,17 @@ export async function buildDurableCheckpointSnapshot(opts: {
liveSnapshot: TerminalSnapshot
restoreInfo: ColdRestoreInfo | null
pendingRecords?: readonly PendingOutputRecord[]
scrollbackRows?: number
}): Promise<TerminalSnapshot> {
const pendingRecords = opts.pendingRecords ?? []
if (!opts.restoreInfo && pendingRecords.length === 0) {
return opts.liveSnapshot
}
if (opts.restoreInfo && pendingRecords.length === 0) {
if (
opts.restoreInfo &&
pendingRecords.length === 0 &&
(opts.scrollbackRows === undefined || opts.scrollbackRows >= DAEMON_RESTORE_SCROLLBACK_ROWS)
) {
return terminalSnapshotFromColdRestore(opts.restoreInfo, {
outputSequence: opts.liveSnapshot.outputSequence,
frameRestoreAnsi: opts.liveSnapshot.frameRestoreAnsi
@@ -57,7 +62,10 @@ export async function buildDurableCheckpointSnapshot(opts: {
const emulator = new HeadlessEmulator({
cols: opts.restoreInfo?.cols ?? opts.liveSnapshot.cols,
rows: opts.restoreInfo?.rows ?? opts.liveSnapshot.rows,
scrollback: DAEMON_RESTORE_SCROLLBACK_ROWS
scrollback: Math.min(
opts.scrollbackRows ?? DAEMON_RESTORE_SCROLLBACK_ROWS,
DAEMON_RESTORE_SCROLLBACK_ROWS
)
})
const replay = new ColdRestoreReplayWriter(emulator)
try {
@@ -104,7 +112,7 @@ export async function buildDurableCheckpointSnapshot(opts: {
function restoreBaseFrom(restoreInfo: ColdRestoreInfo): RestoreBase {
return {
scrollbackAnsi: restoreInfo.scrollbackAnsi,
scrollbackAnsi: restoreInfo.modes.alternateScreen ? restoreInfo.scrollbackAnsi : '',
rehydrateSequences: restoreInfo.rehydrateSequences,
snapshotAnsi: restoreInfo.snapshotAnsi,
...(restoreInfo.pendingEscapeTailAnsi
+12 -5
View File
@@ -70,6 +70,7 @@ import { resolveSafePtyDefaultCwd } from '../providers/pty-default-cwd'
import { PtyWriteUnavailableError } from '../providers/pty-write-unavailable-error'
import { ColdRestorePayloadCache, type ColdRestorePayload } from './cold-restore-payload-cache'
import { buildDurableCheckpointSnapshot } from './daemon-durable-history-snapshot'
import { DAEMON_SESSION_SCROLLBACK_ROWS } from './daemon-session-scrollback-window'
import { PtyProcessListAdmission } from '../providers/pty-process-list-admission'
import {
iterateTerminalHistorySeedChunks,
@@ -87,6 +88,7 @@ import {
} from './daemon-audit-classifier'
import type { DaemonEvidenceSource, ExactDaemonIncarnation } from './daemon-incarnation-evidence'
import { createDaemonAuditEligibilityTracker } from './daemon-audit-eligibility-event'
import { normalizeDesktopTerminalSnapshotRows } from '../../shared/terminal-scrollback-policy'
type PendingDaemonSpawnOperation = {
exitsBySessionId: Map<string, { incarnationId?: string }[]>
@@ -1219,9 +1221,10 @@ export class DaemonPtyAdapter implements IPtyProvider {
return null
}
try {
const scrollbackRows = normalizeDesktopTerminalSnapshotRows(opts.scrollbackRows)
const result = await this.client.request<GetSnapshotResult>('getSnapshot', {
sessionId: id,
...(typeof opts.scrollbackRows === 'number' ? { scrollbackRows: opts.scrollbackRows } : {})
...(scrollbackRows !== undefined ? { scrollbackRows } : {})
})
const snapshot = result.snapshot
// Why: older v19 daemons lack an absolute output sequence, so their snapshot can't reconcile bytes queued on the other socket.
@@ -1229,8 +1232,10 @@ export class DaemonPtyAdapter implements IPtyProvider {
return null
}
const restored =
this.historyManager && this.historyReader && opts.scrollbackRows !== 0
? await this.overlayDurableRestoreSnapshot(id, snapshot)
this.historyManager &&
this.historyReader &&
(scrollbackRows === undefined || scrollbackRows > DAEMON_SESSION_SCROLLBACK_ROWS)
? await this.overlayDurableRestoreSnapshot(id, snapshot, scrollbackRows)
: snapshot
return this.toProviderBufferSnapshot(restored)
} catch {
@@ -1268,7 +1273,8 @@ export class DaemonPtyAdapter implements IPtyProvider {
private async overlayDurableRestoreSnapshot(
sessionId: string,
liveSnapshot: NonNullable<GetSnapshotResult['snapshot']>
liveSnapshot: NonNullable<GetSnapshotResult['snapshot']>,
scrollbackRows?: number
): Promise<NonNullable<GetSnapshotResult['snapshot']>> {
if (!this.historyManager || !this.historyReader) {
return liveSnapshot
@@ -1291,7 +1297,8 @@ export class DaemonPtyAdapter implements IPtyProvider {
}
return await buildDurableCheckpointSnapshot({
liveSnapshot,
restoreInfo
restoreInfo,
scrollbackRows
})
} catch {
return liveSnapshot
@@ -191,6 +191,7 @@ describe('STA-4091 previously recoverable restore depth', () => {
restoreInfo
})
expect(durable.outputSequence).toBe(9)
expect(durable.scrollbackAnsi).toBe('')
expect(snapshotText(durable)).toContain(PREVIOUSLY_RECOVERABLE_LINE)
} finally {
live.dispose()
@@ -272,6 +273,23 @@ describe('STA-4091 previously recoverable restore depth', () => {
expect(text).toContain(NEWEST_WRITTEN_LINE)
expect(text).toContain(PREVIOUSLY_RECOVERABLE_LINE)
expect(text).toContain(OLDEST_WRITTEN_LINE)
expect(text.split(OLDEST_WRITTEN_LINE)).toHaveLength(2)
})
it('honors a bounded remount snapshot depth', async () => {
const { id } = await adapter.spawn({
cols: 80,
rows: 24,
sessionId: 'bounded-remount-depth',
cwd: '/tmp'
})
lastSubprocess.emitData(numberedOutput(DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT))
const snapshot = await adapter.getBufferSnapshot(id, { scrollbackRows: 24 })
const text = `${snapshot?.scrollbackAnsi ?? ''}${snapshot?.data ?? ''}`
expect(text).toContain(NEWEST_WRITTEN_LINE)
expect(text).not.toContain(PREVIOUSLY_RECOVERABLE_LINE)
expect(text).not.toContain(OLDEST_WRITTEN_LINE)
})
it('restores that depth after a keepHistory restart compact', async () => {