Skip to content

fix(troika-three-text): survive WebGL context loss during SDF glyph generation#388

Open
AlaricBaraou wants to merge 2 commits into
protectwise:mainfrom
AlaricBaraou:fix/sdf-atlas-context-loss
Open

fix(troika-three-text): survive WebGL context loss during SDF glyph generation#388
AlaricBaraou wants to merge 2 commits into
protectwise:mainfrom
AlaricBaraou:fix/sdf-atlas-context-loss

Conversation

@AlaricBaraou

Copy link
Copy Markdown
Contributor

Problem

atlas.contextLost (added with the context-restoration handling) guards entry into glyph generation, but the flag only flips when the async webglcontextlost event dispatches — while context loss itself is synchronous (WebKit's per-page context cap evicting the oldest context, GPU process resets, tab backgrounding on iOS). Any glyph generation already in flight reaches the atlas with a dead context in two unguarded places:

  1. SDFGenerator.js — JS-worker fallback write. The GPU generate path is caught (falls back to the JS worker), but the fallback then writes the worker's result into the same lost WebGL1 atlas canvas via webglUtils.renderImageData, inside a .then with no rejection handler downstream. Result: one unhandled rejection per in-flight glyph, and the Promise.all in TextBuilder never resolves, so the typeset callback never fires — the Text keeps stale render info even after the context is restored.
  2. TextBuilder.js — atlas grow. resizeWebGLCanvasWithoutClearing throws the same way when growth coincides with a lost context.

Which GL call throws first varies by browser build: production Safari throws at shaderSource (TypeError: Argument 1 ('shader') to WebGLRenderingContext.shaderSource must be an instance of WebGLShader — we collected hundreds of these in Sentry from a page that holds several WebGL contexts), while current WebKit 26.4 and headless Chromium tolerate the null shader and throw Error: ANGLE_instanced_arrays not supported from the extension lookup a few calls later. Same path, same consequence in all cases.

Fix

Drop the two atlas writes when the context reports lost (rethrow otherwise). This is safe by design: the existing webglcontextrestored handler already regenerates every known glyph into the restored atlas, so dropped writes self-heal. In the resize case the canvas dimensions are applied before the copy-over throws, and sdfTexture.dispose() still runs, so the texture reallocates at the new size for the restore-time regeneration.

With the fix, a typeset that overlaps a context loss completes normally (glyphs regenerate on restore) instead of rejecting and stranding the Text.

Verification

Deterministic reproduction (below) forcing the loss into the in-flight window: gpuAccelerateSDF = false routes glyphs through the JS-worker fallback; after the SDF workers' 2s idle termination, a hooked Worker constructor loses the atlas context on a microtask right as the fresh SDF worker spawns — i.e. after the whole glyph batch is posted to workers, before any result returns.

build result
troika-three-text@0.52.4 (npm dist) unhandled rejection + typeset callback never fires
current main, built from source unhandled rejection + typeset callback never fires
main + this fix, built from source no rejection, typeset completes, atlas regenerates after restoreContext()

Each row verified in both headless Chromium and WebKit 26.4 (Playwright). npx jest: the three failing suites (troika-3d, troika-core, troika-flex-layout) fail identically on unmodified main.

Deterministic repro — single file, npm dists via unpkg (serve statically, watch the console)
<!doctype html>
<title>troika-three-text: unhandled TypeError on SDF atlas write when WebGL context is lost mid-generation</title>
<pre id="out">see console —
</pre>
<script>
window.__result = { rejections: [], secondSyncDone: false, done: false }
</script>
<script type="importmap">
{
  "imports": {
    "three": "https://unpkg.com/three@0.180.0/build/three.module.js",
    "troika-three-text": "https://unpkg.com/troika-three-text@0.52.4/dist/troika-three-text.esm.js",
    "troika-three-utils": "https://unpkg.com/troika-three-utils@0.52.4/dist/troika-three-utils.esm.js",
    "troika-worker-utils": "https://unpkg.com/troika-worker-utils@0.52.0/dist/troika-worker-utils.esm.js",
    "webgl-sdf-generator": "https://unpkg.com/webgl-sdf-generator@1.1.1/dist/webgl-sdf-generator.mjs",
    "bidi-js": "https://unpkg.com/bidi-js@1.0.3/dist/bidi.mjs"
  }
}
</script>
<script type="module">
import { Text } from "troika-three-text"

const out = []
const log = (m) => {
  out.push(m); document.getElementById("out").textContent += m + "\n"
  console.log("[repro] " + m)
}
window.__log = out
window.__result = { rejections: [], secondSyncDone: false, done: false }

window.addEventListener("unhandledrejection", (e) => {
  const msg = String((e.reason && e.reason.message) || e.reason)
  window.__result.rejections.push(msg)
  log("UNHANDLED REJECTION: " + msg)
})

// Deterministic kill switch. troika's JS-SDF worker threads self-terminate after 2s idle,
// so after arming, the next Worker constructed is the fresh SDF worker — built synchronously
// inside the glyph-batch dispatch. Losing the atlas context on the following microtask lands
// the loss after every glyph in the batch has been posted to workers but before any result
// returns: the exact in-flight window the production storm needs. The queued webglcontextlost
// event then wipes webgl-sdf-generator's program cache ahead of the first worker reply, so
// each reply's atlas write must recompile on a dead context.
const OrigWorker = window.Worker
let armed = false
let killed = false
let loseExt = null
window.Worker = class extends OrigWorker {
  constructor(...args) {
    super(...args)
    if (armed && !killed) {
      killed = true
      queueMicrotask(() => {
        log("forcing atlas context loss (glyph batch in flight)")
        loseExt.loseContext()
      })
    }
  }
}

const text = new Text()
text.text = "abc"
text.fontSize = 1
// Route every glyph through the JS-worker fallback → the unguarded atlas write.
text.gpuAccelerateSDF = false
log("first sync dispatched")
text.sync(() => {
  log("first typeset complete")
  const atlasCanvas = text.textRenderInfo.sdfTexture.image
  const gl = atlasCanvas.getContext("webgl")
  loseExt = gl.getExtension("WEBGL_lose_context")
  setTimeout(() => {
    armed = true
    text.text = "XYZQWKLMNOPRSTUV0123456789ghijklm"
    log("second sync dispatched (fresh glyphs)")
    text.sync(() => {
      window.__result.secondSyncDone = true
      log("second typeset complete")
    })
    setTimeout(() => {
      log("restoring context")
      try {
        loseExt.restoreContext()
      } catch (e) {
        log("restoreContext threw: " + e)
      }
      setTimeout(() => {
        window.__result.done = true
        log("done — rejections: " + window.__result.rejections.length + ", secondSyncDone: " + window.__result.secondSyncDone)
      }, 2000)
    }, 1500)
  }, 2600) // > troika's 2s SDF-worker idle timeout, so the batch constructs a fresh Worker
})

</script>

Pristine 0.52.4 logs the unhandled rejection and secondSyncDone: false; swap the troika-three-text import-map entry for a build with this fix and the same run logs no rejections and secondSyncDone: true.

…eneration

The atlas.contextLost flag guards entry into glyph generation, but the flag
only flips when the async webglcontextlost event dispatches, while context
loss itself is synchronous (cap eviction, GPU process reset). Glyphs already
in flight reach the atlas with a dead context in two unguarded places:

- SDFGenerator's JS-worker fallback writes the worker result into the atlas
  via webglUtils.renderImageData inside a .then with no rejection handler
  downstream: one unhandled rejection per in-flight glyph, and the typeset
  callback never fires, so the Text keeps stale render info even after the
  context is restored.
- TextBuilder's atlas-grow copy (resizeWebGLCanvasWithoutClearing) throws
  the same way when growth coincides with a lost context.

Both writes are now dropped when the context reports lost (and rethrow
otherwise); the existing webglcontextrestored handler already regenerates
every known glyph, so dropped writes self-heal on restore. In the resize
case the canvas dimensions are applied before the copy throws, and the
sdfTexture.dispose() still runs, so the texture reallocates at the new size
for the restore-time regeneration.
@AlaricBaraou

Copy link
Copy Markdown
Contributor Author

This was AI written from a patch I have on a local project. Feel free to close, edit, whatever is convenient / best for you!

…was refused

A null getContext probe in the catch means context creation was refused
under the same GPU pressure (a canvas whose webgl creation failed stays in
mode 'none', so re-attempts can also fail) - the write is equally
impossible and no restore event will ever fire, so rethrowing only keeps
the per-glyph rejection storm alive for that variant. The probe now also
passes the generator's context attributes so a probe that happens to
succeed can't stick a wrong-attribute (non-preserved drawing buffer)
context onto the atlas canvas.
@AlaricBaraou

Copy link
Copy Markdown
Contributor Author

Follow-up commit: adversarial review of our production integration found two edge cases in the original guard shape, both now fixed here — (1) when context creation was refused (canvas stays in mode 'none' under the same GPU pressure), the null probe used to rethrow, keeping the per-glyph rejection storm alive for that variant; the guard now drops the write in that case too, since no restore event can ever fire for a context that never existed. (2) The catch probe now passes the generator's context attributes, so a probe that happens to succeed can't stick a wrong-attribute (non-preserved drawing buffer) context onto the atlas canvas. Re-verified the full matrix (0.52.4 / main baseline / this branch, Chromium + WebKit) with the deterministic repro from the description.

@arpu

arpu commented Jul 10, 2026

Copy link
Copy Markdown

looks like we see the same problem on our installation on some users ( safari) ( found with sentry )

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.

2 participants