fix(terminal): paint a new WebGL canvas through an open synchronized frame instead of flashing blank (#37604)

* fix(terminal): paint a new WebGL canvas inside a synchronized frame instead of leaving it blank

A replaced WebGL renderer's first paint went through terminal.refresh(), which
xterm buffers while DEC 2026 synchronized output is held. The old canvas is
already gone, so the pane showed an empty canvas until the program closed its
frame. Over a remote link Codex's frames often span packets, so revealing a
remote worktree flashed blank/content for under a second, only with WebGL.

Paint a just-attached or just-resized canvas at once. A canvas created while
its renderer is paused (hidden worktree) is remembered until its first paint,
so the reveal present paints it through the open frame instead of waiting.
Also drop the WebGL rebuild after every reattach replay: it worked around an
atlas problem that no longer exists and replaced the renderer on every reveal.

* fix(terminal): limit a never-painted canvas to one mid-frame paint

* test(terminal): prove a swapped WebGL canvas paints through spinner sync frames

* fix(terminal): clear the never-painted mark on any forced paint; drop e2e casts

* test(terminal): keep the spinner repro inside the flaky-pattern ratchet
This commit is contained in:
Neil
2026-11-05 01:49:43 -08:00
committed by GitHub
parent 41e77c0764
commit 2367c160d3
13 changed files with 286 additions and 34 deletions
@@ -180,7 +180,6 @@ function createManager(pane: Record<string, unknown>): Record<string, unknown> {
return {
setPaneGpuRendering: vi.fn(),
markPaneHasComplexScriptOutput: vi.fn(),
rebuildPaneWebgl: vi.fn(),
hasWebglRenderer: vi.fn(() => false),
getPanes: vi.fn(() => [pane]),
closePane: vi.fn(),
@@ -314,7 +314,6 @@ export function registerPtyConnectionReplayPayloadHandlingTests(): void {
expect(pane.terminal.write).toHaveBeenCalledWith('remote prompt\r\n$ ', expect.any(Function))
expect(refresh).toHaveBeenCalledWith(0, 39, true)
expect(manager.rebuildPaneWebgl).toHaveBeenCalledWith(1)
disposable.dispose()
})
@@ -640,7 +639,6 @@ export function registerPtyConnectionReplayPayloadHandlingTests(): void {
expect(pane.terminal.write).not.toHaveBeenCalledWith('first replay', expect.any(Function))
expect(pane.terminal.write).toHaveBeenCalledWith('second replay', expect.any(Function))
expect(manager.rebuildPaneWebgl).toHaveBeenCalledTimes(1)
disposable.dispose()
})
@@ -251,7 +251,6 @@ export type MockPaneSummary = { id: number; leafId: string }
export type MockPaneManager = {
setPaneGpuRendering: Mock
markPaneHasComplexScriptOutput: Mock
rebuildPaneWebgl: Mock
hasWebglRenderer: Mock<() => boolean>
getPanes: Mock<() => MockPaneSummary[]>
closePane: Mock
@@ -273,7 +272,6 @@ export function createManager(
return {
setPaneGpuRendering: vi.fn(),
markPaneHasComplexScriptOutput: vi.fn(),
rebuildPaneWebgl: vi.fn(),
hasWebglRenderer: vi.fn(() => false),
getPanes: vi.fn(() => panes),
closePane: vi.fn(),
@@ -178,8 +178,6 @@ export function bindReplayDataDrain(session: ConnectPanePtySession): void {
if (!isCurrentPayload()) {
continue
}
// Rebuild WebGL after replay parses so an atlas attached to an empty buffer gains current glyphs.
session.manager.rebuildPaneWebgl(session.pane.id)
appliedCurrentPayload = true
}
return appliedCurrentPayload
@@ -267,7 +267,6 @@ function createManager(paneCount = 1, initialActivePaneId: number | null = null)
return {
setPaneGpuRendering: vi.fn(),
markPaneHasComplexScriptOutput: vi.fn(),
rebuildPaneWebgl: vi.fn(),
hasWebglRenderer: vi.fn(() => false),
getPanes: vi.fn(() => panes),
closePane: vi.fn(),
@@ -267,7 +267,6 @@ function createManager(paneCount = 1, initialActivePaneId: number | null = null)
return {
setPaneGpuRendering: vi.fn(),
markPaneHasComplexScriptOutput: vi.fn(),
rebuildPaneWebgl: vi.fn(),
hasWebglRenderer: vi.fn(() => false),
getPanes: vi.fn(() => panes),
closePane: vi.fn(),
@@ -17,7 +17,7 @@ import { setInlineImagesEnabled } from './pane-inline-images'
import { fitAllPanesInternal } from './pane-tree-ops'
import { collectPublicPanes, toPublicPane } from './pane-public-view'
import { applyTerminalGpuAcceleration } from './pane-terminal-gpu-acceleration'
import { detachPanesWebglForRebuild, rebuildAttachedWebgl } from './pane-webgl-reattach'
import { detachPanesWebglForRebuild } from './pane-webgl-reattach'
import {
markPaneComplexScriptOutput,
clearPaneWebglTextureAtlases,
@@ -259,10 +259,6 @@ export class PaneManager {
markPaneComplexScriptOutput(this.panes, paneId)
}
rebuildPaneWebgl(paneId: number): void {
this.withPane(paneId, rebuildAttachedWebgl)
}
resetWebglTextureAtlases(): void {
resetPaneWebglTextureAtlases(this.panes.values())
}
@@ -8,6 +8,7 @@ import {
} from './pane-webgl-renderer'
import { safeFit } from './pane-fit'
import { disposePane } from './pane-lifecycle'
import { presentPaneViewport } from './pane-viewport-present'
function createPane(options: { loadAddon?: () => void } = {}): ManagedPaneInternal {
const leafId = '22222222-2222-4222-8222-222222222222' as never
@@ -121,6 +122,85 @@ describe('terminal WebGL addon lifecycle', () => {
expect(disposeSpy).toHaveBeenCalledTimes(1)
})
it('paints a newly attached canvas inside a synchronized frame instead of leaving it blank', () => {
const renderRows = vi.fn()
const pane = createPane()
Object.assign(pane.terminal, {
_core: {
_renderService: {
_isPaused: false,
_needsFullRefresh: false,
refreshRows: vi.fn(),
_renderer: { value: { renderRows } }
},
coreService: { decPrivateModes: { synchronizedOutput: true } }
}
})
attachWebgl(pane)
expect(pane.webglAddon).not.toBeNull()
expect(renderRows).toHaveBeenCalledWith(0, 23)
})
it('keeps an attached, already painted renderer tear-free inside a synchronized frame', () => {
const renderRows = vi.fn()
const refreshRows = vi.fn()
const pane = createPane()
Object.assign(pane.container, { getBoundingClientRect: () => ({ width: 800, height: 400 }) })
const decPrivateModes = { synchronizedOutput: false }
Object.assign(pane.terminal, {
_core: {
_renderService: {
_isPaused: false,
_needsFullRefresh: false,
refreshRows,
_renderer: { value: { renderRows } }
},
coreService: { decPrivateModes }
}
})
attachWebgl(pane)
decPrivateModes.synchronizedOutput = true
presentPaneViewport(pane)
expect(renderRows).not.toHaveBeenCalled()
expect(refreshRows).toHaveBeenCalledWith(0, 23, true)
})
it('paints a canvas attached while paused on its reveal present, inside a synchronized frame', () => {
const renderRows = vi.fn()
const renderService = {
_isPaused: true,
_needsFullRefresh: false,
refreshRows: vi.fn(),
_renderer: { value: { renderRows } }
}
const pane = createPane()
Object.assign(pane.container, { getBoundingClientRect: () => ({ width: 800, height: 400 }) })
Object.assign(pane.terminal, {
onRender: vi.fn(() => ({ dispose: vi.fn() })),
_core: {
_renderService: renderService,
coreService: { decPrivateModes: { synchronizedOutput: true } }
}
})
attachWebgl(pane)
expect(renderRows).not.toHaveBeenCalled()
expect(renderService._isPaused).toBe(true)
presentPaneViewport(pane)
expect(renderRows).toHaveBeenCalledWith(0, 23)
// Only that one paint may land mid-frame; later presents wait for the frame to close.
renderRows.mockClear()
presentPaneViewport(pane)
expect(renderRows).not.toHaveBeenCalled()
expect(renderService.refreshRows).toHaveBeenCalledWith(0, 23, true)
})
it('keeps attaching to healthy panes after another pane fails to attach', () => {
// A module-global attach latch once stranded all later panes on wider/bolder DOM rendering after one pane failed.
const failing = createPane({
@@ -14,6 +14,7 @@ import {
} from './terminal-canvas-dpr-repair'
import { recordPaneWebglContextLoss } from './pane-webgl-context-loss-policy'
import { presentPaneViewport } from './pane-viewport-present'
import { presentEmptiedCanvas } from './terminal-render-pause-release'
import {
releaseWebglCanvasBackingWhileHidden,
stopReleasingWebglCanvasBacking
@@ -89,7 +90,9 @@ export function shouldUseTerminalWebgl(pane: ManagedPaneInternal): boolean {
function refreshTerminalAfterWebglAttach(pane: ManagedPaneInternal): void {
try {
// New WebGL canvases are empty; repaint immediately so resume/reparent/settings do not look frozen until output.
pane.terminal.refresh(0, pane.terminal.rows - 1)
if (!presentEmptiedCanvas(pane.terminal)) {
pane.terminal.refresh(0, pane.terminal.rows - 1)
}
} catch {}
}
@@ -1,6 +1,6 @@
import type { ManagedPane } from './pane-manager-types'
import { recordTerminalWebglDiagnostic } from '../../../../shared/terminal/diagnostics/terminal-webgl-diagnostics'
import { forceFullViewportPresent } from './terminal-render-pause-release'
import { forceFullViewportPresent, presentEmptiedCanvas } from './terminal-render-pause-release'
import { isWebglCanvasBackingReleased } from './terminal-webgl-hidden-canvas-release'
// Hidden-time DPR changes can evade WebGL’s device-pixel observer, leaving stale-scale canvas backing (e.g. 2160px for 1080 CSS pixels at DPR 1). Use xterm’s resize path to recompute device dimensions/backing at current DPR.
@@ -63,7 +63,10 @@ export function repairPaneWebglCanvasDpr(pane: ManagedPane): PaneWebglCanvasDprR
// Refresh cached DPR/dimensions before resize rebuilds backing and layer sizes.
renderer.handleDevicePixelRatioChange?.()
renderer.handleResize?.(pane.terminal.cols, pane.terminal.rows)
pane.terminal.refresh(0, pane.terminal.rows - 1)
// Resizing the backing wiped it, so DEC 2026 must not leave it blank.
if (!presentEmptiedCanvas(pane.terminal)) {
pane.terminal.refresh(0, pane.terminal.rows - 1)
}
} catch {
// Pane may be mid-teardown; the next reveal/fit retries the check.
return 'deferred'
@@ -65,8 +65,45 @@ export function forceRepaintThroughRenderPause(terminal: unknown): boolean {
}
}
type RenderObservableTerminal = {
onRender?: (listener: () => void) => { dispose: () => void }
}
// Terminals whose WebGL canvas was emptied while paused, mapped to the render listener that clears the mark.
const unpaintedCanvasTerminals = new WeakMap<object, { dispose: () => void }>()
function hasUnpaintedCanvas(terminal: unknown): terminal is object {
return typeof terminal === 'object' && terminal !== null && unpaintedCanvasTerminals.has(terminal)
}
function forgetUnpaintedCanvas(terminal: unknown): void {
if (typeof terminal !== 'object' || terminal === null) {
return
}
unpaintedCanvasTerminals.get(terminal)?.dispose()
unpaintedCanvasTerminals.delete(terminal)
}
function rememberUnpaintedCanvas(terminal: unknown): void {
if (typeof terminal !== 'object' || terminal === null || unpaintedCanvasTerminals.has(terminal)) {
return
}
const observable: RenderObservableTerminal = terminal
if (typeof observable.onRender !== 'function') {
return
}
unpaintedCanvasTerminals.set(
terminal,
observable.onRender(() => forgetUnpaintedCanvas(terminal))
)
}
/** Preserve the coherent canvas through DEC 2026; RenderService waits for frame close with xterm’s bounded safety timeout. */
export function requestFullViewportPresent(terminal: unknown): boolean {
if (hasUnpaintedCanvas(terminal)) {
// Nothing coherent to preserve: the canvas was emptied while paused and has not painted since.
return forceFullViewportPresent(terminal)
}
const service = getRenderService(terminal)
if (!service) {
return false
@@ -113,7 +150,23 @@ export function isSynchronizedOutputHeld(terminal: unknown): boolean {
)
}
/** Force a full present through pause/DEC 2026 only for held keystroke echo, replay hold-cap expiry, or revealing a released canvas (no coherent frame left to keep). Mid-frame painting exposes unfinished TUIs (#32198); reveal/recovery use requestFullViewportPresent. Normal unpaused paint falls back to refresh: direct first-splash renderRows precedes stable cell metrics and leaves a 1px gutter. */
/** A new or resized WebGL canvas holds no frame to keep, so DEC 2026 must not leave it blank until the frame closes. A paused one paints on its reveal present instead. */
export function presentEmptiedCanvas(terminal: unknown): boolean {
const service = getRenderService(terminal)
if (!service) {
return false
}
if (service._isPaused === true) {
rememberUnpaintedCanvas(terminal)
return false
}
if (!isSynchronizedOutputHeld(terminal)) {
return false
}
return forceFullViewportPresent(terminal)
}
/** Force a full present through pause/DEC 2026 only for held keystroke echo, replay hold-cap expiry, or a released, new or resized canvas (no coherent frame left to keep). Mid-frame painting exposes unfinished TUIs (#32198); reveal/recovery use requestFullViewportPresent. Normal unpaused paint falls back to refresh: direct first-splash renderRows precedes stable cell metrics and leaves a 1px gutter. */
export function forceFullViewportPresent(terminal: unknown): boolean {
const service = getRenderService(terminal)
if (!service) {
@@ -139,6 +192,8 @@ export function forceFullViewportPresent(terminal: unknown): boolean {
// New tabs can still be observer-paused; use refreshRows for its dimension clamp to avoid a 1px gutter. Only DEC 2026 requires direct renderRows.
if (syncHeld && typeof renderer?.renderRows === 'function') {
renderer.renderRows(0, rows - 1)
// renderRows skips onRender; any forced paint fills the canvas, so only one may land mid-frame.
forgetUnpaintedCanvas(terminal)
return true
}
service.refreshRows(0, rows - 1, true)
@@ -225,7 +225,6 @@ function createManager() {
return {
setPaneGpuRendering: vi.fn(),
markPaneHasComplexScriptOutput: vi.fn(),
rebuildPaneWebgl: vi.fn(),
hasWebglRenderer: vi.fn(() => false),
getPanes: vi.fn(() => panes),
closePane: vi.fn(),
@@ -36,6 +36,7 @@ type RevealProbeWindow = Window & {
__revealRenderProbe?: RevealRenderDebug
__syncRevealProbe?: {
paintFrame: (marker: string, background: number, release: boolean) => Promise<void>
writeSpinnerFrame: (glyph: string, release: boolean) => Promise<void>
forceRendererPresent: () => void
read: () => {
atlasClears: number
@@ -240,6 +241,17 @@ async function installSynchronizedRevealProbe(page: Page, tabId: string): Promis
originalRenderRows(start, end)
}
const writeFrame = (data: string, release: boolean): Promise<void> =>
new Promise((resolve) => {
terminal.write(data, () => {
if (!release && service._syncOutputHandler?._timeout !== undefined) {
window.clearTimeout(service._syncOutputHandler._timeout)
service._syncOutputHandler._timeout = undefined
}
resolve()
})
})
const paintFrame = (marker: string, background: number, release: boolean): Promise<void> => {
const width = Math.max(1, terminal.cols)
const rows = Math.max(1, terminal.rows)
@@ -248,22 +260,22 @@ async function installSynchronizedRevealProbe(page: Page, tabId: string): Promis
{ length: rows },
(_, row) => `\x1b[${row + 1};1H\x1b[48;5;${background}m\x1b[38;5;231m${line}`
).join('')
return new Promise((resolve) => {
terminal.write(
`\x1b[?2026h\x1b[?1049h\x1b[2J${frame}\x1b[0m${release ? '\x1b[?2026l' : ''}`,
() => {
if (!release && service._syncOutputHandler?._timeout !== undefined) {
window.clearTimeout(service._syncOutputHandler._timeout)
service._syncOutputHandler._timeout = undefined
}
resolve()
}
)
})
return writeFrame(
`\x1b[?2026h\x1b[?1049h\x1b[2J${frame}\x1b[0m${release ? '\x1b[?2026l' : ''}`,
release
)
}
// Codex-like idle tick: title spinner and cursor park, each in its own DEC 2026 frame.
const writeSpinnerFrame = (glyph: string, release: boolean): Promise<void> =>
writeFrame(
`\x1b[?2026h\x1b]0;${glyph} work\x07${release ? `\x1b[${terminal.rows};3H\x1b[?2026l` : ''}`,
release
)
;(window as RevealProbeWindow).__syncRevealProbe = {
paintFrame,
writeSpinnerFrame,
forceRendererPresent: () => renderer.renderRows?.(0, terminal.rows - 1),
read: () => ({
atlasClears,
@@ -278,8 +290,8 @@ async function installSynchronizedRevealProbe(page: Page, tabId: string): Promis
async function callSynchronizedRevealProbe(
page: Page,
action: 'forceRendererPresent' | 'paintFrame',
frame?: { marker: string; background: number; release: boolean }
action: 'forceRendererPresent' | 'paintFrame' | 'writeSpinnerFrame',
frame?: { marker: string; background?: number; release: boolean }
): Promise<void> {
await page.evaluate(
async ({ action, frame }) => {
@@ -291,7 +303,11 @@ async function callSynchronizedRevealProbe(
if (!frame) {
throw new Error('Synchronized frame missing')
}
await probe.paintFrame(frame.marker, frame.background, frame.release)
await probe.paintFrame(frame.marker, frame.background ?? 0, frame.release)
return
}
if (action === 'writeSpinnerFrame') {
await probe.writeSpinnerFrame(frame?.marker ?? '*', frame?.release ?? true)
return
}
probe.forceRendererPresent()
@@ -316,6 +332,33 @@ async function captureFirstRevealedFrame(page: Page, tabId: string): Promise<Buf
return screen.screenshot({ animations: 'disabled' })
}
// Codex-like idle output: ~30 Hz spinner frames; the last stays open so a renderer swap lands mid-frame deterministically.
async function runSpinnerFramesEndingOpen(page: Page): Promise<void> {
const glyphs = ['\u280b', '\u2819', '\u2839', '\u2838', '\u283c', '\u2834']
// ast-grep-ignore: test-no-polling-loop -- models Codex's ~30 Hz spinner cadence; nothing is polled
for (let i = 0; i < 12; i++) {
const marker = glyphs[i % glyphs.length]
await callSynchronizedRevealProbe(page, 'writeSpinnerFrame', {
marker,
release: true
})
// ast-grep-ignore: test-no-real-time-wait -- models Codex's ~30 Hz spinner cadence
await page.waitForTimeout(33)
}
await callSynchronizedRevealProbe(page, 'writeSpinnerFrame', {
marker: glyphs[0],
release: false
})
}
async function replaceWebglRenderer(page: Page, tabId: string): Promise<void> {
await page.evaluate((tabId) => {
const manager = window.__paneManagers?.get(tabId)
manager?.setTerminalGpuAcceleration('off')
manager?.setTerminalGpuAcceleration('on')
}, tabId)
}
test.describe('terminal reveal paused-render recovery', () => {
test("reveal repaint forces a render through xterm's paused gate", async ({ orcaPage }) => {
// Use orcaPage: the main renderer owns __store/__paneManagers, unlike Playwright’s first page.
@@ -391,6 +434,88 @@ test.describe('terminal reveal paused-render recovery', () => {
).toBe(true)
})
test('a WebGL canvas replaced while spinner frames hold DEC 2026 paints instead of staying blank', async ({
orcaPage
}, testInfo) => {
await waitForSessionReady(orcaPage)
await waitForActiveTerminalManager(orcaPage)
const tabId = (await getActiveTabId(orcaPage))!
await forceWebglOn(orcaPage, tabId)
const hasWebgl = () =>
orcaPage
.waitForFunction(
(tabId) =>
(window.__paneManagers?.get(tabId)?.getRenderingDiagnostics?.() ?? []).some(
(diagnostic) => diagnostic.hasWebgl
),
tabId,
{ timeout: 15_000 }
)
.then(() => true)
.catch(() => false)
const webglAttached = await hasWebgl()
test.skip(!webglAttached, 'WebGL renderer is unavailable')
if (!webglAttached) {
return
}
expect(await installSynchronizedRevealProbe(orcaPage, tabId)).toBe(true)
await callSynchronizedRevealProbe(orcaPage, 'paintFrame', {
marker: 'SPINNER_PANE',
background: 17,
release: true
})
const coherent = await captureStableTabScreenshot(orcaPage, tabId)
await runSpinnerFramesEndingOpen(orcaPage)
expect((await readSynchronizedRevealProbe(orcaPage)).synchronizedOutput).toBe(true)
await replaceWebglRenderer(orcaPage, tabId)
expect(await hasWebgl(), 'renderer swapped back to WebGL').toBe(true)
const afterSwap = await captureFirstRevealedFrame(orcaPage, tabId)
const swapDiff = compareTerminalScreenshots(coherent, afterSwap)
// A painted canvas keeps honoring DEC 2026: the next frame's cells stay hidden until it closes.
await callSynchronizedRevealProbe(orcaPage, 'paintFrame', {
marker: 'PENDING_FRAME',
background: 52,
release: false
})
await orcaPage.evaluate((tabId) => {
window.__paneManagers?.get(tabId)?.scheduleRevealPresent()
}, tabId)
// Let the settled-frame present run before capturing, so the held check is not vacuous.
await orcaPage.evaluate(
() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))
)
const held = await captureFirstRevealedFrame(orcaPage, tabId)
const heldDiff = compareTerminalScreenshots(coherent, held)
await callSynchronizedRevealProbe(orcaPage, 'paintFrame', {
marker: 'PENDING_FRAME',
background: 52,
release: true
})
const released = await captureStableTabScreenshot(orcaPage, tabId)
const afterSwapPath = testInfo.outputPath('spinner-canvas-after-swap.png')
await writeFile(afterSwapPath, afterSwap)
await testInfo.attach('spinner-canvas-after-swap.png', {
path: afterSwapPath,
contentType: 'image/png'
})
expect(
swapDiff.matches,
`new canvas shows the pane, not a blank, while DEC 2026 is open (diffRatio=${swapDiff.diffRatio})`
).toBe(true)
expect(
heldDiff.matches,
`painted canvas keeps the coherent frame while DEC 2026 is held (diffRatio=${heldDiff.diffRatio})`
).toBe(true)
expect(
compareTerminalScreenshots(coherent, released).matches,
'release presents the new frame'
).toBe(false)
})
test('@headful atlas recovery keeps the coherent frame until synchronized output releases', async ({
orcaPage
}, testInfo) => {