fix(terminal): robust context loss recovery and direct DOM fallback during GPU resets (#11354)

Co-authored-by: Gerry Burde <gerry.burde@example.com>
Co-authored-by: Eugene <inbox@null.page>
Co-authored-by: Eugene <x@null.page>
This commit is contained in:
Gerry9000
2026-06-23 08:32:06 +02:00
committed by GitHub
co-authored by Gerry Burde Eugene Eugene
parent 7f1562605c
commit 0cd8d4006d
4 changed files with 77 additions and 76 deletions
@@ -9,7 +9,6 @@ import { BaseSession } from '../session'
import { Frontend } from '../frontends/frontend'
import { XTermFrontend, XTermWebGLFrontend } from '../frontends/xtermFrontend'
import { syncTerminalVisibility } from '../frontends/visibility'
import { ResizeEvent, BaseTerminalProfile } from './interfaces'
import { TerminalDecorator } from './decorator'
import { SearchPanelComponent } from '../components/searchPanel.component'
@@ -446,8 +445,8 @@ export class BaseTerminalTabComponent<P extends BaseTerminalProfile> extends Bas
this.visibility$
.pipe(debounce(visibility => interval(visibility ? 0 : INACTIVE_TAB_UNLOAD_DELAY)))
.subscribe(visibility => {
if (this.frontend instanceof XTermFrontend) {
syncTerminalVisibility(this.frontend, visibility)
if (visibility && this.frontend instanceof XTermFrontend) {
this.frontend.reactivate()
}
})
}
@@ -1,38 +0,0 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { syncTerminalVisibility } from './visibility.ts'
test('syncTerminalVisibility reactivates the frontend when the tab becomes visible', () => {
let reactivated = 0
let deactivated = 0
syncTerminalVisibility({
reactivateAfterVisibilityChange: () => {
reactivated++
},
deactivateAfterVisibilityChange: () => {
deactivated++
},
}, true)
assert.equal(reactivated, 1)
assert.equal(deactivated, 0)
})
test('syncTerminalVisibility releases hidden-tab resources when the tab becomes invisible', () => {
let reactivated = 0
let deactivated = 0
syncTerminalVisibility({
reactivateAfterVisibilityChange: () => {
reactivated++
},
deactivateAfterVisibilityChange: () => {
deactivated++
},
}, false)
assert.equal(reactivated, 0)
assert.equal(deactivated, 1)
})
@@ -1,12 +0,0 @@
export interface VisibilityManagedTerminalFrontend {
reactivateAfterVisibilityChange: () => void
deactivateAfterVisibilityChange: () => void
}
export function syncTerminalVisibility (frontend: VisibilityManagedTerminalFrontend, visible: boolean): void {
if (visible) {
frontend.reactivateAfterVisibilityChange()
} else {
frontend.deactivateAfterVisibilityChange()
}
}
+75 -23
View File
@@ -1,5 +1,5 @@
import deepEqual from 'deep-equal'
import { BehaviorSubject, filter, firstValueFrom, takeUntil } from 'rxjs'
import { BehaviorSubject, filter, firstValueFrom, fromEvent, takeUntil } from 'rxjs'
import { Injector } from '@angular/core'
import { ConfigService, getCSSFontFamily, getWindows10Build, HostAppService, HotkeysService, Platform, PlatformService, TerminalColorScheme, ThemesService } from 'tabby-core'
import { Frontend, SearchOptions, SearchState } from './frontend'
@@ -22,6 +22,10 @@ const COLOR_NAMES = [
'brightBlack', 'brightRed', 'brightGreen', 'brightYellow', 'brightBlue', 'brightMagenta', 'brightCyan', 'brightWhite',
]
// How many times to recreate the WebGL renderer after a lost GPU context
// before giving up and letting xterm fall back to its DOM renderer.
const MAX_WEBGL_RECOVERY_ATTEMPTS = 3
class FlowControl {
private blocked = false
private blocked$ = new BehaviorSubject<boolean>(false)
@@ -83,6 +87,8 @@ export class XTermFrontend extends Frontend {
private resizeObserver?: any
private flowControl: FlowControl
private pinnedToBottom = true
private pendingRendererRecovery = false
private rendererRecoveryAttempts = 0
private configService: ConfigService
private hotkeysService: HotkeysService
@@ -98,20 +104,15 @@ export class XTermFrontend extends Frontend {
this.hostApp = injector.get(HostAppService)
this.themes = injector.get(ThemesService)
const terminalOptions = {
this.xterm = new Terminal({
allowTransparency: true,
allowProposedApi: true,
overviewRulerWidth: 8,
windowsPty: process.platform === 'win32' ? {
backend: this.configService.store.terminal.useConPTY ? 'conpty' as const : 'winpty' as const,
backend: this.configService.store.terminal.useConPTY ? 'conpty' : 'winpty',
buildNumber: getWindows10Build(),
} : undefined,
}
;(terminalOptions as Record<string, unknown>).overviewRuler = {
width: 8,
showBottomBorder: false,
showTopBorder: false,
}
this.xterm = new Terminal(terminalOptions)
})
this.flowControl = new FlowControl(this.xterm)
this.xtermCore = this.xterm['_core']
@@ -271,8 +272,7 @@ export class XTermFrontend extends Frontend {
this.configureColors(profile.terminalColorScheme)
if (this.enableWebGL) {
this.webGLAddon = new WebglAddon()
this.xterm.loadAddon(this.webGLAddon)
this.attachWebGLAddon()
this.platformService.displayMetricsChanged$.pipe(
takeUntil(this.destroyed$),
).subscribe(() => {
@@ -302,6 +302,12 @@ export class XTermFrontend extends Frontend {
window.addEventListener('resize', this.resizeHandler)
// The GPU context is often dropped while the app is in the background;
// retry recovery once the window is focused again and WebGL is usable.
fromEvent(window, 'focus').pipe(
takeUntil(this.destroyed$),
).subscribe(() => this.recoverRenderer())
this.resizeHandler()
// Allow an animation frame
@@ -365,17 +371,6 @@ export class XTermFrontend extends Frontend {
delete this.resizeObserver
}
reactivateAfterVisibilityChange (): void {
this.resizeHandler()
}
deactivateAfterVisibilityChange (): void {
this.xterm.element?.querySelectorAll('canvas').forEach(c => {
c.height = c.width = 0
c.style.height = c.style.width = '0px'
})
}
destroy (): void {
super.destroy()
this.webGLAddon?.dispose()
@@ -633,6 +628,63 @@ export class XTermFrontend extends Frontend {
this.resizeHandler()
}
/**
* Redraw the terminal and recover the renderer when its tab is shown again.
* Reactivating clears stale renderer state left behind while the tab was
* hidden, and flushes any GPU context recovery deferred until now.
*/
reactivate (): void {
if (this.pendingRendererRecovery) {
this.recoverRenderer()
} else {
this.redraw()
}
}
private attachWebGLAddon (): void {
const addon = new WebglAddon()
// xterm fires this when the GPU drops the canvas context (driver reset,
// backgrounded app, too many live contexts).
addon.onContextLoss(() => this.onWebGLContextLoss())
this.xterm.loadAddon(addon)
this.webGLAddon = addon
}
private onWebGLContextLoss (): void {
this.webGLAddon?.dispose()
this.webGLAddon = undefined
this.pendingRendererRecovery = true
this.recoverRenderer()
}
/**
* Recreate the WebGL renderer after a lost GPU context. A new context can
* only be created on a visible, focused canvas, so this no-ops while the
* tab is hidden and is retried on reactivation or window focus.
*/
private recoverRenderer (): void {
if (!this.pendingRendererRecovery || !this.canRecoverRenderer()) {
return
}
this.pendingRendererRecovery = false
if (this.rendererRecoveryAttempts < MAX_WEBGL_RECOVERY_ATTEMPTS) {
this.rendererRecoveryAttempts++
this.attachWebGLAddon()
}
// Once the retry budget is exhausted xterm falls back to its DOM renderer.
this.redraw()
}
private canRecoverRenderer (): boolean {
return !!this.element && this.element.offsetParent !== null && document.hasFocus()
}
private redraw (): void {
const renderService = this.xtermCore._renderService
renderService?.clear()
renderService?.handleResize(this.xterm.cols, this.xterm.rows)
}
private getSelectionAsHTML (): string {
return this.serializeAddon.serializeAsHTML({ includeGlobalBackground: true, onlySelection: true })
}