diff --git a/src/renderer/src/components/terminal-pane/remote-hidden-output-restore-outcomes.test.ts b/src/renderer/src/components/terminal-pane/remote-hidden-output-restore-outcomes.test.ts index 0c9b286703c..0e98202aab3 100644 --- a/src/renderer/src/components/terminal-pane/remote-hidden-output-restore-outcomes.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-hidden-output-restore-outcomes.test.ts @@ -32,6 +32,7 @@ function leafIdForPane(paneId: number): string { type ConnectCallbacks = { onReattachDetermined?: () => void onConnect?: () => void + onStreamRecovered?: () => void onData?: ( data: string, meta?: { seq?: number; rawLength?: number; background?: boolean; droppedOutput?: boolean } @@ -379,6 +380,7 @@ type RemotePaneDrive = { disposable: { dispose: () => void } deliver: (data: string, seq: number) => void setOutputPaused: (paused: boolean) => void + recoverStream: () => void writtenChunks: () => string[] } @@ -396,9 +398,11 @@ async function connectHiddenRemoteAgentPane( const capturedOutputPauseCallback: { current: ((paused: boolean, supported: boolean) => void) | null } = { current: null } + const capturedStreamRecoveredCallback: { current: (() => void) | null } = { current: null } transport.connect.mockImplementation(async ({ callbacks }: { callbacks?: ConnectCallbacks }) => { capturedDataCallback.current = callbacks?.onData ?? null capturedOutputPauseCallback.current = callbacks?.onOutputPauseChanged ?? null + capturedStreamRecoveredCallback.current = callbacks?.onStreamRecovered ?? null return REMOTE_PTY_ID }) transportFactoryQueue.push(transport) @@ -415,6 +419,7 @@ async function connectHiddenRemoteAgentPane( disposable, deliver: (data, seq) => capturedDataCallback.current?.(data, { seq, rawLength: data.length }), setOutputPaused: (paused) => capturedOutputPauseCallback.current?.(paused, true), + recoverStream: () => capturedStreamRecoveredCallback.current?.(), writtenChunks: () => pane.terminal.write.mock.calls.map(([data]) => String(data)) } } @@ -618,6 +623,24 @@ describe('remote hidden-output restore outcomes', () => { resetAgentStartupDelayedDeliveryForTests() }) + it('[modern] repaints a recovered visible pane from the retained host buffer', async () => { + const serializeBuffer = vi.fn() + const serializeBufferOutcome = vi.fn().mockResolvedValue({ + availability: { kind: 'snapshot' }, + snapshot: HOST_SNAPSHOT + }) + const drive = await connectHiddenRemoteAgentPane(serializeBuffer, serializeBufferOutcome) + ;(drive.deps.isVisibleRef as { current: boolean }).current = true + + drive.recoverStream() + await flushAsyncTicks(20) + + expect(serializeBufferOutcome).toHaveBeenCalledTimes(1) + expect(drive.writtenChunks().join('')).toContain(HOST_SNAPSHOT_MARKER) + expect(serializeBuffer).not.toHaveBeenCalled() + drive.disposable.dispose() + }) + it('[modern] accepts an empty snapshot as successful recovery without a loss banner', async () => { const serializeBuffer = vi.fn() const serializeBufferOutcome = vi.fn().mockResolvedValue({ 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 f0d62cd765b..4087cf59a15 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 @@ -5109,6 +5109,33 @@ describe('createRemoteRuntimePtyTransport', () => { await vi.waitFor(() => expect(onStreamRecovered).toHaveBeenCalledTimes(1)) expect(onReplayData.mock.calls.map((call) => call[0])).toEqual(['INITIAL_SNAPSHOT']) + + subscriptionCallbacks?.onClose?.() + await vi.waitFor(() => expect(runtimeSubscribe).toHaveBeenCalledTimes(3)) + await vi.waitFor(() => + expect( + subscriptionSendBinary.mock.calls + .map((call) => decodeTerminalStreamFrame(call[0])) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Subscribe) + ).toHaveLength(3) + ) + const populatedReconnectStreamId = latestSubscribePayload().streamId + emitSnapshot(populatedReconnectStreamId, 'RECOVERY_SNAPSHOT') + subscriptionCallbacks?.onResponse({ + ok: true, + result: { + type: 'subscribed', + streamId: populatedReconnectStreamId, + capabilities: { outputPause: 1 } + } + }) + + await vi.waitFor(() => expect(onConnect).toHaveBeenCalledTimes(3)) + expect(onStreamRecovered).toHaveBeenCalledTimes(1) + expect(onReplayData.mock.calls.map((call) => call[0])).toEqual([ + 'INITIAL_SNAPSHOT', + 'RECOVERY_SNAPSHOT' + ]) transport.destroy?.() }) 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 8180f078d44..6eff59d84ce 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 @@ -1735,6 +1735,7 @@ export function createRemoteRuntimePtyTransport( setAttachmentReady(false) let transportClosed = false let subscriptionAttached = false + let subscriptionSnapshotHadContent = false // Why: viewport handed to subscribe; a resize during the round-trip falls back to the refresh-only one-shot RPC, replayed through the stream below once current. const subscribedViewport = desiredViewport const isCurrentSubscription = (): boolean => @@ -1760,6 +1761,7 @@ 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()) { + subscriptionSnapshotHadContent = true if (subscribedPtyId && bufferPtyShutdownReplayData(subscribedPtyId, data)) { return } @@ -1809,7 +1811,7 @@ export function createRemoteRuntimePtyTransport( // Why: a recovery subscribe replays nothing when the host's push snapshot is // empty (idle or exited pane), so ask for the retained buffer instead of // waiting for bytes that an exited process will never send. - if (expectedRecoveryEpoch !== undefined) { + if (expectedRecoveryEpoch !== undefined && !subscriptionSnapshotHadContent) { storedCallbacks.onStreamRecovered?.() } storedCallbacks.onStatus?.('shell')