fix(terminal): preserve snapshot grid through rollback

This commit is contained in:
Brennan Benson
2026-08-06 01:27:18 -07:00
parent df848a1de2
commit c64d77efbc
5 changed files with 85 additions and 31 deletions
@@ -32,16 +32,27 @@ it('does not remove handlers installed by a remount while sleep is pending', ()
it('replays rollback output in its original replay and live arrival order', () => {
const ptyId = 'pty-shutdown-output-order'
const delivered: string[] = []
ptyDataHandlers.set(ptyId, (data) => delivered.push(`data:${data}`))
ptyReplayHandlers.set(ptyId, (data) => delivered.push(`replay:${data}`))
const delivered: unknown[] = []
ptyDataHandlers.set(ptyId, (data) => delivered.push({ kind: 'data', data }))
ptyReplayHandlers.set(ptyId, (data, meta) => delivered.push({ kind: 'replay', data, meta }))
const [snapshot] = unregisterPtyDataHandlers([ptyId])
bufferPtyShutdownReplayData(ptyId, 'old')
bufferPtyShutdownReplayData(ptyId, 'old', {
snapshotCols: 80,
snapshotRows: 24,
pendingEscapeTailAnsi: '\x1b[3'
})
bufferPtyShutdownData(ptyId, 'new')
snapshot.rollback()
expect(delivered).toEqual(['replay:old', 'data:new'])
expect(delivered).toEqual([
{
kind: 'replay',
data: 'old',
meta: { snapshotCols: 80, snapshotRows: 24, pendingEscapeTailAnsi: '\x1b[3' }
},
{ kind: 'data', data: 'new' }
])
ptyDataHandlers.delete(ptyId)
ptyReplayHandlers.delete(ptyId)
})
@@ -5,7 +5,13 @@ import type { PtyDataMeta } from './pty-dispatcher'
export const ptyDataHandlers = new Map<string, (data: string, meta?: PtyDataMeta) => void>()
export const ptyDataSidecars = new Map<string, Set<(data: string) => void>>()
export const ptyReplayHandlers = new Map<string, (data: string) => void>()
export type PtyReplayDataMeta = {
clearBeforeReplay?: boolean
pendingEscapeTailAnsi?: string
snapshotCols?: number
snapshotRows?: number
}
export const ptyReplayHandlers = new Map<string, (data: string, meta?: PtyReplayDataMeta) => void>()
export const ptyExitHandlers = new Map<string, (code: number) => void>()
export const ptyTeardownHandlers = new Map<string, () => void>()
export const ptyShutdownLifecycleHandlers = new Map<
@@ -16,7 +22,7 @@ export const ptyShutdownLifecycleHandlers = new Map<
export type PtyDataHandlerShutdownSnapshot = {
ptyId: string
dataHandler?: (data: string, meta?: PtyDataMeta) => void
replayHandler?: (data: string) => void
replayHandler?: (data: string, meta?: PtyReplayDataMeta) => void
teardownHandler?: () => void
commit: () => void
rollback: () => void
@@ -28,7 +34,7 @@ type PendingPtyHandlerShutdown = {
bufferedBytes: number
events: PtyShutdownOutputEvent[]
dataHandler?: (data: string, meta?: PtyDataMeta) => void
replayHandler?: (data: string) => void
replayHandler?: (data: string, meta?: PtyReplayDataMeta) => void
teardownHandler?: () => void
lifecycleHandler?: { pause: () => void; rollback: () => void; commit: () => void }
}
@@ -36,7 +42,7 @@ type PendingPtyHandlerShutdown = {
const pendingPtyHandlerShutdowns = new Map<string, PendingPtyHandlerShutdown>()
type PtyShutdownOutputEvent =
| { kind: 'data'; data: string; meta?: PtyDataMeta }
| { kind: 'replay'; data: string }
| { kind: 'replay'; data: string; meta?: PtyReplayDataMeta }
const rolledBackShutdownEvents = new Map<string, PtyShutdownOutputEvent[]>()
const ROLLED_BACK_SHUTDOWN_REPLAY_MAX_PTYS = 64
@@ -100,8 +106,12 @@ export function isPtyDataHandlerShutdownPending(ptyId: string): boolean {
return pendingPtyHandlerShutdowns.has(ptyId)
}
export function bufferPtyShutdownReplayData(ptyId: string, data: string): boolean {
return bufferPtyShutdownOutput(ptyId, { kind: 'replay', data })
export function bufferPtyShutdownReplayData(
ptyId: string,
data: string,
meta?: PtyReplayDataMeta
): boolean {
return bufferPtyShutdownOutput(ptyId, { kind: 'replay', data, meta })
}
export function bufferPtyShutdownData(ptyId: string, data: string, meta?: PtyDataMeta): boolean {
@@ -201,11 +211,15 @@ function deliverShutdownEvents(
ptyId: string,
events: readonly PtyShutdownOutputEvent[],
dataHandler: (data: string, meta?: PtyDataMeta) => void,
replayHandler: (data: string) => void
replayHandler: (data: string, meta?: PtyReplayDataMeta) => void
): void {
for (const event of events) {
if (event.kind === 'replay') {
replayHandler(event.data)
if (event.meta) {
replayHandler(event.data, event.meta)
} else {
replayHandler(event.data)
}
continue
}
dataHandler(event.data, event.meta)
@@ -14,6 +14,7 @@ import type { TerminalOscColorQueryReplyColors } from '../../../../shared/termin
import type { TuiAgent } from '../../../../shared/types'
import type { ExecutionHostId } from '../../../../shared/execution-host'
import type { PtyDataMeta } from './pty-dispatcher'
import type { PtyReplayDataMeta } from './pty-shutdown-data-suspension'
import type { RemoteRuntimeSnapshotOutcome } from '../../runtime/remote-runtime-terminal-multiplexer'
export type PtyBufferSnapshot = {
@@ -78,15 +79,7 @@ type PtyCallbacks = {
onConnect?: () => void
onDisconnect?: () => void
onData?: (data: string, meta?: PtyDataMeta) => void
onReplayData?: (
data: string,
meta?: {
clearBeforeReplay?: boolean
pendingEscapeTailAnsi?: string
snapshotCols?: number
snapshotRows?: number
}
) => void
onReplayData?: (data: string, meta?: PtyReplayDataMeta) => void
onStatus?: (shell: string) => void
onError?: (message: string, errors?: string[]) => void
onExit?: (code: number) => void
@@ -392,6 +392,37 @@ describe('createRemoteRuntimePtyTransport', () => {
transport.destroy?.()
})
it('preserves the authoritative snapshot grid through shutdown rollback', async () => {
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
const { unregisterPtyDataHandlers } = await import('./pty-shutdown-data-suspension')
const onReplayData = vi.fn()
const transport = createRemoteRuntimePtyTransport('env-1', { worktreeId: 'wt-1' })
transport.attach({
existingPtyId: 'remote:terminal-1',
callbacks: { onReplayData }
})
await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled())
const ptyId = transport.getPtyId()
if (!ptyId) {
throw new Error('missing remote PTY id')
}
const [shutdown] = unregisterPtyDataHandlers([ptyId])
emitSnapshot(latestSubscribePayload().streamId, 'authoritative state', {
cols: 80,
rows: 24
})
expect(onReplayData).not.toHaveBeenCalled()
shutdown.rollback()
expect(onReplayData).toHaveBeenCalledWith('authoritative state', {
snapshotCols: 80,
snapshotRows: 24
})
transport.destroy?.()
})
it('recovers when the first restored-terminal subscription attempt is offline', async () => {
vi.useFakeTimers()
try {
@@ -80,7 +80,8 @@ import {
isPtyDataHandlerShutdownPending,
ptyDataHandlers,
ptyReplayHandlers,
ptyShutdownLifecycleHandlers
ptyShutdownLifecycleHandlers,
type PtyReplayDataMeta
} from './pty-shutdown-data-suspension'
import { getRuntimeEnvironmentRevision } from '@/runtime/runtime-environment-revision'
@@ -372,10 +373,11 @@ export function createRemoteRuntimePtyTransport(
): void => {
outputProcessor.processData(data, storedCallbacks, undefined, meta)
}
const shutdownReplayHandler = (data: string): void => {
const shutdownReplayHandler = (data: string, meta?: PtyReplayDataMeta): void => {
outputProcessor.processData(data, storedCallbacks, {
replayingBufferedData: true,
suppressAttentionEvents: true
suppressAttentionEvents: true,
...meta
})
}
const shutdownLifecycle = {
@@ -1760,17 +1762,20 @@ export function createRemoteRuntimePtyTransport(
onSnapshot: (data, meta) => {
// Why: an empty snapshot can still carry a pending mid-escape tail that must replay so the next live chunk completes it.
if ((data || meta?.pendingEscapeTailAnsi) && isCurrentSubscription()) {
if (subscribedPtyId && bufferPtyShutdownReplayData(subscribedPtyId, data)) {
return
}
outputProcessor.processData(data, storedCallbacks, {
replayingBufferedData: true,
suppressAttentionEvents: true,
const replayMeta: PtyReplayDataMeta = {
...(meta?.cols !== undefined ? { snapshotCols: meta.cols } : {}),
...(meta?.rows !== undefined ? { snapshotRows: meta.rows } : {}),
...(meta?.pendingEscapeTailAnsi
? { pendingEscapeTailAnsi: meta.pendingEscapeTailAnsi }
: {})
}
if (subscribedPtyId && bufferPtyShutdownReplayData(subscribedPtyId, data, replayMeta)) {
return
}
outputProcessor.processData(data, storedCallbacks, {
replayingBufferedData: true,
suppressAttentionEvents: true,
...replayMeta
})
}
},