Prevent stale terminal redraw fragments (#6449)

This commit is contained in:
Brennan Benson
2026-06-26 15:03:41 -07:00
committed by GitHub
parent ccb09db440
commit 949b30ea1f
4 changed files with 399 additions and 27 deletions
@@ -25,7 +25,11 @@ import { safeFit } from '@/lib/pane-manager/pane-tree-ops'
import { getFitOverrideForPty, bindPanePtyId } from '@/lib/pane-manager/mobile-fit-overrides'
import { isPtyLocked } from '@/lib/pane-manager/mobile-driver-state'
import { isPaneReplaying, replayIntoTerminal, replayIntoTerminalAsync } from './replay-guard'
import { terminalOutputPrefersRenderRefresh } from '@/lib/pane-manager/terminal-complex-script'
import {
terminalOutputPrefersRenderRefresh,
terminalRewriteOutputRenderRefreshDecision,
terminalRewriteOutputPrefersRenderRefresh
} from '@/lib/pane-manager/terminal-complex-script'
import {
PANE_PTY_RESIZE_HOLD_FLUSH_EVENT,
queuePanePtyResizeIfHeld,
@@ -2594,6 +2598,8 @@ export function connectPanePty(
let hiddenOutputRestoreGeneration = 0
let foregroundImmediateBudgetChars = 0
let foregroundImmediateBudgetWindowStart = 0
let foregroundRewriteChunkEndedWithCarriageReturn = false
let foregroundRewriteCsiScanTail = ''
let hiddenMode2031ScanTail = ''
const shouldSnapshotHiddenCodexOutput = shouldKeepHiddenStartupRendererQueriesLive(paneStartup)
let hiddenStartupRendererQueryPending = ''
@@ -2710,35 +2716,31 @@ export function connectPanePty(
}
function containsWindowsRewriteControl(data: string): boolean {
if (data.includes('\r') || data.includes('\b')) {
return true
}
let escapeIndex = data.indexOf('\x1b[')
while (escapeIndex !== -1) {
for (let index = escapeIndex + 2; index < data.length; index++) {
const char = data[index]
if (char >= '0' && char <= '9') {
continue
}
if (char === ';' || char === '?') {
continue
}
if (char === 'J' || char === 'K') {
return true
}
break
}
escapeIndex = data.indexOf('\x1b[', escapeIndex + 2)
}
return false
return data.includes('\r') || terminalRewriteOutputPrefersRenderRefresh(data)
}
function foregroundRewriteOutputPrefersRenderRefresh(data: string): boolean {
const decision = terminalRewriteOutputRenderRefreshDecision(data, {
previousChunkEndsWithCarriageReturn: foregroundRewriteChunkEndedWithCarriageReturn,
previousRewriteCsiScanTail: foregroundRewriteCsiScanTail
})
foregroundRewriteChunkEndedWithCarriageReturn = decision.nextChunkEndsWithCarriageReturn
foregroundRewriteCsiScanTail = decision.nextRewriteCsiScanTail
return decision.prefersRenderRefresh
}
function shouldForceForegroundRenderRefresh(data: string): boolean {
const rewriteOutputPrefersRenderRefresh = foregroundRewriteOutputPrefersRenderRefresh(data)
if (foregroundAnsiOutputPrefersRenderRefresh(data)) {
// Why: Codex-style background SGR panels can paint cell fills while
// glyphs lag behind; refresh only renderer-risk ANSI chunks, not all output.
return true
}
if (rewriteOutputPrefersRenderRefresh) {
// Why: resize fixes these panes because xterm's buffer is right but
// in-place redraw cells can remain stale in the renderer until repaint.
return true
}
return (
shouldApplyNativeWindowsRewriteRefresh &&
containsNonAsciiOutput(data) &&
@@ -2779,21 +2781,24 @@ export function connectPanePty(
// cursor-only restores need row invalidation even outside DEC 2026.
const nativeWindowsCursorRestore =
shouldProtectNativeWindowsSynchronizedOutput && foreground && containsCursorRestore(data)
const foregroundOutput = foreground || parseHiddenStartupOutput
const foregroundRenderRefreshNeeded =
foregroundOutput && shouldForceForegroundRenderRefresh(data)
synchronizedForegroundOutputActive = nextSynchronizedForegroundOutputActive
if (hiddenMode2031ScanTail) {
if (!foreground && hiddenMode2031ScanTail) {
respondToSkippedMode2031Subscribe(data)
}
writeTerminalOutput(pane.terminal, data, {
foreground: foreground || parseHiddenStartupOutput,
foreground: foregroundOutput,
beforeWrite: beforeTerminalOutputWrite,
onBackgroundBacklogDropped: markHiddenOutputRestoreNeeded,
latencySensitive:
!foreground || parseHiddenStartupOutput ? true : isLatencySensitiveForegroundOutput(data),
forceForegroundRefresh:
(foreground || parseHiddenStartupOutput) &&
foregroundOutput &&
(synchronizedForegroundOutput ||
nativeWindowsCursorRestore ||
shouldForceForegroundRenderRefresh(data)),
foregroundRenderRefreshNeeded),
followupForegroundRefresh: nativeWindowsCursorRestore,
stripTransientCursorShows: shouldProtectNativeWindowsSynchronizedOutput && foreground,
coalesceForeground: synchronizedForegroundOutput && synchronizedOutputEnded,
@@ -1,5 +1,9 @@
import { describe, expect, it } from 'vitest'
import { terminalOutputPrefersRenderRefresh } from './terminal-complex-script'
import {
terminalOutputPrefersRenderRefresh,
terminalRewriteOutputRenderRefreshDecision,
terminalRewriteOutputPrefersRenderRefresh
} from './terminal-complex-script'
describe('terminalOutputPrefersRenderRefresh', () => {
it('detects Arabic terminal output', () => {
@@ -69,3 +73,156 @@ describe('terminalOutputPrefersRenderRefresh', () => {
)
})
})
describe('terminalRewriteOutputPrefersRenderRefresh', () => {
it('detects in-place carriage-return redraws', () => {
expect(terminalRewriteOutputPrefersRenderRefresh('\r• Working')).toBe(true)
expect(terminalRewriteOutputPrefersRenderRefresh('prefix\r\x1b[2K• Working')).toBe(true)
})
it('does not treat normal CRLF output as an in-place redraw', () => {
expect(terminalRewriteOutputPrefersRenderRefresh('line one\r\nline two\r\n')).toBe(false)
})
it('waits on a trailing carriage return so split CRLF output does not refresh early', () => {
expect(terminalRewriteOutputPrefersRenderRefresh('line one\r')).toBe(false)
expect(terminalRewriteOutputPrefersRenderRefresh('\nline two')).toBe(false)
})
it('detects terminal erase rewrites and backspace updates', () => {
expect(terminalRewriteOutputPrefersRenderRefresh('\x1b[2K• Working')).toBe(true)
expect(terminalRewriteOutputPrefersRenderRefresh('\x1b[2J\x1b[Hredraw')).toBe(true)
expect(terminalRewriteOutputPrefersRenderRefresh('progress 10%\b\b20%')).toBe(true)
})
it('still detects split Codex-style rewrites through the erase-line chunk', () => {
expect(terminalRewriteOutputPrefersRenderRefresh('\r')).toBe(false)
expect(terminalRewriteOutputPrefersRenderRefresh('\x1b[2K• Working')).toBe(true)
})
it('ignores ordinary cursor movement and style output', () => {
expect(terminalRewriteOutputPrefersRenderRefresh('\x1b[10;2Hcursor move')).toBe(false)
expect(terminalRewriteOutputPrefersRenderRefresh('\x1b[32mplain green\x1b[0m')).toBe(false)
})
})
describe('terminalRewriteOutputRenderRefreshDecision', () => {
it('refreshes when a trailing carriage return continues as a split redraw', () => {
const trailingCarriageReturn = terminalRewriteOutputRenderRefreshDecision('\r', {
previousChunkEndsWithCarriageReturn: false,
previousRewriteCsiScanTail: ''
})
expect(trailingCarriageReturn).toEqual({
nextChunkEndsWithCarriageReturn: true,
nextRewriteCsiScanTail: '',
prefersRenderRefresh: false
})
expect(
terminalRewriteOutputRenderRefreshDecision('• Working without erase-line', {
previousChunkEndsWithCarriageReturn: true,
previousRewriteCsiScanTail: ''
})
).toEqual({
nextChunkEndsWithCarriageReturn: false,
nextRewriteCsiScanTail: '',
prefersRenderRefresh: true
})
})
it('does not refresh split CRLF output', () => {
const trailingCarriageReturn = terminalRewriteOutputRenderRefreshDecision('line one\r', {
previousChunkEndsWithCarriageReturn: false,
previousRewriteCsiScanTail: ''
})
expect(trailingCarriageReturn).toEqual({
nextChunkEndsWithCarriageReturn: true,
nextRewriteCsiScanTail: '',
prefersRenderRefresh: false
})
expect(
terminalRewriteOutputRenderRefreshDecision('\nline two', {
previousChunkEndsWithCarriageReturn: true,
previousRewriteCsiScanTail: ''
})
).toEqual({
nextChunkEndsWithCarriageReturn: false,
nextRewriteCsiScanTail: '',
prefersRenderRefresh: false
})
})
it('refreshes when a rewrite erase sequence is split across chunks', () => {
const trailingRewriteCsi = terminalRewriteOutputRenderRefreshDecision('\r\x1b[', {
previousChunkEndsWithCarriageReturn: false,
previousRewriteCsiScanTail: ''
})
expect(trailingRewriteCsi).toEqual({
nextChunkEndsWithCarriageReturn: false,
nextRewriteCsiScanTail: '\x1b[',
prefersRenderRefresh: true
})
expect(
terminalRewriteOutputRenderRefreshDecision('2K• Working', {
previousChunkEndsWithCarriageReturn: false,
previousRewriteCsiScanTail: '\x1b['
})
).toEqual({
nextChunkEndsWithCarriageReturn: false,
nextRewriteCsiScanTail: '',
prefersRenderRefresh: true
})
})
it('carries rewrite erase sequence tails split before CSI introducer or params', () => {
expect(
terminalRewriteOutputRenderRefreshDecision('\x1b', {
previousChunkEndsWithCarriageReturn: false,
previousRewriteCsiScanTail: ''
})
).toEqual({
nextChunkEndsWithCarriageReturn: false,
nextRewriteCsiScanTail: '\x1b',
prefersRenderRefresh: false
})
expect(
terminalRewriteOutputRenderRefreshDecision('2J', {
previousChunkEndsWithCarriageReturn: false,
previousRewriteCsiScanTail: '\x1b['
})
).toEqual({
nextChunkEndsWithCarriageReturn: false,
nextRewriteCsiScanTail: '',
prefersRenderRefresh: true
})
})
it('drops overlong rewrite CSI tails', () => {
expect(
terminalRewriteOutputRenderRefreshDecision(`\x1b[${'1'.repeat(80)}`, {
previousChunkEndsWithCarriageReturn: false,
previousRewriteCsiScanTail: ''
})
).toEqual({
nextChunkEndsWithCarriageReturn: false,
nextRewriteCsiScanTail: '',
prefersRenderRefresh: false
})
})
it('preserves pending trailing carriage return state across empty chunks', () => {
expect(
terminalRewriteOutputRenderRefreshDecision('', {
previousChunkEndsWithCarriageReturn: true,
previousRewriteCsiScanTail: '\x1b['
})
).toEqual({
nextChunkEndsWithCarriageReturn: true,
nextRewriteCsiScanTail: '\x1b[',
prefersRenderRefresh: false
})
})
})
@@ -3,8 +3,23 @@
// without switching renderers based on the text content.
const EMOJI_PRESENTATION_PATTERN = /\p{Emoji_Presentation}/u
const ESCAPE_CHARACTER = String.fromCharCode(0x1b)
const REWRITE_CSI_SCAN_TAIL_MAX_CHARS = 64
const SGR_SEQUENCE_PATTERN = new RegExp(`${ESCAPE_CHARACTER}\\[([0-9:;]*)m`, 'g')
function containsStandaloneCarriageReturn(data: string): boolean {
let index = data.indexOf('\r')
while (index !== -1) {
if (index === data.length - 1) {
return false
}
if (data[index + 1] !== '\n') {
return true
}
index = data.indexOf('\r', index + 1)
}
return false
}
function isInRange(value: number, start: number, end: number): boolean {
return value >= start && value <= end
}
@@ -86,6 +101,99 @@ function containsBackgroundSgr(data: string): boolean {
return false
}
function containsRewriteEraseSequence(data: string): boolean {
let escapeIndex = data.indexOf('\x1b[')
while (escapeIndex !== -1) {
for (let index = escapeIndex + 2; index < data.length; index++) {
const char = data[index]
if (char >= '0' && char <= '9') {
continue
}
if (char === ';' || char === '?') {
continue
}
// Why: erase-in-line/screen rewrites can leave stale renderer cells until
// the next resize; xterm's buffer is correct, but the visible layer needs repainting.
if (char === 'J' || char === 'K') {
return true
}
break
}
escapeIndex = data.indexOf('\x1b[', escapeIndex + 2)
}
return false
}
function trailingIncompleteRewriteCsiTail(data: string): string {
const escapeIndex = data.lastIndexOf(ESCAPE_CHARACTER)
if (escapeIndex === -1) {
return ''
}
const tail = data.slice(escapeIndex)
if (tail === ESCAPE_CHARACTER) {
return tail
}
if (!tail.startsWith('\x1b[')) {
return ''
}
if (tail.length > REWRITE_CSI_SCAN_TAIL_MAX_CHARS) {
return ''
}
for (let index = 2; index < tail.length; index++) {
const char = tail[index]
if (char >= '0' && char <= '9') {
continue
}
if (char === ';' || char === '?') {
continue
}
return ''
}
return tail
}
export function terminalRewriteOutputPrefersRenderRefresh(data: string): boolean {
if (data.includes('\b') || containsStandaloneCarriageReturn(data)) {
return true
}
return containsRewriteEraseSequence(data)
}
export type TerminalRewriteOutputRenderRefreshDecision = {
nextChunkEndsWithCarriageReturn: boolean
nextRewriteCsiScanTail: string
prefersRenderRefresh: boolean
}
export type TerminalRewriteOutputRenderRefreshState = {
previousChunkEndsWithCarriageReturn: boolean
previousRewriteCsiScanTail: string
}
export function terminalRewriteOutputRenderRefreshDecision(
data: string,
state: TerminalRewriteOutputRenderRefreshState
): TerminalRewriteOutputRenderRefreshDecision {
if (!data) {
return {
nextChunkEndsWithCarriageReturn: state.previousChunkEndsWithCarriageReturn,
nextRewriteCsiScanTail: state.previousRewriteCsiScanTail,
prefersRenderRefresh: false
}
}
const scanData = state.previousRewriteCsiScanTail
? `${state.previousRewriteCsiScanTail}${data}`
: data
return {
nextChunkEndsWithCarriageReturn: data.endsWith('\r'),
nextRewriteCsiScanTail: trailingIncompleteRewriteCsiTail(scanData),
prefersRenderRefresh:
(state.previousChunkEndsWithCarriageReturn && data[0] !== '\n') ||
terminalRewriteOutputPrefersRenderRefresh(scanData)
}
}
export function terminalOutputPrefersRenderRefresh(data: string): boolean {
if (containsBackgroundSgr(data)) {
return true
@@ -39,8 +39,17 @@ type SchedulerDebugWindow = Window & {
}
}
type RefreshProbeWindow = SchedulerDebugWindow & {
__terminalRefreshProbe?: {
count: () => number
dispose: () => void
}
}
const REDRAW_FRAME_COUNT = 270
const REDRAW_PAYLOAD_CHARS = 520
const REWRITE_REDRAW_FRAME_COUNT = REDRAW_FRAME_COUNT
const REWRITE_REDRAW_PAYLOAD_CHARS = REDRAW_PAYLOAD_CHARS
const TIMER_SAMPLE_MS = 16
const MAX_RENDERER_TIMER_DRIFT_MS = 500
const FOREGROUND_IMMEDIATE_BUDGET_CHARS = 128 * 1024
@@ -82,6 +91,18 @@ async function measureRendererDuringBurst(page: Page, paneKey: string): Promise<
return measureRendererDuringFrames(page, paneKey, frames)
}
async function measureRendererDuringRewriteBurst(
page: Page,
paneKey: string
): Promise<BurstMeasurement> {
const frames = Array.from({ length: REWRITE_REDRAW_FRAME_COUNT }, (_, frame) => {
const text = `• Working ${String(frame).padStart(4, '0')}`
const payload = 'x'.repeat(REWRITE_REDRAW_PAYLOAD_CHARS)
return `\r\x1b[2K${text} ${payload}`
})
return measureRendererDuringFrames(page, paneKey, frames)
}
async function measureRendererDuringFrames(
page: Page,
paneKey: string,
@@ -133,6 +154,58 @@ async function measureRendererDuringFrames(
)
}
async function installActivePaneRefreshProbe(page: Page): Promise<void> {
await page.evaluate(() => {
;(window as RefreshProbeWindow).__terminalRefreshProbe?.dispose()
const state = window.__store?.getState()
const worktreeId = state?.activeWorktreeId
const tabId =
state?.activeTabType === 'terminal'
? state.activeTabId
: worktreeId
? (state?.activeTabIdByWorktree?.[worktreeId] ?? null)
: null
const manager = tabId ? window.__paneManagers?.get(tabId) : null
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
if (!pane) {
throw new Error('Active terminal pane is unavailable')
}
const terminal = pane.terminal as unknown as {
_core?: { refresh?: (start: number, end: number, sync?: boolean) => void }
}
const originalCoreRefresh = terminal._core?.refresh?.bind(terminal._core)
if (!terminal._core || !originalCoreRefresh) {
throw new Error('Active terminal core refresh hook is unavailable')
}
let refreshCount = 0
terminal._core.refresh = (start, end, sync) => {
if (sync === true) {
refreshCount += 1
}
originalCoreRefresh(start, end, sync)
}
;(window as RefreshProbeWindow).__terminalRefreshProbe = {
count: () => refreshCount,
dispose: () => {
if (terminal._core) {
terminal._core.refresh = originalCoreRefresh
}
delete (window as RefreshProbeWindow).__terminalRefreshProbe
}
}
})
}
async function readRefreshProbeCount(page: Page): Promise<number> {
return page.evaluate(() => (window as RefreshProbeWindow).__terminalRefreshProbe?.count() ?? 0)
}
async function disposeActivePaneRefreshProbe(page: Page): Promise<void> {
await page.evaluate(() => {
;(window as RefreshProbeWindow).__terminalRefreshProbe?.dispose()
})
}
function loadCapturedOpenCodeSmallRedrawFrames(): string[] {
if (!existsSync(OPENCODE_CAPTURE_PATH)) {
return []
@@ -176,6 +249,35 @@ function annotateMeasurement(
}
test.describe('Terminal foreground redraw freeze repro', () => {
test('Codex-style line rewrites request a visible row refresh', async ({ orcaPage }) => {
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
await ensureTerminalVisible(orcaPage)
await waitForActiveTerminalManager(orcaPage, 30_000)
const { paneKey } = await waitForActivePaneHookDescriptor(orcaPage)
await waitForTerminalPtyDataInjector(orcaPage, paneKey)
await installActivePaneRefreshProbe(orcaPage)
try {
const refreshBaseline = await readRefreshProbeCount(orcaPage)
await resetSchedulerDebug(orcaPage)
const measurement = await measureRendererDuringRewriteBurst(orcaPage, paneKey)
const scheduler = await readSchedulerDebug(orcaPage)
expect(measurement.injectedFrames).toBe(REWRITE_REDRAW_FRAME_COUNT)
expect(measurement.maxTimerDriftMs).toBeLessThan(MAX_RENDERER_TIMER_DRIFT_MS)
expect(scheduler.deferredForegroundEnqueueCount).toBeGreaterThan(0)
await expect
.poll(async () => (await readRefreshProbeCount(orcaPage)) - refreshBaseline, {
timeout: 5_000,
message: 'Codex-style terminal rewrites did not request an xterm refresh'
})
.toBeGreaterThan(0)
} finally {
await disposeActivePaneRefreshProbe(orcaPage)
}
})
test('active OpenTUI-style redraw bursts do not monopolize the renderer', async ({
orcaPage
}, testInfo) => {