fix(terminal): preserve intact replay escape tails across fit deadlines (#33677)

This commit is contained in:
Neil
2026-10-25 17:14:36 -07:00
committed by GitHub
parent 2209d55b9d
commit 3ffe2af9fa
4 changed files with 58 additions and 13 deletions
+3
View File
@@ -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",
@@ -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()
}
}
}
)
})
@@ -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) {
@@ -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)
}