fix(terminal): bound reveal recovery work

This commit is contained in:
Neil
2026-08-23 14:16:18 -07:00
parent 0b48a3fde9
commit 23d7013eb3
8 changed files with 43 additions and 4 deletions
+1 -1
View File
@@ -12350,7 +12350,7 @@
},
"performanceBudget": {
"required": true,
"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."
"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."
},
"promotionCriteria": [
"Establish a stable material-diff threshold across platforms via nightly runs.",
@@ -204,6 +204,7 @@ describe('useTerminalWindowWakeRecovery', () => {
const pane = { id: 1, terminal: {} }
const resizeManager = { getPanes: () => [pane] } as unknown as PaneManager
repairPaneWebglCanvasDprMismatchMock.mockReturnValue(true)
vi.stubGlobal('devicePixelRatio', 1)
const { unmount } = renderHook(() =>
useTerminalWindowWakeRecovery({
isVisible: true,
@@ -213,6 +214,10 @@ describe('useTerminalWindowWakeRecovery', () => {
})
)
window.dispatchEvent(new Event('resize'))
expect(repairPaneWebglCanvasDprMismatchMock).not.toHaveBeenCalled()
vi.stubGlobal('devicePixelRatio', 2)
window.dispatchEvent(new Event('resize'))
expect(repairPaneWebglCanvasDprMismatchMock).toHaveBeenCalledWith(pane)
@@ -220,6 +225,7 @@ describe('useTerminalWindowWakeRecovery', () => {
expect(recoverVisibleTerminalWindowWakeMock).not.toHaveBeenCalled()
unmount()
vi.stubGlobal('devicePixelRatio', 3)
window.dispatchEvent(new Event('resize'))
expect(repairPaneWebglCanvasDprMismatchMock).toHaveBeenCalledTimes(1)
})
@@ -31,6 +31,7 @@ export function useTerminalWindowWakeRecovery({
}
let wakeRecoveryFrameId: number | null = null
let settledClearGlyphAtlases = false
let observedDevicePixelRatio = window.devicePixelRatio
const cancelScheduledWakeRecovery = (): void => {
if (wakeRecoveryFrameId === null || typeof cancelAnimationFrame !== 'function') {
wakeRecoveryFrameId = null
@@ -117,6 +118,11 @@ export function useTerminalWindowWakeRecovery({
// 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 devicePixelRatio = window.devicePixelRatio
if (devicePixelRatio === observedDevicePixelRatio) {
return
}
observedDevicePixelRatio = devicePixelRatio
const manager = managerRef.current
if (!manager || !isVisibleRef.current) {
return
@@ -254,15 +254,17 @@ describe('terminal WebGL addon lifecycle', () => {
const { pane, renderService } = createPausedPane('none')
presentPaneViewport(pane)
presentPaneViewport(pane)
expect(queued).toHaveLength(1)
let callbacks = 0
while (queued.length > 0) {
queued.shift()?.(callbacks * 16)
callbacks += 1
}
expect(callbacks).toBe(17)
expect(callbacks).toBe(16)
expect(renderService._isPaused).toBe(true)
expect(renderService.refreshRows).toHaveBeenCalledTimes(1)
expect(renderService.refreshRows).toHaveBeenCalledTimes(2)
})
it('still forces the paused render through for a pane that has a layout box', () => {
@@ -170,6 +170,10 @@ function schedulePresentWhenDisplayed(pane: ManagedPaneInternal): void {
return
}
if (isManagedPaneDisplayNone(pane)) {
if (remaining === 1) {
pendingDisplayedPresentRetries.delete(pane)
return
}
pendingDisplayedPresentRetries.set(pane, remaining - 1)
globalThis.requestAnimationFrame(tick)
return
@@ -6,6 +6,7 @@ function makePane(args: {
backingWidth: number
expectedWidth: number
dpr: number
cachedDpr?: number
backingHeight?: number
expectedHeight?: number
connected?: boolean
@@ -32,6 +33,7 @@ function makePane(args: {
(args.hasRenderer ?? true)
? {
_canvas: canvas,
_devicePixelRatio: args.cachedDpr ?? args.dpr,
dimensions: {
device: {
canvas: { width: args.expectedWidth, height: args.expectedHeight ?? 1200 }
@@ -79,6 +81,19 @@ describe('repairPaneWebglCanvasDprMismatch', () => {
expect(handleResize).toHaveBeenCalledTimes(1)
})
it('repairs when the canvas and renderer dimensions share a stale dpr cache', () => {
const { pane, handleDevicePixelRatioChange, handleResize } = makePane({
backingWidth: 1080,
expectedWidth: 1080,
dpr: 2,
cachedDpr: 1
})
expect(repairPaneWebglCanvasDprMismatch(pane)).toBe(true)
expect(handleDevicePixelRatioChange).toHaveBeenCalledTimes(1)
expect(handleResize).toHaveBeenCalledWith(120, 40)
})
it('is a no-op when backing matches the renderer device dimensions', () => {
const { pane, handleResize, refresh } = makePane({
backingWidth: 2160,
@@ -14,6 +14,7 @@ import { recordTerminalWebglDiagnostic } from '../../../../shared/terminal-webgl
*/
type XtermRendererInternals = {
_canvas?: HTMLCanvasElement
_devicePixelRatio?: number
_gl?: { canvas?: HTMLCanvasElement }
dimensions?: {
device?: { canvas?: { width?: number; height?: number } }
@@ -41,10 +42,14 @@ export function repairPaneWebglCanvasDprMismatch(pane: ManagedPane): boolean {
}
const staleBackingWidth = canvas.width
const staleBackingHeight = canvas.height
const cachedDevicePixelRatio = renderer._devicePixelRatio
const devicePixelRatioChanged =
typeof cachedDevicePixelRatio === 'number' && cachedDevicePixelRatio !== view.devicePixelRatio
// xterm rounds its CSS canvas size before ResizeObserver converts it back to
// device pixels; allow that round trip without forcing layout on every fit.
const roundingTolerance = Math.max(1, Math.ceil(view.devicePixelRatio / 2))
if (
!devicePixelRatioChanged &&
Math.abs(staleBackingWidth - expectedWidth) <= roundingTolerance &&
Math.abs(staleBackingHeight - expectedHeight) <= roundingTolerance
) {
@@ -64,6 +69,7 @@ export function repairPaneWebglCanvasDprMismatch(pane: ManagedPane): boolean {
paneId: pane.id,
staleBackingWidth,
expectedBackingWidth: expectedWidth,
...(cachedDevicePixelRatio === undefined ? {} : { cachedDevicePixelRatio }),
devicePixelRatio: view.devicePixelRatio
})
return true
@@ -20,7 +20,7 @@ import { compareTerminalScreenshots } from './terminal-screenshot-diff'
* is then swallowed and the freshly-cleared render model never repaints.
*
* This spec drives the REAL production reveal path (manager.resetWebglTextureAtlases
* -> resetWebglTextureAtlas -> forceRepaintThroughRenderPause) against a real
* -> resetWebglTextureAtlas -> forceFullViewportPresent) against a real
* xterm Terminal + RenderService. It:
* 1. proves the bug: while paused, a plain refresh() renders nothing;
* 2. proves the fix: the real reveal repaint forces a full-viewport render