diff --git a/src/renderer/src/components/terminal-pane/legacy-relay-bridge-transport.ts b/src/renderer/src/components/terminal-pane/legacy-relay-bridge-transport.ts index 5569edd214c..96348138059 100644 --- a/src/renderer/src/components/terminal-pane/legacy-relay-bridge-transport.ts +++ b/src/renderer/src/components/terminal-pane/legacy-relay-bridge-transport.ts @@ -8,6 +8,7 @@ * can't be reached, the pane stays bound and reattaches once it can: loss of contact is never exit. */ import type { PtyConnectResult, PtyTransport } from './pty-transport-types' +import type { RemoteRuntimeSnapshotRequestOptions } from '../../runtime/remote-runtime-terminal-multiplexer-types' import { SSH_SESSION_EXPIRED_ERROR } from './pty-connection/pty-connect-limits' import { legacyRelayBridgedPane, @@ -173,7 +174,7 @@ export function createLegacyRelayBridgeTransport(ptyId: string, inner: PtyTransp : {}), ...(innerSnapshotOutcome ? { - serializeBufferOutcome: (opts?: { scrollbackRows?: number }) => + serializeBufferOutcome: (opts?: RemoteRuntimeSnapshotRequestOptions) => bridged() ? Promise.resolve({ availability: { kind: 'unknown-legacy-host' as const }, diff --git a/src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts index 01e8554026f..751b3eb85d8 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts @@ -202,7 +202,10 @@ describe('connectPanePty', () => { await flushAsyncTicks(20) expect(getMainBufferSnapshot).not.toHaveBeenCalled() - expect(transport.serializeBuffer).toHaveBeenCalledWith({ scrollbackRows: 5000 }) + expect(transport.serializeBuffer).toHaveBeenCalledWith({ + scrollbackRows: 5000, + repaintsPane: true + }) expect(pane.terminal.write).not.toHaveBeenCalledWith(hidden) expect(pane.terminal.write).toHaveBeenCalledWith( expect.stringContaining('remote snapshot with hidden remote output'), diff --git a/src/renderer/src/components/terminal-pane/pty-connection-hidden-output-restore.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-hidden-output-restore.spec.ts index ea37977da89..5ef6d2ba158 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-hidden-output-restore.spec.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-hidden-output-restore.spec.ts @@ -488,7 +488,10 @@ export function registerPtyConnectionHiddenOutputRestoreTests(): void { await flushAsyncTicks(20) expect(transport.setOutputPaused).toHaveBeenLastCalledWith(false) - expect(transport.serializeBuffer).toHaveBeenCalledWith({ scrollbackRows: 5000 }) + expect(transport.serializeBuffer).toHaveBeenCalledWith({ + scrollbackRows: 5000, + repaintsPane: true + }) expect(transport.setOutputPaused.mock.invocationCallOrder.at(-1)).toBeLessThan( transport.serializeBuffer.mock.invocationCallOrder[0] ) @@ -564,7 +567,10 @@ export function registerPtyConnectionHiddenOutputRestoreTests(): void { await flushAsyncTicks(20) expect(transport.setOutputPaused).toHaveBeenLastCalledWith(false) - expect(transport.serializeBuffer).toHaveBeenCalledWith({ scrollbackRows: 5000 }) + expect(transport.serializeBuffer).toHaveBeenCalledWith({ + scrollbackRows: 5000, + repaintsPane: true + }) callbacks.onData?.(live, { seq: hidden.length + live.length, rawLength: live.length diff --git a/src/renderer/src/components/terminal-pane/pty-connection-park-reveal-model-snapshot.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-park-reveal-model-snapshot.spec.ts index f1d022054c1..18ec6cc0b6a 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-park-reveal-model-snapshot.spec.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-park-reveal-model-snapshot.spec.ts @@ -95,7 +95,10 @@ export function registerPtyConnectionParkRevealModelSnapshotTests(): void { await flushAsyncTicks(2) } - expect(transport.serializeBuffer).toHaveBeenCalledWith({ scrollbackRows: 5000 }) + expect(transport.serializeBuffer).toHaveBeenCalledWith({ + scrollbackRows: 5000, + repaintsPane: true + }) expect(transport.attach).not.toHaveBeenCalled() expect(writes.join('')).toContain('DEEP_PAIRED_SCROLLBACK') expect(writes.join('')).toContain('current screen from initial subscribe') diff --git a/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-snapshot-serialize.ts b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-snapshot-serialize.ts index 16050e74100..c14c69c9a05 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-snapshot-serialize.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-snapshot-serialize.ts @@ -51,11 +51,15 @@ export function bindSerializeHiddenOutputSnapshot(session: ConnectPanePtySession session.hiddenOutputRestoreLegacyPtyId === ptyId || typeof session.transport.serializeBufferOutcome !== 'function' ) { - const snapshot = await session.transport.serializeBuffer(opts) + const snapshot = await session.transport.serializeBuffer({ ...opts, repaintsPane: true }) return snapshot ? { kind: 'snapshot', snapshot } : { kind: 'unknown-legacy-host' } } try { - const outcome = await session.transport.serializeBufferOutcome(opts) + // Why repaintsPane: every caller paints this image, so live output resumes after its seq. + const outcome = await session.transport.serializeBufferOutcome({ + ...opts, + repaintsPane: true + }) if (outcome.availability.kind === 'snapshot') { // A success frame with no image is still the host's own answer to a request it received. return outcome.snapshot 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 f81f4c3e332..f9d2564f512 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-types.ts @@ -20,7 +20,10 @@ import type { TuiAgent } from '../../../../shared/tui-agent' import type { TerminalPanePlacement } from '../../../../shared/terminal-pane-placement' import type { ExecutionHostId } from '../../../../shared/execution-host' import type { PtyDataMeta } from './pty-dispatcher' -import type { RemoteRuntimeSnapshotOutcome } from '../../runtime/remote-runtime-terminal-multiplexer-types' +import type { + RemoteRuntimeSnapshotOutcome, + RemoteRuntimeSnapshotRequestOptions +} from '../../runtime/remote-runtime-terminal-multiplexer-types' import type { AcceptedInputOptions, PtyPreconnectInputEntry } from './pty-preconnect-input-buffer' import type { AgentPaneLaunchRequest } from '../../../../shared/agent-pane-launch-request' @@ -254,10 +257,12 @@ export type PtyTransport = { * model-restore marker reports dropped bytes — a carry spanning the gap * would corrupt the next live chunk. IPC transports only. */ resetCrossChunkParserState?: () => void - serializeBuffer?: (opts?: { scrollbackRows?: number }) => Promise - serializeBufferOutcome?: (opts?: { - scrollbackRows?: number - }) => Promise + serializeBuffer?: ( + opts?: RemoteRuntimeSnapshotRequestOptions + ) => Promise + serializeBufferOutcome?: ( + opts?: RemoteRuntimeSnapshotRequestOptions + ) => Promise preserve?: () => void /** Hand the live PTY to a successor without process teardown. Terminal for this instance: * it also drops the transport's output processor from the pty side-effect memory census, 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 e03511f1840..ea56a0879d0 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 @@ -591,6 +591,10 @@ describe('remote hidden-output restore outcomes', () => { await flushAsyncTicks(20) expect(serializeBufferOutcome).toHaveBeenCalledTimes(1) + // Why: the multiplexer only moves its gap baseline for images a pane repaints from. + expect(serializeBufferOutcome).toHaveBeenCalledWith( + expect.objectContaining({ repaintsPane: true }) + ) expect(drive.writtenChunks().join('')).toContain(HOST_SNAPSHOT_MARKER) expect(serializeBuffer).not.toHaveBeenCalled() drive.disposable.dispose() diff --git a/src/renderer/src/components/terminal-pane/remote-hidden-output-restore-unavailable-banner.repro.test.ts b/src/renderer/src/components/terminal-pane/remote-hidden-output-restore-unavailable-banner.repro.test.ts index 6b3f2e702b3..6c0a99815e2 100644 --- a/src/renderer/src/components/terminal-pane/remote-hidden-output-restore-unavailable-banner.repro.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-hidden-output-restore-unavailable-banner.repro.test.ts @@ -620,7 +620,7 @@ describe('remote hidden-output restore abandonment (issue2-hidden-output-skip)', // Reveal triggered exactly one remote snapshot request against the owning runtime. expect(serializeBuffer).toHaveBeenCalledTimes(1) - expect(serializeBuffer).toHaveBeenNthCalledWith(1, { scrollbackRows: 5000 }) + expect(serializeBuffer).toHaveBeenNthCalledWith(1, { scrollbackRows: 5000, repaintsPane: true }) await advanceThroughNullRetryBudget(drive) @@ -628,7 +628,7 @@ describe('remote hidden-output restore abandonment (issue2-hidden-output-skip)', // 1 initial + 3 deferred retries (50ms each), then abandonment unblocks live // output — but for a remote pane it must stay quiet, not claim loss. expect(serializeBuffer).toHaveBeenCalledTimes(4) - expect(serializeBuffer).toHaveBeenNthCalledWith(4, { scrollbackRows: 5000 }) + expect(serializeBuffer).toHaveBeenNthCalledWith(4, { scrollbackRows: 5000, repaintsPane: true }) expect(written.some((data) => data.includes('LIVE_AGENT_CHUNK'))).toBe(true) expect(written.some((data) => data.includes(BANNER_FRAGMENT))).toBe(false) // Remote pane never consulted local main recovery — the banner's "main diff --git a/src/renderer/src/runtime/remote-runtime-terminal-binary-snapshots.ts b/src/renderer/src/runtime/remote-runtime-terminal-binary-snapshots.ts index adece51d459..da016c51fa9 100644 --- a/src/renderer/src/runtime/remote-runtime-terminal-binary-snapshots.ts +++ b/src/renderer/src/runtime/remote-runtime-terminal-binary-snapshots.ts @@ -98,8 +98,20 @@ export abstract class RemoteRuntimeTerminalBinarySnapshots extends RemoteRuntime carriesHistory: (info?.scrollbackRows ?? 0) > 0 } if (matchesPendingRequest) { + const availability = classifySnapshotAvailability(stream.snapshotOverflowed, info) + if ( + pendingRequest.repaintsPane && + availability.kind === 'snapshot' && + typeof info?.seq === 'number' && + typeof stream.expectedSeq === 'number' && + info.seq > stream.expectedSeq + ) { + // Why: the pane repaints through info.seq (output paused while hidden was dropped); + // a stale baseline would flag the next chunk as a gap and clear the pane (#22087). + stream.expectedSeq = info.seq + } pendingRequest.resolve({ - availability: classifySnapshotAvailability(stream.snapshotOverflowed, info), + availability, snapshot: { data: data ?? '', cols: info?.cols ?? 80, diff --git a/src/renderer/src/runtime/remote-runtime-terminal-frame-drop-resync.test.ts b/src/renderer/src/runtime/remote-runtime-terminal-frame-drop-resync.test.ts index 7ada7a3b283..0537df90f78 100644 --- a/src/renderer/src/runtime/remote-runtime-terminal-frame-drop-resync.test.ts +++ b/src/renderer/src/runtime/remote-runtime-terminal-frame-drop-resync.test.ts @@ -285,6 +285,48 @@ describe('remote terminal frame-drop resync', () => { expect(data).toEqual(['aaa', 'ddd']) }) + // Why: an old host drops output while a hidden pane's stream is paused; the reveal's + // own requested image already covers that gap, so a resync would clear and repaint + // the TUI one round trip later (#22087). + it('takes one requested image on reveal and no untagged resync after paused output', async () => { + const { data, snapshots, stream } = await subscribeClient() + server.output('aaa') + server.dropNextOutput = true + server.output('dropped while paused') + + const revealImage = await stream.serializeBufferOutcome({ + scrollbackRows: 100, + repaintsPane: true + }) + expect(revealImage.snapshot?.data).toBe('RECOVERED') + + server.output('live after reveal') + await Promise.resolve() + await Promise.resolve() + + expect(server.snapshotRequests).toEqual([expect.any(Number)]) + expect(snapshots).toEqual(['INITIAL']) + expect(data).toEqual(['aaa', 'live after reveal']) + }) + + // Why: stall probes and previews read the host without repainting the pane, so a gap + // they span is still real and must resync. + it('still resyncs a gap that a non-repainting request spanned', async () => { + const { data, snapshots, stream } = await subscribeClient() + server.output('aaa') + server.dropNextOutput = true + server.output('dropped') + + await stream.serializeBufferOutcome({ scrollbackRows: 100 }) + server.output('ccc') + await Promise.resolve() + await Promise.resolve() + + expect(server.snapshotRequests).toEqual([expect.any(Number), undefined]) + expect(snapshots).toEqual(['INITIAL', '\x1b[?2026l\x1b[2J\x1b[3J\x1b[HRECOVERED']) + expect(data).toEqual(['aaa']) + }) + it('retries a truncated recovery on a backoff while keeping live output flowing', async () => { vi.useFakeTimers() try { diff --git a/src/renderer/src/runtime/remote-runtime-terminal-multiplexer-types.ts b/src/renderer/src/runtime/remote-runtime-terminal-multiplexer-types.ts index 219e1f4e490..9a6af8dc9c9 100644 --- a/src/renderer/src/runtime/remote-runtime-terminal-multiplexer-types.ts +++ b/src/renderer/src/runtime/remote-runtime-terminal-multiplexer-types.ts @@ -157,13 +157,13 @@ export type RemoteRuntimeMultiplexedTerminal = { setOutputPaused: (paused: boolean) => boolean // Why: only then does a pause with no drop notice prove nothing was lost. reportsPausedOutputDrops?: () => boolean - serializeBuffer: (opts?: { - scrollbackRows?: number - }) => Promise + serializeBuffer: ( + opts?: RemoteRuntimeSnapshotRequestOptions + ) => Promise // Why: same request as serializeBuffer, but keeps the host's reason for an absent buffer instead of collapsing it to null. - serializeBufferOutcome: (opts?: { - scrollbackRows?: number - }) => Promise + serializeBufferOutcome: ( + opts?: RemoteRuntimeSnapshotRequestOptions + ) => Promise close: () => void } @@ -230,8 +230,15 @@ export type RemoteRuntimeSnapshotInfo = { pendingEscapeTailAnsi?: string } +export type RemoteRuntimeSnapshotRequestOptions = { + scrollbackRows?: number + /** Client-only: the pane repaints from this image, so live output resumes after its seq. */ + repaintsPane?: boolean +} + export type RemoteRuntimeSnapshotRequest = { requestId: number + repaintsPane?: boolean resolve: (outcome: RemoteRuntimeSnapshotOutcome) => void reject: (error: Error) => void timer: ReturnType diff --git a/src/renderer/src/runtime/remote-runtime-terminal-paused-output-drop.test.ts b/src/renderer/src/runtime/remote-runtime-terminal-paused-output-drop.test.ts index 9a5ca73cd83..b57354fbc76 100644 --- a/src/renderer/src/runtime/remote-runtime-terminal-paused-output-drop.test.ts +++ b/src/renderer/src/runtime/remote-runtime-terminal-paused-output-drop.test.ts @@ -63,7 +63,16 @@ describe('remote terminal paused-output drop notice', () => { send(TerminalStreamOpcode.SnapshotStart, encodeTerminalStreamJson({ seq: cursor })) send(TerminalStreamOpcode.SnapshotEnd, new Uint8Array()) } else if (frame?.opcode === TerminalStreamOpcode.SnapshotRequest) { - snapshotRequests.push(decodeTerminalStreamJson(frame.payload) ?? {}) + const request = decodeTerminalStreamJson<{ requestId?: number }>(frame.payload) ?? {} + snapshotRequests.push(request) + // Answer only a pane's tagged request; resyncs stay unanswered so tests count them. + if (typeof request.requestId === 'number') { + send( + TerminalStreamOpcode.SnapshotStart, + encodeTerminalStreamJson({ requestId: request.requestId, seq: cursor }) + ) + send(TerminalStreamOpcode.SnapshotEnd, new Uint8Array()) + } } } } @@ -122,6 +131,32 @@ describe('remote terminal paused-output drop notice', () => { expect(snapshotRequests[0]?.requestId).toBeUndefined() }) + // Why: with both the drop notice and the pane's repaint-baseline advance, a reveal + // costs one tagged snapshot and the stream never falls back to a resync. + it('composes with the reveal snapshot: one tagged request, no resync', async () => { + for (const announce of [true, false]) { + hostCapabilities = announce + ? { outputPause: 1, outputPauseDropNotice: 1 } + : { outputPause: 1 } + snapshotRequests = [] + const { data, stream } = await subscribeClient() + output('before') + stream.setOutputPaused(true) + cursor += 'dropped-while-hidden'.length + if (announce) { + send(TerminalStreamOpcode.PausedOutputDropped, new Uint8Array()) + } + stream.setOutputPaused(false) + await stream.serializeBufferOutcome({ repaintsPane: true }) + output('live') + + expect(snapshotRequests).toEqual([{ requestId: expect.any(Number) }]) + expect(data).toEqual(['before', 'live']) + stream.close() + resetRemoteRuntimeTerminalMultiplexersForTests() + } + }) + it('keeps the resync for an older host that does not negotiate drop notices', async () => { hostCapabilities = { outputPause: 1 } const { data, onPausedOutputDropped, stream } = await subscribeClient() diff --git a/src/renderer/src/runtime/remote-runtime-terminal-snapshot-controller.ts b/src/renderer/src/runtime/remote-runtime-terminal-snapshot-controller.ts index 3fe96d3b98b..cf289671408 100644 --- a/src/renderer/src/runtime/remote-runtime-terminal-snapshot-controller.ts +++ b/src/renderer/src/runtime/remote-runtime-terminal-snapshot-controller.ts @@ -19,7 +19,8 @@ import { import type { RemoteRuntimeMultiplexedTerminalState, RemoteRuntimeSnapshotImage, - RemoteRuntimeSnapshotOutcome + RemoteRuntimeSnapshotOutcome, + RemoteRuntimeSnapshotRequestOptions } from './remote-runtime-terminal-multiplexer-types' export abstract class RemoteRuntimeTerminalSnapshotController extends RemoteRuntimeTerminalMultiplexerBase { @@ -151,7 +152,7 @@ export abstract class RemoteRuntimeTerminalSnapshotController extends RemoteRunt protected async requestSnapshot( stream: RemoteRuntimeMultiplexedTerminalState, - opts?: { scrollbackRows?: number } + opts?: RemoteRuntimeSnapshotRequestOptions ): Promise { const outcome = await this.requestSnapshotOutcome(stream, opts) // Why: the concurrent-request guard used to reject before the outcome existed; keep that contract for legacy callers. @@ -166,7 +167,7 @@ export abstract class RemoteRuntimeTerminalSnapshotController extends RemoteRunt protected requestSnapshotOutcome( stream: RemoteRuntimeMultiplexedTerminalState, - opts?: { scrollbackRows?: number } + opts?: RemoteRuntimeSnapshotRequestOptions ): Promise { if (this.streams.get(stream.streamId) !== stream) { return Promise.resolve(retryWorthySnapshotOutcome('stream-detached')) @@ -194,7 +195,13 @@ export abstract class RemoteRuntimeTerminalSnapshotController extends RemoteRunt if (typeof timer.unref === 'function') { timer.unref() } - stream.pendingSnapshotRequest = { requestId, resolve, reject, timer } + stream.pendingSnapshotRequest = { + requestId, + resolve, + reject, + timer, + ...(opts?.repaintsPane ? { repaintsPane: true } : {}) + } if ( !this.sendFrame( stream.streamId,