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:
parent
a51e675804
commit
4681edb520
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
export type TerminalVisibilityEffectPhase = 'layout' | 'passive'
|
||||
|
||||
export function getTerminalVisibilityEffectPhase(
|
||||
platform: NodeJS.Platform
|
||||
): TerminalVisibilityEffectPhase {
|
||||
return platform === 'darwin' ? 'layout' : 'passive'
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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' }
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -127,7 +127,6 @@ export function createPaneDOM(
|
|||
gpuRenderingEnabled: ENABLE_WEBGL_RENDERER,
|
||||
webglAttachmentDeferred: false,
|
||||
webglDisabledAfterContextLoss: false,
|
||||
webglRebuildDeferred: false,
|
||||
hasComplexScriptOutput: false,
|
||||
fitAddon,
|
||||
fitResizeObserver: null,
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Loading…
Reference in New Issue