From 2367c160d3d7ed732b73e00a448a3c261d2e34b6 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 5 Nov 2026 01:49:43 -0800 Subject: [PATCH] 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 --- ...-color-scheme-child-stdin.node-pty.test.ts | 1 - ...connection-replay-payload-handling.spec.ts | 2 - .../pty-connection-test-pane-fixtures.ts | 2 - .../pty-connection/replay-data-drain.ts | 2 - ...ote-hidden-output-restore-outcomes.test.ts | 1 - ...t-restore-unavailable-banner.repro.test.ts | 1 - .../src/lib/pane-manager/pane-manager.ts | 6 +- .../pane-manager/pane-webgl-renderer.test.ts | 80 +++++++++ .../lib/pane-manager/pane-webgl-renderer.ts | 5 +- .../terminal-canvas-dpr-repair.ts | 7 +- .../terminal-render-pause-release.ts | 57 ++++++- ...untime-rejected-input-remount.unit.test.ts | 1 - ...erminal-reveal-paused-render-repro.spec.ts | 155 ++++++++++++++++-- 13 files changed, 286 insertions(+), 34 deletions(-) diff --git a/src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts b/src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts index d2f9782dc9b..0f103765769 100644 --- a/src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts +++ b/src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts @@ -180,7 +180,6 @@ function createManager(pane: Record): Record { return { setPaneGpuRendering: vi.fn(), markPaneHasComplexScriptOutput: vi.fn(), - rebuildPaneWebgl: vi.fn(), hasWebglRenderer: vi.fn(() => false), getPanes: vi.fn(() => [pane]), closePane: vi.fn(), diff --git a/src/renderer/src/components/terminal-pane/pty-connection-replay-payload-handling.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-replay-payload-handling.spec.ts index eb4e253b94a..5208789f701 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-replay-payload-handling.spec.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-replay-payload-handling.spec.ts @@ -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() }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-test-pane-fixtures.ts b/src/renderer/src/components/terminal-pane/pty-connection-test-pane-fixtures.ts index b040d7f07d8..8b7d9082675 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-test-pane-fixtures.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-test-pane-fixtures.ts @@ -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(), diff --git a/src/renderer/src/components/terminal-pane/pty-connection/replay-data-drain.ts b/src/renderer/src/components/terminal-pane/pty-connection/replay-data-drain.ts index a4dcd68e3dd..6183642aa1f 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/replay-data-drain.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/replay-data-drain.ts @@ -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 diff --git a/src/renderer/src/components/terminal-pane/remote-hidden-output-restore-outcomes.test.ts b/src/renderer/src/components/terminal-pane/remote-hidden-output-restore-outcomes.test.ts index 2b6a067d62f..5202d88a079 100644 --- a/src/renderer/src/components/terminal-pane/remote-hidden-output-restore-outcomes.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-hidden-output-restore-outcomes.test.ts @@ -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(), diff --git a/src/renderer/src/components/terminal-pane/remote-hidden-output-restore-unavailable-banner.repro.test.ts b/src/renderer/src/components/terminal-pane/remote-hidden-output-restore-unavailable-banner.repro.test.ts index 1df15ca5147..874899ea014 100644 --- a/src/renderer/src/components/terminal-pane/remote-hidden-output-restore-unavailable-banner.repro.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-hidden-output-restore-unavailable-banner.repro.test.ts @@ -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(), diff --git a/src/renderer/src/lib/pane-manager/pane-manager.ts b/src/renderer/src/lib/pane-manager/pane-manager.ts index f971bcabe3e..9da6e3cd64a 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager.ts @@ -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()) } diff --git a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts index 6891e65c567..befa7e5e10e 100644 --- a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts @@ -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({ diff --git a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts index 9c212dd4fba..6abaa8a1bd1 100644 --- a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts +++ b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts @@ -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 {} } diff --git a/src/renderer/src/lib/pane-manager/terminal-canvas-dpr-repair.ts b/src/renderer/src/lib/pane-manager/terminal-canvas-dpr-repair.ts index 17558728c4b..0130336f71e 100644 --- a/src/renderer/src/lib/pane-manager/terminal-canvas-dpr-repair.ts +++ b/src/renderer/src/lib/pane-manager/terminal-canvas-dpr-repair.ts @@ -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' diff --git a/src/renderer/src/lib/pane-manager/terminal-render-pause-release.ts b/src/renderer/src/lib/pane-manager/terminal-render-pause-release.ts index 46cec446945..8c015ae2fd9 100644 --- a/src/renderer/src/lib/pane-manager/terminal-render-pause-release.ts +++ b/src/renderer/src/lib/pane-manager/terminal-render-pause-release.ts @@ -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 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) diff --git a/tests/e2e/paired-runtime-rejected-input-remount.unit.test.ts b/tests/e2e/paired-runtime-rejected-input-remount.unit.test.ts index a9d37341081..789c8eeb930 100644 --- a/tests/e2e/paired-runtime-rejected-input-remount.unit.test.ts +++ b/tests/e2e/paired-runtime-rejected-input-remount.unit.test.ts @@ -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(), diff --git a/tests/e2e/terminal-reveal-paused-render-repro.spec.ts b/tests/e2e/terminal-reveal-paused-render-repro.spec.ts index b01997f87e6..78bc66aed9f 100644 --- a/tests/e2e/terminal-reveal-paused-render-repro.spec.ts +++ b/tests/e2e/terminal-reveal-paused-render-repro.spec.ts @@ -36,6 +36,7 @@ type RevealProbeWindow = Window & { __revealRenderProbe?: RevealRenderDebug __syncRevealProbe?: { paintFrame: (marker: string, background: number, release: boolean) => Promise + writeSpinnerFrame: (glyph: string, release: boolean) => Promise 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 => + 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 => { 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 => + 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 { 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 { + 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 { + 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) => {