fix(terminal): retry deferred DPR repair

This commit is contained in:
Neil
2026-08-23 14:16:19 -07:00
parent 46f608b64b
commit c1f8479f56
7 changed files with 224 additions and 33 deletions
+45 -6
View File
@@ -12307,10 +12307,40 @@
"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": [
"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",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts src/renderer/src/lib/pane-manager/terminal-canvas-dpr-repair.test.ts src/renderer/src/lib/pane-manager/terminal-render-pause-release.test.ts",
"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"],
"testFiles": [
"src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts",
"src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts",
"src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts",
"src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts",
"src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts",
"src/renderer/src/lib/pane-manager/terminal-canvas-dpr-repair.test.ts",
"src/renderer/src/lib/pane-manager/terminal-render-pause-release.test.ts",
"tests/e2e/terminal-reveal-paused-render-repro.spec.ts"
],
"assertionRefs": [
{
"file": "src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts",
"assertions": [
"deduplicates a deferred dpr retry and consumes it after repair succeeds",
"caps a deferred dpr transition at sixteen animation-frame retries"
]
},
{
"file": "src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts",
"assertions": [
"checks dpr after a heavy reveal even when the stable fit skips geometry work"
]
},
{
"file": "src/renderer/src/lib/pane-manager/terminal-canvas-dpr-repair.test.ts",
"assertions": [
"repairs when the canvas and renderer dimensions share a stale dpr cache",
"defers an unmeasurable WebGL canvas but accepts a renderer-less pane"
]
},
{
"file": "tests/e2e/terminal-reveal-paused-render-repro.spec.ts",
"assertions": ["@headful tab reveal presents a synchronized-output WebGL frame"]
@@ -12318,13 +12348,22 @@
],
"evidenceRuns": [
{
"date": "2026-08-22",
"date": "2026-08-23",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts src/renderer/src/lib/pane-manager/terminal-canvas-dpr-repair.test.ts src/renderer/src/lib/pane-manager/terminal-render-pause-release.test.ts",
"result": "passed",
"durationSeconds": 2,
"summary": "All 98 renderer contracts passed, including same-DPR zero-scan behavior, visible-only fanout, tri-state DPR repair, deduplicated bounded retry, heavy-reveal repair, and listener cleanup."
},
{
"date": "2026-08-23",
"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."
"durationSeconds": 15,
"summary": "The final branch state passed: 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",
@@ -12342,7 +12381,7 @@
},
"flakeHistory": {
"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."
"evidence": "Five local macOS headed runs passed, including one on the final branch state and three consecutive video-recorded repeats; CI soak history and cross-platform GPU runs are not yet available."
},
"redGreenEvidence": {
"status": "complete",
@@ -12350,7 +12389,7 @@
},
"performanceBudget": {
"required": true,
"evidence": "The tab-reveal present path runs only at event-driven recovery boundaries and adds no polling, provider calls, PTY scans, or background output work. The shared terminal-render.atlas-recovery-fanout contracts separately prove zero hidden-manager atlas clears or presents and a per-pane display:none retry deduped and capped at 16 animation frames."
"evidence": "The tab-reveal present path runs only at event-driven recovery boundaries and adds no polling, provider calls, PTY inventory scans, or background output work. Gate-local contracts prove ordinary same-DPR resize performs zero pane scans; a true transition scans only the visible manager; unavailable canvas retry is deduplicated and capped at 16 frames; and heavy reveal adds one bounded visible-pane scan. The shared terminal-render.atlas-recovery-fanout contracts separately prove zero hidden-manager atlas clears or presents and a per-pane display:none retry capped at 16 frames."
},
"promotionCriteria": [
"Establish a stable material-diff threshold across platforms via nightly runs.",
@@ -148,6 +148,16 @@ describe('resumeTerminalVisibility reveal repaint', () => {
expect(flushDeferredPaneMetricOptionsIfMeasurable).not.toHaveBeenCalled()
})
it('checks dpr after a heavy reveal even when the stable fit skips geometry work', () => {
const pane = { terminal: {} }
const manager = createManager()
manager.getPanes.mockReturnValue([pane])
resumeTerminalVisibility(resumeArgs(manager, false))
expect(repairPaneWebglCanvasDprMismatch).toHaveBeenCalledWith(pane)
})
it('does not fit on a light tab reveal', () => {
const manager = createManager()
resumeTerminalVisibility(resumeArgs(manager, true))
@@ -226,6 +226,13 @@ function resumeTerminalVisibilityHeavy(manager: PaneManager, isActive: boolean):
// Windows (ANGLE -> D3D11) it can be 100-500 ms but a deferred resume
// would paint a stretched DOM-fallback flash, which is worse UX.
manager.resumeRendering()
// Why: unchanged grid geometry can skip the reveal fit, but a retained WebGL
// canvas may still carry the previous display's backing-store DPR.
for (const pane of manager.getPanes()) {
if (repairPaneWebglCanvasDprMismatch(pane)) {
presentPaneViewport(pane)
}
}
// Why: resumeRendering just re-attached WebGL, whose cell metrics briefly differ
// from the DOM renderer's; a raw fit here reflows on a transient one-column-off
// grid and garbles diff-painting inline TUIs (grok minimize→restore).
@@ -2,14 +2,15 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { renderHook } from '@testing-library/react'
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
import type { PaneWebglCanvasDprRepairState } from '@/lib/pane-manager/terminal-canvas-dpr-repair'
const {
recoverVisibleTerminalWindowWakeMock,
repairPaneWebglCanvasDprMismatchMock,
repairPaneWebglCanvasDprMock,
presentPaneViewportMock
} = vi.hoisted(() => ({
recoverVisibleTerminalWindowWakeMock: vi.fn(),
repairPaneWebglCanvasDprMismatchMock: vi.fn(() => false),
repairPaneWebglCanvasDprMock: vi.fn<() => PaneWebglCanvasDprRepairState>(() => 'current'),
presentPaneViewportMock: vi.fn()
}))
@@ -18,7 +19,7 @@ vi.mock('./terminal-visibility-resume', () => ({
}))
vi.mock('@/lib/pane-manager/terminal-canvas-dpr-repair', () => ({
repairPaneWebglCanvasDprMismatch: repairPaneWebglCanvasDprMismatchMock
repairPaneWebglCanvasDpr: repairPaneWebglCanvasDprMock
}))
vi.mock('@/lib/pane-manager/pane-webgl-renderer', () => ({
@@ -43,9 +44,9 @@ describe('useTerminalWindowWakeRecovery', () => {
beforeEach(() => {
systemResumedCallback = null
recoverVisibleTerminalWindowWakeMock.mockClear()
repairPaneWebglCanvasDprMismatchMock.mockClear()
repairPaneWebglCanvasDprMock.mockClear()
presentPaneViewportMock.mockClear()
repairPaneWebglCanvasDprMismatchMock.mockReturnValue(false)
repairPaneWebglCanvasDprMock.mockReturnValue('current')
unsubscribeSystemResumed.mockClear()
onSystemResumed.mockClear()
resetTerminalFreezeBreadcrumbsForTesting()
@@ -203,7 +204,7 @@ describe('useTerminalWindowWakeRecovery', () => {
// move) even if the CSS box is unchanged.
const pane = { id: 1, terminal: {} }
const resizeManager = { getPanes: () => [pane] } as unknown as PaneManager
repairPaneWebglCanvasDprMismatchMock.mockReturnValue(true)
repairPaneWebglCanvasDprMock.mockReturnValue('repaired')
vi.stubGlobal('devicePixelRatio', 1)
const { unmount } = renderHook(() =>
useTerminalWindowWakeRecovery({
@@ -215,19 +216,19 @@ describe('useTerminalWindowWakeRecovery', () => {
)
window.dispatchEvent(new Event('resize'))
expect(repairPaneWebglCanvasDprMismatchMock).not.toHaveBeenCalled()
expect(repairPaneWebglCanvasDprMock).not.toHaveBeenCalled()
vi.stubGlobal('devicePixelRatio', 2)
window.dispatchEvent(new Event('resize'))
expect(repairPaneWebglCanvasDprMismatchMock).toHaveBeenCalledWith(pane)
expect(repairPaneWebglCanvasDprMock).toHaveBeenCalledWith(pane)
expect(presentPaneViewportMock).toHaveBeenCalledWith(pane)
expect(recoverVisibleTerminalWindowWakeMock).not.toHaveBeenCalled()
unmount()
vi.stubGlobal('devicePixelRatio', 3)
window.dispatchEvent(new Event('resize'))
expect(repairPaneWebglCanvasDprMismatchMock).toHaveBeenCalledTimes(1)
expect(repairPaneWebglCanvasDprMock).toHaveBeenCalledTimes(1)
})
it('retains a dpr transition until the visible manager is available', () => {
@@ -245,10 +246,70 @@ describe('useTerminalWindowWakeRecovery', () => {
vi.stubGlobal('devicePixelRatio', 2)
window.dispatchEvent(new Event('resize'))
expect(repairPaneWebglCanvasDprMismatchMock).not.toHaveBeenCalled()
expect(repairPaneWebglCanvasDprMock).not.toHaveBeenCalled()
managerRef.current = { getPanes: () => [pane] } as unknown as PaneManager
window.dispatchEvent(new Event('resize'))
expect(repairPaneWebglCanvasDprMismatchMock).toHaveBeenCalledWith(pane)
expect(repairPaneWebglCanvasDprMock).toHaveBeenCalledWith(pane)
})
it('deduplicates a deferred dpr retry and consumes it after repair succeeds', () => {
const pane = { id: 1, terminal: {} }
const callbacks: FrameRequestCallback[] = []
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
callbacks.push(callback)
return callbacks.length
})
vi.stubGlobal('devicePixelRatio', 1)
repairPaneWebglCanvasDprMock.mockReturnValueOnce('deferred').mockReturnValue('repaired')
renderHook(() =>
useTerminalWindowWakeRecovery({
isVisible: true,
managerRef: { current: { getPanes: () => [pane] } as unknown as PaneManager },
isActiveRef: { current: true },
isVisibleRef: { current: true }
})
)
vi.stubGlobal('devicePixelRatio', 2)
window.dispatchEvent(new Event('resize'))
window.dispatchEvent(new Event('resize'))
expect(repairPaneWebglCanvasDprMock).toHaveBeenCalledTimes(1)
expect(callbacks).toHaveLength(1)
callbacks.shift()?.(performance.now())
expect(presentPaneViewportMock).toHaveBeenCalledWith(pane)
window.dispatchEvent(new Event('resize'))
expect(repairPaneWebglCanvasDprMock).toHaveBeenCalledTimes(2)
})
it('caps a deferred dpr transition at sixteen animation-frame retries', () => {
const pane = { id: 1, terminal: {} }
const callbacks: FrameRequestCallback[] = []
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
callbacks.push(callback)
return callbacks.length
})
vi.stubGlobal('devicePixelRatio', 1)
repairPaneWebglCanvasDprMock.mockReturnValue('deferred')
renderHook(() =>
useTerminalWindowWakeRecovery({
isVisible: true,
managerRef: { current: { getPanes: () => [pane] } as unknown as PaneManager },
isActiveRef: { current: true },
isVisibleRef: { current: true }
})
)
vi.stubGlobal('devicePixelRatio', 2)
window.dispatchEvent(new Event('resize'))
let callbackCount = 0
while (callbacks.length > 0) {
callbacks.shift()?.(performance.now())
callbackCount += 1
}
expect(callbackCount).toBe(16)
expect(repairPaneWebglCanvasDprMock).toHaveBeenCalledTimes(17)
})
})
@@ -1,7 +1,7 @@
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 { repairPaneWebglCanvasDpr } 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'
@@ -18,6 +18,8 @@ type WindowWakePtyBinding = IDisposable & {
reassertPtySizeAfterWindowWake?: () => void
}
const DPR_RECOVERY_RETRY_FRAMES = 16
export function useTerminalWindowWakeRecovery({
isVisible,
managerRef,
@@ -30,6 +32,8 @@ export function useTerminalWindowWakeRecovery({
return
}
let wakeRecoveryFrameId: number | null = null
let dprRecoveryFrameId: number | null = null
let dprRecoveryFramesRemaining = 0
let settledClearGlyphAtlases = false
let observedDevicePixelRatio = window.devicePixelRatio
const cancelScheduledWakeRecovery = (): void => {
@@ -40,6 +44,13 @@ export function useTerminalWindowWakeRecovery({
cancelAnimationFrame(wakeRecoveryFrameId)
wakeRecoveryFrameId = null
}
const cancelScheduledDprRecovery = (): void => {
if (dprRecoveryFrameId !== null && typeof cancelAnimationFrame === 'function') {
cancelAnimationFrame(dprRecoveryFrameId)
}
dprRecoveryFrameId = null
dprRecoveryFramesRemaining = 0
}
const reassertPanePtySizes = (): void => {
for (const binding of panePtyBindingsRef?.current.values() ?? []) {
// Why: one settled read avoids duplicate SSH RPCs while still detecting a dropped resize.
@@ -114,6 +125,42 @@ export function useTerminalWindowWakeRecovery({
recoverVisibleWake(true, 'system-resumed')
}
}
const repairVisiblePanesForDpr = (devicePixelRatio: number): boolean => {
const manager = managerRef.current
if (!manager || !isVisibleRef.current) {
return false
}
let deferred = false
for (const pane of manager.getPanes?.() ?? []) {
const state = repairPaneWebglCanvasDpr(pane)
deferred ||= state === 'deferred'
if (state === 'repaired') {
presentPaneViewport(pane)
}
}
if (!deferred) {
observedDevicePixelRatio = devicePixelRatio
}
return !deferred
}
const scheduleDprRecovery = (): void => {
if (dprRecoveryFrameId !== null || typeof requestAnimationFrame !== 'function') {
return
}
dprRecoveryFramesRemaining = DPR_RECOVERY_RETRY_FRAMES
const retry = (): void => {
dprRecoveryFrameId = null
if (repairVisiblePanesForDpr(window.devicePixelRatio)) {
dprRecoveryFramesRemaining = 0
return
}
dprRecoveryFramesRemaining -= 1
if (dprRecoveryFramesRemaining > 0) {
dprRecoveryFrameId = requestAnimationFrame(retry)
}
}
dprRecoveryFrameId = requestAnimationFrame(retry)
}
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
@@ -122,15 +169,11 @@ export function useTerminalWindowWakeRecovery({
if (devicePixelRatio === observedDevicePixelRatio) {
return
}
const manager = managerRef.current
if (!manager || !isVisibleRef.current) {
if (dprRecoveryFrameId !== null) {
return
}
observedDevicePixelRatio = devicePixelRatio
for (const pane of manager.getPanes?.() ?? []) {
if (repairPaneWebglCanvasDprMismatch(pane)) {
presentPaneViewport(pane)
}
if (!repairVisiblePanesForDpr(devicePixelRatio)) {
scheduleDprRecovery()
}
}
window.addEventListener('focus', onFocus)
@@ -148,6 +191,7 @@ export function useTerminalWindowWakeRecovery({
: null
return () => {
cancelScheduledWakeRecovery()
cancelScheduledDprRecovery()
window.removeEventListener('focus', onFocus)
window.removeEventListener('resize', onWindowResize)
if (typeof document !== 'undefined' && typeof document.removeEventListener === 'function') {
@@ -1,6 +1,9 @@
import { describe, expect, it, vi } from 'vitest'
import type { ManagedPane } from './pane-manager-types'
import { repairPaneWebglCanvasDprMismatch } from './terminal-canvas-dpr-repair'
import {
repairPaneWebglCanvasDpr,
repairPaneWebglCanvasDprMismatch
} from './terminal-canvas-dpr-repair'
function makePane(args: {
backingWidth: number
@@ -151,6 +154,24 @@ describe('repairPaneWebglCanvasDprMismatch', () => {
expect(repairPaneWebglCanvasDprMismatch(noRenderer.pane)).toBe(false)
})
it('defers an unmeasurable WebGL canvas but accepts a renderer-less pane', () => {
const detached = makePane({
backingWidth: 2160,
expectedWidth: 1080,
dpr: 1,
connected: false
})
const noRenderer = makePane({
backingWidth: 2160,
expectedWidth: 1080,
dpr: 1,
hasRenderer: false
})
expect(repairPaneWebglCanvasDpr(detached.pane)).toBe('deferred')
expect(repairPaneWebglCanvasDpr(noRenderer.pane)).toBe('current')
})
it('reports failure without throwing when the repair path throws mid-teardown', () => {
const { pane, handleResize } = makePane({
backingWidth: 2160,
@@ -23,22 +23,27 @@ type XtermRendererInternals = {
handleResize?: (cols: number, rows: number) => void
}
export function repairPaneWebglCanvasDprMismatch(pane: ManagedPane): boolean {
export type PaneWebglCanvasDprRepairState = 'current' | 'deferred' | 'repaired'
export function repairPaneWebglCanvasDpr(pane: ManagedPane): PaneWebglCanvasDprRepairState {
const renderer = (
pane.terminal as unknown as {
_core?: { _renderService?: { _renderer?: { value?: XtermRendererInternals } } }
}
)._core?._renderService?._renderer?.value
const canvas = renderer?._canvas ?? renderer?._gl?.canvas
if (!renderer || !canvas?.isConnected) {
return false
if (!renderer || !canvas) {
return 'current'
}
if (!canvas.isConnected) {
return 'deferred'
}
const view = canvas.ownerDocument?.defaultView
const expected = renderer.dimensions?.device?.canvas
const expectedWidth = expected?.width ?? 0
const expectedHeight = expected?.height ?? 0
if (!view || expectedWidth <= 0 || expectedHeight <= 0) {
return false
return 'deferred'
}
const staleBackingWidth = canvas.width
const staleBackingHeight = canvas.height
@@ -53,7 +58,7 @@ export function repairPaneWebglCanvasDprMismatch(pane: ManagedPane): boolean {
Math.abs(staleBackingWidth - expectedWidth) <= roundingTolerance &&
Math.abs(staleBackingHeight - expectedHeight) <= roundingTolerance
) {
return false
return 'current'
}
try {
// Order matters: refresh the renderer's cached dpr/dimensions first, then
@@ -63,7 +68,7 @@ export function repairPaneWebglCanvasDprMismatch(pane: ManagedPane): boolean {
pane.terminal.refresh(0, pane.terminal.rows - 1)
} catch {
// Pane may be mid-teardown; the next reveal/fit retries the check.
return false
return 'deferred'
}
recordTerminalWebglDiagnostic('webgl-canvas-dpr-repair', {
paneId: pane.id,
@@ -72,5 +77,9 @@ export function repairPaneWebglCanvasDprMismatch(pane: ManagedPane): boolean {
...(cachedDevicePixelRatio === undefined ? {} : { cachedDevicePixelRatio }),
devicePixelRatio: view.devicePixelRatio
})
return true
return 'repaired'
}
export function repairPaneWebglCanvasDprMismatch(pane: ManagedPane): boolean {
return repairPaneWebglCanvasDpr(pane) === 'repaired'
}