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 <m4air@Mac.localdomain>
This commit is contained in:
OrcaWin
2026-10-28 06:59:27 -07:00
committed by GitHub
co-authored by m4air
parent 3fd928373c
commit a6fdeda541
6 changed files with 262 additions and 5 deletions
@@ -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,
@@ -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') {
@@ -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.
}
}
@@ -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) {
@@ -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)
})
})
@@ -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<HTMLCanvasElement, CanvasRelease>()
/**
* 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
}