@perspective-dev/viewer-charts 4.5.1 → 4.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -177,6 +177,41 @@ export function deferIfDraining(
177
177
  ops.push(op);
178
178
  }
179
179
 
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
+
180
215
  /**
181
216
  * Test-only: clear pending state. Production callers must not use
182
217
  * this — outstanding waiters are silently dropped.
@@ -230,31 +265,39 @@ export function _resetForTest(): void {
230
265
  async function drain(): Promise<void> {
231
266
  const snapshot = Array.from(pending.values());
232
267
  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[]>();
234
277
  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()) {
251
284
  for (const w of entry.waiters) {
252
- w.reject(err);
285
+ w.resolve();
253
286
  }
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]);
254
297
  }
255
298
  }
256
299
 
257
- await Promise.all(ready.map(present));
300
+ await Promise.all(Array.from(groups.values()).map(presentGroup));
258
301
 
259
302
  // Now (and only now) clear rafId. If new requests landed during
260
303
  // this drain, schedule the next RAF.
@@ -264,16 +307,51 @@ async function drain(): Promise<void> {
264
307
  }
265
308
  }
266
309
 
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
+
267
330
  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.
276
335
  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
+
277
355
  try {
278
356
  await entry.glManager.awaitGpuFence();
279
357
  entry.glManager.endFrame();
@@ -296,21 +374,26 @@ async function present(entry: Entry): Promise<void> {
296
374
  w.reject(err);
297
375
  }
298
376
  } 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);
314
397
  }
315
398
  }
316
399
  }