diff --git a/src/renderer/src/components/terminal-pane/pty-shutdown-data-suspension.test.ts b/src/renderer/src/components/terminal-pane/pty-shutdown-data-suspension.test.ts index 45846d08143..381a85ec737 100644 --- a/src/renderer/src/components/terminal-pane/pty-shutdown-data-suspension.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-shutdown-data-suspension.test.ts @@ -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) }) diff --git a/src/renderer/src/components/terminal-pane/pty-shutdown-data-suspension.ts b/src/renderer/src/components/terminal-pane/pty-shutdown-data-suspension.ts index 0bc654bfe8e..508fe32cfc7 100644 --- a/src/renderer/src/components/terminal-pane/pty-shutdown-data-suspension.ts +++ b/src/renderer/src/components/terminal-pane/pty-shutdown-data-suspension.ts @@ -5,7 +5,13 @@ import type { PtyDataMeta } from './pty-dispatcher' export const ptyDataHandlers = new Map void>() export const ptyDataSidecars = new Map void>>() -export const ptyReplayHandlers = new Map void>() +export type PtyReplayDataMeta = { + clearBeforeReplay?: boolean + pendingEscapeTailAnsi?: string + snapshotCols?: number + snapshotRows?: number +} +export const ptyReplayHandlers = new Map void>() export const ptyExitHandlers = new Map void>() export const ptyTeardownHandlers = new Map 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() type PtyShutdownOutputEvent = | { kind: 'data'; data: string; meta?: PtyDataMeta } - | { kind: 'replay'; data: string } + | { kind: 'replay'; data: string; meta?: PtyReplayDataMeta } const rolledBackShutdownEvents = new Map() 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) diff --git a/src/renderer/src/components/terminal-pane/pty-transport-types.ts b/src/renderer/src/components/terminal-pane/pty-transport-types.ts index 1658ea07a6d..96f14071f76 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-types.ts @@ -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 diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts index 91c9ae0c8a3..cf1b515fc92 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts @@ -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 { diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts index 881127fb64b..32ff4556fb7 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts @@ -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 }) } },