mirror of
https://github.com/stablyai/orca.git
synced 2026-11-01 09:37:33 +00:00
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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user