Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WebGL context loss handling #5

Closed
Closed
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 23 additions & 17 deletions src/Terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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()));
Expand All @@ -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)));
Expand Down Expand Up @@ -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);
}

Expand Down
1 change: 1 addition & 0 deletions src/Types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
143 changes: 139 additions & 4 deletions src/renderer/webgl/WebglRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand All @@ -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 {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you pull this into WebglContextHelper.ts?

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 = <WebglRenderer>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
Expand Down Expand Up @@ -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);
Expand All @@ -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');
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should happen automatically after dispose on the next GC?

Copy link
Author

@juancampa juancampa Jan 21, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, however, we force it so that another context can be created before the next GC. If we don't force it here, Chrome might not notice there's an available context and could kill an active one.

What I observed is:

  1. open 20 tabs (the first 4 will lose their context)
  2. close the last 16 tabs
  3. close one more tab
  4. result: Chrome will warn that the max number of contexts has been reached
  5. expected: no warning

So, it seems like one of these is happening:

  1. GC is not firing during the closing of the 17 tabs
  2. GC isn't collecting the underlying webgl contexts (maybe it's lazy by design?)
  3. Someone's keeping a ref to this instance, preventing it from being GC

I'll grab a snapshot to make sure (3) is not happening

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can confirm all webgl contexts are eventually GC'd. But we still need this to prevent Chrome from killing the oldest context (as opposed to the LRU one)

this.loseContext();
}
WebglRenderer._contextHelper.onRendererDestroyed(this);
}

public loseContext(): void {
this._gl.getExtension('WEBGL_lose_context').loseContext()
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nah, that seems outdated. It's definitely supported on Chrome and Chromium: https://codesandbox.io/embed/xpjjjmn1oq?view=preview

}

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);
}
}
}

Expand Down
3 changes: 3 additions & 0 deletions src/ui/TestUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.');
}
Expand Down