@@ -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 {
230265async 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+
267330async 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