diff --git a/src/Terminal.ts b/src/Terminal.ts index 6da8f33952..371007dd7a 100644 --- a/src/Terminal.ts +++ b/src/Terminal.ts @@ -469,17 +469,7 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II } break; case 'rendererType': - if (this.renderer) { - this.unregister(this.renderer); - this.renderer.dispose(); - this.renderer = null; - } - this._setupRenderer(); - this.renderer.onCharSizeChanged(); - if (this._theme) { - this.renderer.setTheme(this._theme); - } - this.mouseHelper.setRenderer(this.renderer); + this.recreateRenderer(); break; case 'scrollback': this.buffers.resize(this.cols, this.rows); @@ -507,6 +497,21 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II } } + public recreateRenderer(): void { + if (this.renderer) { + this.unregister(this.renderer); + this.renderer.dispose(); + this.renderer = null; + } + this._setupRenderer(); + this.renderer.onCharSizeChanged(); + + if (this._theme) { + this.renderer.setTheme(this._theme); + } + this.mouseHelper.setRenderer(this.renderer); + } + /** * Binds the desired focus behavior on a given terminal object. */ @@ -701,13 +706,14 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II // Performance: Add viewport and helper elements from the fragment this.element.appendChild(fragment); - this._setupRenderer(); this._theme = this.options.theme; this.options.theme = null; this.viewport = new Viewport(this, this._viewportElement, this._viewportScrollArea, this.charMeasure); - this.viewport.onThemeChanged(this.renderer.colorManager.colors); this.register(this.viewport); + this._setupRenderer(); + this.viewport.onThemeChanged(this.renderer.colorManager.colors); + this.register(this.addDisposableListener('cursormove', () => this.renderer.onCursorMove())); this.register(this.addDisposableListener('resize', () => this.renderer.onResize(this.cols, this.rows))); this.register(this.addDisposableListener('blur', () => this.renderer.onBlur())); @@ -717,7 +723,6 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II // matchMedia query. this.register(addDisposableDomListener(window, 'resize', () => this.renderer.onWindowResize(window.devicePixelRatio))); this.register(this.charMeasure.addDisposableListener('charsizechanged', () => this.renderer.onCharSizeChanged())); - this.register(this.renderer.addDisposableListener('resize', (dimensions) => this.viewport.syncScrollArea())); this.selectionManager = new SelectionManager(this, this.charMeasure); this.register(addDisposableDomListener(this.element, 'mousedown', (e: MouseEvent) => this.selectionManager.onMouseDown(e))); @@ -768,11 +773,12 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II private _setupRenderer(): void { switch (this.options.rendererType) { - case 'canvas': this.renderer = new Renderer(this, this.options.theme); break; - case 'dom': this.renderer = new DomRenderer(this, this.options.theme); break; - case 'webgl': this.renderer = new WebglRenderer(this, this.options.theme); break; + case 'canvas': this.renderer = new Renderer(this, this._theme); break; + case 'dom': this.renderer = new DomRenderer(this, this._theme); break; + case 'webgl': this.renderer = new WebglRenderer(this, this._theme); break; default: throw new Error(`Unrecognized rendererType "${this.options.rendererType}"`); } + this.register(this.renderer.addDisposableListener('resize', (dimensions) => this.viewport.syncScrollArea())); this.register(this.renderer); } diff --git a/src/Types.ts b/src/Types.ts index 888a321f0c..a292139d5a 100644 --- a/src/Types.ts +++ b/src/Types.ts @@ -230,6 +230,7 @@ export interface ITerminal extends PublicTerminal, IElementAccessor, IBufferAcce cancel(ev: Event, force?: boolean): boolean | void; log(text: string): void; showCursor(): void; + recreateRenderer(): void; } export interface IBufferAccessor { diff --git a/src/renderer/webgl/WebglRenderer.ts b/src/renderer/webgl/WebglRenderer.ts index c627fa6f01..4d13d4ba53 100644 --- a/src/renderer/webgl/WebglRenderer.ts +++ b/src/renderer/webgl/WebglRenderer.ts @@ -20,9 +20,15 @@ import { CHAR_DATA_ATTR_INDEX, CHAR_DATA_CODE_INDEX, CHAR_DATA_CHAR_INDEX, NULL_ import { IWebGL2RenderingContext } from './Types'; import { INVERTED_DEFAULT_COLOR, DEFAULT_COLOR } from '../atlas/Types'; import { RenderModel, COMBINED_CHAR_BIT_MASK } from './RenderModel'; +import { addDisposableDomListener } from '../../ui/Lifecycle'; export const INDICIES_PER_CELL = 4; +interface IPendingRecover { + renderer: WebglRenderer; + justLost: boolean +} + export class WebglRenderer extends EventEmitter implements IRenderer { private _renderDebouncer: RenderDebouncer; private _renderLayers: IRenderLayer[]; @@ -39,10 +45,101 @@ export class WebglRenderer extends EventEmitter implements IRenderer { private _isPaused: boolean = false; private _needsFullRefresh: boolean = false; + private _salvaged: boolean = false; public dimensions: IRenderDimensions; public colorManager: ColorManager; + // Destroys paused renderes if the resources are needed for unpaused ones. + // This is unique to WebglRenderer because 'webgl2' contexts can become + // invalidated at any moment, typically by creating other canvases with + // 'webgl2' context, but also arbitrarily by the OS. + private static _contextHelper = new class { + private _recoverTimeout: any = null; + + // Terminals ordered by least-recently-used first + private _lru: ITerminal[] = []; + + // Renderers that lost context and need to be recreated + private _pendingRecover: IPendingRecover[] = []; + + // Called when a renderer is created or unpaused to signify that the user + // just used this terminal. Terminals are kept in least-recently-used order, + // so that if resources are needed, we can sacrifice the least important + // ones + public onRendererUnpaused(renderer: WebglRenderer) { + const index = this._lru.indexOf(renderer._terminal); + if (index >= 0) { + this._lru.splice(index, 1); + } + this._lru.push(renderer._terminal); + } + + public onRendererCreated(renderer: WebglRenderer) { + this.onRendererUnpaused(renderer); + } + + + public onRendererDestroyed(renderer: WebglRenderer) { + const index = this._lru.indexOf(renderer._terminal); + this._lru.splice(index, 1); + } + + public recoverContext(renderer: WebglRenderer, justLost: boolean) { + this._pendingRecover.push({ renderer, justLost }); + if (!this._recoverTimeout) { + this._recoverTimeout = setTimeout(() => { + this._recoverTimeout = null; + + // webglcontextlost can fire before resize observer, which means that some + // of the renderers that are pending recovery, might not actually need it + // if they are now paused + const toRecover = this._pendingRecover.filter(({ renderer }) => !renderer._isPaused); + this._pendingRecover = []; + if (toRecover.length > 0) { + this._recoverPendingContexts(toRecover) + } + }, 0); + } + } + + private _recoverPendingContexts(toRecover: IPendingRecover[]) { + // try to kill some paused renderers to make room for this one + let killCount = 0; + for (let term of this._lru) { + const renderer = term.renderer; + if (renderer._isPaused && !renderer._gl.isContextLost()) { + // Prevent this renderer from being immediately recreated when + // webglcontextlost fires + renderer.loseContext(); + renderer._salvaged = true; + + // Only kill enough renderers to recover all the pending contexts + killCount++; + if (killCount >= toRecover.length) { + break; + } + } + } + + // If any of these contexts were *just* lost, it means that we're currently + // at a resource limit, but if we failed to release any resources + // (i.e. everything is unpaused), there's no point in trying to recreate a + // renderer because we would be killing an unpaused one. In this case, + // just give up. + const requireKill = toRecover.some(({ justLost }) => justLost); + if (requireKill && killCount === 0) { + console.error('Too many simultaneous webgl contexts'); + return; + } + + // Recreate these renderer + toRecover.forEach(({ renderer }) => { + renderer._terminal.recreateRenderer() + }); + } + } + constructor( private _terminal: ITerminal, theme: ITheme @@ -87,6 +184,7 @@ export class WebglRenderer extends EventEmitter implements IRenderer { if (!this._gl) { throw new Error('WebGL2 not supported'); } + this.register(addDisposableDomListener(this._canvas, 'webglcontextlost', () => { this._onContextLost() })); this._terminal.screenElement.appendChild(this._canvas); this._rectangleRenderer = new RectangleRenderer(this._terminal, this.colorManager, this._gl, this.dimensions); @@ -95,22 +193,59 @@ export class WebglRenderer extends EventEmitter implements IRenderer { // Detect whether IntersectionObserver is detected and enable renderer pause // and resume based on terminal visibility if so if ('IntersectionObserver' in window) { - const observer = new IntersectionObserver(e => this.onIntersectionChange(e[0]), { threshold: 0 }); + const observer = new IntersectionObserver(e => this._onIntersectionChange(e[0]), { threshold: 0 }); observer.observe(this._terminal.element); this.register({ dispose: () => observer.disconnect() }); } + + WebglRenderer._contextHelper.onRendererCreated(this); } public dispose(): void { this._renderLayers.forEach(l => l.dispose()); this._terminal.screenElement.removeChild(this._canvas); super.dispose(); + this._canvas.width = 0; + this._canvas.height = 0; + + if (!this._gl.isContextLost()) { + console.log('Force context loss'); + this.loseContext(); + } + WebglRenderer._contextHelper.onRendererDestroyed(this); + } + + public loseContext(): void { + this._gl.getExtension('WEBGL_lose_context').loseContext() } - public onIntersectionChange(entry: IntersectionObserverEntry): void { + private _onContextLost(): void { + if (this._salvaged) { + this._salvaged = false; + return; + } + + WebglRenderer._contextHelper.recoverContext(this, true); + } + + private _onIntersectionChange(entry: IntersectionObserverEntry): void { + const wasPaused = this._isPaused; this._isPaused = entry.intersectionRatio === 0; - if (!this._isPaused && this._needsFullRefresh) { - this._terminal.refresh(0, this._terminal.rows - 1); + + if (!this._isPaused) { + if (wasPaused) { + WebglRenderer._contextHelper.onRendererUnpaused(this); + } + if (this._gl.isContextLost()) { + WebglRenderer._contextHelper.recoverContext(this, false); + + // Return here because this renderer will be re-created to acquire a + // valid context, so there's no point in refreshing this one + return; + } + if (this._needsFullRefresh) { + this._terminal.refresh(0, this._terminal.rows - 1); + } } } diff --git a/src/ui/TestUtils.test.ts b/src/ui/TestUtils.test.ts index e6e4aaa32f..9b848775f9 100644 --- a/src/ui/TestUtils.test.ts +++ b/src/ui/TestUtils.test.ts @@ -157,6 +157,9 @@ export class MockTerminal implements ITerminal { showCursor(): void { throw new Error('Method not implemented.'); } + recreateRenderer(): void { + throw new Error('Method not implemented.'); + } refresh(start: number, end: number): void { throw new Error('Method not implemented.'); }