diff --git a/src/renderer/src/lib/pane-manager/pane-manager-types.ts b/src/renderer/src/lib/pane-manager/pane-manager-types.ts index 62c69836841..33f914fd0aa 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager-types.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager-types.ts @@ -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 diff --git a/src/renderer/src/lib/pane-manager/pane-rendering-control.ts b/src/renderer/src/lib/pane-manager/pane-rendering-control.ts index 1de4621a60f..c46c2e760b6 100644 --- a/src/renderer/src/lib/pane-manager/pane-rendering-control.ts +++ b/src/renderer/src/lib/pane-manager/pane-rendering-control.ts @@ -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) diff --git a/src/renderer/src/lib/pane-manager/pane-rendering-diagnostics.ts b/src/renderer/src/lib/pane-manager/pane-rendering-diagnostics.ts index 15faad3136c..be60429ebbb 100644 --- a/src/renderer/src/lib/pane-manager/pane-rendering-diagnostics.ts +++ b/src/renderer/src/lib/pane-manager/pane-rendering-diagnostics.ts @@ -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(), diff --git a/src/renderer/src/lib/pane-manager/pane-reveal-repaint.test.ts b/src/renderer/src/lib/pane-manager/pane-reveal-repaint.test.ts index 8604a0fd6dc..6c1c4f3191e 100644 --- a/src/renderer/src/lib/pane-manager/pane-reveal-repaint.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-reveal-repaint.test.ts @@ -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() + }) }) }) diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-gpu-acceleration.ts b/src/renderer/src/lib/pane-manager/pane-terminal-gpu-acceleration.ts index 86b474196f7..099e3a40c82 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-gpu-acceleration.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-gpu-acceleration.ts @@ -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, @@ -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)) { diff --git a/src/renderer/src/lib/pane-manager/pane-webgl-context-loss-policy.ts b/src/renderer/src/lib/pane-manager/pane-webgl-context-loss-policy.ts new file mode 100644 index 00000000000..66963851a8e --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-webgl-context-loss-policy.ts @@ -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 +} diff --git a/src/renderer/src/lib/pane-manager/pane-webgl-context-recovery.test.ts b/src/renderer/src/lib/pane-manager/pane-webgl-context-recovery.test.ts index 128057e463d..09016e67429 100644 --- a/src/renderer/src/lib/pane-manager/pane-webgl-context-recovery.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-webgl-context-recovery.test.ts @@ -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) + } } ]) }) diff --git a/src/renderer/src/lib/pane-manager/pane-webgl-reattach.ts b/src/renderer/src/lib/pane-manager/pane-webgl-reattach.ts index 98c5c10e079..1bb79d6f6e0 100644 --- a/src/renderer/src/lib/pane-manager/pane-webgl-reattach.ts +++ b/src/renderer/src/lib/pane-manager/pane-webgl-reattach.ts @@ -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) } } 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 463bc76e53a..b19a5b92c55 100644 --- a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts +++ b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts @@ -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 }) })