fix(terminal): retry bounded WebGL recovery on tab reveal

This commit is contained in:
Brennan Benson
2026-08-24 18:38:47 -07:00
parent cc4801320a
commit ee60aaa51a
9 changed files with 102 additions and 9 deletions
@@ -114,6 +114,7 @@ export type PaneRenderingDiagnostics = {
gpuRenderingEnabled: boolean
webglAttachmentDeferred: boolean
webglDisabledAfterContextLoss: boolean
webglContextLossesInWindow?: number
webglAttachFailedSinceRecovery: boolean
hasComplexScriptOutput: boolean
terminalWebglAutoDecision: TerminalWebglAutoDecision
@@ -142,6 +143,8 @@ export type ManagedPaneInternal = {
gpuRenderingEnabled: boolean
webglAttachmentDeferred: boolean
webglDisabledAfterContextLoss: boolean
// Context losses are retained for a bounded reveal retry policy.
webglContextLossTimestamps?: number[]
// Hidden retained renderers rebuild at the resume boundary, never behind the hidden surface.
webglRebuildDeferred?: boolean
// Why per-pane: one pane's failed WebGL attach must not strand every other
@@ -10,7 +10,11 @@ import {
presentPaneViewport,
resetWebglTextureAtlas
} from './pane-webgl-renderer'
import { rebuildAttachedWebgl, reattachWebglIfNeeded } from './pane-webgl-reattach'
import {
clearPaneWebglContextLossForRetry,
rebuildAttachedWebgl,
reattachWebglIfNeeded
} from './pane-webgl-reattach'
import {
releaseHiddenWebglRetention,
tryRetainHiddenPanesWebgl
@@ -85,7 +89,7 @@ export function resumePaneRendering(
clearTerminalWebglAttachBackoff(pane)
const rebuildDeferred = pane.webglRebuildDeferred === true
pane.webglAttachmentDeferred = false
pane.webglDisabledAfterContextLoss = false
clearPaneWebglContextLossForRetry(pane)
pane.webglRebuildDeferred = false
if (pane.webglAddon && isPaneWebglContextLost(pane)) {
disposeWebgl(pane)
@@ -10,6 +10,7 @@ export function collectPaneRenderingDiagnostics(
gpuRenderingEnabled: pane.gpuRenderingEnabled,
webglAttachmentDeferred: pane.webglAttachmentDeferred,
webglDisabledAfterContextLoss: pane.webglDisabledAfterContextLoss,
webglContextLossesInWindow: pane.webglContextLossTimestamps?.length ?? 0,
webglAttachFailedSinceRecovery: pane.webglAttachFailedSinceRecovery === true,
hasComplexScriptOutput: pane.hasComplexScriptOutput,
terminalWebglAutoDecision: getTerminalWebglAutoDecision(),
@@ -235,5 +235,17 @@ describe('schedulePaneRevealRepaint', () => {
expect(pane.webglAddon).not.toBeNull()
expect(pane.terminal.refresh).toHaveBeenCalled()
})
it('reattaches a pane that lost WebGL while hidden when its tab is revealed', () => {
const pane = createPane()
pane.webglDisabledAfterContextLoss = true
schedulePaneRevealPresent(() => [pane])
flushFrame()
flushFrame()
expect(pane.webglDisabledAfterContextLoss).toBe(false)
expect(pane.webglAddon).not.toBeNull()
})
})
})
@@ -6,6 +6,7 @@ import {
shouldUseTerminalWebgl
} from './pane-webgl-renderer'
import { safeFit } from './pane-tree-ops'
import { resetPaneWebglContextLosses } from './pane-webgl-context-loss-policy'
export function applyTerminalGpuAcceleration(
panes: Iterable<ManagedPaneInternal>,
@@ -26,6 +27,7 @@ export function applyTerminalGpuAcceleration(
// renderer; context-loss and attach-failure latches from the old mode
// should not pin DOM.
pane.webglDisabledAfterContextLoss = false
resetPaneWebglContextLosses(pane)
pane.webglAttachFailedSinceRecovery = false
}
if (!shouldUseTerminalWebgl(pane)) {
@@ -0,0 +1,30 @@
import type { ManagedPaneInternal } from './pane-manager-types'
/** Retry a pane after a few transient losses, but stop retrying a persistently unstable context. */
export const WEBGL_CONTEXT_LOSS_RETRY_LIMIT = 3
export const WEBGL_CONTEXT_LOSS_RETRY_WINDOW_MS = 60_000
function recentContextLosses(pane: ManagedPaneInternal, now: number): number[] {
const cutoff = now - WEBGL_CONTEXT_LOSS_RETRY_WINDOW_MS
return (pane.webglContextLossTimestamps ?? []).filter((timestamp) => timestamp > cutoff)
}
export function recordPaneWebglContextLoss(pane: ManagedPaneInternal, now = Date.now()): number {
const losses = recentContextLosses(pane, now)
losses.push(now)
pane.webglContextLossTimestamps = losses
return losses.length
}
export function canRetryPaneWebglAfterContextLoss(
pane: ManagedPaneInternal,
now = Date.now()
): boolean {
const losses = recentContextLosses(pane, now)
pane.webglContextLossTimestamps = losses
return losses.length < WEBGL_CONTEXT_LOSS_RETRY_LIMIT
}
export function resetPaneWebglContextLosses(pane: ManagedPaneInternal): void {
pane.webglContextLossTimestamps = undefined
}
@@ -168,6 +168,27 @@ describe('terminal WebGL context recovery', () => {
expect(pane.webglAddon).toBeNull()
})
it('stops reveal and resume retries after repeated losses in the retry window', () => {
const pane = createPane()
attachWebgl(pane)
fireContextLoss(pane)
resumePaneRendering([pane])
fireContextLoss(pane)
resumePaneRendering([pane])
fireContextLoss(pane)
expect(pane.webglContextLossTimestamps).toHaveLength(3)
expect(pane.webglDisabledAfterContextLoss).toBe(true)
expect(pane.webglAddon).toBeNull()
resumePaneRendering([pane])
expect(pane.webglDisabledAfterContextLoss).toBe(true)
expect(pane.webglAddon).toBeNull()
expect(pane.terminal.loadAddon).toHaveBeenCalledTimes(3)
})
// Why exact keys: a GPU death loses every pane's context at once and the
// crash ring coalesces the repeats, so the population has to survive on the
// payload. It must use the same names the fit-retry crumb uses, or one ring
@@ -186,7 +207,12 @@ describe('terminal WebGL context recovery', () => {
expect(recorded).toEqual([
{
kind: 'webgl-context-loss',
detail: { paneId: 1, livePanes: expect.any(Number), livePaneManagers: expect.any(Number) }
detail: {
paneId: 1,
lossesInWindow: 1,
livePanes: expect.any(Number),
livePaneManagers: expect.any(Number)
}
}
])
})
@@ -1,8 +1,20 @@
import type { ManagedPaneInternal } from './pane-manager-types'
import { attachWebgl, clearTerminalWebglAttachBackoff, disposeWebgl } from './pane-webgl-renderer'
import { canRetryPaneWebglAfterContextLoss } from './pane-webgl-context-loss-policy'
export function clearPaneWebglContextLossForRetry(pane: ManagedPaneInternal): boolean {
if (!pane.webglDisabledAfterContextLoss) {
return true
}
if (!canRetryPaneWebglAfterContextLoss(pane)) {
return false
}
pane.webglDisabledAfterContextLoss = false
return true
}
export function reattachWebglIfNeeded(pane: ManagedPaneInternal): void {
if (pane.gpuRenderingEnabled && !pane.webglAddon && !pane.webglDisabledAfterContextLoss) {
if (pane.gpuRenderingEnabled && !pane.webglAddon && clearPaneWebglContextLossForRetry(pane)) {
attachWebgl(pane)
}
}
@@ -14,6 +14,7 @@ import {
import { safeFit, safeFitAndThen } from './pane-fit'
import { setPaneFitWebglAttachHook } from './pane-fit-webgl-attach-signal'
import { repairPaneWebglCanvasDprMismatch } from './terminal-canvas-dpr-repair'
import { recordPaneWebglContextLoss } from './pane-webgl-context-loss-policy'
export const ENABLE_WEBGL_RENDERER = true
let suggestedRendererType: 'dom' | undefined
@@ -340,15 +341,17 @@ export function attachWebgl(pane: ManagedPaneInternal): void {
// once, and the crash-report ring coalesces repeats, so the count has to
// be in the payload rather than in the number of crumbs.
const census = getLivePaneCensus()
recordTerminalWebglDiagnostic('webgl-context-loss', {
paneId: pane.id,
livePanes: census.panes,
livePaneManagers: census.managers
})
// Why: Chromium starts reclaiming terminal contexts under pressure.
// Recreating WebGL for this pane can loop context loss and leave xterm
// visually blank, so keep the pane on the DOM renderer until the next
// rendering resume (worktree foreground / window wake) retries it.
const lossesInWindow = recordPaneWebglContextLoss(pane)
recordTerminalWebglDiagnostic('webgl-context-loss', {
paneId: pane.id,
lossesInWindow,
livePanes: census.panes,
livePaneManagers: census.managers
})
pane.webglDisabledAfterContextLoss = true
disposeWebgl(pane, { refreshDimensions: true })
})