From 42ad0ff21a48fddd3f408d6045e4f217812e486e Mon Sep 17 00:00:00 2001 From: Juan Campa Date: Wed, 16 Jan 2019 02:05:11 -0500 Subject: [PATCH 1/4] Allow renderer to be recreated if the webgl context is lost --- src/Terminal.ts | 40 +++++++++++++++++------------ src/Types.ts | 1 + src/renderer/webgl/WebglRenderer.ts | 4 +++ src/ui/TestUtils.test.ts | 3 +++ 4 files changed, 31 insertions(+), 17 deletions(-) diff --git a/src/Terminal.ts b/src/Terminal.ts index 25ccf3cb9e..cfa468de60 100644 --- a/src/Terminal.ts +++ b/src/Terminal.ts @@ -466,17 +466,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); @@ -504,6 +494,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. */ @@ -698,13 +703,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())); @@ -714,7 +720,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))); @@ -758,11 +763,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 30879f8207..812e8b013d 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..5e9c01c00d 100644 --- a/src/renderer/webgl/WebglRenderer.ts +++ b/src/renderer/webgl/WebglRenderer.ts @@ -109,6 +109,10 @@ export class WebglRenderer extends EventEmitter implements IRenderer { public onIntersectionChange(entry: IntersectionObserverEntry): void { this._isPaused = entry.intersectionRatio === 0; + if (this._gl.isContextLost()) { + this._terminal.recreateRenderer(); + return; + } if (!this._isPaused && 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.'); } From a8cbf988404724fb054522c64b4e76267cfe3468 Mon Sep 17 00:00:00 2001 From: Juan Campa Date: Wed, 16 Jan 2019 02:05:35 -0500 Subject: [PATCH 2/4] Missing super.dispose() in WebGLRenderer --- src/renderer/webgl/WebglRenderer.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/renderer/webgl/WebglRenderer.ts b/src/renderer/webgl/WebglRenderer.ts index 5e9c01c00d..08c4fb7672 100644 --- a/src/renderer/webgl/WebglRenderer.ts +++ b/src/renderer/webgl/WebglRenderer.ts @@ -102,6 +102,7 @@ export class WebglRenderer extends EventEmitter implements IRenderer { } public dispose(): void { + super.dispose(); this._renderLayers.forEach(l => l.dispose()); this._terminal.screenElement.removeChild(this._canvas); super.dispose(); From 15def95c7656eef7f7d5ffd7abea57c0d0e14ca1 Mon Sep 17 00:00:00 2001 From: Juan Campa Date: Mon, 21 Jan 2019 10:46:21 -0500 Subject: [PATCH 3/4] WebglRenderer's context manager --- src/renderer/webgl/WebglRenderer.ts | 123 ++++++++++++++++++++++++++-- 1 file changed, 115 insertions(+), 8 deletions(-) diff --git a/src/renderer/webgl/WebglRenderer.ts b/src/renderer/webgl/WebglRenderer.ts index 08c4fb7672..b3f3df0f10 100644 --- a/src/renderer/webgl/WebglRenderer.ts +++ b/src/renderer/webgl/WebglRenderer.ts @@ -20,6 +20,7 @@ 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; @@ -39,10 +40,91 @@ 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: WebglRenderer[] = []; + + // 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) { + this._pendingRecover.push(renderer); + 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 were paused + const toRecover = this._pendingRecover.filter(({ _isPaused }) => !_isPaused); + this._pendingRecover = []; + this._recoverPendingContexts(toRecover) + }, 0); + } + } + + private _recoverPendingContexts(toRecover: WebglRenderer[]) { + // This terminal's context was lost but it's not paused, try to dispose the + // 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()) { + renderer._gl.getExtension('WEBGL_lose_context').loseContext() + renderer._salvaged = true; + + // Only kill enough to recover all the pending contexts + killCount++; + if (killCount >= toRecover.length) { + break; + } + } + } + + // If we managed to release some resources, try to recreate thes renderer + if (killCount > 0) { + toRecover.forEach((renderer) => { + renderer._terminal.recreateRenderer() + }); + } else { + console.error('Too many simultaneous webgl contexts'); + } + } + } + constructor( private _terminal: ITerminal, theme: ITheme @@ -87,6 +169,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,27 +178,51 @@ 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 { - super.dispose(); this._renderLayers.forEach(l => l.dispose()); this._terminal.screenElement.removeChild(this._canvas); super.dispose(); + this._canvas.width = 0; + this._canvas.height = 0; + + WebglRenderer._contextHelper.onRendererDestroyed(this); } - public onIntersectionChange(entry: IntersectionObserverEntry): void { - this._isPaused = entry.intersectionRatio === 0; - if (this._gl.isContextLost()) { - this._terminal.recreateRenderer(); + private _onContextLost(): void { + if (this._salvaged) { + this._salvaged = false; return; } - if (!this._isPaused && this._needsFullRefresh) { - this._terminal.refresh(0, this._terminal.rows - 1); + + WebglRenderer._contextHelper.recoverContext(this); + } + + private _onIntersectionChange(entry: IntersectionObserverEntry): void { + const wasPaused = this._isPaused; + this._isPaused = entry.intersectionRatio === 0; + + if (!this._isPaused) { + if (wasPaused) { + WebglRenderer._contextHelper.onRendererUnpaused(this); + } + if (this._gl.isContextLost()) { + WebglRenderer._contextHelper.recoverContext(this); + + // 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); + } } } From 8d3a07671b8ad0bbcd2a862fcc11e0182d97e733 Mon Sep 17 00:00:00 2001 From: Juan Campa Date: Mon, 21 Jan 2019 12:59:01 -0500 Subject: [PATCH 4/4] Adding justLost flag --- src/renderer/webgl/WebglRenderer.ts | 63 ++++++++++++++++++++--------- 1 file changed, 43 insertions(+), 20 deletions(-) diff --git a/src/renderer/webgl/WebglRenderer.ts b/src/renderer/webgl/WebglRenderer.ts index b3f3df0f10..4d13d4ba53 100644 --- a/src/renderer/webgl/WebglRenderer.ts +++ b/src/renderer/webgl/WebglRenderer.ts @@ -24,6 +24,11 @@ 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[]; @@ -56,7 +61,7 @@ export class WebglRenderer extends EventEmitter implements IRenderer { private _lru: ITerminal[] = []; // Renderers that lost context and need to be recreated - private _pendingRecover: WebglRenderer[] = []; + 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, @@ -80,33 +85,36 @@ export class WebglRenderer extends EventEmitter implements IRenderer { this._lru.splice(index, 1); } - public recoverContext(renderer: WebglRenderer) { - this._pendingRecover.push(renderer); + 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 were paused - const toRecover = this._pendingRecover.filter(({ _isPaused }) => !_isPaused); + // 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 = []; - this._recoverPendingContexts(toRecover) + if (toRecover.length > 0) { + this._recoverPendingContexts(toRecover) + } }, 0); } } - private _recoverPendingContexts(toRecover: WebglRenderer[]) { - // This terminal's context was lost but it's not paused, try to dispose the - // paused renderers to make room for this one + 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()) { - renderer._gl.getExtension('WEBGL_lose_context').loseContext() + // Prevent this renderer from being immediately recreated when + // webglcontextlost fires + renderer.loseContext(); renderer._salvaged = true; - // Only kill enough to recover all the pending contexts + // Only kill enough renderers to recover all the pending contexts killCount++; if (killCount >= toRecover.length) { break; @@ -114,14 +122,21 @@ export class WebglRenderer extends EventEmitter implements IRenderer { } } - // If we managed to release some resources, try to recreate thes renderer - if (killCount > 0) { - toRecover.forEach((renderer) => { - renderer._terminal.recreateRenderer() - }); - } else { + // 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() + }); } } @@ -193,16 +208,24 @@ export class WebglRenderer extends EventEmitter implements IRenderer { 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() + } + private _onContextLost(): void { if (this._salvaged) { this._salvaged = false; return; } - WebglRenderer._contextHelper.recoverContext(this); + WebglRenderer._contextHelper.recoverContext(this, true); } private _onIntersectionChange(entry: IntersectionObserverEntry): void { @@ -214,7 +237,7 @@ export class WebglRenderer extends EventEmitter implements IRenderer { WebglRenderer._contextHelper.onRendererUnpaused(this); } if (this._gl.isContextLost()) { - WebglRenderer._contextHelper.recoverContext(this); + 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