fix(terminal): present synchronized frames after reveal

This commit is contained in:
Neil
2026-08-23 14:16:17 -07:00
parent 7a72f341f7
commit 0b48a3fde9
16 changed files with 769 additions and 42 deletions
+46 -19
View File
@@ -12284,7 +12284,7 @@
"id": "terminal-render.pixel-refresh-repair",
"title": "Forced repaint does not change revealed pane pixels",
"maturity": "experimental",
"protection": "none",
"protection": "partial",
"owner": "terminal-rendering",
"layer": "electron-visual-golden",
"surfaces": [
@@ -12296,44 +12296,71 @@
],
"platforms": ["macos", "linux", "windows"],
"providers": ["local", "daemon"],
"coveredPlatforms": [],
"coveredProviders": [],
"coverageNotes": "Registered gap only; the live repro harness with a refresh-repair oracle exists but is not productized into the release-blocking terminal-rendering-golden suite.",
"coveredPlatforms": ["macos"],
"coveredProviders": ["local"],
"coverageNotes": "A headed macOS Electron test drives a real WebGL terminal through a tab hide/reveal while DEC 2026 output is held. It verifies the serialized buffer contains the new frame, then compares compositor screenshots before and after a direct full-buffer present. The same renderer choreography is provider-local, but daemon, SSH, remote-runtime, Linux, and Windows GPU runs remain gaps.",
"motivatingLinks": [
"https://github.com/stablyai/orca/pull/7133",
"https://github.com/stablyai/orca/pull/7054"
],
"invariant": "After any reveal, restore, or wake, the pixels a pane displays must equal what a forced full model-invalidating repaint would draw from the buffer; a material refresh-repair diff means stale rendering escaped, whatever the mechanism.",
"oracle": "Screenshot the revealed pane, force a model-invalidating full repaint, screenshot again, and assert the bounded pixel diff is immaterial; pair with a JS-level render-model-vs-buffer comparison. Compositor screenshots only, because WebGL canvas readback is blank with preserveDrawingBuffer disabled.",
"commands": [],
"testFiles": [],
"assertionRefs": [],
"evidenceRuns": [],
"commands": [
"pnpm run ensure:electron-runtime && pnpm exec playwright test tests/e2e/terminal-reveal-paused-render-repro.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
"ORCA_E2E_RECORD_VIDEO=1 pnpm exec playwright test tests/e2e/terminal-reveal-paused-render-repro.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1 --repeat-each=3"
],
"testFiles": ["tests/e2e/terminal-reveal-paused-render-repro.spec.ts"],
"assertionRefs": [
{
"file": "tests/e2e/terminal-reveal-paused-render-repro.spec.ts",
"assertions": ["@headful tab reveal presents a synchronized-output WebGL frame"]
}
],
"evidenceRuns": [
{
"date": "2026-08-22",
"runner": "local",
"platform": "macos",
"command": "pnpm run ensure:electron-runtime && pnpm exec playwright test tests/e2e/terminal-reveal-paused-render-repro.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
"result": "passed",
"durationSeconds": 16,
"summary": "A real WebGL tab reveal matched the current serialized synchronized-output frame with zero changed pixels after a direct renderer present. The test persists and attaches the exact before/after screenshots and pixel-diff metadata."
},
{
"date": "2026-08-22",
"runner": "local",
"platform": "macos",
"command": "ORCA_E2E_RECORD_VIDEO=1 pnpm exec playwright test tests/e2e/terminal-reveal-paused-render-repro.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1 --repeat-each=3",
"result": "passed",
"durationSeconds": 48,
"summary": "Three consecutive headed real-WebGL runs passed in 15.0-15.7 seconds each and emitted a 1920x1080 video per BrowserWindow."
}
],
"runtimeBudget": {
"p95Seconds": 300,
"scope": "release-blocking golden Electron visual test"
"p95Seconds": 120,
"scope": "focused prebuilt headed Electron visual test"
},
"flakeHistory": {
"status": "not-started",
"evidence": "Runs nightly only while the per-platform diff threshold stabilizes; the destination is the release-blocking terminal-rendering-golden suite in release-cut.yml."
"status": "unknown",
"evidence": "Four local macOS headed runs passed, including three consecutive video-recorded repeats; CI soak history and cross-platform GPU runs are not yet available."
},
"redGreenEvidence": {
"status": "missing",
"evidence": "Needs an intentionally fossilized render model (skip the model invalidation on reveal) proving the refresh-repair diff fires."
"status": "complete",
"evidence": "On unmodified main, the revealed screenshot differed from a direct renderer present across 87.68% of pixels even though serialization already contained the new frame. With synchronized-output presentation enabled, the same test passes the 1.5% material-diff threshold."
},
"performanceBudget": {
"required": true,
"evidence": "Soak-only; must not add product-path repaints beyond the existing reveal hardening."
"evidence": "Recovery remains event-driven and limited to visible managers. The deterministic 64-hidden-manager count test requires zero hidden atlas clears or presents. A display:none retry is deduped per pane and capped at 16 animation frames; no polling, provider calls, PTY scans, or background output work was added."
},
"promotionCriteria": [
"Productize the existing stale-render repro harness with the refresh-repair oracle and failure artifacts.",
"Establish a stable material-diff threshold across platforms via nightly runs.",
"Promotion means membership in the release-blocking terminal-rendering-golden suite that release-cut.yml runs against every release tag; never PR-blocking.",
"Register the gate command as a direct spec-file invocation; the checker rejects --grep tag selectors, so do not reuse the existing --grep-based golden script as the command."
"Add Linux and Windows GPU evidence or retain those platform gaps explicitly."
],
"knownGaps": [
"No manifest command yet.",
"Buffer-clean, pixels-stale variants are otherwise invisible to every buffer oracle in the manifest."
"The deterministic test holds synchronized output past xterm's one-second safety timeout so the reveal race is stable.",
"The renderer-level frame injection isolates the paint failure; provider delivery and hidden-output snapshot restore have separate gates.",
"Linux, Windows, daemon, SSH, and remote-runtime live GPU evidence is not yet recorded."
],
"demotionRule": "Drop from the golden suite back to nightly diagnostic if diff thresholds cannot be kept stable across CI runners."
},
@@ -11,6 +11,7 @@ import {
writeTerminalOutput
} from '@/lib/pane-manager/pane-terminal-output-scheduler'
import { recordTerminalOutput } from '@/lib/pane-manager/pane-scroll'
import { presentPaneViewport } from '@/lib/pane-manager/pane-webgl-renderer'
import {
buildMainModelSnapshotReplayWrites,
hasPositiveTerminalDimensions,
@@ -22,6 +23,7 @@ import { HIDDEN_OUTPUT_RESTORE_UNAVAILABLE_WARNING } from './hidden-output-resto
import { shouldWritePtyOutputForeground } from './foreground-output-scan'
import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore'
import { restoredSnapshotPaintsPrintableContent } from '../restored-snapshot-coverage'
import { recordTerminalFreezeBreadcrumb } from '../terminal-freeze-breadcrumbs'
import type { ConnectPanePtySession } from './connect-pane-pty-session'
@@ -152,6 +154,12 @@ export function bindHiddenOutputRestoreSnapshot(session: ConnectPanePtySession):
session.recordRendererOrderedSeq(snapshot)
recordTerminalOutput(session.pane.terminal)
await waitForTerminalReplayWritesParsed(session.pane.terminal)
if (session.deps.isVisibleRef.current) {
presentPaneViewport(session.pane)
recordTerminalFreezeBreadcrumb('stale-pixel-restore-present', {
paneId: session.pane.id
})
}
},
{
shouldRestore: () =>
@@ -87,6 +87,7 @@ describe('resumeTerminalVisibility reveal repaint', () => {
resumeTerminalVisibility(resumeArgs(manager, true))
expect(manager.scheduleRevealRepaint).toHaveBeenCalledTimes(1)
expect(manager.scheduleRevealPresent).toHaveBeenCalledTimes(1)
expect(manager.resumeRendering).not.toHaveBeenCalled()
expect(scheduleTabRevealWebglAtlasRecovery).toHaveBeenCalledTimes(1)
})
@@ -193,6 +194,25 @@ describe('resumeTerminalVisibility reveal repaint', () => {
expect(manager.fitAllPanes).not.toHaveBeenCalled()
})
it('repairs WebGL canvas backing-store dpr on window wake', () => {
// Clamshell undock: dpr changes while the pane stayed "visible" with a
// stale backing store; tab-reveal is not in the path.
const first = { terminal: { name: 'pane-a' } }
const second = { terminal: { name: 'pane-b' } }
const manager = createManager()
manager.getPanes.mockReturnValue([first, second])
recoverVisibleTerminalWindowWake({
manager: manager as never as PaneManager,
isActive: true,
clearGlyphAtlases: false
})
expect(repairPaneWebglCanvasDprMismatch).toHaveBeenCalledTimes(2)
expect(repairPaneWebglCanvasDprMismatch).toHaveBeenNthCalledWith(1, first)
expect(repairPaneWebglCanvasDprMismatch).toHaveBeenNthCalledWith(2, second)
})
it('latches viewport intent before refocus recovery flushes streaming output', async () => {
const terminal = { name: 'streaming-terminal' }
const manager = createManager()
@@ -17,6 +17,7 @@ import { focusActivePane } from './pane-helpers'
import { scheduleTabRevealWebglAtlasRecovery } from './terminal-webgl-atlas-recovery'
import { flushDeferredPaneMetricOptionsIfMeasurable } from '@/lib/pane-manager/pane-fit'
import { repairPaneWebglCanvasDprMismatch } from '@/lib/pane-manager/terminal-canvas-dpr-repair'
import { presentPaneViewport } from '@/lib/pane-manager/pane-webgl-renderer'
const VISIBLE_RESUME_FLUSH_CHARS = 256 * 1024
const WINDOW_WAKE_FLUSH_CHARS = 64 * 1024
@@ -90,6 +91,10 @@ export function resumeTerminalVisibility({
requestLightTabBacklogRecovery(manager)
// Why: reveal is the lifecycle boundary that owns hidden renderer repair.
scheduleTabRevealWebglAtlasRecovery()
// Why: the atlas burst often runs while this tab is still display:none
// (paused=true needFull=true in the field trace). A present-only pass after
// overlay layout rebuilds the WebGL model without a second atlas wipe.
manager.scheduleRevealPresent()
if (flushedDeferredMetrics) {
// Why: the light path normally skips fitting, but flushed metrics changed
// cell size — refit so cols/rows match before the overlay settles.
@@ -161,6 +166,13 @@ export function recoverVisibleTerminalWindowWake({
// Why: backlog writes can expose transient viewport geometry while parsing.
syncTerminalViewportIntents(manager)
for (const pane of manager.getPanes()) {
// Why: clamshell undock / monitor move changes devicePixelRatio while the
// pane can stay "visible" with a stale WebGL backing store. The addon's
// device-pixel observer misses that (no CSS-box change, or no box while
// the lid was closed). Repair here — not only on tab reveal.
if (repairPaneWebglCanvasDprMismatch(pane)) {
presentPaneViewport(pane)
}
requestTerminalBacklogRecovery(pane.terminal)
flushTerminalOutput(pane.terminal, { maxChars: WINDOW_WAKE_FLUSH_CHARS })
// Why: window blur fires mouseleave, clearing xterm's current link but not
@@ -3,14 +3,28 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { renderHook } from '@testing-library/react'
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
const { recoverVisibleTerminalWindowWakeMock } = vi.hoisted(() => ({
recoverVisibleTerminalWindowWakeMock: vi.fn()
const {
recoverVisibleTerminalWindowWakeMock,
repairPaneWebglCanvasDprMismatchMock,
presentPaneViewportMock
} = vi.hoisted(() => ({
recoverVisibleTerminalWindowWakeMock: vi.fn(),
repairPaneWebglCanvasDprMismatchMock: vi.fn(() => false),
presentPaneViewportMock: vi.fn()
}))
vi.mock('./terminal-visibility-resume', () => ({
recoverVisibleTerminalWindowWake: recoverVisibleTerminalWindowWakeMock
}))
vi.mock('@/lib/pane-manager/terminal-canvas-dpr-repair', () => ({
repairPaneWebglCanvasDprMismatch: repairPaneWebglCanvasDprMismatchMock
}))
vi.mock('@/lib/pane-manager/pane-webgl-renderer', () => ({
presentPaneViewport: presentPaneViewportMock
}))
import { useTerminalWindowWakeRecovery } from './use-terminal-window-wake-recovery'
import {
getTerminalFreezeBreadcrumbs,
@@ -29,6 +43,9 @@ describe('useTerminalWindowWakeRecovery', () => {
beforeEach(() => {
systemResumedCallback = null
recoverVisibleTerminalWindowWakeMock.mockClear()
repairPaneWebglCanvasDprMismatchMock.mockClear()
presentPaneViewportMock.mockClear()
repairPaneWebglCanvasDprMismatchMock.mockReturnValue(false)
unsubscribeSystemResumed.mockClear()
onSystemResumed.mockClear()
resetTerminalFreezeBreadcrumbsForTesting()
@@ -180,4 +197,30 @@ describe('useTerminalWindowWakeRecovery', () => {
expect(onSystemResumed).not.toHaveBeenCalled()
})
it('repairs WebGL canvas dpr on window resize without wiping the glyph atlas', () => {
// Chromium emits resize when devicePixelRatio changes (undock / monitor
// move) even if the CSS box is unchanged.
const pane = { id: 1, terminal: {} }
const resizeManager = { getPanes: () => [pane] } as unknown as PaneManager
repairPaneWebglCanvasDprMismatchMock.mockReturnValue(true)
const { unmount } = renderHook(() =>
useTerminalWindowWakeRecovery({
isVisible: true,
managerRef: { current: resizeManager },
isActiveRef: { current: true },
isVisibleRef: { current: true }
})
)
window.dispatchEvent(new Event('resize'))
expect(repairPaneWebglCanvasDprMismatchMock).toHaveBeenCalledWith(pane)
expect(presentPaneViewportMock).toHaveBeenCalledWith(pane)
expect(recoverVisibleTerminalWindowWakeMock).not.toHaveBeenCalled()
unmount()
window.dispatchEvent(new Event('resize'))
expect(repairPaneWebglCanvasDprMismatchMock).toHaveBeenCalledTimes(1)
})
})
@@ -1,6 +1,8 @@
import { useEffect } from 'react'
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
import { recoverVisibleTerminalWindowWake } from './terminal-visibility-resume'
import { repairPaneWebglCanvasDprMismatch } from '@/lib/pane-manager/terminal-canvas-dpr-repair'
import { presentPaneViewport } from '@/lib/pane-manager/pane-webgl-renderer'
import { recordTerminalFreezeBreadcrumb } from './terminal-freeze-breadcrumbs'
import type { IDisposable } from '@xterm/xterm'
@@ -111,7 +113,22 @@ export function useTerminalWindowWakeRecovery({
recoverVisibleWake(true, 'system-resumed')
}
}
const onWindowResize = (): void => {
// Why: Chromium emits window resize on devicePixelRatio changes even when
// the CSS box is unchanged (monitor move / undock). xterm's own observer
// misses that while the canvas had no box (laptop lid closed).
const manager = managerRef.current
if (!manager || !isVisibleRef.current) {
return
}
for (const pane of manager.getPanes?.() ?? []) {
if (repairPaneWebglCanvasDprMismatch(pane)) {
presentPaneViewport(pane)
}
}
}
window.addEventListener('focus', onFocus)
window.addEventListener('resize', onWindowResize)
if (typeof document !== 'undefined' && typeof document.addEventListener === 'function') {
document.addEventListener('visibilitychange', onVisibilityChange)
}
@@ -126,6 +143,7 @@ export function useTerminalWindowWakeRecovery({
return () => {
cancelScheduledWakeRecovery()
window.removeEventListener('focus', onFocus)
window.removeEventListener('resize', onWindowResize)
if (typeof document !== 'undefined' && typeof document.removeEventListener === 'function') {
document.removeEventListener('visibilitychange', onVisibilityChange)
}
@@ -85,9 +85,43 @@ describe('pane manager registry', () => {
expect(order).toEqual(['first-reset', 'second-reset', 'first-refresh', 'second-refresh'])
})
it('clears every recovered atlas before presenting any pane', () => {
// Why: per-pane clear+present interleaves a present against atlas generation
// N with the next pane's wipe to N+1. The first synchronized-output column
// then keeps pre-hide footer pixels. Wipe first, present once the generation
// is final.
const order: string[] = []
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')),
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')),
refreshAllPanes: vi.fn<() => void>(() => order.push('second-refresh')),
isVisibleForAtlasRecovery: () => true
}
registerLivePaneManager(first)
registeredManagers.push(first)
registerLivePaneManager(second)
registeredManagers.push(second)
resetAndRefreshAllTerminalWebglAtlases()
expect(order).toEqual(['first-clear', 'second-clear', 'first-present', 'second-present'])
expect(first.resetWebglTextureAtlases).not.toHaveBeenCalled()
expect(second.refreshAllPanes).not.toHaveBeenCalled()
})
it('bounds atlas recovery to visible managers', () => {
const visible = {
resetWebglTextureAtlases: vi.fn<() => void>(),
clearWebglTextureAtlases: vi.fn<() => void>(),
presentForcedViewports: vi.fn<() => void>(),
refreshAllPanes: vi.fn<() => void>(),
isVisibleForAtlasRecovery: () => true
}
@@ -95,6 +129,8 @@ describe('pane manager registry', () => {
registeredManagers.push(visible)
const hidden = Array.from({ length: 64 }, () => ({
resetWebglTextureAtlases: vi.fn<() => void>(),
clearWebglTextureAtlases: vi.fn<() => void>(),
presentForcedViewports: vi.fn<() => void>(),
refreshAllPanes: vi.fn<() => void>(),
isVisibleForAtlasRecovery: () => false
}))
@@ -105,12 +141,14 @@ describe('pane manager registry', () => {
resetAndRefreshAllTerminalWebglAtlases()
expect(visible.resetWebglTextureAtlases).toHaveBeenCalledOnce()
expect(visible.refreshAllPanes).toHaveBeenCalledOnce()
expect(visible.clearWebglTextureAtlases).toHaveBeenCalledOnce()
expect(visible.presentForcedViewports).toHaveBeenCalledOnce()
expect(
hidden.every((manager) => manager.resetWebglTextureAtlases.mock.calls.length === 0)
hidden.every((manager) => manager.clearWebglTextureAtlases.mock.calls.length === 0)
).toBe(true)
expect(hidden.every((manager) => manager.refreshAllPanes.mock.calls.length === 0)).toBe(true)
expect(hidden.every((manager) => manager.presentForcedViewports.mock.calls.length === 0)).toBe(
true
)
})
it('continues reset-and-refresh recovery when one manager throws', () => {
@@ -4,6 +4,8 @@ import type { PaneRenderingDiagnostics } from './pane-manager-types'
type RegisteredPaneManager = {
resetWebglTextureAtlases(): void
clearWebglTextureAtlases?: () => void
presentForcedViewports?: () => void
fitAllPanes?: () => void
refreshAllPanes?: () => void
getRenderingDiagnostics?: () => PaneRenderingDiagnostics[]
@@ -60,9 +62,17 @@ export function resetAndRefreshAllTerminalWebglAtlases(reason?: string): void {
...(reason ? { reason } : {})
})
const resetManagers: RegisteredPaneManager[] = []
// Why: clearTextureAtlas() is module-global. Clearing then presenting per
// pane interleaves a present against generation N with the next pane's wipe
// to N+1, so the first synchronized-output column keeps pre-hide footer pixels.
// Wipe every recovered atlas first; present only once the generation is final.
for (const manager of recoveryManagers) {
try {
manager.resetWebglTextureAtlases()
if (manager.clearWebglTextureAtlases) {
manager.clearWebglTextureAtlases()
} else {
manager.resetWebglTextureAtlases()
}
resetManagers.push(manager)
} catch {
// Why: recovery is best-effort during pane teardown; a disposed manager
@@ -71,7 +81,11 @@ export function resetAndRefreshAllTerminalWebglAtlases(reason?: string): void {
}
for (const manager of resetManagers) {
try {
manager.refreshAllPanes?.()
if (manager.presentForcedViewports) {
manager.presentForcedViewports()
} else {
manager.refreshAllPanes?.()
}
} catch {
// Why: a pane can unmount between atlas reset and repaint; later
// managers still need to repaint from their xterm buffers.
@@ -26,6 +26,8 @@ import { applyTerminalGpuAcceleration } from './pane-terminal-gpu-acceleration'
import { rebuildAttachedWebgl } from './pane-webgl-reattach'
import {
markPaneComplexScriptOutput,
clearPaneWebglTextureAtlases,
presentPaneViewports,
resetPaneWebglTextureAtlases,
resumePaneRendering,
setPaneGpuRenderingState,
@@ -280,6 +282,14 @@ export class PaneManager {
resetPaneWebglTextureAtlases(this.panes.values())
}
clearWebglTextureAtlases(): void {
clearPaneWebglTextureAtlases(this.panes.values())
}
presentForcedViewports(): void {
presentPaneViewports(this.panes.values())
}
setAtlasRecoveryVisible(visible: boolean): void {
this.atlasRecoveryVisible = visible
}
@@ -6,6 +6,8 @@ import {
disposeWebgl,
isPaneWebglContextLost,
markComplexScriptOutput,
clearWebglTextureAtlas,
presentPaneViewport,
resetWebglTextureAtlas
} from './pane-webgl-renderer'
import { rebuildAttachedWebgl, reattachWebglIfNeeded } from './pane-webgl-reattach'
@@ -101,3 +103,15 @@ export function resetPaneWebglTextureAtlases(panes: Iterable<ManagedPaneInternal
resetWebglTextureAtlas(pane)
}
}
export function clearPaneWebglTextureAtlases(panes: Iterable<ManagedPaneInternal>): void {
for (const pane of panes) {
clearWebglTextureAtlas(pane)
}
}
export function presentPaneViewports(panes: Iterable<ManagedPaneInternal>): void {
for (const pane of panes) {
presentPaneViewport(pane)
}
}
@@ -1,5 +1,6 @@
import type { ManagedPaneInternal } from './pane-manager-types'
import { reattachWebglIfNeeded } from './pane-webgl-reattach'
import { presentPaneViewport } from './pane-webgl-renderer'
import { resetAndRefreshAllTerminalWebglAtlases } from './pane-manager-registry'
type PaneGetter = () => Iterable<ManagedPaneInternal>
@@ -96,8 +97,6 @@ export function schedulePaneRevealRepaint(getPanes: () => Iterable<ManagedPaneIn
export function schedulePaneRevealPresent(getPanes: () => Iterable<ManagedPaneInternal>): void {
forEachPaneOnSettledFrame(getPanes, (pane) => {
reattachWebglIfNeeded(pane)
if (pane.terminal.rows > 0) {
pane.terminal.refresh(0, pane.terminal.rows - 1)
}
presentPaneViewport(pane)
})
}
@@ -4,6 +4,7 @@ import type { ManagedPaneInternal } from './pane-manager-types'
import {
attachWebgl,
clearTerminalWebglAttachBackoff,
presentPaneViewport,
resetTerminalWebglSuggestion,
resetWebglTextureAtlas
} from './pane-webgl-renderer'
@@ -75,6 +76,7 @@ type FakeRenderService = {
function createPausedPane(display: 'block' | 'none'): {
pane: ManagedPaneInternal
renderService: FakeRenderService
setDisplay: (next: 'block' | 'none') => void
} {
const pane = createPane()
const renderService: FakeRenderService = {
@@ -87,7 +89,8 @@ function createPausedPane(display: 'block' | 'none'): {
}
})
}
const view = { getComputedStyle: () => ({ display }) }
let currentDisplay = display
const view = { getComputedStyle: () => ({ display: currentDisplay }) }
const element = { ownerDocument: { defaultView: view }, parentElement: null }
pane.container = element as never
pane.xtermContainer = element as never
@@ -96,7 +99,13 @@ function createPausedPane(display: 'block' | 'none'): {
refresh: vi.fn(() => renderService.refreshRows(0, pane.terminal.rows - 1)),
_core: { _renderService: renderService }
} as never
return { pane, renderService }
return {
pane,
renderService,
setDisplay: (next) => {
currentDisplay = next
}
}
}
describe('terminal WebGL addon lifecycle', () => {
@@ -168,6 +177,33 @@ describe('terminal WebGL addon lifecycle', () => {
expect(pane.terminal.refresh).toHaveBeenCalledWith(0, 23)
})
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).
const refreshRows = vi.fn()
const renderRows = vi.fn()
const pane = createPane()
pane.terminal = {
...pane.terminal,
refresh: vi.fn(),
_core: {
_renderService: {
_isPaused: false,
_needsFullRefresh: false,
refreshRows,
_renderer: { value: { renderRows } }
},
coreService: { decPrivateModes: { synchronizedOutput: false } }
}
} as never
presentPaneViewport(pane)
expect(pane.terminal.refresh).toHaveBeenCalledWith(0, 23)
expect(refreshRows).not.toHaveBeenCalled()
expect(renderRows).not.toHaveBeenCalled()
})
it('keeps the render pause latched when resetting a pane that has no layout box', () => {
// Regression: the atlas reset released xterm's pause for every pane of a
// visible manager, including a collapsed sibling of an expanded pane. That
@@ -185,6 +221,50 @@ describe('terminal WebGL addon lifecycle', () => {
expect(pane.terminal.refresh).toHaveBeenCalledWith(0, 23)
})
it('retries a display:none present after the overlay actually shows a box', () => {
// Field traces show paused=true needFull=true because the
// first present ran while the overlay was still display:none. Selection
// later healed the inner rows; only a resize cleared the sides. Flush the
// retry after the box exists so the full present actually runs.
const queued: FrameRequestCallback[] = []
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
queued.push(callback)
return queued.length
})
const { pane, renderService, setDisplay } = createPausedPane('none')
presentPaneViewport(pane)
expect(renderService._isPaused).toBe(true)
expect(queued).toHaveLength(1)
setDisplay('block')
queued.shift()?.(16)
expect(renderService._isPaused).toBe(false)
expect(renderService.refreshRows).toHaveBeenCalledWith(0, 23, true)
})
it('caps display:none present retries', () => {
const queued: FrameRequestCallback[] = []
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
queued.push(callback)
return queued.length
})
const { pane, renderService } = createPausedPane('none')
presentPaneViewport(pane)
let callbacks = 0
while (queued.length > 0) {
queued.shift()?.(callbacks * 16)
callbacks += 1
}
expect(callbacks).toBe(17)
expect(renderService._isPaused).toBe(true)
expect(renderService.refreshRows).toHaveBeenCalledTimes(1)
})
it('still forces the paused render through for a pane that has a layout box', () => {
// The reveal case the release exists for: DOM-visible, but xterm's observer
// has not caught up, so a plain refresh() would be swallowed.
@@ -1,9 +1,9 @@
import { WebglAddon } from '@xterm/addon-webgl'
import type { ManagedPaneInternal } from './pane-manager-types'
import type { ManagedPane, ManagedPaneInternal } from './pane-manager-types'
import { recordTerminalWebglDiagnostic } from '../../../../shared/terminal-webgl-diagnostics'
import { getLivePaneCensus } from './pane-manager-registry'
import { isManagedPaneDisplayNone } from './pane-display-visibility'
import { forceRepaintThroughRenderPause } from './terminal-render-pause-release'
import { forceFullViewportPresent } from './terminal-render-pause-release'
import {
getTerminalWebglAutoDecision,
resetTerminalWebglAutoDecision
@@ -138,7 +138,7 @@ export function markComplexScriptOutput(pane: ManagedPaneInternal): void {
pane.hasComplexScriptOutput = true
}
export function resetWebglTextureAtlas(pane: ManagedPaneInternal): void {
export function clearWebglTextureAtlas(pane: ManagedPaneInternal): void {
if (pane.webglDisabledAfterContextLoss) {
return
}
@@ -147,11 +147,51 @@ export function resetWebglTextureAtlas(pane: ManagedPaneInternal): void {
// context-loss event. Clearing the atlas preserves GPU rendering and forces
// a fresh paint when the pane becomes visible/focused again.
pane.webglAddon?.clearTextureAtlas()
} catch {
/* ignore — pane may have been disposed in the meantime */
}
}
const DISPLAYED_PRESENT_RETRY_FRAMES = 16
const pendingDisplayedPresentRetries = new WeakMap<ManagedPaneInternal, number>()
function schedulePresentWhenDisplayed(pane: ManagedPaneInternal): void {
if (typeof globalThis.requestAnimationFrame !== 'function') {
return
}
if (pendingDisplayedPresentRetries.has(pane)) {
return
}
pendingDisplayedPresentRetries.set(pane, DISPLAYED_PRESENT_RETRY_FRAMES)
const tick = (): void => {
const remaining = pendingDisplayedPresentRetries.get(pane) ?? 0
if (remaining <= 0 || !pane.terminal) {
pendingDisplayedPresentRetries.delete(pane)
return
}
if (isManagedPaneDisplayNone(pane)) {
pendingDisplayedPresentRetries.set(pane, remaining - 1)
globalThis.requestAnimationFrame(tick)
return
}
pendingDisplayedPresentRetries.delete(pane)
presentPaneViewport(pane)
}
globalThis.requestAnimationFrame(tick)
}
export function presentPaneViewport(pane: ManagedPane): void {
const internal = pane as ManagedPaneInternal
if (internal.webglDisabledAfterContextLoss) {
return
}
try {
// Why: on reveal xterm's IntersectionObserver can still report the pane as
// not intersecting, so a plain refresh() is swallowed by RenderService's
// paused-render gate and the cleared model never repaints (stale bottom rows
// until a drag-select forces a redraw). Force the paused render through
// first; only fall back to refresh() when the terminal was not gated.
// until a drag-select forces a redraw). Drive one synchronous full present
// even if the observer already unpaused; only fall back to refresh() when
// internals are unavailable.
//
// Why the display check: that release is only right for a pane that is
// DOM-visible. A pane with no box at all (collapsed sibling of an expanded
@@ -162,7 +202,17 @@ export function resetWebglTextureAtlas(pane: ManagedPaneInternal): void {
// it also drops the full repaint the observer owes the pane on reveal, and
// the deferred _pausedResizeTask that flushes alongside it. Latching is what
// xterm's own gate does, and the reveal repaints from the latch.
if (isManagedPaneDisplayNone(pane) || !forceRepaintThroughRenderPause(pane.terminal)) {
if (isManagedPaneDisplayNone(pane)) {
pane.terminal.refresh(0, pane.terminal.rows - 1)
// Why: light tab reveal runs while the overlay is still display:none
// (field trace: paused=true needFull=true at click). A plain refresh only
// 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)
return
}
if (!forceFullViewportPresent(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)
@@ -172,6 +222,11 @@ export function resetWebglTextureAtlas(pane: ManagedPaneInternal): void {
}
}
export function resetWebglTextureAtlas(pane: ManagedPaneInternal): void {
clearWebglTextureAtlas(pane)
presentPaneViewport(pane)
}
function refitAfterFitAnchoredWebglAttach(pane: ManagedPaneInternal): void {
// Why: the fit that triggered this attach measured DOM cell metrics, but WebGL
// floors the device cell width — keeping that grid leaves an unpainted right
@@ -1,24 +1,34 @@
import { describe, expect, it, vi } from 'vitest'
import { forceRepaintThroughRenderPause } from './terminal-render-pause-release'
import {
forceFullViewportPresent,
forceRepaintThroughRenderPause
} from './terminal-render-pause-release'
type FakeRenderService = {
_isPaused?: boolean
_needsFullRefresh?: boolean
refreshRows?: ReturnType<typeof vi.fn>
_renderer?: {
value?: { clear?: ReturnType<typeof vi.fn>; renderRows?: ReturnType<typeof vi.fn> }
}
}
function createTerminal(options: {
rows?: number
renderService?: FakeRenderService | null
withoutCore?: boolean
synchronizedOutput?: boolean
}): unknown {
const { rows = 24, renderService, withoutCore } = options
const { rows = 24, renderService, withoutCore, synchronizedOutput } = options
if (withoutCore) {
return { rows }
}
return {
rows,
_core: { _renderService: renderService ?? null }
_core: {
_renderService: renderService ?? null,
coreService: { decPrivateModes: { synchronizedOutput: synchronizedOutput === true } }
}
}
}
@@ -85,3 +95,78 @@ describe('forceRepaintThroughRenderPause', () => {
expect(renderService._isPaused).toBe(false)
})
})
describe('forceFullViewportPresent', () => {
it('leaves an unpaused, unsynchronized terminal to the normal refresh path', () => {
// A forced sync renderRows on first splash paints before cell metrics
// settle and shows a 1px black gutter under the TUI composer.
const refreshRows = vi.fn()
const renderRows = vi.fn()
const terminal = createTerminal({
rows: 24,
renderService: {
_isPaused: false,
_needsFullRefresh: false,
refreshRows,
_renderer: { value: { renderRows } }
}
})
expect(forceFullViewportPresent(terminal)).toBe(false)
expect(renderRows).not.toHaveBeenCalled()
expect(refreshRows).not.toHaveBeenCalled()
})
it('paints through the renderer so DEC 2026 cannot swallow the reveal present', () => {
const refreshRows = vi.fn()
const renderRows = vi.fn()
const renderService = {
_isPaused: false,
_needsFullRefresh: false,
refreshRows,
_renderer: { value: { renderRows } }
}
const terminal = createTerminal({
rows: 24,
renderService,
synchronizedOutput: true
})
expect(forceFullViewportPresent(terminal)).toBe(true)
expect(renderRows).toHaveBeenCalledWith(0, 23)
expect(refreshRows).not.toHaveBeenCalled()
})
it('uses RenderService refreshRows when paused without DEC 2026, matching production splash', () => {
const refreshRows = vi.fn()
const renderRows = vi.fn()
const renderService = {
_isPaused: true,
_needsFullRefresh: true,
refreshRows,
_renderer: { value: { renderRows } }
}
const terminal = createTerminal({ rows: 24, renderService })
expect(forceFullViewportPresent(terminal)).toBe(true)
expect(refreshRows).toHaveBeenCalledWith(0, 23, true)
expect(renderRows).not.toHaveBeenCalled()
expect(renderService._isPaused).toBe(false)
})
it('leaves pause cleared when the forced render throws so callers can refresh()', () => {
const renderService = {
_isPaused: true,
_needsFullRefresh: true,
refreshRows: vi.fn(() => {
throw new Error('terminal disposed')
}),
_renderer: { value: { renderRows: vi.fn() } }
}
const terminal = createTerminal({ rows: 24, renderService })
expect(forceFullViewportPresent(terminal)).toBe(false)
expect(renderService._isPaused).toBe(false)
expect(renderService._needsFullRefresh).toBe(false)
})
})
@@ -17,10 +17,15 @@
* never throws into a render frame.
*/
type MaybeWebglRenderer = {
renderRows?: (start: number, end: number) => void
}
type MaybePausableRenderService = {
_isPaused?: boolean
_needsFullRefresh?: boolean
refreshRows?: (start: number, end: number, sync?: boolean) => void
_renderer?: { value?: MaybeWebglRenderer | null } | MaybeWebglRenderer | null
}
type PausableRenderService = MaybePausableRenderService & {
@@ -31,6 +36,8 @@ type TerminalWithRenderService = {
rows?: number
_core?: {
_renderService?: MaybePausableRenderService
coreService?: { decPrivateModes?: { synchronizedOutput?: boolean } }
_coreService?: { decPrivateModes?: { synchronizedOutput?: boolean } }
}
}
@@ -71,3 +78,70 @@ export function forceRepaintThroughRenderPause(terminal: unknown): boolean {
return false
}
}
function getRenderer(service: MaybePausableRenderService): MaybeWebglRenderer | null {
const holder = service._renderer
if (!holder) {
return null
}
if (typeof (holder as MaybeWebglRenderer).renderRows === 'function') {
return holder as MaybeWebglRenderer
}
const wrapped = (holder as { value?: MaybeWebglRenderer | null }).value
return wrapped ?? null
}
function isSynchronizedOutputHeld(terminal: unknown): boolean {
const core = (terminal as TerminalWithRenderService)._core
return (
(core?.coreService?.decPrivateModes ?? core?._coreService?.decPrivateModes)
?.synchronizedOutput === true
)
}
/**
* One synchronous full-viewport present when xterm would otherwise swallow it:
* IntersectionObserver pause, or DEC 2026 synchronized output.
*
* 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
* terminal.refresh() for the normal path.
*/
export function forceFullViewportPresent(terminal: unknown): boolean {
const service = getRenderService(terminal)
const rows = (terminal as TerminalWithRenderService).rows
if (!service || typeof rows !== 'number' || rows < 1) {
return false
}
const paused = service._isPaused === true
const syncHeld = isSynchronizedOutputHeld(terminal)
if (!paused && !syncHeld) {
return false
}
if (paused) {
service._isPaused = false
service._needsFullRefresh = false
}
const renderer = getRenderer(service)
try {
// Why: a new TUI tab is often still paused (observer lag). Painting
// via renderer.renderRows skips RenderService's dimension clamp and draws
// 1px short of the composer box. Production uses refreshRows here.
// renderer.renderRows is only for DEC 2026, which swallows refreshRows.
if (syncHeld && typeof renderer?.renderRows === 'function') {
renderer.renderRows(0, rows - 1)
return true
}
service.refreshRows(0, rows - 1, true)
return true
} catch {
// Why: same as forceRepaintThroughRenderPause — leave the latch cleared so
// the caller's terminal.refresh() fallback can still paint. Restoring
// _isPaused would swallow that refresh until IntersectionObserver fires.
return false
}
}
@@ -1,6 +1,7 @@
import { writeFile } from 'node:fs/promises'
import type { Page } from '@stablyai/playwright-test'
import { test, expect } from './helpers/orca-app'
import { getActiveTabId, waitForSessionReady } from './helpers/store'
import { getActiveTabId, getActiveWorktreeId, waitForSessionReady } from './helpers/store'
import {
execInTerminal,
waitForActivePanePtyId,
@@ -50,6 +51,11 @@ type RevealRenderDebug = {
type RevealProbeWindow = Window & {
__revealRenderProbe?: RevealRenderDebug
__syncRevealProbe?: {
paintFrame: (marker: string, background: number, release: boolean) => Promise<void>
forceRendererPresent: () => void
read: () => { atlasClears: number; screen: string; synchronizedOutput: boolean }
}
}
/**
@@ -182,6 +188,139 @@ async function forceWebglOn(page: Page, tabId: string): Promise<void> {
}, tabId)
}
async function installSynchronizedRevealProbe(page: Page, tabId: string): Promise<boolean> {
return page.evaluate((tabId) => {
const manager = window.__paneManagers?.get(tabId)
const pane = [
...((manager as unknown as { panes?: Map<number, unknown> } | undefined)?.panes?.values() ??
[])
][0] as
| {
serializeAddon?: { serialize?: () => string }
terminal: unknown
webglAddon?: unknown
}
| undefined
const addon = pane?.webglAddon as { clearTextureAtlas: () => void } | null | undefined
type SyncRenderService = {
_renderer?:
| { renderRows?: (start: number, end: number) => void }
| { value?: { renderRows?: (start: number, end: number) => void } | null }
_syncOutputHandler?: { _timeout?: number }
}
const terminal = pane?.terminal as
| {
cols: number
rows: number
write: (data: string, callback: () => void) => void
_core?: {
coreService?: { decPrivateModes?: { synchronizedOutput?: boolean } }
_coreService?: { decPrivateModes?: { synchronizedOutput?: boolean } }
_renderService?: SyncRenderService
}
}
| undefined
const service = terminal?._core?._renderService
const modes =
terminal?._core?.coreService?.decPrivateModes ??
terminal?._core?._coreService?.decPrivateModes
const rendererHolder = service?._renderer
const renderer =
rendererHolder && 'renderRows' in rendererHolder
? rendererHolder
: rendererHolder && 'value' in rendererHolder
? rendererHolder.value
: null
if (!pane || !addon || !terminal || !service || !modes || !renderer?.renderRows) {
throw new Error(
JSON.stringify({
addon: Boolean(addon),
modes: Boolean(modes),
pane: Boolean(pane),
renderer: Boolean(renderer?.renderRows),
rendererKeys: rendererHolder ? Object.keys(rendererHolder) : [],
service: Boolean(service),
terminal: Boolean(terminal)
})
)
}
let atlasClears = 0
const originalClear = addon.clearTextureAtlas.bind(addon)
addon.clearTextureAtlas = () => {
atlasClears += 1
originalClear()
}
const paintFrame = (marker: string, background: number, release: boolean): Promise<void> => {
const width = Math.max(1, terminal.cols)
const rows = Math.max(1, terminal.rows)
const line = ` ${marker} `.padEnd(width, marker[0] ?? '#').slice(0, width)
const frame = Array.from(
{ 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()
}
)
})
}
;(window as RevealProbeWindow).__syncRevealProbe = {
paintFrame,
forceRendererPresent: () => renderer.renderRows?.(0, terminal.rows - 1),
read: () => ({
atlasClears,
screen: pane.serializeAddon?.serialize?.() ?? '',
synchronizedOutput: modes.synchronizedOutput === true
})
}
return true
}, tabId)
}
async function callSynchronizedRevealProbe(
page: Page,
action: 'forceRendererPresent' | 'paintFrame',
frame?: { marker: string; background: number; release: boolean }
): Promise<void> {
await page.evaluate(
async ({ action, frame }) => {
const probe = (window as RevealProbeWindow).__syncRevealProbe
if (!probe) {
throw new Error('Synchronized reveal probe not installed')
}
if (action === 'paintFrame') {
if (!frame) {
throw new Error('Synchronized frame missing')
}
await probe.paintFrame(frame.marker, frame.background, frame.release)
return
}
probe.forceRendererPresent()
},
{ action, frame }
)
}
async function readSynchronizedRevealProbe(page: Page) {
return page.evaluate(() => {
const probe = (window as RevealProbeWindow).__syncRevealProbe
if (!probe) {
throw new Error('Synchronized reveal probe not installed')
}
return probe.read()
})
}
test.describe('terminal reveal paused-render recovery', () => {
test("reveal repaint forces a render through xterm's paused gate", async ({ orcaPage }) => {
// Why: __store / __paneManagers live on the main Orca renderer window
@@ -265,4 +404,95 @@ test.describe('terminal reveal paused-render recovery', () => {
`recovered surface matches the revealed content (diffRatio=${diff.diffRatio})`
).toBe(true)
})
test('@headful tab reveal presents a synchronized-output WebGL frame', async ({
orcaPage
}, testInfo) => {
await waitForSessionReady(orcaPage)
await waitForActiveTerminalManager(orcaPage)
const tabId = (await getActiveTabId(orcaPage))!
const worktreeId = (await getActiveWorktreeId(orcaPage))!
await forceWebglOn(orcaPage, tabId)
const webglAttached = await orcaPage
.waitForFunction(
(tabId) =>
(window.__paneManagers?.get(tabId)?.getRenderingDiagnostics?.() ?? []).some(
(diagnostic) => diagnostic.hasWebgl
),
tabId,
{ timeout: 15_000 }
)
.then(() => true)
.catch(() => false)
test.skip(!webglAttached, 'WebGL renderer is unavailable')
if (!webglAttached) {
return
}
const installed = await installSynchronizedRevealProbe(orcaPage, tabId)
expect(installed, 'WebGL renderer internals are available').toBe(true)
await callSynchronizedRevealProbe(orcaPage, 'paintFrame', {
marker: 'BASELINE_FRAME',
background: 17,
release: true
})
const siblingTabId = await orcaPage.evaluate((worktreeId) => {
const state = window.__store?.getState()
if (!state) {
throw new Error('Renderer store unavailable')
}
return state.createTab(worktreeId, undefined, undefined, { activate: false }).id
}, worktreeId)
await orcaPage.evaluate(
(siblingTabId) => window.__store?.getState().setActiveTab(siblingTabId),
siblingTabId
)
await expect(orcaPage.locator(`[data-terminal-tab-id="${tabId}"]`)).toBeHidden()
await callSynchronizedRevealProbe(orcaPage, 'paintFrame', {
marker: 'REVEALED_FRAME',
background: 52,
release: false
})
const held = await readSynchronizedRevealProbe(orcaPage)
expect(held.synchronizedOutput).toBe(true)
expect(held.screen).toContain('REVEALED_FRAME')
await orcaPage.evaluate((tabId) => window.__store?.getState().setActiveTab(tabId), tabId)
await expect.poll(() => getActiveTabId(orcaPage)).toBe(tabId)
await expect
.poll(async () => (await readSynchronizedRevealProbe(orcaPage)).atlasClears)
.toBeGreaterThan(0)
const afterReveal = await captureStableTabScreenshot(orcaPage, tabId)
await callSynchronizedRevealProbe(orcaPage, 'forceRendererPresent')
const afterForcedPresent = await captureStableTabScreenshot(orcaPage, tabId)
const diff = compareTerminalScreenshots(afterReveal, afterForcedPresent)
const afterRevealPath = testInfo.outputPath('synchronized-frame-after-reveal.png')
const afterForcedPresentPath = testInfo.outputPath(
'synchronized-frame-after-forced-present.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))
])
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'
})
expect(
diff.matches,
`revealed pixels already match a forced buffer present (diffRatio=${diff.diffRatio})`
).toBe(true)
})
})