Improve Windows terminal performance: retain WebGL contexts, warm first ConPTY (#7085)

This commit is contained in:
Jinwoo Hong
2026-07-02 18:41:23 -04:00
committed by GitHub
parent ce997b001d
commit 2ce9314acb
27 changed files with 7103 additions and 53 deletions
@@ -237,11 +237,7 @@ export function disposePane(
} catch {
/* ignore */
}
try {
pane.webglAddon?.dispose()
} catch {
/* ignore */
}
disposeWebgl(pane)
try {
pane.searchAddon.dispose()
} catch {
@@ -168,6 +168,11 @@ export class PaneManager {
refreshAllPanes(): void {
for (const pane of this.panes.values()) {
// Why: suspended panes are invisible and repaint on rendering resume;
// recovery repaints must not scale with hidden-workspace pane count.
if (pane.webglAttachmentDeferred) {
continue
}
try {
if (pane.terminal.rows > 0) {
pane.terminal.refresh(0, pane.terminal.rows - 1)
@@ -7,6 +7,11 @@ import {
markComplexScriptOutput,
resetWebglTextureAtlas
} from './pane-webgl-renderer'
import {
retainSuspendedWebglPane,
shouldRetainSuspendedWebglContexts,
unretainWebglPane
} from './pane-webgl-context-retention'
import { reattachWebglIfNeeded } from './pane-webgl-reattach'
export function setPaneGpuRenderingState(
@@ -43,9 +48,20 @@ export function markPaneComplexScriptOutput(
}
export function suspendPaneRendering(panes: Iterable<ManagedPaneInternal>): void {
const retainContexts = shouldRetainSuspendedWebglContexts()
for (const pane of panes) {
// Why: deferred blocks NEW context creation while hidden; on Windows,
// live contexts are retained (not disposed) so the return switch repaints
// instantly instead of paying ANGLE context re-creation. The LRU cap
// bounds how many hidden panes may keep one.
pane.webglAttachmentDeferred = true
disposeWebgl(pane)
if (!retainContexts) {
disposeWebgl(pane)
continue
}
for (const evicted of retainSuspendedWebglPane(pane)) {
disposeWebgl(evicted)
}
}
}
@@ -55,8 +71,23 @@ 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) {
unretainWebglPane(pane)
pane.webglAttachmentDeferred = false
pane.webglDisabledAfterContextLoss = false
if (pane.webglAddon) {
// Why: recovery bursts skip suspended panes, so the shared glyph atlas
// may have been cleared/rebuilt while this pane sat hidden with its
// retained context. Repaint from the buffer so stale glyph coordinates
// never reach the screen.
try {
if (pane.terminal.rows > 0) {
pane.terminal.refresh(0, pane.terminal.rows - 1)
}
} catch {
/* ignore — pane may be tearing down during resume */
}
continue
}
reattachWebglIfNeeded(pane)
}
}
@@ -0,0 +1,235 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ManagedPaneInternal } from './pane-manager-types'
import {
clearRetainedWebglPanes,
RETAINED_WEBGL_PANE_CAP,
retainedWebglPaneCount
} from './pane-webgl-context-retention'
import { resumePaneRendering, suspendPaneRendering } from './pane-rendering-control'
import {
attachWebgl,
disposeWebgl,
resetTerminalWebglSuggestion,
resetWebglTextureAtlas
} from './pane-webgl-renderer'
let nextPaneId = 1
function createPane(): ManagedPaneInternal {
const leafId = '11111111-1111-4111-8111-111111111111' as never
return {
id: nextPaneId++,
leafId,
stablePaneId: leafId,
terminal: {
cols: 80,
rows: 24,
refresh: vi.fn(),
loadAddon: vi.fn()
} as never,
container: {} as never,
xtermContainer: {} as never,
linkTooltip: {} as never,
terminalGpuAcceleration: 'on',
gpuRenderingEnabled: true,
webglAttachmentDeferred: false,
webglDisabledAfterContextLoss: false,
hasComplexScriptOutput: false,
webglAddon: null,
ligaturesAddon: null,
fitResizeObserver: null,
pendingObservedFitRafId: null,
pendingWebglRefreshRafId: null,
fitAddon: {
proposeDimensions: vi.fn(() => ({ cols: 80, rows: 23 })),
fit: vi.fn()
} as never,
searchAddon: {} as never,
serializeAddon: {} as never,
unicode11Addon: {} as never,
webLinksAddon: {} as never,
compositionHandler: null,
pendingSplitScrollState: null,
debugLabel: null
}
}
function createAttachedPane(): ManagedPaneInternal {
const pane = createPane()
attachWebgl(pane)
expect(pane.webglAddon).not.toBeNull()
return pane
}
function fireContextLoss(pane: ManagedPaneInternal): void {
const addon = pane.webglAddon as unknown as { _onContextLoss: { fire: () => void } }
addon._onContextLoss.fire()
}
function stubCommonGlobals(userAgent: string): void {
vi.stubGlobal('navigator', { userAgent })
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
callback(16)
return 1
})
vi.stubGlobal('cancelAnimationFrame', vi.fn())
}
describe('terminal WebGL context retention across hide/show (Windows)', () => {
beforeEach(() => {
resetTerminalWebglSuggestion()
clearRetainedWebglPanes()
vi.spyOn(console, 'warn').mockImplementation(() => {})
stubCommonGlobals('Mozilla/5.0 (Windows NT 10.0; Win64; x64)')
})
afterEach(() => {
clearRetainedWebglPanes()
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
it('keeps the live WebGL addon when rendering is suspended', () => {
const pane = createAttachedPane()
const addon = pane.webglAddon
suspendPaneRendering([pane])
expect(pane.webglAttachmentDeferred).toBe(true)
expect(pane.webglAddon).toBe(addon)
expect(retainedWebglPaneCount()).toBe(1)
})
it('does not create a new context when resuming a retained pane', () => {
const pane = createAttachedPane()
const addon = pane.webglAddon
expect(pane.terminal.loadAddon).toHaveBeenCalledTimes(1)
suspendPaneRendering([pane])
resumePaneRendering([pane])
expect(pane.webglAddon).toBe(addon)
expect(pane.webglAttachmentDeferred).toBe(false)
expect(pane.terminal.loadAddon).toHaveBeenCalledTimes(1)
expect(retainedWebglPaneCount()).toBe(0)
})
it('still blocks new context creation while suspended', () => {
const pane = createPane()
suspendPaneRendering([pane])
attachWebgl(pane)
expect(pane.webglAddon).toBeNull()
expect(pane.terminal.loadAddon).not.toHaveBeenCalled()
})
it('evicts and disposes the least-recently-suspended pane past the cap', () => {
const panes = Array.from({ length: RETAINED_WEBGL_PANE_CAP + 2 }, () => createAttachedPane())
suspendPaneRendering(panes)
expect(retainedWebglPaneCount()).toBe(RETAINED_WEBGL_PANE_CAP)
expect(panes[0].webglAddon).toBeNull()
expect(panes[1].webglAddon).toBeNull()
expect(panes[2].webglAddon).not.toBeNull()
expect(panes.at(-1)?.webglAddon).not.toBeNull()
})
it('re-attaches an evicted pane on resume', () => {
const panes = Array.from({ length: RETAINED_WEBGL_PANE_CAP + 1 }, () => createAttachedPane())
suspendPaneRendering(panes)
expect(panes[0].webglAddon).toBeNull()
resumePaneRendering([panes[0]])
expect(panes[0].webglAddon).not.toBeNull()
expect(panes[0].terminal.loadAddon).toHaveBeenCalledTimes(2)
})
it('drops the retention entry when a suspended pane is disposed', () => {
const pane = createAttachedPane()
suspendPaneRendering([pane])
expect(retainedWebglPaneCount()).toBe(1)
disposeWebgl(pane)
expect(retainedWebglPaneCount()).toBe(0)
expect(pane.webglAddon).toBeNull()
})
it('repaints a retained pane on resume so a cleared shared atlas cannot leave stale glyphs', () => {
const pane = createAttachedPane()
suspendPaneRendering([pane])
vi.mocked(pane.terminal.refresh).mockClear()
resumePaneRendering([pane])
expect(pane.terminal.refresh).toHaveBeenCalledWith(0, pane.terminal.rows - 1)
})
it('skips suspended panes during atlas recovery resets', () => {
const pane = createAttachedPane()
const clearTextureAtlas = vi.fn()
;(pane.webglAddon as unknown as { clearTextureAtlas: () => void }).clearTextureAtlas =
clearTextureAtlas
suspendPaneRendering([pane])
vi.mocked(pane.terminal.refresh).mockClear()
resetWebglTextureAtlas(pane)
expect(clearTextureAtlas).not.toHaveBeenCalled()
expect(pane.terminal.refresh).not.toHaveBeenCalled()
})
it('drops the retention entry on context loss while hidden and recovers on resume', () => {
const pane = createAttachedPane()
suspendPaneRendering([pane])
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()
})
})
describe('terminal WebGL context retention is Windows-only', () => {
beforeEach(() => {
resetTerminalWebglSuggestion()
clearRetainedWebglPanes()
vi.spyOn(console, 'warn').mockImplementation(() => {})
stubCommonGlobals('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)')
})
afterEach(() => {
clearRetainedWebglPanes()
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
it('keeps the dispose-on-hide behavior off Windows', () => {
const pane = createAttachedPane()
suspendPaneRendering([pane])
expect(pane.webglAttachmentDeferred).toBe(true)
expect(pane.webglAddon).toBeNull()
expect(retainedWebglPaneCount()).toBe(0)
})
it('re-attaches on resume off Windows as before', () => {
const pane = createAttachedPane()
suspendPaneRendering([pane])
resumePaneRendering([pane])
expect(pane.webglAddon).not.toBeNull()
expect(pane.terminal.loadAddon).toHaveBeenCalledTimes(2)
})
})
@@ -0,0 +1,56 @@
import { getRendererAppPlatform } from '@/lib/renderer-app-platform'
import type { ManagedPaneInternal } from './pane-manager-types'
// Why: workspace switches used to dispose every hidden pane's WebGL context
// and recreate it on return — ~5ms on macOS but 100-500ms per pane on Windows
// (ANGLE → D3D11), paid synchronously on the switch path. With the app-level
// context budget raised to 128 (#7064), hidden panes can keep their contexts;
// this LRU cap only bounds hidden-pane GPU memory. Sized for the reported hot
// set (~10-16 worktrees × ~2 terminals) while staying far under the budget.
export const RETAINED_WEBGL_PANE_CAP = 32
/** Windows-only: context re-creation is cheap on macOS/Linux GL, so hidden
* panes there keep the long-proven dispose-on-hide behavior instead of
* paying retention's GPU-memory cost for no perceptible switch latency win. */
export function shouldRetainSuspendedWebglContexts(): boolean {
return getRendererAppPlatform() === 'win32'
}
// Insertion order doubles as LRU order: re-retaining deletes + re-adds.
const retainedPanes = new Set<ManagedPaneInternal>()
/**
* Registers a suspended pane's live WebGL context for retention across
* hide/show. Returns panes evicted by the cap — the caller disposes them
* (dependency points that way to avoid a cycle with pane-webgl-renderer).
*/
export function retainSuspendedWebglPane(pane: ManagedPaneInternal): ManagedPaneInternal[] {
if (!pane.webglAddon) {
return []
}
retainedPanes.delete(pane)
retainedPanes.add(pane)
const evicted: ManagedPaneInternal[] = []
while (retainedPanes.size > RETAINED_WEBGL_PANE_CAP) {
const oldest = retainedPanes.values().next().value
if (!oldest) {
break
}
retainedPanes.delete(oldest)
evicted.push(oldest)
}
return evicted
}
export function unretainWebglPane(pane: ManagedPaneInternal): void {
retainedPanes.delete(pane)
}
export function retainedWebglPaneCount(): number {
return retainedPanes.size
}
/** Drops all retention entries without disposing anything (test isolation). */
export function clearRetainedWebglPanes(): void {
retainedPanes.clear()
}
@@ -65,6 +65,30 @@ describe('pane WebGL refresh lifecycle', () => {
expect(pane.pendingWebglRefreshRafId).toBe(29)
})
it('actively releases the xterm WebGL context before disposing the addon', () => {
const loseContext = vi.fn()
const canvas = { width: 120, height: 40 }
const dispose = vi.fn()
const pane = createPane({
webglAddon: {
dispose,
_renderer: {
_gl: {
getExtension: vi.fn(() => ({ loseContext }))
},
_canvas: canvas
}
} as never
})
disposeWebgl(pane)
expect(loseContext).toHaveBeenCalledTimes(1)
expect(dispose).toHaveBeenCalledTimes(1)
expect(canvas).toEqual({ width: 0, height: 0 })
expect(pane.webglAddon).toBeNull()
})
it('cancels a pending WebGL refresh when the pane is disposed', () => {
const cancelAnimationFrame = vi.fn()
vi.stubGlobal('cancelAnimationFrame', cancelAnimationFrame)
@@ -1,5 +1,6 @@
import { WebglAddon } from '@xterm/addon-webgl'
import type { ManagedPaneInternal } from './pane-manager-types'
import { unretainWebglPane } from './pane-webgl-context-retention'
import {
getTerminalWebglAutoDecision,
resetTerminalWebglAutoDecision
@@ -14,6 +15,17 @@ let suggestedRendererType: 'dom' | undefined
// until the next recovery boundary (rendering resume or GPU-setting change).
let webglAttachFailedSinceRecovery = false
type ReleasableWebglContext = {
getExtension(name: 'WEBGL_lose_context'): WEBGL_lose_context | null
}
type XtermWebglAddonInternals = {
_renderer?: {
_gl?: ReleasableWebglContext
_canvas?: HTMLCanvasElement
}
}
export function resetTerminalWebglSuggestion(): void {
// Why: toggling GPU settings should let "auto" retry WebGL after an earlier
// attach failure suggested DOM rendering for this app session.
@@ -61,9 +73,14 @@ export function disposeWebgl(
options?: { refreshDimensions?: boolean }
): void {
cancelPendingWebglRefresh(pane)
// Why: every dispose path (pane close, context loss, GPU-setting off) must
// drop the retention entry, or the LRU would later evict-dispose a pane
// whose addon was already gone.
unretainWebglPane(pane)
if (!pane.webglAddon) {
return
}
releaseXtermWebglContext(pane.webglAddon)
try {
pane.webglAddon.dispose()
} catch {
@@ -85,12 +102,32 @@ export function disposeWebgl(
}
}
function releaseXtermWebglContext(webglAddon: ManagedPaneInternal['webglAddon']): void {
try {
// Why: xterm removes the canvas on dispose, but Windows/ANGLE can keep the
// driver context alive long enough for rapid terminal activation to hit
// Chromium's active WebGL context budget (#6874).
const renderer = (webglAddon as unknown as XtermWebglAddonInternals | null)?._renderer
renderer?._gl?.getExtension('WEBGL_lose_context')?.loseContext()
if (renderer?._canvas) {
renderer._canvas.width = 0
renderer._canvas.height = 0
}
} catch {
/* ignore - WebGL teardown must not block fallback to the DOM renderer */
}
}
export function markComplexScriptOutput(pane: ManagedPaneInternal): void {
pane.hasComplexScriptOutput = true
}
export function resetWebglTextureAtlas(pane: ManagedPaneInternal): void {
if (pane.webglDisabledAfterContextLoss) {
// Why: suspended panes keep retained contexts but are invisible; their
// rebuild is deferred to resumePaneRendering. Clearing + repainting them
// here would make every recovery burst scale with total pane count across
// all workspaces instead of visible panes.
if (pane.webglDisabledAfterContextLoss || pane.webglAttachmentDeferred) {
return
}
try {