fix(terminal): fully ground byte-gap recovery state

This commit is contained in:
Brennan Benson
2026-08-13 02:04:24 -07:00
parent ed50c8143e
commit 6dd2bbfd38
4 changed files with 60 additions and 36 deletions
@@ -9,6 +9,7 @@ import {
POST_REPLAY_MODE_RESET,
POST_REPLAY_REATTACH_RESET,
POST_REPLAY_REATTACH_RESET_KEEP_MOUSE,
RESET_AFTER_BYTE_GAP,
RESET_KITTY_KEYBOARD_PROTOCOL,
RESET_TERMINAL_CURSOR_STYLE
} from '../../../../shared/terminal-mode-reset-profiles'
@@ -10210,7 +10211,7 @@ describe('connectPanePty', () => {
claim: true
})
expect(written).toContain(viewportClear)
expect(written).not.toContain('\x1b[0m\x1b(B\x0f\x1b[2J\x1b[3J\x1b[H')
expect(written).not.toContain(`${RESET_AFTER_BYTE_GAP}\x1b[2J\x1b[3J\x1b[H`)
expect(written).toEqual(
expect.arrayContaining([coldScrollback, POST_REPLAY_MODE_RESET, blankViewport])
)
@@ -12119,7 +12120,7 @@ describe('connectPanePty', () => {
await flushAsyncTicks(4)
const written = pane.terminal.write.mock.calls.map(([data]) => data as string)
const gapReset = written.find((data) => data.startsWith('\x1b[0m'))
const gapReset = written.find((data) => data === RESET_AFTER_BYTE_GAP)
expect(gapReset).toBeDefined()
// charset too: a dropped ESC(B otherwise renders text as box characters
expect(gapReset).toContain('\x1b(B')
@@ -15488,7 +15489,7 @@ describe('connectPanePty', () => {
expect(getMainBufferSnapshot).toHaveBeenCalledWith('pty-id', { scrollbackRows: 5000 })
expect(pane.terminal.resize).toHaveBeenCalledWith(100, 30)
expect(pane.terminal.write).toHaveBeenCalledWith(
'\x1b[0m\x1b(B\x0f\x1b[2J\x1b[3J\x1b[H',
`${RESET_AFTER_BYTE_GAP}\x1b[2J\x1b[3J\x1b[H`,
expect.any(Function)
)
expect(pane.terminal.write).toHaveBeenCalledWith('snapshot-state\r\n', expect.any(Function))
@@ -15541,7 +15542,7 @@ describe('connectPanePty', () => {
expect(getMainBufferSnapshot).toHaveBeenCalledWith('pty-id', { scrollbackRows: 5000 })
expect(pane.terminal.write).toHaveBeenCalledWith(
'\x1b[0m\x1b(B\x0f\x1b[?1049l\x1b[2J\x1b[3J\x1b[H',
`${RESET_AFTER_BYTE_GAP}\x1b[?1049l\x1b[2J\x1b[3J\x1b[H`,
expect.any(Function)
)
expect(pane.terminal.write).toHaveBeenCalledWith(
@@ -15549,16 +15550,16 @@ describe('connectPanePty', () => {
expect.any(Function)
)
expect(pane.terminal.write).toHaveBeenCalledWith(
'\x1b[0m\x1b(B\x0f\x1b[?1049h\x1b[2J\x1b[H',
`${RESET_AFTER_BYTE_GAP}\x1b[?1049h\x1b[2J\x1b[H`,
expect.any(Function)
)
const writes = (pane.terminal.write as ReturnType<typeof vi.fn>).mock.calls.map(
(call) => call[0]
)
expect(writes.indexOf('preserved-shell-history\r\n')).toBeLessThan(
writes.indexOf('\x1b[0m\x1b(B\x0f\x1b[?1049h\x1b[2J\x1b[H')
writes.indexOf(`${RESET_AFTER_BYTE_GAP}\x1b[?1049h\x1b[2J\x1b[H`)
)
expect(writes.indexOf('\x1b[0m\x1b(B\x0f\x1b[?1049h\x1b[2J\x1b[H')).toBeLessThan(
expect(writes.indexOf(`${RESET_AFTER_BYTE_GAP}\x1b[?1049h\x1b[2J\x1b[H`)).toBeLessThan(
writes.indexOf('altscreen-snapshot\r\n')
)
expect(pane.terminal.write).toHaveBeenCalledWith('altscreen-snapshot\r\n', expect.any(Function))
@@ -15760,7 +15761,7 @@ describe('connectPanePty', () => {
disposable.dispose()
})
it('abandons a stalled hidden restore and drains pending foreground chunks warning-first', async () => {
it('abandons a stalled hidden restore with reset, warning, then pending foreground', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('pty-id')
const capturedDataCallback: {
@@ -15822,6 +15823,7 @@ describe('connectPanePty', () => {
const warningIndex = written.findIndex((data) => data.includes('main recovery was unavailable'))
const combinedLiveIndex = written.indexOf(firstLive + secondLive)
expect(warningIndex).toBeGreaterThanOrEqual(0)
expect(written[warningIndex - 1]).toBe(RESET_AFTER_BYTE_GAP)
expect(combinedLiveIndex).toBeGreaterThan(warningIndex)
snapshot.resolve({
@@ -15888,7 +15890,7 @@ describe('connectPanePty', () => {
await flushAsyncTicks(10)
const written = pane.terminal.write.mock.calls.map(([data]) => data as string)
const resetIndex = written.findIndex((data) => data.startsWith('\x1b[0m'))
const resetIndex = written.indexOf(RESET_AFTER_BYTE_GAP)
const liveIndex = written.findIndex((data) => data.includes('live-after-reveal'))
expect(resetIndex).toBeGreaterThanOrEqual(0)
expect(liveIndex).toBeGreaterThanOrEqual(0)
@@ -16412,7 +16414,7 @@ describe('connectPanePty', () => {
expect(pane.terminal.clear).toHaveBeenCalled()
expect(pane.terminal.write).not.toHaveBeenCalledWith(live, expect.any(Function))
expect(pane.terminal.write).not.toHaveBeenCalledWith(
'\x1b[0m\x1b(B\x0f\x1b[2J\x1b[3J\x1b[H',
`${RESET_AFTER_BYTE_GAP}\x1b[2J\x1b[3J\x1b[H`,
expect.any(Function)
)
disposable.dispose()
@@ -17310,7 +17312,7 @@ describe('connectPanePty', () => {
await flushAsyncTicks(6)
expect(pane.terminal.write).not.toHaveBeenCalledWith(
'\x1b[0m\x1b(B\x0f\x1b[2J\x1b[3J\x1b[H',
`${RESET_AFTER_BYTE_GAP}\x1b[2J\x1b[3J\x1b[H`,
expect.any(Function)
)
expect(pane.terminal.write).toHaveBeenCalledWith(
@@ -413,7 +413,7 @@ const FOREGROUND_GRID_DRIFT_CHECK_MIN_MS = 250
// Why: this is only shown if hidden renderer output was skipped and main-owned
// terminal state is unavailable, so the user has an explicit loss signal.
const HIDDEN_OUTPUT_RESTORE_UNAVAILABLE_WARNING =
'\x18\x1b[0m\r\n[Orca skipped hidden terminal output because main recovery was unavailable.]\r\n'
'\r\n[Orca skipped hidden terminal output because main recovery was unavailable.]\r\n'
type E2eTerminalPtyDataInjectionApi = {
inject: (paneKey: string, data: string, meta?: PtyDataMeta) => boolean
keys: () => string[]
@@ -7066,16 +7066,9 @@ export function connectPanePty(
// backpressure must not outlive it — it would re-open recovery and banner a second time.
clearHiddenOutputRestoreFloodRepaintTimer()
writeRestoreUnavailableWarning()
} else {
writePtyOutputToXterm(RESET_AFTER_BYTE_GAP, true)
}
// Why before both exits: the gate dropped renderer-bound bytes, so the pen
// that was live when it started dropping is unknown — if the dropped span
// held the `ESC[22m`/`ESC[0m` closing a run, xterm still has bold (or any
// other attribute) latched. Abandoning means no snapshot will rebuild the
// buffer, so nothing else ever clears it and every later cell inherits it
// (STA-4042). Same for the charset designation, whose loss renders text as
// box characters. Cheap and idempotent; a live TUI re-asserts both on its
// next write.
writePtyOutputToXterm(RESET_AFTER_BYTE_GAP, true)
if (hadPendingOverflow) {
return
}
@@ -7216,6 +7209,8 @@ export function connectPanePty(
}
function writeRestoreUnavailableWarning(): void {
// The reset must parse before both the warning and any foreground drain.
writePtyOutputToXterm(RESET_AFTER_BYTE_GAP, true)
if (!shouldWritePtyOutputForeground(deps.isVisibleRef.current)) {
return
}
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import { Terminal } from '@xterm/headless'
import { RESET_AFTER_BYTE_GAP } from '../../../../shared/terminal-mode-reset-profiles'
import {
buildMainModelSnapshotReplayWrites,
hasPositiveTerminalDimensions,
@@ -11,6 +12,32 @@ function writeTerminal(terminal: Terminal, data: string): Promise<void> {
return new Promise((resolve) => terminal.write(data, resolve))
}
type CellWithOscLink = {
extended?: { urlId?: number }
isBold: () => number
}
describe('RESET_AFTER_BYTE_GAP', () => {
it('grounds incomplete escapes, SGR, OSC 8, and every charset register', async () => {
const terminal = new Terminal({ cols: 40, rows: 2, allowProposedApi: true })
try {
await writeTerminal(
terminal,
'\x1b[1m\x1b(0\x1b)0\x1b*0\x1b+0\x0e\x1b]8;;https://example.test\x1b\\q\x1b['
)
await writeTerminal(terminal, `${RESET_AFTER_BYTE_GAP}A\x0eq\x1bnq\x1boq`)
const line = terminal.buffer.active.getLine(0)
const plainCell = line?.getCell(1) as CellWithOscLink | undefined
expect(line?.translateToString(true)).toBe('─Aqqq')
expect(plainCell?.isBold()).toBe(0)
expect(plainCell?.extended?.urlId ?? 0).toBe(0)
} finally {
terminal.dispose()
}
})
})
describe('hasPositiveTerminalDimensions', () => {
it('accepts only finite positive numeric pairs', () => {
expect(hasPositiveTerminalDimensions(80, 24)).toBe(true)
@@ -42,7 +69,7 @@ describe('resolvePositiveTerminalDimensions', () => {
describe('buildMainModelSnapshotReplayWrites', () => {
it('clears normal buffer + scrollback before a normal-buffer snapshot', () => {
expect(buildMainModelSnapshotReplayWrites({ data: 'shell-output' })).toEqual([
'\x1b[0m\x1b(B\x0f\x1b[2J\x1b[3J\x1b[H',
`${RESET_AFTER_BYTE_GAP}\x1b[2J\x1b[3J\x1b[H`,
'shell-output'
])
})
@@ -59,9 +86,9 @@ describe('buildMainModelSnapshotReplayWrites', () => {
scrollbackAnsi: 'normal-history'
})
).toEqual([
'\x1b[0m\x1b(B\x0f\x1b[?1049l\x1b[2J\x1b[3J\x1b[H',
`${RESET_AFTER_BYTE_GAP}\x1b[?1049l\x1b[2J\x1b[3J\x1b[H`,
'normal-history',
'\x1b[0m\x1b(B\x0f\x1b[?1049h\x1b[2J\x1b[H',
`${RESET_AFTER_BYTE_GAP}\x1b[?1049h\x1b[2J\x1b[H`,
'alt-frame'
])
})
@@ -69,7 +96,7 @@ describe('buildMainModelSnapshotReplayWrites', () => {
it('enters a cleared alt screen when no split scrollback is available', () => {
expect(
buildMainModelSnapshotReplayWrites({ data: 'alt-frame', alternateScreen: true })
).toEqual(['\x1b[0m\x1b(B\x0f\x1b[?1049h\x1b[2J\x1b[H', 'alt-frame'])
).toEqual([`${RESET_AFTER_BYTE_GAP}\x1b[?1049h\x1b[2J\x1b[H`, 'alt-frame'])
})
})
@@ -153,9 +180,9 @@ describe('buildMainModelSnapshotReplayWrites alt-frame skip', () => {
{ skipAltFrame: true }
)
).toEqual([
'\x1b[0m\x1b(B\x0f\x1b[?1049l\x1b[2J\x1b[3J\x1b[H',
`${RESET_AFTER_BYTE_GAP}\x1b[?1049l\x1b[2J\x1b[3J\x1b[H`,
'normal-history',
'\x1b[0m\x1b(B\x0f\x1b[?1049h\x1b[2J\x1b[H',
`${RESET_AFTER_BYTE_GAP}\x1b[?1049h\x1b[2J\x1b[H`,
'complete-live-state'
])
})
@@ -172,7 +199,7 @@ describe('buildMainModelSnapshotReplayWrites alt-frame skip', () => {
},
{ skipAltFrame: true }
)
).toEqual(['\x1b[0m\x1b(B\x0f\x1b[?1049h\x1b[2J\x1b[H', 'complete-live-state'])
).toEqual([`${RESET_AFTER_BYTE_GAP}\x1b[?1049h\x1b[2J\x1b[H`, 'complete-live-state'])
})
it('keeps composed data when an older producer omits the mode boundary', () => {
@@ -181,13 +208,13 @@ describe('buildMainModelSnapshotReplayWrites alt-frame skip', () => {
{ data: 'legacy-modes-and-frame', alternateScreen: true },
{ skipAltFrame: true }
)
).toEqual(['\x1b[0m\x1b(B\x0f\x1b[?1049h\x1b[2J\x1b[H', 'legacy-modes-and-frame'])
).toEqual([`${RESET_AFTER_BYTE_GAP}\x1b[?1049h\x1b[2J\x1b[H`, 'legacy-modes-and-frame'])
})
it('never drops a normal-buffer snapshot, whose rows reflow correctly', () => {
expect(
buildMainModelSnapshotReplayWrites({ data: 'shell-output' }, { skipAltFrame: true })
).toEqual(['\x1b[0m\x1b(B\x0f\x1b[2J\x1b[3J\x1b[H', 'shell-output'])
).toEqual([`${RESET_AFTER_BYTE_GAP}\x1b[2J\x1b[3J\x1b[H`, 'shell-output'])
})
// STA-4042: a replay only runs because renderer-bound bytes were dropped, so
@@ -205,7 +232,7 @@ describe('buildMainModelSnapshotReplayWrites alt-frame skip', () => {
buildMainModelSnapshotReplayWrites({ data: 'alt-frame', alternateScreen: true })
]
for (const writes of branches) {
expect(writes[0].startsWith('\x1b[0m')).toBe(true)
expect(writes[0].startsWith(RESET_AFTER_BYTE_GAP)).toBe(true)
// charset too: a dropped ESC(B renders text as box characters
expect(writes[0].includes('\x1b(B')).toBe(true)
// Nothing may be replayed ahead of the first reset.
+5 -5
View File
@@ -40,10 +40,10 @@ export const COLD_RESTORE_SEED_MODE_RESET = RESET_MOUSE_REPORTING
// drains queued foreground chunks under the stale pen).
export const RESET_GRAPHIC_RENDITION = '\x1b[0m'
// Designate ASCII into G0 and shift back to it. Same failure as the pen but a
// louder symptom: a dropped `ESC(B` leaves line-drawing selected and ordinary
// text renders as box characters.
const RESET_CHARSET_TO_ASCII = '\x1b(B\x0f'
// Cancel any partial escape, close OSC 8, restore every ISO 2022 register, and select G0.
const RESET_INCOMPLETE_ESCAPE = '\x18'
const RESET_OSC_HYPERLINK = '\x1b]8;;\x1b\\'
const RESET_CHARSETS_TO_ASCII = '\x1b(B\x1b)B\x1b*B\x1b+B\x0f'
/**
* State to re-establish when renderer-bound bytes were dropped and the gap
@@ -53,7 +53,7 @@ const RESET_CHARSET_TO_ASCII = '\x1b(B\x0f'
* negotiates them at startup. So reset the state a gap can strand and that no
* running TUI re-asserts on its own, and leave the rest to its next repaint.
*/
export const RESET_AFTER_BYTE_GAP = `${RESET_GRAPHIC_RENDITION}${RESET_CHARSET_TO_ASCII}`
export const RESET_AFTER_BYTE_GAP = `${RESET_INCOMPLETE_ESCAPE}${RESET_OSC_HYPERLINK}${RESET_GRAPHIC_RENDITION}${RESET_CHARSETS_TO_ASCII}`
// Why: DECTCEM applies in emission order, so the payload's last ?25l/?25h is the cursor state the TUI left.
export function replayPayloadEndsWithCursorHidden(payload: string): boolean {