mirror of
https://github.com/stablyai/orca.git
synced 2026-10-26 00:03:55 +00:00
Improve Windows terminal performance: retain WebGL contexts, warm first ConPTY (#7085)
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user