Skip to content

Share WebGL context budget across same-origin iframes - #149

Merged
masenf merged 3 commits into
mainfrom
claude/fastapi-webgl-context-warnings-8hlpte
Jul 22, 2026
Merged

Share WebGL context budget across same-origin iframes#149
masenf merged 3 commits into
mainfrom
claude/fastapi-webgl-context-warnings-8hlpte

Conversation

@masenf

@masenf masenf commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Summary

Extend the WebGL context governor to coordinate a shared budget across same-origin frames using BroadcastChannel. This prevents "Too many active WebGL contexts" storms on chart-per-iframe pages (docs sites, SaaS dashboards, the examples/fastapi gallery) where the browser's process-wide cap is shared but the per-document governor sees only its own charts.

Changes

  • Cross-frame coordination via BroadcastChannel: Each frame announces its live-context count and listens to peers' counts. The effective budget is now localLive() + foreignLive() - budget() instead of just localLive() - budget().

  • New governor methods:

    • _initCrossFrame(): Lazily initialize the channel on first register(), with frame ID and peer tracking
    • _onForeignMessage(): Handle live (count update), hello (peer joined), and bye (peer left) messages
    • localLive() / foreignLive(): Count live contexts in this frame and across peers
    • _announceLive(force): Broadcast this frame's live-context count when it changes (deduped)
    • _rebalance(): Release off-screen views when the shared budget is exceeded
  • Visibility-aware shedding: _rebalance() only releases off-screen views (!view._ctxVisible), never visible ones — a sibling frame loading cannot blank a chart the user is looking at.

  • Announcement hooks: Call _announceLive() after context loss, recovery, rebuild, and release; call _rebalance() when a chart scrolls off-screen.

  • Graceful degradation: Cross-origin frames, sandboxed contexts, and browsers without BroadcastChannel fall back to per-document budgeting. Coordination is best-effort: transient overshoots during synchronous construction are self-healing, and a crashed frame that never sends bye only lowers the effective budget (a few extra off-screen releases, revived on demand).

Implementation Details

  • Frame IDs are generated as ${Date.now().toString(36)}-${Math.random().toString(36).slice(2)} to avoid collisions.
  • The _announcedLive cache dedupes redundant broadcasts when the count hasn't changed.
  • IntersectionObserver already reports off-screen iframes' charts as not-intersecting (clips to top-level viewport), so visibility signals are correct across frame boundaries.
  • Updated spec/design-dossier.md (§18) and spec/process/production-readiness.md to document the shared-budget behavior and cross-origin fallback.
  • Added test guards in tests/test_static_client_security.py to prevent silent removal of the BroadcastChannel machinery.

https://claude.ai/code/session_017AuTTjAitxe94Lp3tPEtN4

The context governor caps live WebGL contexts per document, but the
browser's ~16-context limit is process-wide — shared across every
same-origin iframe in a tab. A page that renders each chart in its own
iframe (docs sites, SaaS dashboards, the examples/fastapi gallery, which
needs iframes to host each standalone to_html document) defeated the
governor entirely: each per-document governor saw only its single chart
and never released, so once enough iframes loaded the browser began
LRU-evicting live charts, and the evicted charts fought to recover and
re-evict — a scroll-driven "Too many active WebGL contexts" storm.

Coordinate one shared budget across same-origin frames over a
BroadcastChannel: each frame announces its live-context count, and any
frame over the shared budget sheds its own off-screen views (never a
visible one, so a sibling frame loading cannot blank a chart the user is
looking at). IntersectionObserver already clips to the top-level viewport,
so an off-screen iframe's chart correctly reports not-visible — the budget
accounting was the only gap. Cross-origin and sandboxed frames get an
isolated channel scope and fall back to the per-document behavior.

Reproduced with the real bundle (24 lazy iframes): before, 8 charts
browser-evicted and 45 "Too many active WebGL contexts" warnings on
scroll; after, 0 evictions and 0 warnings, off-screen charts released
under the governor and revived on scroll-in. Single-document dashboards
are unchanged (no peer frames -> foreignLive() is 0 and every added path
is a no-op).

Regenerate the static bundles, guard the new machinery with client-source
marker tests, and update the dossier (Sec. 18), deployment matrix, and
production-readiness notes.
@codspeed-hq

codspeed-hq Bot commented Jul 21, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 97 untouched benchmarks
⏩ 1 skipped benchmark1


Comparing claude/fastapi-webgl-context-warnings-8hlpte (237323e) with main (245ab57)

Open in CodSpeed

Footnotes

  1. 1 benchmark was skipped, so the baseline result was used instead. If it was deleted from the codebase, click here and archive it to remove it from the performance reports.

@greptile-apps

greptile-apps Bot commented Jul 21, 2026

Copy link
Copy Markdown

Greptile Summary

This PR shares the WebGL context budget across same-origin frames. The main changes are:

  • Coordinates live-context counts through BroadcastChannel.
  • Releases off-screen contexts when the shared budget is exceeded.
  • Rebuilds peer state after a back-forward cache restore.
  • Defers context recovery until the loss event has completed.
  • Updates generated clients, design notes, and structural tests.

Confidence Score: 5/5

This looks safe to merge.

The latest changes restore frame membership after cache restoration. Peer state is rebuilt to avoid retaining frames that left while frozen. Context recovery waits for the asynchronous loss event. No additional blocking issue was found in the changed code.

No files require additional attention.

T-Rex T-Rex Logs

What T-Rex did

  • Validated that after the change, the recording renders gallery charts throughout the real scroll lifecycle, with 13 released contexts while the currently visited iframe remained live, and that every iframe painted successfully with a minimum render of 2,104 lit pixels.

View all artifacts

T-Rex Ran code and verified through T-Rex

Important Files Changed

Filename Overview
js/src/50_chartview.js Adds cross-frame context accounting, visibility-aware shedding, cache-restore reconciliation, and deferred WebGL recovery.
python/xy/static/index.js Updates the generated browser client with the shared context governor behavior.
python/xy/static/standalone.js Updates the standalone browser bundle with the shared context governor behavior.
spec/design-dossier.md Documents cross-frame budgeting, recovery ordering, and incremental shedding.
spec/process/production-readiness.md Documents same-origin coordination and the fallback for isolated frames.
tests/test_static_client_security.py Adds structural checks for cross-frame coordination, cache restoration, recovery ordering, and off-screen shedding.

Reviews (3): Last reviewed commit: "Drop stale peer membership on bfcache re..." | Re-trigger Greptile

Comment thread js/src/50_chartview.js
Comment thread js/src/50_chartview.js Outdated
Follow-ups on the shared-budget governor, found via the failing
reflex_lifecycle_smoke index-iframe pass and PR review:

- Recovery race (the smoke failure): a governed release is
  WEBGL_lose_context.loseContext(); re-acquire is restoreContext().
  Chromium silently drops a restoreContext() issued before that context's
  webglcontextlost event has dispatched — or synchronously inside the
  dispatch — leaving the canvas lost forever. A chart shed by a peer's
  message and scrolled back into view in the same task hit exactly that
  window, so it never repainted (the index gallery left the 11th chart
  blank). Defer recovery until the loss event lands (_ctxLostPending) and
  retry on a fresh task.

- Back/forward cache: pagehide posts "bye" (a frozen frame cannot
  participate), but nothing re-announced on restore, so peers omitted a
  bfcache-restored frame indefinitely and the page could exceed the cap.
  Re-announce on pageshow(persisted).

- Concurrent shedding: frames over budget released the whole computed
  excess from an async snapshot, so several frames observing the same
  over-budget state each dropped the full deficit and collectively
  over-released. Shed one off-screen view per turn, announce, and
  re-evaluate against the fresher count — converging on the budget.

Verified against the real example (all 20 index iframes paint; lifecycle
smoke passes) and the many-iframe repro (0 "Too many active WebGL
contexts" warnings, 0 browser evictions). Regenerate the static bundles,
extend the client marker tests, and update the dossier (Sec. 18).

masenf commented Jul 21, 2026

Copy link
Copy Markdown
Contributor Author

Pushed 6cba934 — it fixes the CI failure and both review findings, which turned out to share a root cause in the governed release/recover path.

CI (Test (Rust + Python + JS) → index-iframe smoke): the gallery left the 11th chart blank on scroll-in. A governed release is WEBGL_lose_context.loseContext() and re-acquire is restoreContext() — and Chromium silently drops restoreContext() if it's called before that context's webglcontextlost event has dispatched (or synchronously inside the dispatch), stranding the canvas lost. A chart shed by a peer's BroadcastChannel message and scrolled into view in the same task hit exactly that window. Fix: defer recovery until the loss event lands (_ctxLostPending), then retry on a fresh task. (Confirmed the drop with a minimal loseContext(); restoreContext() probe: restored:false same-tick vs restored:true after the event.)

Concurrent shedding (P1, line 213): frames now release one off-screen view per event-loop turn, announce, and re-evaluate against the fresher count — so several frames observing the same over-budget snapshot converge on the budget instead of each dropping the full deficit. (Over-releasing an off-screen view was always safe — it revives on demand — but this avoids the needless rebuilds.)

BFCache (P1, line 159): re-announce on pageshow when event.persisted, so peers re-add a restored frame after the bye sent on pagehide.

Verified locally against the real example — all 20 index iframes paint and reflex_lifecycle_smoke passes — and against a 24-iframe repro built on the shipped bundle (0 "Too many active WebGL contexts" warnings, 0 browser evictions; single-document dashboards unchanged at exactly the budget of 12 live).

One caveat on the BFCache fix: a true bfcache restore can't be exercised in headless Chromium (it reports persisted=false and creates a fresh document, which re-runs init anyway), so that path is covered by review + a client marker test rather than the smoke.


Generated by Claude Code

Comment thread js/src/50_chartview.js
Comment thread js/src/50_chartview.js
A document frozen in the back/forward cache can miss a peer's `bye`
(BroadcastChannel does not deliver to a frozen frame), so after restore
its `foreign` map may still count contexts for frames that have since
gone away — over-counting the shared budget and triggering unnecessary
off-screen releases. Clear the map on `pageshow(persisted)` and rebuild
it from live peers' replies to the re-`hello`, instead of carrying the
stale membership forward. Regenerate the static bundles and add a marker
for the clear.
@masenf
masenf merged commit a763d5f into main Jul 22, 2026
50 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant