Skip to content

Commit 6826171

Browse files
committed
Fix viewer-charts context cleanup, implement pooling in blit mode (now default).
Signed-off-by: Andrew Stein <steinlink@gmail.com>
1 parent 6724c02 commit 6826171

23 files changed

Lines changed: 1559 additions & 594 deletions

packages/viewer-charts/src/ts/config.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,27 @@ export const RUNTIME_MODE: "worker" | "inprocess" = "worker";
3333
* `ImageBitmap` over the control channel; the host blits the bitmap
3434
* into the visible canvas via `drawImage`.
3535
*/
36-
export const RENDER_BLIT_MODE: "direct" | "blit" = "direct";
36+
export const RENDER_BLIT_MODE: "direct" | "blit" = "blit";
37+
38+
/**
39+
* Number of shared WebGL contexts in pooled blit mode.
40+
*
41+
* The browser caps live contexts per agent (~16 in Chromium) and
42+
* force-loses the oldest past that cap, so a page with more charts than
43+
* the cap cannot give each its own context. When `> 0` *and*
44+
* `RENDER_BLIT_MODE === "blit"`, every chart borrows one of this many
45+
* shared contexts (round-robin, sticky for the chart's lifetime) instead
46+
* of allocating its own — decoupling live-context count from chart
47+
* count. N charts render through K = this many contexts; the scheduler
48+
* serializes renders that land on the same context.
49+
*
50+
* `0` disables pooling (every chart gets its own context — the original
51+
* behavior). Pooling never applies to `"direct"` mode, which renders
52+
* into the host's transferred visible canvas and is permanently 1:1 with
53+
* a context; keep pages that need more than ~16 simultaneous charts on
54+
* `"blit"`.
55+
*/
56+
export const RENDER_CONTEXT_POOL_SIZE: number = 4;
3757

3858
/**
3959
* Strict-mode validation for `BufferPool.upload`.

packages/viewer-charts/src/ts/plugin/plugin.ts

Lines changed: 76 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,22 @@ const GLOBAL_STYLES = (() => {
125125
return [sheet];
126126
})();
127127

128+
/**
129+
* Process-global GL presentation strategy, shared by *every* chart-type
130+
* plugin in this renderer. Seeded from the build-time {@link RENDER_BLIT_MODE}
131+
* and overridable at runtime via
132+
* {@link HTMLPerspectiveViewerWebGLPluginElement.setBlitMode}.
133+
*
134+
* Module scope (not per-instance) on purpose: blit-vs-direct is a
135+
* whole-renderer decision — in `"blit"` mode the worker shares a pool of
136+
* GL contexts across all charts ([webgl/context-pool.ts]), so a page
137+
* can't sensibly mix strategies per chart. Every per-chart-type subclass
138+
* registered in [index.ts] reads this one value when it builds its
139+
* renderer, so setting it once (before the first chart renders) applies
140+
* to all of them.
141+
*/
142+
let BLIT_MODE: "direct" | "blit" = RENDER_BLIT_MODE;
143+
128144
export class HTMLPerspectiveViewerWebGLPluginElement
129145
extends HTMLElement
130146
implements IPerspectiveViewerPlugin
@@ -139,7 +155,6 @@ export class HTMLPerspectiveViewerWebGLPluginElement
139155
private _rendererPromise: Promise<RendererTransport> | null = null;
140156
private _rawEventForwarder: RawEventForwarder | null = null;
141157
private _generation = 0;
142-
private _renderBlitMode: "direct" | "blit" = RENDER_BLIT_MODE;
143158
private _resetClickAbort: AbortController | null = null;
144159

145160
/**
@@ -257,13 +272,41 @@ export class HTMLPerspectiveViewerWebGLPluginElement
257272
return this._rendererPromise;
258273
}
259274

260-
this._rendererPromise = this._buildRenderer(view).then((r) => {
261-
this._renderer = r;
262-
this._setupInteraction(r);
263-
return r;
264-
});
275+
// `_buildRenderer` is async — it awaits `getClient`, `getTable`,
276+
// and a full worker handshake — so a `disconnectedCallback`
277+
// (toggle / chart-type switch) frequently lands *before* this
278+
// `.then` runs. At that point `this._renderer` is still null,
279+
// so `delete()`'s `if (this._renderer)` teardown is skipped and
280+
// the freshly-built transport (and its WebGL context) would be
281+
// assigned to a detached element and leak — one context per
282+
// raced toggle, until the browser evicts the oldest.
283+
//
284+
// `delete()` sets `_rendererPromise = null`, so promise identity
285+
// is the dispose signal: if `this._rendererPromise` no longer
286+
// points at *this* build when it resolves, the element was
287+
// deleted (or a reconnect started a newer build) and we destroy
288+
// the orphan instead of adopting it. Promise identity — not
289+
// `_generation`, which every `draw`/`update` bumps — is the
290+
// right token: a rapid draw→update must NOT tear down the
291+
// single in-flight build it shares via this memoized promise.
292+
const p: Promise<RendererTransport> = this._buildRenderer(view).then(
293+
(r) => {
294+
if (this._rendererPromise !== p) {
295+
r.destroy();
296+
throw new Error("renderer disposed during init");
297+
}
298+
299+
this._renderer = r;
300+
this._setupInteraction(r);
301+
return r;
302+
},
303+
);
265304

266-
return this._rendererPromise;
305+
// Swallow the dispose rejection so it doesn't surface as an
306+
// unhandled rejection; `_drawImpl` catches it and bails.
307+
p.catch(() => {});
308+
this._rendererPromise = p;
309+
return p;
267310
}
268311

269312
/**
@@ -350,15 +393,28 @@ export class HTMLPerspectiveViewerWebGLPluginElement
350393
},
351394
pluginConfig: this._pluginConfig,
352395
defaultChartType: this._chartType.default_chart_type,
353-
renderBlitMode: this._renderBlitMode,
396+
renderBlitMode: BLIT_MODE,
354397
});
355398

356399
return transport;
357400
}
358401

359-
setBlitMode(mode: "direct" | "blit") {
360-
console.assert(this._initialized, "Already initialized");
361-
this._renderBlitMode = mode;
402+
/**
403+
* Select the GL presentation strategy for *all* chart-type plugins
404+
* in this renderer. Static + process-global: the value is shared by
405+
* every per-chart-type subclass, so it must be set once before the
406+
* charts that should use it build their renderers (a renderer reads
407+
* {@link BLIT_MODE} at construction in `_buildRenderer`; charts
408+
* already built keep their mode until torn down and rebuilt).
409+
*
410+
* - `"direct"` — each chart owns a GL context 1:1 with its visible
411+
* canvas (lowest latency; bounded by the browser's ~16-context
412+
* cap).
413+
* - `"blit"` — charts render off-screen and share a pool of GL
414+
* contexts ([webgl/context-pool.ts]), so a page can exceed the cap.
415+
*/
416+
static setBlitMode(mode: "direct" | "blit") {
417+
BLIT_MODE = mode;
362418
}
363419

364420
get_static_config(): PluginStaticConfig {
@@ -499,7 +555,15 @@ export class HTMLPerspectiveViewerWebGLPluginElement
499555

500556
private async _drawImpl(view: View): Promise<void> {
501557
const gen = ++this._generation;
502-
const renderer = await this._ensureRenderer(view);
558+
let renderer: RendererTransport;
559+
try {
560+
renderer = await this._ensureRenderer(view);
561+
} catch {
562+
// Renderer was disposed mid-init (element disconnected
563+
// during the async build) — nothing to draw.
564+
return;
565+
}
566+
503567
if (this._generation !== gen) {
504568
return;
505569
}

packages/viewer-charts/src/ts/render/scheduler.ts

Lines changed: 125 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,41 @@ export function deferIfDraining(
177177
ops.push(op);
178178
}
179179

180+
/**
181+
* Drop every scheduler reference to `glManager` — called from
182+
* `WebGLContextManager`'s owning renderer at teardown, *before*
183+
* `glManager.destroy()` loses the GL context.
184+
*
185+
* Without this, a frame queued for the next RAF (an `Entry` in
186+
* `pending`) or a `deferIfDraining` op still parked in `deferred`
187+
* would survive the destroy and, on the next `drain()`, drive
188+
* `fullRender` / `present` against a context-lost manager. In blit
189+
* mode that surfaces as `endFrame`'s `transferToImageBitmap` throwing
190+
* "Cannot transfer to ImageBitmap because WebGL context is lost" —
191+
* logged as "scheduler: present failed". In direct mode it's a wasted
192+
* paint against a dead context.
193+
*
194+
* Outstanding waiters for a destroyed manager are resolved (not
195+
* rejected): the caller asked to tear the chart down, so its awaited
196+
* `draw()` should observe a clean no-op rather than an error it would
197+
* have to suppress. An in-flight `present()` (manager in `inFlight`)
198+
* is left to unwind on its own — its `finally` clause will not find a
199+
* `deferred` entry to flush, and the manager is already gone from
200+
* `pending`, so it cannot re-enqueue.
201+
*/
202+
export function unregister(glManager: WebGLContextManager): void {
203+
const entry = pending.get(glManager);
204+
if (entry) {
205+
pending.delete(glManager);
206+
for (const w of entry.waiters) {
207+
w.resolve();
208+
}
209+
}
210+
211+
deferred.delete(glManager);
212+
inFlight.delete(glManager);
213+
}
214+
180215
/**
181216
* Test-only: clear pending state. Production callers must not use
182217
* this — outstanding waiters are silently dropped.
@@ -230,31 +265,39 @@ export function _resetForTest(): void {
230265
async function drain(): Promise<void> {
231266
const snapshot = Array.from(pending.values());
232267
pending.clear();
233-
const ready: Entry[] = [];
268+
269+
// Group ready entries by the GL context behind each manager. Under
270+
// pooled blit mode many managers share one context (one drawing
271+
// buffer): their renders MUST serialize — interleaving two charts'
272+
// paints into one canvas would corrupt the bitmap the first ships.
273+
// In the default 1:1 mode every manager has its own backend, so
274+
// every group has exactly one entry and all groups run in parallel,
275+
// exactly as before pooling.
276+
const groups = new Map<number, Entry[]>();
234277
for (const entry of snapshot) {
235-
try {
236-
// Apply any dimension change recorded by
237-
// `glManager.requestResize` *before* the paint, in the
238-
// same un-yielded synchronous Phase 1 loop. This pairs
239-
// the canvas-clearing `canvas.width = N` assignment
240-
// with the immediately-following `_fullRender`, so the
241-
// browser's compositor only ever observes the canvas
242-
// post-paint. In direct/in-process modes the visible
243-
// canvas IS the GL canvas, and a clear-without-matching-
244-
// paint in the previous task would otherwise present an
245-
// empty frame to the user.
246-
entry.glManager.applyPendingResize();
247-
entry.fullRender();
248-
ready.push(entry);
249-
} catch (err) {
250-
console.error("scheduler: fullRender threw", err);
278+
// The browser force-loses the oldest context when a page
279+
// exceeds its per-agent WebGL context cap (~16). A frame queued
280+
// before that eviction would paint + present against a dead
281+
// context; skip it and settle its waiters cleanly rather than
282+
// letting `endFrame`'s `transferToImageBitmap` throw.
283+
if (entry.glManager.isContextLost()) {
251284
for (const w of entry.waiters) {
252-
w.reject(err);
285+
w.resolve();
253286
}
287+
288+
continue;
289+
}
290+
291+
const key = entry.glManager.backendId;
292+
const group = groups.get(key);
293+
if (group) {
294+
group.push(entry);
295+
} else {
296+
groups.set(key, [entry]);
254297
}
255298
}
256299

257-
await Promise.all(ready.map(present));
300+
await Promise.all(Array.from(groups.values()).map(presentGroup));
258301

259302
// Now (and only now) clear rafId. If new requests landed during
260303
// this drain, schedule the next RAF.
@@ -264,16 +307,51 @@ async function drain(): Promise<void> {
264307
}
265308
}
266309

310+
/**
311+
* Render every entry sharing one GL context, sequentially: each chart
312+
* gets the shared drawing buffer to itself for a full
313+
* `beginFrame` → `fullRender` → `awaitGpuFence` → `endFrame` (present)
314+
* cycle before the next chart touches it. Groups for *different*
315+
* contexts run concurrently (via `Promise.all` in `drain`), so K pooled
316+
* contexts give K-way parallelism and the 1:1 default keeps full
317+
* cross-chart overlap.
318+
*
319+
* The synchronous prefix of each group (`beginFrame` + `fullRender`)
320+
* runs before its first `awaitGpuFence` yields, so when groups run
321+
* concurrently all first-chart draws are still submitted before any
322+
* fence wait — preserving the GPU overlap the old two-phase drain had.
323+
*/
324+
async function presentGroup(entries: Entry[]): Promise<void> {
325+
for (const entry of entries) {
326+
await present(entry);
327+
}
328+
}
329+
267330
async function present(entry: Entry): Promise<void> {
268-
// Mark this glManager as in-flight *synchronously*, before the
269-
// first await. `Promise.all(ready.map(present))` calls each
270-
// `present` synchronously to collect its returned promise, so
271-
// every entry's glManager is registered in `inFlight` before
272-
// any fence-wait yields and before any sibling message handler
273-
// can run. Mutations posted by sibling handlers (resize, clear)
274-
// route through `deferIfDraining` and queue into `deferred`
275-
// until the `finally` block flushes them.
331+
// Mark this glManager as in-flight *synchronously*, before the first
332+
// await, so sibling message handlers' canvas mutations (resize,
333+
// clear) route through `deferIfDraining` into `deferred` until the
334+
// `finally` flushes them.
276335
inFlight.add(entry.glManager);
336+
try {
337+
// Apply the dimension change and (in pooled mode) reset shared
338+
// GL state in the same un-yielded step as the paint that fills
339+
// the buffer. Pairing the canvas-clearing `canvas.width = N`
340+
// with `_fullRender` keeps the compositor from ever observing a
341+
// cleared-but-unpainted canvas (visible flicker in direct/in-
342+
// process modes, where the visible canvas IS the GL canvas).
343+
entry.glManager.beginFrame();
344+
entry.fullRender();
345+
} catch (err) {
346+
console.error("scheduler: fullRender threw", err);
347+
for (const w of entry.waiters) {
348+
w.reject(err);
349+
}
350+
351+
flushDeferred(entry.glManager);
352+
return;
353+
}
354+
277355
try {
278356
await entry.glManager.awaitGpuFence();
279357
entry.glManager.endFrame();
@@ -296,21 +374,26 @@ async function present(entry: Entry): Promise<void> {
296374
w.reject(err);
297375
}
298376
} finally {
299-
// Bitmap shipped (or error reported). Re-open the canvas to
300-
// mutations and flush any deferred ops in arrival order.
301-
// Deferred ops may call `requestRender`; the resulting
302-
// entry queues into `pending` and the drain's tail check
303-
// picks it up for the next RAF.
304-
inFlight.delete(entry.glManager);
305-
const ops = deferred.get(entry.glManager);
306-
if (ops) {
307-
deferred.delete(entry.glManager);
308-
for (const op of ops) {
309-
try {
310-
op();
311-
} catch (err) {
312-
console.error("scheduler: deferred op threw", err);
313-
}
377+
flushDeferred(entry.glManager);
378+
}
379+
}
380+
381+
/**
382+
* Re-open a glManager's canvas to mutations and flush any ops deferred
383+
* during its present, in arrival order. Deferred ops may call
384+
* `requestRender`; the resulting entry queues into `pending` and the
385+
* drain's tail check picks it up for the next RAF.
386+
*/
387+
function flushDeferred(glManager: WebGLContextManager): void {
388+
inFlight.delete(glManager);
389+
const ops = deferred.get(glManager);
390+
if (ops) {
391+
deferred.delete(glManager);
392+
for (const op of ops) {
393+
try {
394+
op();
395+
} catch (err) {
396+
console.error("scheduler: deferred op threw", err);
314397
}
315398
}
316399
}

0 commit comments

Comments
 (0)