From 23d7013eb3fa12a7da9081c88ead680d68f60310 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 23 Aug 2026 03:18:41 -0700 Subject: [PATCH] fix(terminal): bound reveal recovery work --- config/reliability-gates.jsonc | 2 +- .../use-terminal-window-wake-recovery.test.ts | 6 ++++++ .../use-terminal-window-wake-recovery.ts | 6 ++++++ .../lib/pane-manager/pane-webgl-renderer.test.ts | 6 ++++-- .../src/lib/pane-manager/pane-webgl-renderer.ts | 4 ++++ .../terminal-canvas-dpr-repair.test.ts | 15 +++++++++++++++ .../pane-manager/terminal-canvas-dpr-repair.ts | 6 ++++++ .../terminal-reveal-paused-render-repro.spec.ts | 2 +- 8 files changed, 43 insertions(+), 4 deletions(-) diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 9a94d74c2cf..321339f1786 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -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.", diff --git a/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts b/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts index bed8aa76c8f..f7dce9897d2 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts @@ -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) }) diff --git a/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.ts b/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.ts index 1afc70377a2..064b651326e 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.ts @@ -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 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 c3392d6a81a..1b50a176169 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 @@ -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', () => { 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 c3b13c186af..497aca16382 100644 --- a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts +++ b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts @@ -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 diff --git a/src/renderer/src/lib/pane-manager/terminal-canvas-dpr-repair.test.ts b/src/renderer/src/lib/pane-manager/terminal-canvas-dpr-repair.test.ts index 188b8441104..5c5dc972a12 100644 --- a/src/renderer/src/lib/pane-manager/terminal-canvas-dpr-repair.test.ts +++ b/src/renderer/src/lib/pane-manager/terminal-canvas-dpr-repair.test.ts @@ -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, 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 72bde40b30b..4b60b80f497 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 @@ -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 diff --git a/tests/e2e/terminal-reveal-paused-render-repro.spec.ts b/tests/e2e/terminal-reveal-paused-render-repro.spec.ts index 202c6c3a4b3..4a9fa17e974 100644 --- a/tests/e2e/terminal-reveal-paused-render-repro.spec.ts +++ b/tests/e2e/terminal-reveal-paused-render-repro.spec.ts @@ -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