fix(terminal): force the re-measure, bound the wait, and test both

Review of the previous commit found that waiting for the font does not, on its
own, do anything.

`FitAddon.proposeDimensions()` measures nothing — it divides the container by a
CACHED cell size, and xterm refreshes that cache only from `open()`, from a
resize that actually changed the grid, and on a device-pixel-ratio change.
Nothing in it listens for font loading. So a fit that runs after the font
arrives can still divide by the fallback cell, propose the grid it already has,
and short-circuit before anything re-measures. The wait now ends by calling
`_charSizeService.measure()` itself, which is the step that makes the following
fit see the real font. Private API, as FitAddon's own dependency on `_core` is,
and guarded because a terminal can be disposed mid-wait.

The wait was also unbounded, and it sat behind the buffer-load gate. Neither
`FontFaceSet.load()` nor `FontFaceSet.ready` has a deadline, so a font request
that never settled left the tab spinning with live output queued behind it —
permanently, and on every session, since they share one promise. The comment
claimed the opposite ("a font that never loads must not block the terminal, so
this always resolves"), which was true of the per-face loads and false of
`ready`. It is now raced against TERMINAL_FONT_WAIT_MS, and the await moved
ahead of `_beginBufferLoad` so a slow font cannot hold output back at all —
which also removes the stale-select interaction with `_restoringFlushedState`,
since that flag is not yet set when the wait runs.

The awaited set no longer includes faces that cannot move the measured cell.
The bundled symbols font is ~1.2MB of private-use-area glyphs and xterm
measures `W`, so awaiting it put a megabyte in front of the first frame for
nothing; the generic families match no FontFace at all.

A runtime font change had the same race the boot-time one did:
applyTerminalFontFamily wrote the new family and fit on the next line, against
a family the browser might not have loaded. It now re-arms the wait and fits
again when it settles.

The claim that this could not be tested was wrong: the repo's vm harness
reaches both halves. The new suite pins the family filter, the forced
re-measure, the deadline, a rejecting load, a browser with no font API, and a
terminal disposed mid-wait — plus the four ordering properties in
selectSession, including that iOS Safari's synchronous focus still precedes the
first await. Each assertion was checked by reverting its fix.

Also corrects the docstring's reason for calling `document.fonts.load` (the
stylesheet is render-blocking and long parsed by then; the real reason is that
the WebGL renderer rasterises through a canvas atlas, and canvas text never
triggers a CSS font fetch), restores the JSDoc block the previous commit
displaced from getTerminalDimensions, and fixes a comment that described the
first fit as already having run when the mobile-Safari branch defers it.
This commit is contained in:
Michael Grundberg
2026-09-09 16:57:42 +02:00
parent 0e82443222
commit 77fcd65b4a
4 changed files with 300 additions and 32 deletions
+17 -13
View File
@@ -5906,6 +5906,23 @@ class CodemanApp {
}
}
// Hold for the terminal font before measuring anything. A cell measured
// against a fallback font gives the wrong column and row count, and the
// correction would land after the replay, leaving the CLI drawing against a
// frame the terminal no longer shows. Resolves immediately once the font is
// in, so this costs a tab switch nothing after the first load, and it is
// bounded, so a font that never arrives cannot strand the session.
// ⚠️ BEFORE `_beginBufferLoad` on purpose: inside it, every live SSE event
// for this session queues instead of painting, so a slow font would hold
// output back rather than merely mis-measuring the grid.
if (this._terminalFontReady) {
await this._terminalFontReady;
if (this._isStaleSelect(selectGen)) {
this._clearTerminalLoadState(sessionId, selectGen);
return;
}
}
// Load terminal buffer for this session
// Show cached content instantly while fetching fresh data in background.
// Use tail mode for faster initial load (128KB is enough for recent visible content).
@@ -5926,19 +5943,6 @@ class CodemanApp {
let bufferWasEmpty = false;
let cacheResetAndParseMs = 0;
try {
// Hold for the terminal font before measuring anything. A cell measured
// against a fallback font gives the wrong column and row count, and the
// correction lands after the replay, leaving the CLI drawing against a
// frame the terminal no longer shows. Resolves immediately once the font
// is in, so this costs a tab switch nothing after the first load.
if (this._terminalFontReady) {
await this._terminalFontReady;
if (this._isStaleSelect(selectGen)) {
this._clearTerminalLoadState(sessionId, selectGen);
return;
}
}
// Fit terminal to container BEFORE writing any buffer data.
// If the browser was resized while viewing another session, the terminal
// canvas may be at stale dimensions — content would render at wrong width.
+16
View File
@@ -659,6 +659,22 @@ function sortSessionsByActivity(rows) {
// prompt icons (powerline segments, folder/git glyphs from p10k, starship,
// oh-my-posh) render even though the text fonts carry no private-use-area
// symbols — while all readable text keeps coming from the text fonts.
/**
* How long a terminal fit will wait for the terminal font, in ms.
*
* `FontFaceSet.ready` has no deadline of its own and the wait sits in front of
* the buffer replay, so a font request that never settles would leave the
* session unpainted. Past this we measure whatever is painted.
*/
const TERMINAL_FONT_WAIT_MS = 2000;
/**
* Families in the stack that cannot move the measured cell, so nothing waits on
* them: the generics match no `FontFace`, and the bundled symbols face carries
* private-use-area glyphs only (xterm measures `W`) while weighing ~1.2MB.
*/
const TERMINAL_FONT_UNMEASURED = new Set(['monospace', 'serif', 'sans-serif', 'system-ui', 'symbols nerd font mono']);
const TERMINAL_FONT_DEFAULT_STACK =
'"Fira Code", "Cascadia Code", "JetBrains Mono", "SF Mono", Monaco, "Symbols Nerd Font Mono", monospace';
+57 -19
View File
@@ -514,9 +514,10 @@ Object.assign(CodemanApp.prototype, {
} else {
this.fitAddon.fit();
}
// That first fit measures whatever font the browser has painted with so far,
// which is not necessarily the terminal font. Start the wait now so the
// buffer load can hold for it.
// Whenever that first fit runs — on this line, or a frame or two later on
// the mobile-Safari branch above — it measures whatever font the browser has
// painted with so far, which is not necessarily the terminal font. Start the
// wait now so the buffer load can hold for it.
this._terminalFontReady = this._awaitTerminalFont();
// Register link provider for clickable file paths in Bash tool output
@@ -4789,6 +4790,15 @@ Object.assign(CodemanApp.prototype, {
const resolved = window.CodemanTerminalFont.resolve(custom);
if (!this.terminal || this.terminal.options.fontFamily === resolved) return;
this.terminal.options.fontFamily = resolved;
// Changing the family at runtime is the same race as the boot-time one: the
// option write makes xterm re-measure immediately, against a family the
// browser may not have loaded. Re-arm the wait for the new stack and fit
// again once it settles, so the setting takes effect at the right size
// without needing a tab switch. The fit below still runs, so the terminal
// is never left unfitted if the wait is slow.
this._terminalFontReady = this._awaitTerminalFont().then(() => {
if (this.terminal?.options?.fontFamily === resolved) this.fitAddon?.fit();
});
this.fitAddon?.fit();
this._localEchoOverlay?.refreshFont();
this._predictiveEcho?.refreshFont();
@@ -4806,12 +4816,7 @@ Object.assign(CodemanApp.prototype, {
},
/**
* Get terminal dimensions with minimum enforcement.
* Prevents extremely narrow terminals that cause vertical text wrapping.
* @returns {{cols: number, rows: number}|null}
*/
/**
* Resolve once the terminal's own font is loaded and measurable.
* Wait for the terminal's own font, then make xterm re-measure against it.
*
* A character cell measured against a fallback font has a different width and
* height from one measured against the terminal font, so a fit taken too early
@@ -4819,12 +4824,24 @@ Object.assign(CodemanApp.prototype, {
* the buffer has been replayed, and the CLI redraws a frame that no longer
* matches what the terminal is showing.
*
* `document.fonts.ready` is not enough on its own: it can resolve before the
* stylesheet declaring @font-face has even been parsed. `document.fonts.load`
* for each family in the stack is what actually requests the faces, so this
* asks for those first and only then waits for the document to go quiet.
* Every step is best-effort — a font that never loads must not block the
* terminal, so this always resolves.
* ⚠️ Waiting is not sufficient on its own, which is what the re-measure at the
* end is for. `FitAddon.proposeDimensions()` divides the container by a CACHED
* cell size, and xterm refreshes that cache only from `open()`, from a resize
* that actually changed the grid, and on a device-pixel-ratio change — nothing
* in it listens for font loading. So a fit that runs after the font arrives can
* still divide by the fallback cell, propose the grid it already has, and
* short-circuit before anything re-measures.
*
* `document.fonts.load` for each family is what actually REQUESTS the faces:
* the WebGL renderer rasterises glyphs through a canvas texture atlas, and
* canvas text never triggers a CSS font fetch, so `document.fonts.ready` can
* resolve with a face never having been asked for at all.
*
* Every step is best-effort and the whole thing is bounded, because a font
* request that never settles must not hold up the terminal: `FontFaceSet.ready`
* has no deadline of its own, and the caller awaits this in front of the buffer
* replay. Past the deadline we fit against whatever is painted, which is the
* old behaviour rather than a new failure.
*/
async _awaitTerminalFont() {
try {
@@ -4832,15 +4849,36 @@ Object.assign(CodemanApp.prototype, {
const size = this.terminal?.options?.fontSize || 14;
const families = String(this.terminal?.options?.fontFamily || '')
.split(',')
.map((family) => family.trim())
.filter(Boolean);
await Promise.all(families.map((family) => document.fonts.load(`${size}px ${family}`).catch(() => {})));
await document.fonts.ready;
.map((family) => family.trim().replace(/^["']|["']$/g, ''))
.filter(Boolean)
// Only the faces that can supply the measured glyph are worth waiting on.
// The bundled symbols font is ~1.2MB and carries private-use-area glyphs
// only — xterm measures `W`, which it does not contain — so awaiting it
// puts a megabyte between the user and their first frame for nothing.
// Generic families match no FontFace at all.
.filter((family) => !TERMINAL_FONT_UNMEASURED.has(family.toLowerCase()));
const loaded = Promise.all(
families.map((family) => document.fonts.load(`${size}px "${family}"`).catch(() => {}))
).then(() => document.fonts.ready);
await Promise.race([loaded, new Promise((resolve) => setTimeout(resolve, TERMINAL_FONT_WAIT_MS))]);
} catch {
/* font loading is unavailable or failed — fit against whatever is painted */
}
// Force the cache refresh xterm will not do for us. Without this the wait
// buys nothing on the common path (see the warning above). Private API, as
// FitAddon itself is; guarded because a terminal can be disposed mid-wait.
try {
this.terminal?._core?._charSizeService?.measure();
} catch {
/* renderer not ready or internals moved — the next real resize re-measures */
}
},
/**
* Get terminal dimensions with minimum enforcement.
* Prevents extremely narrow terminals that cause vertical text wrapping.
* @returns {{cols: number, rows: number}|null}
*/
getTerminalDimensions() {
const MIN_COLS = 40;
const MIN_ROWS = 10;