From 3ffe2af9fa58c75b2998507ae8577e38cd03474d Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:14:36 -0700 Subject: [PATCH] fix(terminal): preserve intact replay escape tails across fit deadlines (#33677) --- config/reliability-gates.jsonc | 3 ++ ...ection-hidden-restore-fit-overflow.test.ts | 53 ++++++++++++++++--- .../hidden-output-restore-abandon.ts | 6 +-- .../hidden-output-restore-drain.ts | 9 ++-- 4 files changed, 58 insertions(+), 13 deletions(-) diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index f4e7f8fa66b..97126e2ccfb 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -17092,6 +17092,9 @@ "assertions": [ "recovers small live output when the deadline interrupts an answered snapshot", "recovers partially covered live output when the deadline interrupts an answered snapshot", + "recovers split escape live output when the deadline interrupts an answered snapshot", + "recovers styled live output when the deadline interrupts an answered snapshot", + "recovers styled gap live output when the deadline interrupts an answered snapshot", "recovers unmapped live output when the deadline interrupts an answered snapshot", "recovers unhide gap live output when the deadline interrupts an answered snapshot", "recovers fresh snapshot gap live output when the deadline interrupts an answered snapshot", diff --git a/src/renderer/src/components/terminal-pane/pty-connection-hidden-restore-fit-overflow.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-hidden-restore-fit-overflow.test.ts index 19b8b64371f..1496645b545 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-hidden-restore-fit-overflow.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-hidden-restore-fit-overflow.test.ts @@ -1,6 +1,12 @@ import type * as React from 'react' +import { Terminal } from '@xterm/headless' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { flushAsyncTicks, createDeferred, renderHeadlessBuffer } from './pty-connection-test-async' +import { + flushAsyncTicks, + createDeferred, + renderHeadlessBuffer, + writeHeadlessTerminal +} from './pty-connection-test-async' import { createMockTransport, createPane, @@ -152,6 +158,9 @@ describe('connectPanePty', () => { { name: 'overflowed', pending: 'v'.repeat(512 * 1024 + 1), repaint: true, covered: 0 }, { name: 'small', pending: 'Working\r\n', repaint: false, covered: 0 }, { name: 'partially covered', pending: 'Working\r\n', repaint: false, covered: 4 }, + { name: 'split escape', pending: '14mWorking\r\n', repaint: false, covered: 0 }, + { name: 'styled', pending: 'Working\r\n', repaint: false, covered: 0 }, + { name: 'styled gap', pending: 'Working\r\n', repaint: true, covered: 0 }, { name: 'unmapped', pending: 'Working\r\n', repaint: true, covered: 4 }, { name: 'unhide gap', pending: 'Working\r\n', repaint: true, covered: 0 }, { name: 'fresh snapshot gap', pending: 'Working\r\n', repaint: true, covered: 0 } @@ -173,12 +182,24 @@ describe('connectPanePty', () => { const getSnapshot = vi.mocked(window.api.pty.getMainBufferSnapshot) const hidden = 'hidden-before-flood\r\n' - const snapshot = covered > 0 ? `${hidden}v${pending.slice(0, covered)}` : hidden + const pendingEscapeTailAnsi = name === 'split escape' ? '\x1b[38;5;' : '' + const style = name.startsWith('styled') ? '\x1b[38;5;214m' : '' + const hiddenData = `${hidden}${style}${pendingEscapeTailAnsi}` + const firstLive = name === 'split escape' ? '2' : 'v' + const snapshot = + covered > 0 ? `${hidden}${firstLive}${pending.slice(0, covered)}` : `${hidden}${style}` + const snapshotSeq = hiddenData.length + (covered > 0 ? firstLive.length + covered : 0) const done = 'HIDDEN_FLOOD_DONE\r\n' const rawLength = pending.length + (name === 'unmapped' ? 5 : 0) - let seq = hidden.length + 1 + rawLength + let seq = hiddenData.length + firstLive.length + rawLength getSnapshot - .mockResolvedValueOnce({ data: snapshot, cols: 120, rows: 40, seq: snapshot.length }) + .mockResolvedValueOnce({ + data: snapshot, + pendingEscapeTailAnsi, + cols: 120, + rows: 40, + seq: snapshotSeq + }) .mockResolvedValue({ data: done, cols: 120, @@ -207,11 +228,14 @@ describe('connectPanePty', () => { }) } isVisibleRef.current = true - onData?.('v', { seq: hidden.length + 1, rawLength: 1 }) + onData?.(firstLive, { + seq: hiddenData.length + firstLive.length, + rawLength: firstLive.length + }) await flushAsyncTicks(30) expect(safeFitAndThen).toHaveBeenCalledTimes(1) onData?.(pending, { seq, rawLength }) - if (name === 'unhide gap' || name === 'fresh snapshot gap') { + if (name === 'unhide gap' || name === 'fresh snapshot gap' || name === 'styled gap') { isVisibleRef.current = name === 'unhide gap' _dispatchPtyModelRestoreNeededForTest({ id: 'pty-id', @@ -245,7 +269,7 @@ describe('connectPanePty', () => { expect(getSnapshot).toHaveBeenCalledTimes(repaint ? 2 : 1) disposable.dispose() vi.useRealTimers() - const drained = `${hidden}v${pending}` + const drained = `${hiddenData}${firstLive}${pending}` expect(await renderHeadlessBuffer([output])).toEqual( await renderHeadlessBuffer([repaint ? done : drained + laterOutput]) ) @@ -254,6 +278,21 @@ describe('connectPanePty', () => { await renderHeadlessBuffer([drained]) ) } + if (pendingEscapeTailAnsi || style) { + const terminal = new Terminal({ cols: 80, rows: 8, allowProposedApi: true }) + try { + await writeHeadlessTerminal(terminal, deadlineOutput) + const cell = terminal.buffer.active.getLine(1)?.getCell(0) + if (name === 'styled gap') { + expect(cell?.isFgDefault()).toBe(true) + } else { + expect(cell?.isFgPalette()).toBe(true) + expect(cell?.getFgColor()).toBe(214) + } + } finally { + terminal.dispose() + } + } } ) }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-abandon.ts b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-abandon.ts index 94cbd9be048..fd2f568e63e 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-abandon.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-abandon.ts @@ -17,7 +17,7 @@ export function bindAbandonHiddenOutputRestore(session: ConnectPanePtySession): session.abandonHiddenOutputRestoreAndDrainPendingForeground = function ( expectedPtyId: string, - opts: { quiet?: boolean; rearmRemote?: boolean } = {} + opts: { quiet?: boolean; rearmRemote?: boolean; preserveReplayState?: boolean } = {} ): void { if ( session.transport.getPtyId() !== expectedPtyId || @@ -68,8 +68,8 @@ export function bindAbandonHiddenOutputRestore(session: ConnectPanePtySession): session.clearHiddenOutputRestoreFloodRepaintTimer() session.writeRestoreUnavailableWarning() } - // Only quiet flood abandonment lacks another grounding path; warning and remote re-arm already ground before drain. - else if (!rearmedRemoteRestore) { + // Intact replay tails must reach queued live bytes without a parser or rendition reset. + else if (!rearmedRemoteRestore && !opts.preserveReplayState) { session.writeAbandonedRestoreGap() } if (hadPendingOverflow) { diff --git a/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-drain.ts b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-drain.ts index ecac303cb9f..a2ad336bd09 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-drain.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-drain.ts @@ -131,9 +131,10 @@ export function bindHiddenOutputRestoreDrain(session: ConnectPanePtySession): vo } // A painted snapshot awaiting fit proves recovery answered, regardless of live queue size. const replayed = session.hiddenOutputRestoreReplayingSnapshot + let preserveReplayState = false if (replayed) { session.setRestoredSnapshotBaseline(ptyId, replayed, replayed.paintsContent === true) - if ( + const needsRepaint = session.hiddenOutputRestorePendingOverflow || session.hiddenOutputRestoreFreshSnapshotNeeded || (session.hiddenOutputRestoreUnhideSeq !== null && @@ -142,12 +143,14 @@ export function bindHiddenOutputRestoreDrain(session: ConnectPanePtySession): vo (chunk: PendingHiddenOutputRestoreChunk) => session.getChunkDataAfterSnapshot(chunk, replayed.seq) === null ) - ) { + preserveReplayState = !needsRepaint + if (needsRepaint) { session.noteHiddenOutputRestoreFloodBackpressure() } } session.abandonHiddenOutputRestoreAndDrainPendingForeground(ptyId, { - quiet: replayed !== null + quiet: replayed !== null, + preserveReplayState }) }, HIDDEN_OUTPUT_RESTORE_FOREGROUND_TIMEOUT_MS) }