fix(terminal): limit pre-paint WebGL resume to macOS (#10794)

Run terminal visibility transitions pre-paint only on macOS. Restore passive disposal and recreation on Windows/Linux, remove the Windows retained-context LRU machinery, and preserve the normal 128-context startup ceiling.
This commit is contained in:
Neil 2026-07-26 15:24:26 -07:00 committed by GitHub
parent a51e675804
commit 4681edb520
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 92 additions and 684 deletions

View File

@ -73,7 +73,6 @@ import {
notifyServeSupervisorReady
} from './serve-update-handoff'
import {
configureMainProcessWebglContextBudget,
configureElectronNetworkCompatibility,
configureDevUserDataPath,
configureOrcaUserDataPathEnv,
@ -666,10 +665,7 @@ if (hasSingleInstanceLock) {
configureElectronNetworkCompatibility()
enableRendererHeapHeadroom()
maybeApplyGpuFallbackForThisLaunch()
if (gpuFallbackActiveThisLaunch) {
// Software fallback still shares the renderer's bounded Windows retention policy.
configureMainProcessWebglContextBudget()
} else {
if (!gpuFallbackActiveThisLaunch) {
enableMainProcessGpuFeatures()
}
// Why: headless serve's offscreen BrowserWindows need an X display (Xvfb) on Linux; the result gates whether the offscreen backend is installed.

View File

@ -398,21 +398,6 @@ describe('enableMainProcessGpuFeatures', () => {
expect(app.commandLine.appendSwitch).toHaveBeenCalledWith('max-active-webgl-contexts', '128')
})
it('configures the shared WebGL budget independently for GPU fallback', async () => {
const { app } = await import('electron')
const { TERMINAL_WEBGL_MAX_ACTIVE_CONTEXTS } =
await import('../../shared/terminal-webgl-context-budget')
const { configureMainProcessWebglContextBudget } = await import('./configure-process')
vi.mocked(app.commandLine.appendSwitch).mockClear()
configureMainProcessWebglContextBudget()
expect(app.commandLine.appendSwitch).toHaveBeenCalledWith(
'max-active-webgl-contexts',
String(TERMINAL_WEBGL_MAX_ACTIVE_CONTEXTS)
)
})
it('disables Skia Graphite only on macOS without disabling hardware acceleration', async () => {
const { app } = await import('electron')
const { enableMainProcessGpuFeatures } = await import('./configure-process')

View File

@ -2,7 +2,6 @@ import { app } from 'electron'
import { existsSync, mkdirSync, readFileSync } from 'node:fs'
import { homedir } from 'node:os'
import { join, resolve } from 'node:path'
import { TERMINAL_WEBGL_MAX_ACTIVE_CONTEXTS } from '../../shared/terminal-webgl-context-budget'
import { getVersionManagerBinPaths } from '../codex-cli/command'
import { getMainE2EConfig } from '../e2e-config'
@ -255,13 +254,6 @@ export function installDevParentSignalQuit(isDev: boolean): void {
process.once('SIGTERM', onSignal)
}
export function configureMainProcessWebglContextBudget(): void {
app.commandLine.appendSwitch(
'max-active-webgl-contexts',
String(TERMINAL_WEBGL_MAX_ACTIVE_CONTEXTS)
)
}
export function enableMainProcessGpuFeatures(): void {
if (process.platform === 'linux' && getMainE2EConfig().userDataDir) {
// Why: Ubuntu/Xvfb runners fail Electron startup with "GPU process isn't usable"; E2E needs no GPU, so use the software path.
@ -279,7 +271,7 @@ export function enableMainProcessGpuFeatures(): void {
// Why: Blink evicts the oldest WebGL context past 16/renderer and each terminal pane holds one, silently downgrading panes to DOM.
// 128 raises the ceiling for real layouts while staying bounded so context leaks still surface.
configureMainProcessWebglContextBudget()
app.commandLine.appendSwitch('max-active-webgl-contexts', '128')
const ozonePlatform = (app.commandLine.getSwitchValue('ozone-platform') ?? '').toLowerCase()
const ozonePlatformHint = (process.env.ELECTRON_OZONE_PLATFORM_HINT ?? '').toLowerCase()

View File

@ -0,0 +1,10 @@
import { describe, expect, it } from 'vitest'
import { getTerminalVisibilityEffectPhase } from './terminal-visibility-effect-phase'
describe('terminal visibility effect phase', () => {
it('runs pre-paint only on macOS', () => {
expect(getTerminalVisibilityEffectPhase('darwin')).toBe('layout')
expect(getTerminalVisibilityEffectPhase('win32')).toBe('passive')
expect(getTerminalVisibilityEffectPhase('linux')).toBe('passive')
})
})

View File

@ -0,0 +1,7 @@
export type TerminalVisibilityEffectPhase = 'layout' | 'passive'
export function getTerminalVisibilityEffectPhase(
platform: NodeJS.Platform
): TerminalVisibilityEffectPhase {
return platform === 'darwin' ? 'layout' : 'passive'
}

View File

@ -126,7 +126,7 @@ describe('resumeTerminalVisibility reveal repaint', () => {
})
it('defers backlog and shared atlas recovery until after resume and fit', async () => {
// Why: resume before flush avoids DOM bold flash; fit before flush avoids
// On macOS resume before flush avoids DOM bold flash; fit before flush avoids
// writing TUI backlog onto the transient DOM↔WebGL one-column-off grid.
const order: string[] = []
const manager = createManager(order)

View File

@ -128,7 +128,6 @@ export function hideTerminalVisibility({
captureViewportPositions(false)
}
if (!isWorktreeActive && (wasVisible || surfaceBecameHidden)) {
// Windows defers new contexts but retains live ones; other clients dispose.
manager.suspendRendering()
return { hiddenReason: 'surface', renderingSuspended: true }
}
@ -203,7 +202,6 @@ function requestLightTabBacklogRecovery(manager: PaneManager): void {
}
function resumeTerminalVisibilityBeforePaint(manager: PaneManager, isActive: boolean): void {
// Retained Windows surfaces reuse WebGL; disposed or pressure-lost surfaces reattach.
manager.resumeRendering()
manager.fitAllRevealedPanes()
if (isActive) {

View File

@ -30,7 +30,8 @@ const mocks = vi.hoisted(() => ({
const reactRefState = vi.hoisted(() => ({
slots: [] as { current: unknown }[],
index: 0
index: 0,
effectPhase: null as 'layout' | 'passive' | null
}))
function beginHookRender(): void {
@ -48,12 +49,21 @@ vi.mock('react', async (importOriginal) => {
...actual,
useCallback: <T extends (...args: never[]) => unknown>(callback: T) => callback,
useEffect: (effect: () => void | (() => void)) => {
effect()
reactRefState.effectPhase = 'passive'
try {
effect()
} finally {
reactRefState.effectPhase = null
}
},
// Why: visibility suspend/resume runs in useLayoutEffect so WebGL is live
// before the first paint of a revealed worktree (avoids DOM bold flash).
// macOS visibility suspend/resume runs here before reveal paint.
useLayoutEffect: (effect: () => void | (() => void)) => {
effect()
reactRefState.effectPhase = 'layout'
try {
effect()
} finally {
reactRefState.effectPhase = null
}
},
useRef: <T>(value: T) => {
const index = reactRefState.index
@ -146,7 +156,8 @@ function useMountForFileDrop(
isWorktreeActive?: boolean
isSyncFitEnabled?: boolean
paneCount?: number
} = {}
} = {},
useGlobalEffects: typeof useTerminalPaneGlobalEffects = useTerminalPaneGlobalEffects
): {
onFileDrop: DropCallback
manager: {
@ -160,6 +171,7 @@ function useMountForFileDrop(
fitAllRevealedPanes: ReturnType<typeof vi.fn>
}
paneTransports: Map<number, never>
renderingEffectPhases: ('layout' | 'passive' | null)[]
} {
let onFileDrop: DropCallback = () => {
throw new Error('onFileDrop callback was not registered')
@ -168,9 +180,10 @@ function useMountForFileDrop(
onFileDrop = callback
return vi.fn()
})
const renderingEffectPhases: ('layout' | 'passive' | null)[] = []
const manager = {
getPanes: vi.fn(() => []),
resumeRendering: vi.fn(),
resumeRendering: vi.fn(() => renderingEffectPhases.push(reactRefState.effectPhase)),
resetWebglTextureAtlases: vi.fn(),
scheduleRevealRepaint: vi.fn(),
scheduleRevealPresent: vi.fn(),
@ -181,7 +194,7 @@ function useMountForFileDrop(
const paneTransports = new Map<number, never>()
beginHookRender()
useTerminalPaneGlobalEffects({
useGlobalEffects({
tabId: options.tabId ?? 'tab-1',
worktreeId: options.worktreeId ?? 'wt-1',
cwd: options.cwd,
@ -198,7 +211,7 @@ function useMountForFileDrop(
toggleExpandPane: vi.fn()
})
return { onFileDrop, manager, paneTransports }
return { onFileDrop, manager, paneTransports, renderingEffectPhases }
}
describe('useTerminalPaneGlobalEffects', () => {
@ -231,6 +244,26 @@ describe('useTerminalPaneGlobalEffects', () => {
;(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = MockResizeObserver
})
it.each([
['darwin', 'layout'],
['win32', 'passive'],
['linux', 'passive']
] as const)('runs visibility transitions in the %s effect phase', async (platform, phase) => {
window.api.platform = {
get: () => ({ platform, osRelease: 'test', displayServer: null })
}
vi.resetModules()
const { useTerminalPaneGlobalEffects: usePlatformTerminalPaneGlobalEffects } =
await import('./use-terminal-pane-global-effects')
const { renderingEffectPhases } = useMountForFileDrop(
{ isActive: true, isVisible: true },
usePlatformTerminalPaneGlobalEffects
)
expect(renderingEffectPhases).toEqual([phase])
})
afterEach(() => {
for (const manager of registeredManagers.splice(0)) {
unregisterLivePaneManager(manager)
@ -242,7 +275,7 @@ describe('useTerminalPaneGlobalEffects', () => {
})
it('resumes WebGL and fits before flushing backlog so paint is GPU and grid is stable', () => {
// Why: resume before flush avoids DOM bold flash; fit before flush avoids
// On macOS resume before flush avoids DOM bold flash; fit before flush avoids
// writing backlog onto the transient DOM↔WebGL one-column-off grid.
const order: string[] = []
const terminalA = { name: 'terminal-a' }

View File

@ -26,6 +26,13 @@ import {
releaseRendererPtyVisibilityClaim,
setRendererPtyVisibilityClaim
} from './pty-renderer-delivery-claims'
import { getRendererAppPlatform } from '@/lib/renderer-app-platform'
import { getTerminalVisibilityEffectPhase } from './terminal-visibility-effect-phase'
const useTerminalVisibilityEffect =
getTerminalVisibilityEffectPhase(getRendererAppPlatform()) === 'layout'
? useLayoutEffect
: useEffect
type UseTerminalPaneGlobalEffectsArgs = {
tabId: string
@ -130,8 +137,8 @@ export function useTerminalPaneGlobalEffects({
}
}, [rendererVisible, paneTransportsRef])
// Why layout: pre-paint WebGL resume avoids one-frame DOM bold flash on reveal.
useLayoutEffect(() => {
// macOS can rebuild WebGL pre-paint without blocking reveal on slow ANGLE paths.
useTerminalVisibilityEffect(() => {
isActiveRef.current = isActive
isVisibleRef.current = rendererVisible
postPaintVisibilityRecoveryRef.current = applyTerminalVisibilityTransition({

View File

@ -127,7 +127,6 @@ export function createPaneDOM(
gpuRenderingEnabled: ENABLE_WEBGL_RENDERER,
webglAttachmentDeferred: false,
webglDisabledAfterContextLoss: false,
webglRebuildDeferred: false,
hasComplexScriptOutput: false,
fitAddon,
fitResizeObserver: null,

View File

@ -6,7 +6,7 @@ import {
markComplexScriptOutput,
resetTerminalWebglSuggestion
} from './pane-webgl-renderer'
import { attachLigatures, disposePane, openTerminal, setLigaturesEnabled } from './pane-lifecycle'
import { attachLigatures, disposePane, openTerminal } from './pane-lifecycle'
import { ensureArabicShapingJoinerForText } from './terminal-arabic-shaping-joiner'
import {
buildDefaultTerminalOptions,
@ -430,32 +430,6 @@ describe('attachLigatures', () => {
expect(pane.terminal.refresh).toHaveBeenCalledWith(0, 23)
expect(pane.ligaturesAddon).not.toBeNull()
})
it('defers a retained WebGL rebuild while enabling ligatures offscreen', () => {
const pane = createPane()
const retainedAddon = { dispose: vi.fn() } as never
pane.webglAddon = retainedAddon
pane.webglAttachmentDeferred = true
attachLigatures(pane)
expect(pane.webglAddon).toBe(retainedAddon)
expect(pane.webglRebuildDeferred).toBe(true)
expect(pane.terminal.refresh).not.toHaveBeenCalled()
})
it('defers a retained WebGL rebuild while disabling ligatures offscreen', () => {
const pane = createPane()
const retainedAddon = { dispose: vi.fn() } as never
pane.webglAddon = retainedAddon
pane.ligaturesAddon = { dispose: vi.fn() } as never
pane.webglAttachmentDeferred = true
setLigaturesEnabled(pane, false)
expect(pane.webglAddon).toBe(retainedAddon)
expect(pane.webglRebuildDeferred).toBe(true)
})
})
describe('openTerminal — addon and provider wiring', () => {

View File

@ -167,18 +167,6 @@ export function disposeLigatures(pane: ManagedPaneInternal): void {
}
}
function rebuildWebglAfterLigatureChange(pane: ManagedPaneInternal): void {
if (!pane.webglAddon) {
return
}
if (pane.webglAttachmentDeferred) {
pane.webglRebuildDeferred = true
return
}
disposeWebgl(pane)
attachWebgl(pane)
}
export function attachLigatures(pane: ManagedPaneInternal): void {
if (pane.ligaturesAddon) {
return
@ -189,15 +177,16 @@ export function attachLigatures(pane: ManagedPaneInternal): void {
pane.ligaturesAddon = ligaturesAddon
// Why: ligatures can be enabled after rows already rendered, especially
// from Settings. Force existing glyph runs to be recomputed immediately.
if (!pane.webglAttachmentDeferred) {
pane.terminal.refresh(0, pane.terminal.rows - 1)
}
pane.terminal.refresh(0, pane.terminal.rows - 1)
// Why: the WebGL renderer builds its glyph texture atlas at activation
// time, so `font-feature-settings` applied after WebGL loaded won't
// reach the GPU-rendered cells until the atlas is rebuilt. The upstream
// docs call this out explicitly — reactivating WebGL after ligatures
// forces a fresh atlas that includes the ligated glyphs.
rebuildWebglAfterLigatureChange(pane)
if (pane.webglAddon) {
disposeWebgl(pane)
attachWebgl(pane)
}
} catch (err) {
console.warn('[terminal] ligatures addon failed to attach for pane', pane.id, err)
pane.ligaturesAddon = null
@ -214,7 +203,10 @@ export function setLigaturesEnabled(pane: ManagedPaneInternal, enabled: boolean)
// Why: ligatures lived inside the WebGL atlas, so after disposing the
// addon the atlas still holds the ligated glyphs. Rebuild it so text
// renders as the non-ligated fallback immediately.
rebuildWebglAfterLigatureChange(pane)
if (pane.webglAddon) {
disposeWebgl(pane)
attachWebgl(pane)
}
}
}

View File

@ -136,7 +136,6 @@ export type ManagedPaneInternal = {
gpuRenderingEnabled: boolean
webglAttachmentDeferred: boolean
webglDisabledAfterContextLoss: boolean
webglRebuildDeferred?: boolean
// Why: expose complex-output diagnostics without changing renderer choice;
// auto renderer fallback is reserved for platform or WebGL failures.
hasComplexScriptOutput: boolean

View File

@ -214,10 +214,6 @@ export class PaneManager {
refreshAllPanes(): void {
for (const pane of this.panes.values()) {
// Retained contexts repaint on resume without scaling recovery across hidden workspaces.
if (pane.webglAttachmentDeferred && pane.webglAddon) {
continue
}
try {
if (pane.terminal.rows > 0) {
pane.terminal.refresh(0, pane.terminal.rows - 1)

View File

@ -4,16 +4,10 @@ import {
attachWebgl,
clearTerminalWebglAttachBackoff,
disposeWebgl,
isPaneWebglContextLost,
markComplexScriptOutput,
resetWebglTextureAtlas
} from './pane-webgl-renderer'
import {
releaseRetainedWebglPane,
retainSuspendedWebglPane,
shouldRetainSuspendedWebglContexts
} from './pane-webgl-context-retention'
import { rebuildAttachedWebgl, reattachWebglIfNeeded } from './pane-webgl-reattach'
import { reattachWebglIfNeeded } from './pane-webgl-reattach'
export function setPaneGpuRenderingState(
panes: Map<number, ManagedPaneInternal>,
@ -49,17 +43,9 @@ export function markPaneComplexScriptOutput(
}
export function suspendPaneRendering(panes: Iterable<ManagedPaneInternal>): void {
const retainLiveContexts = shouldRetainSuspendedWebglContexts()
for (const pane of panes) {
pane.webglAttachmentDeferred = true
if (!retainLiveContexts) {
disposeWebgl(pane)
continue
}
const evicted = retainSuspendedWebglPane(pane)
if (evicted) {
disposeWebgl(evicted)
}
disposeWebgl(pane)
}
}
@ -69,31 +55,8 @@ export function resumePaneRendering(panes: Iterable<ManagedPaneInternal>): void
// loss, and bounding retries to resume events cannot loop on live loss.
clearTerminalWebglAttachBackoff()
for (const pane of panes) {
releaseRetainedWebglPane(pane)
const wasDeferred = pane.webglAttachmentDeferred
const rebuildDeferred = pane.webglRebuildDeferred === true
pane.webglAttachmentDeferred = false
pane.webglDisabledAfterContextLoss = false
pane.webglRebuildDeferred = false
const contextLost = Boolean(pane.webglAddon && isPaneWebglContextLost(pane))
if (wasDeferred && pane.webglAddon && !contextLost) {
if (rebuildDeferred) {
rebuildAttachedWebgl(pane)
continue
}
// Shared-atlas recovery skips deferred panes, so repaint the retained model now.
try {
if (pane.terminal.rows > 0) {
pane.terminal.refresh(0, pane.terminal.rows - 1)
}
} catch {
/* ignore — pane may be tearing down during resume */
}
continue
}
if (contextLost) {
disposeWebgl(pane)
}
reattachWebglIfNeeded(pane)
}
}

View File

@ -1,26 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ManagedPaneInternal } from './pane-manager-types'
describe('terminal WebGL context inspection', () => {
afterEach(async () => {
const { setTerminalWebglDiagnosticRecorder } =
await import('../../../../shared/terminal-webgl-diagnostics')
setTerminalWebglDiagnosticRecorder(null)
vi.resetModules()
})
it('records missing xterm internals once without treating the context as lost', async () => {
vi.resetModules()
const diagnostics = vi.fn()
const { setTerminalWebglDiagnosticRecorder } =
await import('../../../../shared/terminal-webgl-diagnostics')
setTerminalWebglDiagnosticRecorder(diagnostics)
const { isPaneWebglContextLost } = await import('./pane-webgl-renderer')
const pane = { id: 7, webglAddon: {} } as ManagedPaneInternal
expect(isPaneWebglContextLost(pane)).toBe(false)
expect(isPaneWebglContextLost(pane)).toBe(false)
expect(diagnostics).toHaveBeenCalledTimes(1)
expect(diagnostics).toHaveBeenCalledWith('webgl-context-inspection-unavailable', { paneId: 7 })
})
})

View File

@ -1,19 +1,13 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { setTerminalWebglDiagnosticRecorder } from '../../../../shared/terminal-webgl-diagnostics'
import type { ManagedPaneInternal } from './pane-manager-types'
import { resumePaneRendering, suspendPaneRendering } from './pane-rendering-control'
import {
clearRetainedWebglPanesForTests,
RETAINED_WEBGL_PANE_LIMIT,
retainedWebglPaneCount
} from './pane-webgl-context-retention'
import { rebuildAttachedWebgl } from './pane-webgl-reattach'
import { resumePaneRendering } from './pane-rendering-control'
import { attachWebgl, resetTerminalWebglSuggestion } from './pane-webgl-renderer'
function createPane(options: { id?: number; loadAddon?: () => void } = {}): ManagedPaneInternal {
function createPane(options: { loadAddon?: () => void } = {}): ManagedPaneInternal {
const leafId = '11111111-1111-4111-8111-111111111111' as never
return {
id: options.id ?? 1,
id: 1,
leafId,
stablePaneId: leafId,
terminal: {
@ -61,13 +55,6 @@ function fireContextLoss(pane: ManagedPaneInternal): void {
addon._onContextLoss.fire()
}
function stubWindowsDesktop(): void {
vi.stubGlobal('window', {
api: { platform: { get: () => ({ platform: 'win32' }) } },
location: { pathname: '/index.html' }
})
}
describe('terminal WebGL context recovery', () => {
beforeEach(() => {
resetTerminalWebglSuggestion()
@ -80,7 +67,6 @@ describe('terminal WebGL context recovery', () => {
})
afterEach(() => {
clearRetainedWebglPanesForTests()
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
@ -126,160 +112,6 @@ describe('terminal WebGL context recovery', () => {
expect(pane.webglAddon).not.toBeNull()
})
it('reuses a retained Windows context without loading another addon', () => {
stubWindowsDesktop()
const pane = createPane()
attachWebgl(pane)
const addon = pane.webglAddon
vi.mocked(pane.terminal.refresh).mockClear()
suspendPaneRendering([pane])
resumePaneRendering([pane])
expect(pane.webglAddon).toBe(addon)
expect(pane.terminal.loadAddon).toHaveBeenCalledTimes(1)
expect(pane.terminal.refresh).toHaveBeenCalledWith(0, 23)
})
it('keeps a healthy visible context on window wake', () => {
const pane = createPane()
attachWebgl(pane)
const addon = pane.webglAddon
resumePaneRendering([pane])
expect(pane.webglAddon).toBe(addon)
expect(pane.terminal.loadAddon).toHaveBeenCalledTimes(1)
})
it('replaces a synchronously lost visible context on window wake', () => {
const pane = createPane()
const dispose = vi.fn()
pane.webglAddon = {
dispose,
_renderer: {
_gl: {
getExtension: vi.fn(() => null),
isContextLost: vi.fn(() => true)
}
}
} as never
resumePaneRendering([pane])
expect(dispose).toHaveBeenCalledTimes(1)
expect(pane.webglAddon).not.toBeNull()
expect(pane.terminal.loadAddon).toHaveBeenCalledTimes(1)
})
it('replaces a retained context lost before xterm fires its delayed event', () => {
stubWindowsDesktop()
const pane = createPane()
const dispose = vi.fn()
pane.webglAddon = {
dispose,
_renderer: {
_gl: {
getExtension: vi.fn(() => null),
isContextLost: vi.fn(() => true)
}
}
} as never
suspendPaneRendering([pane])
resumePaneRendering([pane])
expect(dispose).toHaveBeenCalledTimes(1)
expect(pane.webglAddon).not.toBeNull()
expect(pane.terminal.loadAddon).toHaveBeenCalledTimes(1)
})
it('defers requested WebGL rebuilds until a hidden pane resumes', () => {
stubWindowsDesktop()
const pane = createPane()
attachWebgl(pane)
const retainedAddon = pane.webglAddon
suspendPaneRendering([pane])
rebuildAttachedWebgl(pane)
expect(pane.webglAddon).toBe(retainedAddon)
expect(pane.webglRebuildDeferred).toBe(true)
expect(pane.terminal.loadAddon).toHaveBeenCalledTimes(1)
resumePaneRendering([pane])
expect(pane.webglAddon).not.toBe(retainedAddon)
expect(pane.webglRebuildDeferred).toBe(false)
expect(pane.terminal.loadAddon).toHaveBeenCalledTimes(2)
})
it('reattaches an LRU-evicted context when its pane resumes', () => {
stubWindowsDesktop()
const panes = Array.from({ length: RETAINED_WEBGL_PANE_LIMIT + 1 }, (_, id) =>
createPane({ id })
)
for (const pane of panes) {
attachWebgl(pane)
}
suspendPaneRendering(panes)
expect(panes[0].webglAddon).toBeNull()
expect(panes[0].terminal.loadAddon).toHaveBeenCalledTimes(1)
resumePaneRendering(panes)
expect(retainedWebglPaneCount()).toBe(0)
expect(panes[0].webglAddon).not.toBeNull()
expect(panes[0].terminal.loadAddon).toHaveBeenCalledTimes(2)
expect(panes[1].terminal.loadAddon).toHaveBeenCalledTimes(1)
})
it('blocks new WebGL contexts while a Windows pane is hidden', () => {
stubWindowsDesktop()
const pane = createPane()
suspendPaneRendering([pane])
attachWebgl(pane)
expect(pane.webglAddon).toBeNull()
expect(pane.terminal.loadAddon).not.toHaveBeenCalled()
})
it('recovers a retained Windows context lost while hidden', () => {
stubWindowsDesktop()
const pane = createPane()
attachWebgl(pane)
suspendPaneRendering([pane])
expect(retainedWebglPaneCount()).toBe(1)
fireContextLoss(pane)
expect(pane.webglAddon).toBeNull()
expect(pane.webglDisabledAfterContextLoss).toBe(true)
expect(retainedWebglPaneCount()).toBe(0)
resumePaneRendering([pane])
expect(pane.webglDisabledAfterContextLoss).toBe(false)
expect(pane.webglAddon).not.toBeNull()
expect(pane.terminal.loadAddon).toHaveBeenCalledTimes(2)
})
it('does not schedule a DOM refit when a hidden retained context is lost', () => {
stubWindowsDesktop()
const requestAnimationFrame = vi.fn(() => 1)
vi.stubGlobal('requestAnimationFrame', requestAnimationFrame)
const pane = createPane()
attachWebgl(pane)
suspendPaneRendering([pane])
fireContextLoss(pane)
expect(requestAnimationFrame).not.toHaveBeenCalled()
expect(pane.pendingWebglRefreshRafId).toBeNull()
})
it('re-latches when the retried context is lost again', () => {
const pane = createPane()

View File

@ -1,130 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ManagedPaneInternal } from './pane-manager-types'
import {
clearRetainedWebglPanesForTests,
releaseRetainedWebglPane,
RETAINED_WEBGL_PANE_LIMIT,
retainedWebglPaneCount,
retainSuspendedWebglPane,
shouldRetainSuspendedWebglContexts
} from './pane-webgl-context-retention'
import { resumePaneRendering, suspendPaneRendering } from './pane-rendering-control'
import { disposeWebgl } from './pane-webgl-renderer'
function createAttachedPane(id: number): ManagedPaneInternal {
return {
id,
webglAddon: { dispose: vi.fn() },
webglAttachmentDeferred: false,
pendingWebglRefreshRafId: null
} as never
}
function stubRendererWindow(platform: NodeJS.Platform, webClient = false): void {
vi.stubGlobal('window', {
__ORCA_WEB_CLIENT__: webClient,
api: { platform: { get: () => ({ platform }) } },
location: { pathname: webClient ? '/web-index.html' : '/index.html' }
})
}
describe('pane WebGL context retention', () => {
afterEach(() => {
clearRetainedWebglPanesForTests()
vi.unstubAllGlobals()
})
it('retains contexts only on Windows desktop', () => {
stubRendererWindow('win32')
expect(shouldRetainSuspendedWebglContexts()).toBe(true)
stubRendererWindow('darwin')
expect(shouldRetainSuspendedWebglContexts()).toBe(false)
stubRendererWindow('linux')
expect(shouldRetainSuspendedWebglContexts()).toBe(false)
stubRendererWindow('win32', true)
expect(shouldRetainSuspendedWebglContexts()).toBe(false)
})
it('recognizes a web client from its entrypoint', () => {
vi.stubGlobal('window', {
api: { platform: { get: () => ({ platform: 'win32' }) } },
location: { pathname: '/web-index.html' }
})
expect(shouldRetainSuspendedWebglContexts()).toBe(false)
})
it('evicts and disposes the oldest hidden contexts past the limit', () => {
stubRendererWindow('win32')
const panes = Array.from({ length: RETAINED_WEBGL_PANE_LIMIT + 2 }, (_, index) =>
createAttachedPane(index)
)
const oldestAddon = panes[0].webglAddon
suspendPaneRendering(panes)
expect(retainedWebglPaneCount()).toBe(RETAINED_WEBGL_PANE_LIMIT)
expect(oldestAddon?.dispose).toHaveBeenCalledTimes(1)
expect(panes[0].webglAddon).toBeNull()
expect(panes[1].webglAddon).toBeNull()
expect(panes[2].webglAddon).not.toBeNull()
expect(panes.at(-1)?.webglAddon).not.toBeNull()
disposeWebgl(panes[0])
expect(oldestAddon?.dispose).toHaveBeenCalledTimes(1)
})
it('moves a re-retained pane to the newest position', () => {
const panes = Array.from({ length: RETAINED_WEBGL_PANE_LIMIT + 1 }, (_, index) =>
createAttachedPane(index)
)
for (const pane of panes.slice(0, RETAINED_WEBGL_PANE_LIMIT)) {
retainSuspendedWebglPane(pane)
}
retainSuspendedWebglPane(panes[0])
const evicted = retainSuspendedWebglPane(panes.at(-1)!)
expect(evicted).toBe(panes[1])
expect(retainedWebglPaneCount()).toBe(RETAINED_WEBGL_PANE_LIMIT)
})
it('evicts across pane managers using global worktree hide recency', () => {
stubRendererWindow('win32')
const firstManagerPanes = Array.from({ length: RETAINED_WEBGL_PANE_LIMIT }, (_, index) =>
createAttachedPane(index)
)
const secondManagerPane = createAttachedPane(RETAINED_WEBGL_PANE_LIMIT)
suspendPaneRendering(firstManagerPanes)
suspendPaneRendering([secondManagerPane])
expect(firstManagerPanes[0].webglAddon).toBeNull()
expect(secondManagerPane.webglAddon).not.toBeNull()
expect(retainedWebglPaneCount()).toBe(RETAINED_WEBGL_PANE_LIMIT)
})
it('releases panes from the retained set', () => {
const pane = createAttachedPane(1)
retainSuspendedWebglPane(pane)
releaseRetainedWebglPane(pane)
expect(retainedWebglPaneCount()).toBe(0)
})
it('never leaves resumed panes eligible for eviction', () => {
stubRendererWindow('win32')
const pane = createAttachedPane(1)
pane.webglAttachmentDeferred = true
retainSuspendedWebglPane(pane)
resumePaneRendering([pane])
expect(retainedWebglPaneCount()).toBe(0)
expect(pane.webglAddon).not.toBeNull()
})
})

View File

@ -1,45 +0,0 @@
import { getRendererAppPlatform } from '@/lib/renderer-app-platform'
import { isWebClientLocation } from '@/lib/web-client-location'
import { TERMINAL_WEBGL_RETAINED_WORKTREE_CONTEXTS } from '../../../../shared/terminal-webgl-context-budget'
import type { ManagedPaneInternal } from './pane-manager-types'
export const RETAINED_WEBGL_PANE_LIMIT = TERMINAL_WEBGL_RETAINED_WORKTREE_CONTEXTS
export function shouldRetainSuspendedWebglContexts(): boolean {
return (
typeof window !== 'undefined' && getRendererAppPlatform() === 'win32' && !isWebClientLocation()
)
}
// Set insertion order tracks worktree-surface hide recency.
const retainedPanes = new Set<ManagedPaneInternal>()
export function retainSuspendedWebglPane(pane: ManagedPaneInternal): ManagedPaneInternal | null {
if (!pane.webglAddon) {
retainedPanes.delete(pane)
return null
}
retainedPanes.delete(pane)
retainedPanes.add(pane)
if (retainedPanes.size <= RETAINED_WEBGL_PANE_LIMIT) {
return null
}
const oldest = retainedPanes.values().next().value
if (!oldest) {
return null
}
retainedPanes.delete(oldest)
return oldest
}
export function releaseRetainedWebglPane(pane: ManagedPaneInternal): void {
retainedPanes.delete(pane)
}
export function retainedWebglPaneCount(): number {
return retainedPanes.size
}
export function clearRetainedWebglPanesForTests(): void {
retainedPanes.clear()
}

View File

@ -11,11 +11,6 @@ export function rebuildAttachedWebgl(pane: ManagedPaneInternal): void {
if (!pane.webglAddon || pane.webglDisabledAfterContextLoss) {
return
}
if (pane.webglAttachmentDeferred) {
pane.webglRebuildDeferred = true
return
}
pane.webglRebuildDeferred = false
disposeWebgl(pane)
// Why: the live addon just proved context creation works, so a stale attach
// backoff from an earlier failure must not downgrade this pane to DOM.

View File

@ -1,12 +1,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { PaneManager } from './pane-manager'
import type { ManagedPaneInternal } from './pane-manager-types'
import { disposePane } from './pane-lifecycle'
import { resumePaneRendering, suspendPaneRendering } from './pane-rendering-control'
import {
clearRetainedWebglPanesForTests,
retainedWebglPaneCount
} from './pane-webgl-context-retention'
import { suspendPaneRendering } from './pane-rendering-control'
import { disposeWebgl } from './pane-webgl-renderer'
import {
beginTerminalScrollIntentBufferRebuild,
@ -14,9 +9,7 @@ import {
} from './terminal-scroll-intent-rebuild'
function createPane(
overrides: Partial<
Pick<ManagedPaneInternal, 'id' | 'pendingWebglRefreshRafId' | 'webglAddon'>
> = {}
overrides: Partial<Pick<ManagedPaneInternal, 'pendingWebglRefreshRafId' | 'webglAddon'>> = {}
): ManagedPaneInternal {
const leafId = '11111111-1111-4111-8111-111111111111' as never
return {
@ -66,17 +59,8 @@ function createPane(
}
}
function stubRendererWindow(platform: NodeJS.Platform, webClient = false): void {
vi.stubGlobal('window', {
__ORCA_WEB_CLIENT__: webClient,
api: { platform: { get: () => ({ platform }) } },
location: { pathname: webClient ? '/web-index.html' : '/index.html' }
})
}
describe('pane WebGL refresh lifecycle', () => {
afterEach(() => {
clearRetainedWebglPanesForTests()
vi.unstubAllGlobals()
})
@ -137,8 +121,7 @@ describe('pane WebGL refresh lifecycle', () => {
expect(pane.webglAddon).toBeNull()
})
it('disposes WebGL when rendering is suspended', () => {
stubRendererWindow('darwin')
it('disposes WebGL whenever rendering is suspended', () => {
const dispose = vi.fn()
const pane = createPane({ webglAddon: { dispose } as never })
@ -149,112 +132,6 @@ describe('pane WebGL refresh lifecycle', () => {
expect(pane.webglAddon).toBeNull()
})
it('retains and reuses a live WebGL addon on Windows desktop', () => {
stubRendererWindow('win32')
const dispose = vi.fn()
const addon = { dispose } as never
const pane = createPane({ webglAddon: addon })
vi.mocked(pane.terminal.refresh).mockClear()
suspendPaneRendering([pane])
expect(pane.webglAttachmentDeferred).toBe(true)
expect(pane.webglAddon).toBe(addon)
expect(dispose).not.toHaveBeenCalled()
resumePaneRendering([pane])
expect(pane.webglAttachmentDeferred).toBe(false)
expect(pane.webglAddon).toBe(addon)
expect(pane.terminal.refresh).toHaveBeenCalledWith(0, 23)
})
it('disposes suspended WebGL in a Windows web client', () => {
stubRendererWindow('win32', true)
const dispose = vi.fn()
const pane = createPane({ webglAddon: { dispose } as never })
suspendPaneRendering([pane])
expect(pane.webglAttachmentDeferred).toBe(true)
expect(dispose).toHaveBeenCalledTimes(1)
expect(pane.webglAddon).toBeNull()
})
it('skips retained panes but refreshes hidden DOM panes during global recovery', () => {
const retained = createPane({ id: 1 })
const hiddenDom = createPane({ id: 2, webglAddon: null })
const visible = createPane()
retained.webglAttachmentDeferred = true
hiddenDom.webglAttachmentDeferred = true
vi.mocked(retained.terminal.refresh).mockClear()
vi.mocked(hiddenDom.terminal.refresh).mockClear()
vi.mocked(visible.terminal.refresh).mockClear()
PaneManager.prototype.refreshAllPanes.call({
panes: new Map([
[1, retained],
[2, hiddenDom],
[3, visible]
])
} as never)
expect(retained.terminal.refresh).not.toHaveBeenCalled()
expect(hiddenDom.terminal.refresh).toHaveBeenCalledWith(0, 23)
expect(visible.terminal.refresh).toHaveBeenCalledWith(0, 23)
})
it('disposes a retained Windows context when its pane unmounts', () => {
stubRendererWindow('win32')
const dispose = vi.fn()
const pane = createPane({ webglAddon: { dispose } as never })
const panes = new Map([[pane.id, pane]])
suspendPaneRendering([pane])
expect(retainedWebglPaneCount()).toBe(1)
disposePane(pane, panes)
expect(dispose).toHaveBeenCalledTimes(1)
expect(pane.webglAddon).toBeNull()
expect(panes.has(pane.id)).toBe(false)
expect(retainedWebglPaneCount()).toBe(0)
})
it('drains all retained contexts when a pane manager is destroyed', () => {
stubRendererWindow('win32')
const first = createPane({ id: 1 })
const second = createPane({ id: 2 })
const panes = new Map([
[first.id, first],
[second.id, second]
])
suspendPaneRendering(panes.values())
const root = {
innerHTML: 'mounted',
querySelectorAll: vi.fn(() => [])
}
PaneManager.prototype.destroy.call({
destroyed: false,
panes,
identities: { clear: vi.fn() },
root,
activePaneId: first.id,
dragState: {
dragSourcePaneId: null,
dropOverlay: null,
currentDropTarget: null,
currentExternalDropTarget: null,
cleanupActiveDrag: null
},
cancelPendingPaneReparentFrames: vi.fn()
} as never)
expect(retainedWebglPaneCount()).toBe(0)
expect(panes.size).toBe(0)
expect(root.innerHTML).toBe('')
})
it('cancels a pending WebGL refresh when the pane is disposed', () => {
const cancelAnimationFrame = vi.fn()
vi.stubGlobal('cancelAnimationFrame', cancelAnimationFrame)

View File

@ -123,23 +123,4 @@ describe('terminal WebGL addon lifecycle', () => {
expect(pane.terminal.refresh).not.toHaveBeenCalled()
})
it('skips atlas recovery while a retained pane is hidden', () => {
const pane = createPane()
pane.webglAttachmentDeferred = true
pane.webglAddon = { clearTextureAtlas: vi.fn() } as never
resetWebglTextureAtlas(pane)
expect(pane.terminal.refresh).not.toHaveBeenCalled()
})
it('keeps atlas recovery for a hidden DOM-rendered pane', () => {
const pane = createPane()
pane.webglAttachmentDeferred = true
resetWebglTextureAtlas(pane)
expect(pane.terminal.refresh).toHaveBeenCalledWith(0, 23)
})
})

View File

@ -3,7 +3,6 @@ import type { ManagedPaneInternal } from './pane-manager-types'
import { recordTerminalWebglDiagnostic } from '../../../../shared/terminal-webgl-diagnostics'
import { getLivePaneCensus } from './pane-manager-registry'
import { forceRepaintThroughRenderPause } from './terminal-render-pause-release'
import { releaseRetainedWebglPane } from './pane-webgl-context-retention'
import {
getTerminalWebglAutoDecision,
resetTerminalWebglAutoDecision
@ -18,11 +17,9 @@ let suggestedRendererType: 'dom' | undefined
// attach constantly in "on" mode. Latch the first failure and skip attempts
// until the next recovery boundary (rendering resume or GPU-setting change).
let webglAttachFailedSinceRecovery = false
let contextInspectionUnavailableRecorded = false
type ReleasableWebglContext = {
getExtension(name: 'WEBGL_lose_context'): WEBGL_lose_context | null
isContextLost?: () => boolean
}
type XtermWebglAddonInternals = {
@ -74,28 +71,10 @@ export function cancelPendingWebglRefresh(pane: ManagedPaneInternal): void {
pane.pendingWebglRefreshRafId = null
}
export function isPaneWebglContextLost(pane: ManagedPaneInternal): boolean {
try {
const renderer = (pane.webglAddon as unknown as XtermWebglAddonInternals | null)?._renderer
const isContextLost = renderer?._gl?.isContextLost
if (!isContextLost) {
if (pane.webglAddon && !contextInspectionUnavailableRecorded) {
contextInspectionUnavailableRecorded = true
recordTerminalWebglDiagnostic('webgl-context-inspection-unavailable', { paneId: pane.id })
}
return false
}
return isContextLost.call(renderer._gl) === true
} catch {
return true
}
}
export function disposeWebgl(
pane: ManagedPaneInternal,
options?: { refreshDimensions?: boolean }
): void {
releaseRetainedWebglPane(pane)
cancelPendingWebglRefresh(pane)
if (!pane.webglAddon) {
return
@ -146,8 +125,7 @@ export function markComplexScriptOutput(pane: ManagedPaneInternal): void {
}
export function resetWebglTextureAtlas(pane: ManagedPaneInternal): void {
// Retained contexts repaint on resume; hidden DOM panes keep the existing recovery path.
if (pane.webglDisabledAfterContextLoss || (pane.webglAttachmentDeferred && pane.webglAddon)) {
if (pane.webglDisabledAfterContextLoss) {
return
}
try {
@ -215,11 +193,10 @@ export function attachWebgl(pane: ManagedPaneInternal): void {
// visually blank, so keep the pane on the DOM renderer until the next
// rendering resume (worktree foreground / window wake) retries it.
pane.webglDisabledAfterContextLoss = true
disposeWebgl(pane, { refreshDimensions: !pane.webglAttachmentDeferred })
disposeWebgl(pane, { refreshDimensions: true })
})
pane.terminal.loadAddon(addon)
pane.webglAddon = addon
pane.webglRebuildDeferred = false
refreshTerminalAfterWebglAttach(pane)
} catch (err) {
if (pane.terminalGpuAcceleration === 'auto') {

View File

@ -1,4 +0,0 @@
export const TERMINAL_WEBGL_MAX_ACTIVE_CONTEXTS = 128
// Bounds worktree-surface retention; active-worktree tabs use the remaining budget.
export const TERMINAL_WEBGL_RETAINED_WORKTREE_CONTEXTS = 32