mirror of
https://github.com/Eugeny/tabby.git
synced 2026-08-24 02:34:19 -05:00
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:
co-authored by
Gerry Burde
Eugene
Eugene
parent
7f1562605c
commit
0cd8d4006d
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user