diff --git a/tabby-terminal/src/api/baseTerminalTab.component.ts b/tabby-terminal/src/api/baseTerminalTab.component.ts index 04438017..301b43b9 100644 --- a/tabby-terminal/src/api/baseTerminalTab.component.ts +++ b/tabby-terminal/src/api/baseTerminalTab.component.ts @@ -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

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() } }) } diff --git a/tabby-terminal/src/frontends/visibility.test.js b/tabby-terminal/src/frontends/visibility.test.js deleted file mode 100644 index de564117..00000000 --- a/tabby-terminal/src/frontends/visibility.test.js +++ /dev/null @@ -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) -}) diff --git a/tabby-terminal/src/frontends/visibility.ts b/tabby-terminal/src/frontends/visibility.ts deleted file mode 100644 index 7302e765..00000000 --- a/tabby-terminal/src/frontends/visibility.ts +++ /dev/null @@ -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() - } -} diff --git a/tabby-terminal/src/frontends/xtermFrontend.ts b/tabby-terminal/src/frontends/xtermFrontend.ts index 0eb100ad..2c90fc10 100644 --- a/tabby-terminal/src/frontends/xtermFrontend.ts +++ b/tabby-terminal/src/frontends/xtermFrontend.ts @@ -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(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).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 }) }