Skip to content

fix(terminal): gate the WebGL attach on the font instead of clearing the atlas (#70)#100

Merged
simion merged 1 commit into
simion:mainfrom
Orellius:fix/70-gate-webgl-attach-on-font
Jul 14, 2026
Merged

fix(terminal): gate the WebGL attach on the font instead of clearing the atlas (#70)#100
simion merged 1 commit into
simion:mainfrom
Orellius:fix/70-gate-webgl-attach-on-font

Conversation

@Orellius

@Orellius Orellius commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

What

Fixes #70 (character heights render wrong until an atlas rebuild). This supersedes the earlier document.fonts.check()-based approach on this branch, which @simion confirmed still poisoned the atlas on a cold non-retina spawn.

Root cause (reproduced on WKWebView)

xterm's WebGL atlas keys glyphs per (char, fg, bg, ext) with no font in the key, so a glyph rasterized before the real face is active caches at the fallback height and never corrects until the cell re-rasterizes (selecting text re-keys it, which is why that "fixed" it).

The gate meant to prevent that used document.fonts.check() for "JetBrains Mono". On WKWebView I reproduced that check() (and load()) report a family READY before it is registered in the FontFaceSet: check('400 1em "JetBrains Mono"') returns true, and load() resolves with zero faces, for an unregistered family (vacuous-true). fontsource registers the family via async CSS @font-face, so that pre-registration window is the poison window on a cold spawn: the gate passes, the atlas builds against the fallback, heights stick. check() is honest for a registered face mid-load, so it only bites before registration, which is the cold-resume timing.

The "heals on a display move, just like changing the font" report confirms this is transient atlas poisoning, not a DPR rendering bug: a round-trip ending back on the same non-retina DPR would reproduce a DPR-deterministic bug, but it heals, because any atlas rebuild clears it.

Fix

Stop trusting document.fonts status. Own the latin 400/700 faces as explicit FontFace handles (src/lib/terminalFontReady.ts), registered and load()-awaited at boot. Gate the WebGL attach (loadTerminalRenderer) and the PTY spawn (awaitTerminalFonts) on those real handles instead of document.fonts.check(). The atlas can only build once the exact face it rasterizes with is genuinely loaded, so nothing is ever cleared under a live renderer (no scramble). Drops the 800ms race + poller.

The owned faces duplicate the fontsource latin @font-face rules (same family/weight/URL, so the byte fetch is shared). That is intentional and sound: xterm rasterizes glyphs through Canvas 2D, which draws only with FontFaceSet faces that are actually loaded and ignores unloaded ones, so once the awaited handles load the atlas rasterizes against the real face regardless of the CSS faces' state. Cyrillic stays on the CSS @font-face (terminals are overwhelmingly latin).

Validation

  • WKWebView before/after: the old check gate returns true in the pre-registration window (would attach early); the owned-handle gate stays false until the face genuinely loads, then true.
  • A real terminal spawns with the WebGL renderer attached, no console errors.
  • tsc -b + test suite + vite build all green.

I couldn't force the exact cold non-retina spawn on a warm dev instance (a warm instance can't be forced cold), so a retest on the affected machine is the real confirmation.

@simion

simion commented Jul 14, 2026

Copy link
Copy Markdown
Owner

Checked our your branch on a machine that resumes a session with this issue, still happening.
image
image

one more important detail: after moving termic from one monitor (non retina) to the laptop's retina monitor and back., the issues fixed itself.

@Orellius

Copy link
Copy Markdown
Contributor Author

Checked our your branch on a machine that resumes a session with this issue, still happening. image image

one more important detail: after moving termic from one monitor (non retina) to the laptop's retina monitor and back., the issues fixed itself.

So the issue is from Mac Retina monitors and non-retina?

@simion

simion commented Jul 14, 2026

Copy link
Copy Markdown
Owner

@Orellius it happens on non-retina display, and gets fixed if I move once the window between retina and non retina displays, just like it gets fixed when i change the font.
But basically the issue is still there. No weird scrambled characters but neither the character height fix worked.

)

The bundled JetBrains Mono is a lazy @font-face and xterm's WebGL atlas
keys glyphs per (char,fg,bg,ext) with no font in the key, so a glyph
rasterized before the face is active caches at the fallback height and
never corrects (selecting text re-keys it, which is why that "fixed" it).

The prior gate polled document.fonts for the family, but on WKWebView
check() returns true and load() resolves with zero faces for a family not
yet REGISTERED in the FontFaceSet (vacuous-true). fontsource registers
"JetBrains Mono" via async CSS @font-face, so that pre-registration window
is the poison window on a cold spawn: the gate passes, the atlas builds
against the fallback, and the mixed-height waves stick.

Own the latin 400/700 faces as explicit FontFace handles
(lib/terminalFontReady), registered and load()-awaited at boot. Gate the
WebGL attach and the PTY spawn on those real handles instead of
document.fonts. The atlas is then built against a genuinely-loaded face,
so nothing is ever cleared under a live renderer. Also drops the 800ms
race + poller.

tsc + suite + build green.
@Orellius

Copy link
Copy Markdown
Contributor Author

Thanks, that detail nailed it: "heals when I move between displays, just like changing the font" means this is transient atlas poisoning, not a DPR rendering bug. A display round-trip that ends back on the same non-retina DPR would reproduce a DPR-deterministic bug, but it heals instead, because any atlas rebuild (display move or font change) clears it.

And the root cause is different from what this PR gated on. The gate used document.fonts.check() for "JetBrains Mono", but on WKWebView I reproduced that check() (and load()) return truthy for a family that isn't registered in the FontFaceSet yet: check('400 1em "JetBrains Mono"') returns true, and load() resolves with zero faces, before the @font-face is registered. fontsource registers the family via async CSS, so that pre-registration window is the poison window on a cold spawn: the gate passes, the WebGL atlas builds against the fallback, and the heights stick. check() is honest for a registered face mid-load, so it only bites before registration, which is exactly your cold-resume timing.

So I reworked it to stop trusting document.fonts status entirely: own the latin 400/700 faces as explicit FontFace handles (registered + load()-awaited at boot), and gate the WebGL attach and the PTY spawn on those real handles. The atlas can only build once the exact face it rasterizes with is genuinely loaded, so nothing is ever cleared under a live renderer (no scramble), and the 800ms race is gone.

Validated in WKWebView: the old check gate returns true in the pre-registration window (would attach early), the owned-handle gate stays false until the face genuinely loads. tsc + suite + build green. I couldn't force your exact cold non-retina spawn on a warm dev instance, so a retest on that machine would be the real confirmation. Pushing the reworked version to this PR shortly and I'll ping you to retest.

@Orellius Orellius force-pushed the fix/70-gate-webgl-attach-on-font branch from 494f8c9 to 444da44 Compare July 14, 2026 08:37
@Orellius

Copy link
Copy Markdown
Contributor Author

@simion pushed the reworked fix (444da44) - PR is updated. It's on the fork branch this PR already tracks, so a plain pull/rebuild of this branch picks it up. When you get a sec, retest on that non-retina machine that resumes a session (the cold-spawn case), and check bold specifically - the wrong heights showed on the 700 weight. Fingers crossed this is the one.

@simion simion merged commit 778347c into simion:main Jul 14, 2026
2 checks passed
@simion

simion commented Jul 14, 2026

Copy link
Copy Markdown
Owner

Merged.

What I tested: the race never repros in dev naturally (the fontsource CSS sits in the ES module graph, so the family always registers before app code), so I forced the production window deterministically: pulled the four jetbrains-mono @imports out of index.css and injected the @font-face rules 3s after boot from a plain script in index.html.

On main that reproduces #70 on demand, and worse than any cold launch ever showed: every glyph rasterized before the late registration keeps the fallback height forever, output after 3s comes out correct, selection heals per-cell. On this branch, same delay, clean from the first glyph: the owned handles fetch the woff2 directly and the WebGL attach waits for them, so the CSS timing stops mattering entirely. That confirms the vacuous check() diagnosis end to end.

Not cutting a release yet. I'll run this locally for two days on the machine that hits the cold non-retina spawn, then ship it.

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.

Weird font with different letter heights on Claude Code

2 participants