From a6fdeda54166412c826a9bdeee78d63fa72abc5b Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Wed, 28 Oct 2026 06:59:27 -0700 Subject: [PATCH] perf(terminal): release hidden WebGL canvases' drawing buffers (#34309) * perf(terminal): release hidden WebGL canvases' drawing buffers Chromium keeps a display:none WebGL canvas's drawing buffers alive (on macOS a back buffer plus up to four pooled full-size surfaces). Hidden terminal tabs and retained worktrees therefore held ~80 MB of GPU memory each until they were parked. Collapse the canvas backing store while it has no layout box and keep the context; xterm's device-pixel observer restores the size and redraws synchronously on reveal. 7 terminal tabs, 1920x1050@2x: GPU process 670-760 MB -> 350-408 MB. * perf(terminal): free hidden canvases only after 30s and never for suspended worktrees Reallocating the backing on reveal cost about one frame. Delaying the release keeps rapid switching allocation-free, and worktree-suspended panes keep their backing so worktree switches match main. A MutationObserver re-checks a canvas that xterm resizes while hidden. * fix(terminal): restore released WebGL canvases before the reveal frame paints - Reveal restore reallocates at the current DPR and draws past xterm's paused renderer inside the ResizeObserver callback, so the first painted frame has content; also covers engines without devicePixelContentBoxSize. - DPR repair reports released canvases as 'released' (current) instead of 'deferred', so window-resize recovery stops retrying them. - Veto release for panes of a hidden worktree surface (marked from isWorktreeActive) instead of webglAttachmentDeferred, which a hidden prewarm clears. - Schedule release for canvases attached or regrown while already boxless; a zero-size element gets no initial ResizeObserver entry. - Clear the timer through the canvas's own window. * test(terminal): guard hidden WebGL canvas release and reveal restore * perf(terminal): restore a released WebGL canvas at its exact device size without a full resize Set the backing to the reveal entry's device-pixel size (xterm's own fallback size where that box is unavailable) and draw once, instead of handleResize: xterm's device-pixel observer then finds nothing to resize, which removes a second reallocation and redraw from the reveal. A DPR change while hidden still takes the full resize path. * fix(terminal): paint a revealed released canvas through a held synchronized-output frame A released canvas has no last coherent frame to keep, so when a TUI holds DEC 2026 at reveal, the buffered refresh left it blank for up to xterm's 1s watchdog. Use forceFullViewportPresent, which draws the current buffer once directly; the TUI's sync end still presents its finished frame normally. --------- Co-authored-by: m4air --- .../use-terminal-pane-global-effects.ts | 3 + .../lib/pane-manager/pane-webgl-renderer.ts | 35 +++++- .../terminal-canvas-dpr-repair.ts | 51 ++++++++- .../terminal-render-pause-release.ts | 2 +- ...rminal-webgl-hidden-canvas-release.test.ts | 72 ++++++++++++ .../terminal-webgl-hidden-canvas-release.ts | 104 ++++++++++++++++++ 6 files changed, 262 insertions(+), 5 deletions(-) create mode 100644 src/renderer/src/lib/pane-manager/terminal-webgl-hidden-canvas-release.test.ts create mode 100644 src/renderer/src/lib/pane-manager/terminal-webgl-hidden-canvas-release.ts diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts index 776343f2b11..7246fecc3ea 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts @@ -131,6 +131,8 @@ export function useTerminalPaneGlobalEffects({ return } manager.setAtlasRecoveryVisible?.(rendererVisible) + // Why: hidden-worktree panes keep their WebGL backing; tab-hidden ones may release it (pane-webgl-renderer). + containerRef.current?.toggleAttribute('data-worktree-surface-hidden', !isWorktreeActive) const wasVisible = wasVisibleRef.current const wasWorktreeActive = wasWorktreeActiveRef.current isActiveRef.current = isActive @@ -179,6 +181,7 @@ export function useTerminalPaneGlobalEffects({ rendererVisible, applyPendingFollowOutputRequests, captureViewportPositions, + containerRef, isActiveRef, isVisibleRef, managerRef, 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 6d87594301d..f67c20289bb 100644 --- a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts +++ b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts @@ -8,9 +8,16 @@ import { } from './terminal-webgl-auto-policy' import { safeFit, safeFitAndThen } from './pane-fit' import { setPaneFitWebglAttachHook } from './pane-fit-webgl-attach-signal' -import { repairPaneWebglCanvasDprMismatch } from './terminal-canvas-dpr-repair' +import { + repairPaneWebglCanvasDprMismatch, + restoreReleasedPaneWebglCanvas +} from './terminal-canvas-dpr-repair' import { recordPaneWebglContextLoss } from './pane-webgl-context-loss-policy' import { presentPaneViewport } from './pane-viewport-present' +import { + releaseWebglCanvasBackingWhileHidden, + stopReleasingWebglCanvasBacking +} from './terminal-webgl-hidden-canvas-release' export { presentPaneViewport } from './pane-viewport-present' import { @@ -132,10 +139,27 @@ export function disposeWebgl( } } +function getXtermWebglCanvas(webglAddon: WebglAddon): HTMLCanvasElement | undefined { + if (!('_renderer' in webglAddon)) { + return undefined + } + const renderer = webglAddon._renderer + if (typeof renderer !== 'object' || renderer === null || !('_canvas' in renderer)) { + return undefined + } + const canvas = renderer._canvas + return typeof HTMLCanvasElement !== 'undefined' && canvas instanceof HTMLCanvasElement + ? canvas + : undefined +} + function releaseXtermWebglContext(webglAddon: ManagedPaneInternal['webglAddon']): void { try { // Windows/ANGLE may retain driver contexts after canvas removal; explicitly lose them to avoid Chromium’s active-context budget on rapid activation (#6874). const renderer = (webglAddon as unknown as XtermWebglAddonInternals | null)?._renderer + if (renderer?._canvas) { + stopReleasingWebglCanvasBacking(renderer._canvas) + } renderer?._gl?.getExtension('WEBGL_lose_context')?.loseContext() if (renderer?._canvas) { renderer._canvas.width = 0 @@ -251,6 +275,15 @@ export function attachWebgl(pane: ManagedPaneInternal): void { }) pane.terminal.loadAddon(addon) pane.webglAddon = addon + const canvas = getXtermWebglCanvas(addon) + if (canvas) { + // Why: panes of a hidden worktree surface keep their backing so switching worktrees never pays a reallocation. + releaseWebglCanvasBackingWhileHidden( + canvas, + () => pane.container.closest('[data-worktree-surface-hidden]') === null, + (devicePixelSize) => restoreReleasedPaneWebglCanvas(pane, devicePixelSize) + ) + } refreshTerminalAfterWebglAttach(pane) } catch (err) { if (pane.terminalGpuAcceleration === 'auto') { 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 895d5116a98..f1261fa1ea9 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,5 +1,7 @@ import type { ManagedPane } from './pane-manager-types' import { recordTerminalWebglDiagnostic } from '../../../../shared/terminal-webgl-diagnostics' +import { forceFullViewportPresent } 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. type XtermRendererInternals = { @@ -13,18 +15,26 @@ type XtermRendererInternals = { handleResize?: (cols: number, rows: number) => void } -export type PaneWebglCanvasDprRepairState = 'current' | 'deferred' | 'repaired' +// 'released': the hidden backing was collapsed and its reveal restore resizes at the current DPR, so callers treat it as current. +export type PaneWebglCanvasDprRepairState = 'current' | 'deferred' | 'released' | 'repaired' -export function repairPaneWebglCanvasDpr(pane: ManagedPane): PaneWebglCanvasDprRepairState { - const renderer = ( +function getXtermRenderer(pane: ManagedPane): XtermRendererInternals | undefined { + return ( pane.terminal as unknown as { _core?: { _renderService?: { _renderer?: { value?: XtermRendererInternals } } } } )._core?._renderService?._renderer?.value +} + +export function repairPaneWebglCanvasDpr(pane: ManagedPane): PaneWebglCanvasDprRepairState { + const renderer = getXtermRenderer(pane) const canvas = renderer?._canvas ?? renderer?._gl?.canvas if (!renderer || !canvas) { return 'current' } + if (isWebglCanvasBackingReleased(canvas)) { + return 'released' + } if (!canvas.isConnected) { return 'deferred' } @@ -71,3 +81,38 @@ export function repairPaneWebglCanvasDpr(pane: ManagedPane): PaneWebglCanvasDprR export function repairPaneWebglCanvasDprMismatch(pane: ManagedPane): boolean { return repairPaneWebglCanvasDpr(pane) === 'repaired' } + +/** Reveal of a released canvas: reallocate and draw past xterm's still-paused renderer so the first painted frame has content. Reuses the cached layout and atlas unless the DPR changed while hidden. */ +export function restoreReleasedPaneWebglCanvas( + pane: ManagedPane, + devicePixelSize: { width: number; height: number } | null +): void { + const renderer = getXtermRenderer(pane) + const canvas = renderer?._canvas ?? renderer?._gl?.canvas + const view = canvas?.ownerDocument?.defaultView + if (!renderer || !canvas || !view) { + return + } + try { + if ( + renderer._devicePixelRatio !== view.devicePixelRatio && + renderer.handleDevicePixelRatioChange + ) { + // A monitor move while hidden: recompute dimensions and rebuild at the new DPR. + renderer.handleDevicePixelRatioChange() + } else if (canvas.width === 0 && canvas.height === 0) { + // Match xterm's device-pixel observer exactly so it finds nothing to resize (a second + // reallocation and redraw); engines without that box fall back to xterm's own canvas size. + const size = devicePixelSize ?? renderer.dimensions?.device?.canvas + canvas.width = size?.width ?? 0 + canvas.height = size?.height ?? 0 + } + // Unlike an ordinary reveal, the released canvas holds no last coherent frame to keep, so a + // TUI mid DEC 2026 frame gets this one non-atomic paint instead of up to 1s of blank canvas. + if (!forceFullViewportPresent(pane.terminal)) { + pane.terminal.refresh(0, pane.terminal.rows - 1) + } + } catch { + // Pane may be mid-teardown; xterm's own observers still repaint a frame later. + } +} 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 88ed9bbe200..46cec446945 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 @@ -113,7 +113,7 @@ export function isSynchronizedOutputHeld(terminal: unknown): boolean { ) } -/** Force a full present through pause/DEC 2026 only for held keystroke echo or replay hold-cap expiry. 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. */ +/** 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. */ export function forceFullViewportPresent(terminal: unknown): boolean { const service = getRenderService(terminal) if (!service) { diff --git a/src/renderer/src/lib/pane-manager/terminal-webgl-hidden-canvas-release.test.ts b/src/renderer/src/lib/pane-manager/terminal-webgl-hidden-canvas-release.test.ts new file mode 100644 index 00000000000..a02d30e1224 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-webgl-hidden-canvas-release.test.ts @@ -0,0 +1,72 @@ +// @vitest-environment happy-dom +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + HIDDEN_WEBGL_CANVAS_RELEASE_DELAY_MS, + isWebglCanvasBackingReleased, + releaseWebglCanvasBackingWhileHidden, + stopReleasingWebglCanvasBacking +} from './terminal-webgl-hidden-canvas-release' + +type ResizeCallback = (entries: { contentRect: { width: number; height: number } }[]) => void + +function makeCanvas() { + let onResize: ResizeCallback = () => {} + vi.stubGlobal( + 'ResizeObserver', + class { + constructor(callback: ResizeCallback) { + onResize = callback + } + observe(): void {} + unobserve(): void {} + disconnect(): void {} + } + ) + const canvas = document.createElement('canvas') + canvas.width = 2000 + canvas.height = 1000 + return { + canvas, + reportBox: (width: number) => onResize([{ contentRect: { width, height: width } }]) + } +} + +afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() +}) + +describe('releaseWebglCanvasBackingWhileHidden', () => { + it('releases a canvas attached while hidden and restores it when a box is reported', () => { + vi.useFakeTimers() + const { canvas, reportBox } = makeCanvas() + const restore = vi.fn() + releaseWebglCanvasBackingWhileHidden(canvas, () => true, restore) + + vi.advanceTimersByTime(HIDDEN_WEBGL_CANVAS_RELEASE_DELAY_MS) + expect(canvas.width).toBe(0) + expect(isWebglCanvasBackingReleased(canvas)).toBe(true) + + // The reveal's resize callback must restore before that frame paints. + reportBox(500) + expect(restore).toHaveBeenCalledTimes(1) + expect(isWebglCanvasBackingReleased(canvas)).toBe(false) + stopReleasingWebglCanvasBacking(canvas) + }) + + it('keeps the backing while the veto holds or the canvas has a box', () => { + vi.useFakeTimers() + const vetoed = makeCanvas() + releaseWebglCanvasBackingWhileHidden(vetoed.canvas, () => false, vi.fn()) + vi.advanceTimersByTime(HIDDEN_WEBGL_CANVAS_RELEASE_DELAY_MS * 2) + expect(vetoed.canvas.width).toBe(2000) + + const visible = makeCanvas() + releaseWebglCanvasBackingWhileHidden(visible.canvas, () => true, vi.fn()) + visible.reportBox(500) + vi.advanceTimersByTime(HIDDEN_WEBGL_CANVAS_RELEASE_DELAY_MS * 2) + expect(visible.canvas.width).toBe(2000) + stopReleasingWebglCanvasBacking(vetoed.canvas) + stopReleasingWebglCanvasBacking(visible.canvas) + }) +}) diff --git a/src/renderer/src/lib/pane-manager/terminal-webgl-hidden-canvas-release.ts b/src/renderer/src/lib/pane-manager/terminal-webgl-hidden-canvas-release.ts new file mode 100644 index 00000000000..65261ad8e48 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-webgl-hidden-canvas-release.ts @@ -0,0 +1,104 @@ +// Why: reallocating the backing on reveal costs about one frame, so only terminals left hidden this long pay it. +export const HIDDEN_WEBGL_CANVAS_RELEASE_DELAY_MS = 30_000 + +type CanvasRelease = { + view: Window & typeof globalThis + resize: ResizeObserver + mutation: MutationObserver + timer: number | null + // A zero-size element yields no initial ResizeObserver entry, so "no box" is the default until one reports. + hasBox: boolean + released: boolean +} + +const releasesByCanvas = new WeakMap() + +/** + * Chromium keeps a display:none WebGL canvas's drawing buffers (on macOS a back buffer plus up to + * four pooled full-size surfaces), so collapse the backing store once the canvas has stayed without + * a box. The reveal's ResizeObserver callback runs before that frame paints, and `restore` must + * reallocate and draw synchronously there: xterm's renderer stays paused until its + * IntersectionObserver task lands a frame later, and not every engine re-sizes the canvas itself. + * `canRelease` vetoes panes whose reveal must stay allocation-free. + */ +export function releaseWebglCanvasBackingWhileHidden( + canvas: HTMLCanvasElement, + canRelease: () => boolean, + restore: (devicePixelSize: { width: number; height: number } | null) => void +): void { + const view = canvas.ownerDocument.defaultView + if (!view?.ResizeObserver || !view.MutationObserver || releasesByCanvas.has(canvas)) { + return + } + const cancelRelease = (): void => { + if (release.timer !== null) { + view.clearTimeout(release.timer) + release.timer = null + } + } + const scheduleRelease = (): void => { + if (release.timer !== null || release.hasBox || (canvas.width === 0 && canvas.height === 0)) { + return + } + release.timer = view.setTimeout(() => { + release.timer = null + if (release.hasBox) { + return + } + if (!canRelease()) { + scheduleRelease() + return + } + release.released = true + canvas.width = 0 + canvas.height = 0 + }, HIDDEN_WEBGL_CANVAS_RELEASE_DELAY_MS) + } + const release: CanvasRelease = { + view, + resize: new view.ResizeObserver((entries) => { + const entry = entries.at(-1) + const box = entry?.contentRect + release.hasBox = Boolean(box && (box.width > 0 || box.height > 0)) + if (!release.hasBox) { + scheduleRelease() + return + } + cancelRelease() + if (release.released) { + release.released = false + const devicePixels = entry?.devicePixelContentBoxSize?.[0] + restore( + devicePixels ? { width: devicePixels.inlineSize, height: devicePixels.blockSize } : null + ) + } + }), + // Why: a resize while hidden regrows the backing without a box change, so no resize entry follows. + mutation: new view.MutationObserver(() => scheduleRelease()), + timer: null, + hasBox: false, + released: false + } + release.resize.observe(canvas) + release.mutation.observe(canvas, { attributeFilter: ['width', 'height'] }) + releasesByCanvas.set(canvas, release) + // Attached while hidden: a boxless canvas gets no initial resize entry; one with a box cancels this. + scheduleRelease() +} + +export function stopReleasingWebglCanvasBacking(canvas: HTMLCanvasElement): void { + const release = releasesByCanvas.get(canvas) + if (!release) { + return + } + if (release.timer !== null) { + release.view.clearTimeout(release.timer) + } + release.resize.disconnect() + release.mutation.disconnect() + releasesByCanvas.delete(canvas) +} + +export function isWebglCanvasBackingReleased(canvas: HTMLCanvasElement): boolean { + return releasesByCanvas.get(canvas)?.released === true +}