fix(terminal): never present a half-built synchronized-output frame on reveal or recovery (#32281)

Settled reveal and every other atlas recovery forced the current xterm buffer
onto the screen even while a TUI held DEC 2026 synchronized output. Codex
erases rows inside each held redraw, so a reveal mid-redraw showed a blank
pane for a frame (#32198).

Viewport presents now have one mode: they keep the last coherent canvas and
let xterm present when the TUI closes the frame, bounded by xterm's 1s
synchronized-output safety timeout. Structural replay keeps its retained frame
up until an open synchronized frame closes, bounded by its existing hold cap.

Co-authored-by: YEONGUK LEE <contact@abruption.dev>
This commit is contained in:
Neil
2026-10-23 03:07:30 -07:00
committed by GitHub
co-authored by YEONGUK LEE
parent 81ca8a7aaf
commit 13ab5239f5
17 changed files with 343 additions and 151 deletions
@@ -34,7 +34,7 @@ const {
shouldSeedCacheTimerOnInitialTitle,
toastInfo,
notifyCodexPaneBoundForStaleSweep,
presentPaneViewportPreservingSynchronizedOutput
presentPaneViewport
} = vi.hoisted(() => ({
resetAndRefreshAllTerminalWebglAtlases: vi.fn(),
scheduleTerminalWebglAtlasRecovery: vi.fn(),
@@ -42,7 +42,7 @@ const {
shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false),
toastInfo: vi.fn(),
notifyCodexPaneBoundForStaleSweep: vi.fn(),
presentPaneViewportPreservingSynchronizedOutput: vi.fn()
presentPaneViewport: vi.fn()
}))
let mockStoreState: StoreState
@@ -61,7 +61,7 @@ vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({
vi.mock('@/lib/pane-manager/pane-webgl-renderer', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
presentPaneViewportPreservingSynchronizedOutput
presentPaneViewport
}))
vi.mock('./terminal-webgl-atlas-recovery', () => ({
@@ -211,7 +211,7 @@ describe('connectPanePty', () => {
expect.stringContaining('remote snapshot with hidden remote output'),
expect.any(Function)
)
expect(presentPaneViewportPreservingSynchronizedOutput).toHaveBeenCalledWith(pane)
expect(presentPaneViewport).toHaveBeenCalledWith(pane)
disposable.dispose()
})
@@ -15,7 +15,7 @@ import {
writeTerminalOutput
} from '@/lib/pane-manager/pane-terminal-output-scheduler'
import { recordTerminalOutput } from '@/lib/pane-manager/pane-scroll'
import { presentPaneViewportPreservingSynchronizedOutput } from '@/lib/pane-manager/pane-webgl-renderer'
import { presentPaneViewport } from '@/lib/pane-manager/pane-webgl-renderer'
import {
buildMainModelSnapshotReplayWrites,
hasPositiveTerminalDimensions,
@@ -198,7 +198,7 @@ export function bindHiddenOutputRestoreSnapshot(session: ConnectPanePtySession):
recordTerminalOutput(session.pane.terminal)
await waitForTerminalReplayWritesParsed(session.pane.terminal)
if (session.deps.isVisibleRef.current) {
presentPaneViewportPreservingSynchronizedOutput(session.pane)
presentPaneViewport(session.pane)
recordTerminalFreezeBreadcrumb('stale-pixel-restore-present', {
paneId: session.pane.id
})
@@ -9,11 +9,8 @@ vi.mock('@/lib/pane-manager/pane-manager-registry', () => ({
resetAndRefreshAllTerminalWebglAtlases: vi.fn()
}))
const presentPaneViewport = vi.fn()
const presentPaneViewportPreservingSynchronizedOutput = vi.fn()
vi.mock('@/lib/pane-manager/pane-webgl-renderer', () => ({
presentPaneViewport: (pane: unknown) => presentPaneViewport(pane),
presentPaneViewportPreservingSynchronizedOutput: (pane: unknown) =>
presentPaneViewportPreservingSynchronizedOutput(pane)
presentPaneViewport: (pane: unknown) => presentPaneViewport(pane)
}))
vi.mock('@/lib/pane-manager/pane-terminal-output-scheduler', () => ({
flushTerminalOutput: vi.fn(),
@@ -173,7 +170,7 @@ describe('resumeTerminalVisibility reveal repaint', () => {
expect(repairPaneWebglCanvasDprMismatch).toHaveBeenCalledWith(pane)
expect(resetAndRefreshAllTerminalWebglAtlases).toHaveBeenCalledTimes(1)
expect(resetAndRefreshAllTerminalWebglAtlases).toHaveBeenCalledWith('visibility-resume-dpr')
expect(presentPaneViewportPreservingSynchronizedOutput).not.toHaveBeenCalled()
expect(presentPaneViewport).not.toHaveBeenCalled()
expect(manager.scheduleRevealRepaint).toHaveBeenCalledTimes(1)
})
@@ -189,7 +186,7 @@ describe('resumeTerminalVisibility reveal repaint', () => {
resumeTerminalVisibility(resumeArgs(manager, false))
expect(presentPaneViewportPreservingSynchronizedOutput).toHaveBeenCalledWith(pane)
expect(presentPaneViewport).toHaveBeenCalledWith(pane)
// The expensive registry-wide rebuild is still deferred to the settled frame.
expect(resetAndRefreshAllTerminalWebglAtlases).not.toHaveBeenCalled()
expect(manager.scheduleRevealRepaint).toHaveBeenCalledTimes(1)
@@ -18,10 +18,7 @@ import { useAppStore } from '@/store'
import { auditPaneWeightParity } from './terminal-render-desync-weight-probe'
import { flushDeferredPaneMetricOptionsIfMeasurable } from '@/lib/pane-manager/pane-fit'
import { repairPaneWebglCanvasDprMismatch } from '@/lib/pane-manager/terminal-canvas-dpr-repair'
import {
presentPaneViewport,
presentPaneViewportPreservingSynchronizedOutput
} from '@/lib/pane-manager/pane-webgl-renderer'
import { presentPaneViewport } from '@/lib/pane-manager/pane-webgl-renderer'
const VISIBLE_RESUME_FLUSH_CHARS = 256 * 1024
const WINDOW_WAKE_FLUSH_CHARS = 64 * 1024
@@ -130,7 +127,7 @@ export function resumeTerminalVisibility({
// present now; the settled rebuild is two frames out and would otherwise
// leave pre-hide pixels composited until then.
for (const pane of manager.getPanes()) {
presentPaneViewportPreservingSynchronizedOutput(pane)
presentPaneViewport(pane)
}
// Why: one settled rebuild repairs recreated rendering without paying a
// duplicate global atlas rebuild before the pane is attached and measured.
@@ -66,14 +66,14 @@ describe('pane manager registry', () => {
const first = {
resetWebglTextureAtlases: vi.fn<() => void>(() => order.push('first-reset')),
clearWebglTextureAtlases: vi.fn<() => void>(() => order.push('first-clear')),
presentForcedViewports: vi.fn<() => void>(() => order.push('first-present')),
presentViewports: vi.fn<() => void>(() => order.push('first-present')),
refreshAllPanes: vi.fn<() => void>(() => order.push('first-refresh')),
isVisibleForAtlasRecovery: () => true
}
const second = {
resetWebglTextureAtlases: vi.fn<() => void>(() => order.push('second-reset')),
clearWebglTextureAtlases: vi.fn<() => void>(() => order.push('second-clear')),
presentForcedViewports: vi.fn<() => void>(() => order.push('second-present')),
presentViewports: vi.fn<() => void>(() => order.push('second-present')),
refreshAllPanes: vi.fn<() => void>(() => order.push('second-refresh')),
isVisibleForAtlasRecovery: () => true
}
@@ -93,7 +93,7 @@ describe('pane manager registry', () => {
const visible = {
resetWebglTextureAtlases: vi.fn<() => void>(),
clearWebglTextureAtlases: vi.fn<() => void>(),
presentForcedViewports: vi.fn<() => void>(),
presentViewports: vi.fn<() => void>(),
refreshAllPanes: vi.fn<() => void>(),
isVisibleForAtlasRecovery: () => true
}
@@ -102,7 +102,7 @@ describe('pane manager registry', () => {
const hidden = Array.from({ length: 64 }, () => ({
resetWebglTextureAtlases: vi.fn<() => void>(),
clearWebglTextureAtlases: vi.fn<() => void>(),
presentForcedViewports: vi.fn<() => void>(),
presentViewports: vi.fn<() => void>(),
refreshAllPanes: vi.fn<() => void>(),
isVisibleForAtlasRecovery: () => false
}))
@@ -114,13 +114,11 @@ describe('pane manager registry', () => {
resetAndRefreshAllTerminalWebglAtlases()
expect(visible.clearWebglTextureAtlases).toHaveBeenCalledOnce()
expect(visible.presentForcedViewports).toHaveBeenCalledOnce()
expect(visible.presentViewports).toHaveBeenCalledOnce()
expect(
hidden.every((manager) => manager.clearWebglTextureAtlases.mock.calls.length === 0)
).toBe(true)
expect(hidden.every((manager) => manager.presentForcedViewports.mock.calls.length === 0)).toBe(
true
)
expect(hidden.every((manager) => manager.presentViewports.mock.calls.length === 0)).toBe(true)
})
it('continues reset-and-refresh recovery when one manager throws', () => {
@@ -5,7 +5,7 @@ import type { PaneRenderingDiagnostics } from './pane-manager-types'
type RegisteredPaneManager = {
resetWebglTextureAtlases(): void
clearWebglTextureAtlases?: () => void
presentForcedViewports?: () => void
presentViewports?: () => void
fitAllPanes?: () => void
refreshAllPanes?: () => void
getRenderingDiagnostics?: () => PaneRenderingDiagnostics[]
@@ -60,8 +60,8 @@ export function resetAndRefreshAllTerminalWebglAtlases(reason?: string): void {
}
for (const manager of resetManagers) {
try {
if (manager.presentForcedViewports) {
manager.presentForcedViewports()
if (manager.presentViewports) {
manager.presentViewports()
} else {
manager.refreshAllPanes?.()
}
@@ -291,7 +291,7 @@ export class PaneManager {
clearPaneWebglTextureAtlases(this.panes.values())
}
presentForcedViewports(): void {
presentViewports(): void {
presentPaneViewports(this.panes.values())
}
@@ -1,6 +1,6 @@
import type { ManagedPaneInternal } from './pane-manager-types'
import { reattachWebglIfNeeded } from './pane-webgl-reattach'
import { presentPaneViewportPreservingSynchronizedOutput } from './pane-webgl-renderer'
import { presentPaneViewport } from './pane-webgl-renderer'
import { resetAndRefreshAllTerminalWebglAtlases } from './pane-manager-registry'
type PaneGetter = () => Iterable<ManagedPaneInternal>
@@ -132,6 +132,6 @@ export function schedulePaneRevealPresent(
): void {
forEachPaneOnSettledFrame(resolvePaneGetter(ownerOrGetter, panes), (pane) => {
reattachWebglIfNeeded(pane)
presentPaneViewportPreservingSynchronizedOutput(pane)
presentPaneViewport(pane)
})
}
@@ -10,18 +10,10 @@ import { isTerminalLeafId } from '../../../../shared/stable-pane-id'
import type { ManagedPaneInternal } from './pane-manager-types'
import { disposePane } from './pane-lifecycle'
import { toPublicPane } from './pane-public-view'
import {
cancelPendingTerminalViewportPresents,
presentPaneViewport,
presentPaneViewportPreservingSynchronizedOutput
} from './pane-viewport-present'
import {
forceFullViewportPresent,
requestFullViewportPresent
} from './terminal-render-pause-release'
import { cancelPendingTerminalViewportPresents, presentPaneViewport } from './pane-viewport-present'
import { requestFullViewportPresent } from './terminal-render-pause-release'
vi.mock('./terminal-render-pause-release', () => ({
forceFullViewportPresent: vi.fn(() => false),
requestFullViewportPresent: vi.fn(() => false)
}))
@@ -103,25 +95,23 @@ describe('pane viewport retry lifetime', () => {
vi.clearAllMocks()
})
for (const present of [presentPaneViewport, presentPaneViewportPreservingSynchronizedOutput]) {
for (const elapsed of [0, 3]) {
it(`cancels every public-wrapper retry after ${elapsed} ticks`, () => {
const pane = createPane()
present(toPublicPane(pane))
present(toPublicPane(pane))
for (let tick = 0; tick < elapsed; tick++) {
flushFrame()
}
expect(frames.size).toBe(2)
const lateCallbacks = [...frames.values()]
disposePane(pane, new Map([[pane.id, pane]]))
expect(frames.size).toBe(0)
for (const callback of lateCallbacks) {
callback(16)
}
expect(frames.size).toBe(0)
})
}
for (const elapsed of [0, 3]) {
it(`cancels every public-wrapper retry after ${elapsed} ticks`, () => {
const pane = createPane()
presentPaneViewport(toPublicPane(pane))
presentPaneViewport(toPublicPane(pane))
for (let tick = 0; tick < elapsed; tick++) {
flushFrame()
}
expect(frames.size).toBe(2)
const lateCallbacks = [...frames.values()]
disposePane(pane, new Map([[pane.id, pane]]))
expect(frames.size).toBe(0)
for (const callback of lateCallbacks) {
callback(16)
}
expect(frames.size).toBe(0)
})
}
it('releases real disposed xterm buffers before paused frames resume', async () => {
@@ -131,7 +121,7 @@ describe('pane viewport retry lifetime', () => {
pane.terminal.write('retained history\r\n'.repeat(500), resolve)
)
presentPaneViewport(toPublicPane(pane))
presentPaneViewportPreservingSynchronizedOutput(toPublicPane(pane))
presentPaneViewport(toPublicPane(pane))
flushFrame()
disposePane(pane, new Map([[pane.id, pane]]))
pane.container.remove()
@@ -153,25 +143,24 @@ describe('pane viewport retry lifetime', () => {
const retired = createPane()
const live = createPane()
presentPaneViewport(toPublicPane(retired))
presentPaneViewportPreservingSynchronizedOutput(toPublicPane(live))
presentPaneViewport(toPublicPane(live))
disposePane(retired, new Map([[retired.id, retired]]))
expect(frames.size).toBe(1)
live.container.style.display = 'block'
flushFrame()
expect(requestFullViewportPresent).toHaveBeenCalledOnce()
expect(requestFullViewportPresent).toHaveBeenCalledWith(live.terminal)
expect(forceFullViewportPresent).not.toHaveBeenCalled()
})
it('deduplicates the same wrapper and upgrades its force mode', () => {
it('deduplicates retries for the same wrapper', () => {
const pane = createPane()
const view = toPublicPane(pane)
presentPaneViewportPreservingSynchronizedOutput(view)
presentPaneViewport(view)
presentPaneViewport(view)
expect(frames.size).toBe(1)
pane.container.style.display = 'block'
flushFrame()
expect(forceFullViewportPresent).toHaveBeenCalledOnce()
expect(requestFullViewportPresent).not.toHaveBeenCalled()
expect(requestFullViewportPresent).toHaveBeenCalledOnce()
})
it('preserves the sixteen-frame budget for a live collapsed pane', () => {
@@ -185,13 +174,13 @@ describe('pane viewport retry lifetime', () => {
expect(frames.size).toBe(0)
pane.container.style.display = 'block'
presentPaneViewport(toPublicPane(pane))
expect(forceFullViewportPresent).toHaveBeenCalledOnce()
expect(requestFullViewportPresent).toHaveBeenCalledOnce()
})
it('keeps immediate displayed presentation and the no-frame fallback', () => {
const pane = createPane()
pane.container.style.display = 'block'
presentPaneViewportPreservingSynchronizedOutput(toPublicPane(pane))
presentPaneViewport(toPublicPane(pane))
expect(requestFullViewportPresent).toHaveBeenCalledOnce()
expect(frames.size).toBe(0)
pane.container.style.display = 'none'
@@ -0,0 +1,144 @@
// @vitest-environment happy-dom
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Terminal } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import { SearchAddon } from '@xterm/addon-search'
import { SerializeAddon } from '@xterm/addon-serialize'
import { Unicode11Addon } from '@xterm/addon-unicode11'
import { WebLinksAddon } from '@xterm/addon-web-links'
import { isTerminalLeafId } from '../../../../shared/stable-pane-id'
import type { ManagedPaneInternal } from './pane-manager-types'
import { presentPaneViewport } from './pane-viewport-present'
const SYNC_START = '\x1b[?2026h'
const SYNC_END = '\x1b[?2026l'
const ERASE_LINE = '\x1b[K'
// xterm's SynchronizedOutputHandler safety timeout.
const XTERM_SYNCHRONIZED_OUTPUT_TIMEOUT_MS = 1_000
// Codex 0.158 wraps each redraw in DEC 2026 and erases rows before repainting them.
function firstCodexFrameWithErase(): { erased: string; rest: string } {
const transcript = readFileSync(
join(__dirname, '../../../../main/runtime/__fixtures__/codex-0-158-0-timed-turn.txt'),
'utf8'
)
for (const block of transcript.split(SYNC_START).slice(1)) {
const body = block.split(SYNC_END)[0]
const erase = body.indexOf(ERASE_LINE)
if (erase > 0 && !body.includes('\x1b[?1049h')) {
const cut = erase + ERASE_LINE.length
return { erased: body.slice(0, cut), rest: body.slice(cut) }
}
}
throw new Error('fixture has no erasing synchronized frame')
}
// xterm parses writes on a timer; fake timers must be stepped for the callback to fire.
async function write(terminal: Terminal, data: string): Promise<void> {
let parsed = false
terminal.write(data, () => {
parsed = true
})
await vi.advanceTimersByTimeAsync(0)
expect(parsed).toBe(true)
}
function createPane(terminal: Terminal, container: HTMLElement): ManagedPaneInternal {
const leafId = '11111111-1111-4111-8111-111111111111'
if (!isTerminalLeafId(leafId)) {
throw new Error('Expected a valid fixture leaf id')
}
return {
id: 1,
leafId,
stablePaneId: leafId,
terminal,
container,
xtermContainer: container,
linkTooltip: document.createElement('div'),
fitAddon: new FitAddon(),
searchAddon: new SearchAddon(),
serializeAddon: new SerializeAddon(),
unicode11Addon: new Unicode11Addon(),
webLinksAddon: new WebLinksAddon(),
terminalGpuAcceleration: 'off',
gpuRenderingEnabled: false,
webglAttachmentDeferred: false,
webglDisabledAfterContextLoss: false,
hasComplexScriptOutput: false,
webglAddon: null,
imageAddon: null,
ligaturesAddon: null,
fitResizeObserver: null,
pendingObservedFitRafId: null,
compositionHandler: null,
pendingSplitScrollState: null,
debugLabel: null
}
}
const COHERENT_TEXT = 'COHERENT FRAME'
describe('presentPaneViewport during synchronized output', () => {
const originalGetContext = Object.getOwnPropertyDescriptor(
HTMLCanvasElement.prototype,
'getContext'
)
let terminal: Terminal
let pane: ManagedPaneInternal
// What the DOM renderer has put on screen, not the parsed buffer.
const screenText = (): string => pane.container.querySelector('.xterm-rows')?.textContent ?? ''
beforeEach(async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'requestAnimationFrame'] })
// Why: happy-dom has no canvas text metrics; xterm measures glyphs on open().
Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', {
configurable: true,
value: () => ({ measureText: () => ({ width: 10 }) })
})
const container = document.createElement('div')
document.body.append(container)
terminal = new Terminal({ cols: 120, rows: 40, allowProposedApi: true })
terminal.open(container)
pane = createPane(terminal, container)
await write(terminal, `\x1b[5;2H${COHERENT_TEXT}`)
await vi.advanceTimersByTimeAsync(50)
expect(screenText()).toContain(COHERENT_TEXT)
})
afterEach(() => {
terminal.dispose()
document.body.replaceChildren()
vi.useRealTimers()
vi.restoreAllMocks()
if (originalGetContext) {
Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', originalGetContext)
}
})
it('keeps the last coherent frame until the agent closes its redraw', async () => {
const frame = firstCodexFrameWithErase()
await write(terminal, SYNC_START + frame.erased)
presentPaneViewport(pane)
await vi.advanceTimersByTimeAsync(50)
expect(screenText()).toContain(COHERENT_TEXT)
await write(terminal, frame.rest + SYNC_END)
await vi.advanceTimersByTimeAsync(50)
expect(screenText()).not.toContain(COHERENT_TEXT)
})
it('still paints a frame the agent never closes once xterm times out', async () => {
const frame = firstCodexFrameWithErase()
await write(terminal, SYNC_START + frame.erased)
presentPaneViewport(pane)
await vi.advanceTimersByTimeAsync(XTERM_SYNCHRONIZED_OUTPUT_TIMEOUT_MS - 1)
expect(screenText()).toContain(COHERENT_TEXT)
await vi.advanceTimersByTimeAsync(50)
expect(screenText()).not.toContain(COHERENT_TEXT)
})
})
@@ -1,17 +1,13 @@
import type { ManagedPane, ManagedPaneInternal } from './pane-manager-types'
import { isManagedPaneDisplayNone } from './pane-display-visibility'
import {
forceFullViewportPresent,
requestFullViewportPresent
} from './terminal-render-pause-release'
import { requestFullViewportPresent } from './terminal-render-pause-release'
// Presenting a pane's viewport is a distinct concern from owning the WebGL
// addon's lifecycle: it runs for DOM-rendered panes too, and its retry loop is
// about DOM visibility, not about the renderer.
const DISPLAYED_PRESENT_RETRY_FRAMES = 16
type ViewportPresentMode = 'preserve-synchronized-output' | 'force-current-buffer'
type DisplayedPresentRetry = { frames: number; mode: ViewportPresentMode }
type DisplayedPresentRetry = { frames: number }
const pendingDisplayedPresentRetries = new WeakMap<ManagedPaneInternal, DisplayedPresentRetry>()
// Public pane views are recreated; terminal teardown must cancel every view's retry.
const presentRetryCancellations = new WeakMap<ManagedPane['terminal'], Set<() => void>>()
@@ -22,21 +18,14 @@ export function cancelPendingTerminalViewportPresents(terminal: ManagedPane['ter
}
}
function schedulePresentWhenDisplayed(pane: ManagedPaneInternal, mode: ViewportPresentMode): void {
function schedulePresentWhenDisplayed(pane: ManagedPaneInternal): void {
if (typeof globalThis.requestAnimationFrame !== 'function') {
return
}
const pending = pendingDisplayedPresentRetries.get(pane)
if (pending) {
if (mode === 'force-current-buffer') {
pending.mode = mode
}
if (pendingDisplayedPresentRetries.has(pane)) {
return
}
pendingDisplayedPresentRetries.set(pane, {
frames: DISPLAYED_PRESENT_RETRY_FRAMES,
mode
})
pendingDisplayedPresentRetries.set(pane, { frames: DISPLAYED_PRESENT_RETRY_FRAMES })
const terminal = pane.terminal
const cancellations = presentRetryCancellations.get(terminal) ?? new Set<() => void>()
presentRetryCancellations.set(terminal, cancellations)
@@ -78,12 +67,20 @@ function schedulePresentWhenDisplayed(pane: ManagedPaneInternal, mode: ViewportP
return
}
finish()
presentPaneViewportWithMode(pane, retry.mode)
presentPaneViewport(pane)
}
frameId = globalThis.requestAnimationFrame(tick)
}
function presentPaneViewportWithMode(pane: ManagedPane, mode: ViewportPresentMode): void {
/**
* Presents the full viewport, keeping the last coherent canvas while a TUI holds
* DEC 2026 synchronized output.
*
* Why one mode: a forced paint mid-frame publishes the TUI's erase-then-repaint
* half frame (a blank flash; #32198). Every reveal and atlas recovery waits for
* the frame instead, bounded by xterm's synchronized-output safety timeout.
*/
export function presentPaneViewport(pane: ManagedPane): void {
const internal = pane as ManagedPaneInternal
if (internal.webglDisabledAfterContextLoss) {
return
@@ -112,14 +109,10 @@ function presentPaneViewportWithMode(pane: ManagedPane, mode: ViewportPresentMod
// latches _needsFullRefresh; if IntersectionObserver never fires, the
// canvas keeps pre-hide pixels until a user resize. Retry once the box
// exists so the full present actually runs.
schedulePresentWhenDisplayed(internal, mode)
schedulePresentWhenDisplayed(internal)
return
}
const presented =
mode === 'force-current-buffer'
? forceFullViewportPresent(pane.terminal)
: requestFullViewportPresent(pane.terminal)
if (!presented) {
if (!requestFullViewportPresent(pane.terminal)) {
// Why: refresh even without a WebGL addon so recovery never silently
// no-ops — a DOM-rendered pane can hold stale pixels after reveal too.
pane.terminal.refresh(0, pane.terminal.rows - 1)
@@ -128,11 +121,3 @@ function presentPaneViewportWithMode(pane: ManagedPane, mode: ViewportPresentMod
/* ignore — pane may have been disposed in the meantime */
}
}
export function presentPaneViewport(pane: ManagedPane): void {
presentPaneViewportWithMode(pane, 'force-current-buffer')
}
export function presentPaneViewportPreservingSynchronizedOutput(pane: ManagedPane): void {
presentPaneViewportWithMode(pane, 'preserve-synchronized-output')
}
@@ -5,7 +5,6 @@ import {
attachWebgl,
clearTerminalWebglAttachBackoff,
presentPaneViewport,
presentPaneViewportPreservingSynchronizedOutput,
primeTerminalWebglAddon,
resetTerminalWebglSuggestion,
resetWebglTextureAtlas
@@ -183,7 +182,7 @@ describe('terminal WebGL addon lifecycle', () => {
it('falls back to terminal.refresh on an unpaused, unsynchronized pane', () => {
// Restore-after-replay and other callers use presentPaneViewport even when
// forceFullViewportPresent is a no-op (not paused, no DEC 2026).
// requestFullViewportPresent is a no-op (not paused, no DEC 2026).
const refreshRows = vi.fn()
const renderRows = vi.fn()
const pane = createPane()
@@ -208,7 +207,7 @@ describe('terminal WebGL addon lifecycle', () => {
expect(renderRows).not.toHaveBeenCalled()
})
it('preserves a synchronized frame on an ordinary reveal present', () => {
it('preserves a synchronized frame on a viewport present', () => {
const refreshRows = vi.fn()
const renderRows = vi.fn()
const pane = createPane()
@@ -226,7 +225,7 @@ describe('terminal WebGL addon lifecycle', () => {
}
} as never
presentPaneViewportPreservingSynchronizedOutput(pane)
presentPaneViewport(pane)
expect(refreshRows).toHaveBeenCalledWith(0, 23, true)
expect(renderRows).not.toHaveBeenCalled()
@@ -12,10 +12,7 @@ import { repairPaneWebglCanvasDprMismatch } from './terminal-canvas-dpr-repair'
import { recordPaneWebglContextLoss } from './pane-webgl-context-loss-policy'
import { presentPaneViewport } from './pane-viewport-present'
export {
presentPaneViewport,
presentPaneViewportPreservingSynchronizedOutput
} from './pane-viewport-present'
export { presentPaneViewport } from './pane-viewport-present'
import {
getTerminalWebglAddonConstructor,
primeTerminalWebglAddon,
@@ -157,6 +157,10 @@ export function isSynchronizedOutputHeld(terminal: unknown): boolean {
* One synchronous full-viewport present when xterm would otherwise swallow it:
* IntersectionObserver pause, or DEC 2026 synchronized output.
*
* Why so few callers: painting mid DEC 2026 publishes a half-built TUI frame
* (#32198). Only a keystroke echo inside a held frame and the replay hold cap
* may do that; reveals and recovery use requestFullViewportPresent.
*
* Why not on every paint: a forced renderer.renderRows on a fresh, unpaused
* splash paints before cell metrics settle and leaves a 1px black gutter under
* a TUI composer — production never does that. Callers fall back to
@@ -1,7 +1,8 @@
import {
forceFullViewportPresent,
forceRepaintThroughRenderPause,
isSynchronizedOutputHeld
isSynchronizedOutputHeld,
requestFullViewportPresent
} from './terminal-render-pause-release'
import { retainTerminalReplayFrame } from './terminal-replay-frame'
@@ -23,6 +24,7 @@ type ReplayPresentation = {
generation: number
present: boolean
timeout: ReturnType<typeof setTimeout>
frameWait: number | null
}
const presentations = new WeakMap<HTMLElement, ReplayPresentation>()
@@ -54,15 +56,23 @@ export function beginTerminalReplayPresentation(
terminal.refresh?.(0, terminal.rows - 1)
}
}
const unhide = (held: ReplayPresentation): void => {
const unhide = (held: ReplayPresentation, atHoldCap = false): void => {
clearTimeout(held.timeout)
if (held.frameWait !== null) {
cancelAnimationFrame(held.frameWait)
held.frameWait = null
}
if (presentations.get(screen) === held) {
presentations.delete(screen)
}
try {
const bounds = element.getBoundingClientRect()
if (held.present && bounds.width > 0 && bounds.height > 0 && terminal.rows) {
if (!forceFullViewportPresent(terminal)) {
// Only the hold cap may publish an unfinished DEC 2026 frame.
const presented = atHoldCap
? forceFullViewportPresent(terminal)
: requestFullViewportPresent(terminal)
if (!presented) {
refresh()
}
}
@@ -77,6 +87,28 @@ export function beginTerminalReplayPresentation(
held.frame?.remove()
}
}
// Why: unhiding mid-DEC 2026 publishes the TUI's half-built frame over the
// retained copy; the hold cap still unhides a frame that never closes.
const unhideWhenFrameComplete = (held: ReplayPresentation): void => {
if (held.frameWait !== null) {
return
}
if (
held.present &&
held.frame &&
isSynchronizedOutputHeld(terminal) &&
typeof requestAnimationFrame === 'function'
) {
held.frameWait = requestAnimationFrame(() => {
held.frameWait = null
if (held.pending === 0 && presentations.get(screen) === held) {
unhideWhenFrameComplete(held)
}
})
return
}
unhide(held)
}
let presentation = presentations.get(screen)
if (!presentation) {
const capture = retainTerminalReplayFrame(screen, () => {
@@ -100,9 +132,10 @@ export function beginTerminalReplayPresentation(
pending: 0,
generation: 0,
present: false,
frameWait: null,
timeout: setTimeout(() => {
created.present = true
unhide(created)
unhide(created, true)
}, TERMINAL_REPLAY_PRESENTATION_MAX_HOLD_MS)
}
presentation = created
@@ -127,6 +160,6 @@ export function beginTerminalReplayPresentation(
if (--held.pending > 0 || presentations.get(screen) !== held) {
return
}
unhide(held)
unhideWhenFrameComplete(held)
}
}
@@ -328,25 +328,64 @@ describe('structural replay presentation', () => {
expect(getContext).not.toHaveBeenCalled()
})
it('still retains a DOM frame during synchronized output without repainting it', async () => {
const { terminal, screen } = createTerminal()
const refreshRows = vi.fn()
const synchronized = {
...terminal,
_core: {
...terminal._core,
coreService: { decPrivateModes: { synchronizedOutput: true } },
_renderService: { _isPaused: false, refreshRows }
it('keeps the retained frame up until an open synchronized frame closes', async () => {
vi.useFakeTimers()
try {
const { terminal, screen } = createTerminal()
const refreshRows = vi.fn()
const renderRows = vi.fn()
const decPrivateModes = { synchronizedOutput: true }
const synchronized = {
...terminal,
_core: {
...terminal._core,
coreService: { decPrivateModes },
_renderService: { _isPaused: false, refreshRows, _renderer: { value: { renderRows } } }
}
}
}
await createTerminalStructuralReplayCoordinator(synchronized).run(() => {
await createTerminalStructuralReplayCoordinator(synchronized).run(() => {
expect(screen.style.opacity).toBe('0')
expect(refreshRows).not.toHaveBeenCalled()
})
await vi.advanceTimersByTimeAsync(100)
expect(screen.style.opacity).toBe('0')
expect(terminal.element.querySelector('[data-terminal-replay-frame]')?.textContent).toBe(
'Last coherent frame'
)
expect(refreshRows).not.toHaveBeenCalled()
})
expect(screen.style.opacity).toBe('')
expect(renderRows).not.toHaveBeenCalled()
decPrivateModes.synchronizedOutput = false
await vi.advanceTimersByTimeAsync(20)
expect(screen.style.opacity).toBe('')
expect(terminal.element.querySelector('[data-terminal-replay-frame]')).toBeNull()
expect(renderRows).not.toHaveBeenCalled()
} finally {
vi.useRealTimers()
}
})
it('shows the screen at the hold cap when a synchronized frame never closes', async () => {
vi.useFakeTimers()
try {
const { terminal, screen } = createTerminal()
const synchronized = {
...terminal,
_core: {
...terminal._core,
coreService: { decPrivateModes: { synchronizedOutput: true } },
_renderService: { _isPaused: false, refreshRows: vi.fn() }
}
}
await createTerminalStructuralReplayCoordinator(synchronized).run(() => {})
await vi.advanceTimersByTimeAsync(TERMINAL_REPLAY_PRESENTATION_MAX_HOLD_MS - 1)
expect(screen.style.opacity).toBe('0')
await vi.advanceTimersByTimeAsync(1)
expect(screen.style.opacity).toBe('')
expect(terminal.element.querySelector('[data-terminal-replay-frame]')).toBeNull()
expect(vi.getTimerCount()).toBe(0)
} finally {
vi.useRealTimers()
}
})
it('keeps decorations positioned in the retained frame', async () => {
@@ -425,7 +425,7 @@ test.describe('terminal reveal paused-render recovery', () => {
).toBe(true)
})
test('@headful atlas recovery presents a synchronized-output WebGL frame', async ({
test('@headful atlas recovery keeps the coherent frame until synchronized output releases', async ({
orcaPage
}, testInfo) => {
await waitForSessionReady(orcaPage)
@@ -455,6 +455,7 @@ test.describe('terminal reveal paused-render recovery', () => {
background: 17,
release: true
})
const baseline = await captureStableTabScreenshot(orcaPage, tabId)
await callSynchronizedRevealProbe(orcaPage, 'paintFrame', {
marker: 'REVEALED_FRAME',
background: 52,
@@ -470,36 +471,45 @@ test.describe('terminal reveal paused-render recovery', () => {
await expect
.poll(async () => (await readSynchronizedRevealProbe(orcaPage)).atlasClears)
.toBeGreaterThan(0)
const afterReveal = await captureStableTabScreenshot(orcaPage, tabId)
// Why not a stable capture: the atlas clear's redraw starts xterm's 1s DEC 2026 safety timeout.
const afterReveal = await captureFirstRevealedFrame(orcaPage, tabId)
const heldDiff = compareTerminalScreenshots(baseline, afterReveal)
await callSynchronizedRevealProbe(orcaPage, 'paintFrame', {
marker: 'REVEALED_FRAME',
background: 52,
release: true
})
const released = await captureStableTabScreenshot(orcaPage, tabId)
await callSynchronizedRevealProbe(orcaPage, 'forceRendererPresent')
const afterForcedPresent = await captureStableTabScreenshot(orcaPage, tabId)
const diff = compareTerminalScreenshots(afterReveal, afterForcedPresent)
const direct = await captureStableTabScreenshot(orcaPage, tabId)
const releasedDiff = compareTerminalScreenshots(released, direct)
const afterRevealPath = testInfo.outputPath('synchronized-frame-after-reveal.png')
const afterForcedPresentPath = testInfo.outputPath(
'synchronized-frame-after-forced-present.png'
)
const releasedPath = testInfo.outputPath('synchronized-frame-after-release.png')
const pixelDiffPath = testInfo.outputPath('synchronized-frame-pixel-diff.json')
await Promise.all([
writeFile(afterRevealPath, afterReveal),
writeFile(afterForcedPresentPath, afterForcedPresent),
writeFile(pixelDiffPath, JSON.stringify(diff, null, 2))
writeFile(releasedPath, released),
writeFile(pixelDiffPath, JSON.stringify({ heldDiff, releasedDiff }, null, 2))
])
await testInfo.attach('synchronized-frame-after-reveal.png', {
path: afterRevealPath,
contentType: 'image/png'
})
await testInfo.attach('synchronized-frame-after-forced-present.png', {
path: afterForcedPresentPath,
contentType: 'image/png'
})
await testInfo.attach('synchronized-frame-pixel-diff.json', {
path: pixelDiffPath,
contentType: 'application/json'
})
for (const [name, path, contentType] of [
['synchronized-frame-after-reveal.png', afterRevealPath, 'image/png'],
['synchronized-frame-after-release.png', releasedPath, 'image/png'],
['synchronized-frame-pixel-diff.json', pixelDiffPath, 'application/json']
] as const) {
await testInfo.attach(name, { path, contentType })
}
expect(
diff.matches,
`revealed pixels already match a forced buffer present (diffRatio=${diff.diffRatio})`
heldDiff.matches,
`atlas recovery keeps the coherent frame while DEC 2026 is held (diffRatio=${heldDiff.diffRatio})`
).toBe(true)
expect(
compareTerminalScreenshots(baseline, released).matches,
'release replaces the coherent frame'
).toBe(false)
expect(
releasedDiff.matches,
`release presents the completed frame (diffRatio=${releasedDiff.diffRatio})`
).toBe(true)
})