mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
The vendored WebGL atlas capped its invisible-glyph cache at 4,096 entries but enforced the cap by clearing it. A terminal steadily above that many distinct (char, bg, fg, style) combinations therefore lost every entry on each cap-th admission, and each following miss re-ran _drawToCache, which is a canvas draw plus a getImageData readback on the renderer thread. Steady-state cost was 4,096 rasterizations per 4,096 admissions rather than one. FourKeyMap has no ordering and no delete, so the two invisible FourKeyMaps and their counter collapse into one insertion-ordered Set of composite keys. Every value in those maps was the same NULL_RASTERIZED_GLYPH, so the key set is the whole cache, and a Set gives both the ordering eviction needs and the removal FourKeyMap cannot do. Overflow now drops the single oldest admission. The key is tagged with its type so char code 49 and the combined string '1' cannot share an entry. Bundles, sourcemaps, and the lockfile hash regenerated with config/scripts/regenerate-xterm-patches.mjs; --check passes. The audit script gains the assertion that distinguishes the two policies: after a fresh fill of the cap, redrawing that whole window rasterizes nothing and redrawing the one variant that aged out rasterizes exactly once. It passes against both shipped bundles in both output modes.
191 lines
9.3 KiB
Diff
191 lines
9.3 KiB
Diff
diff --git a/src/GlyphRenderer.ts b/src/GlyphRenderer.ts
|
|
index 742f0879ff4f04509e4a07c8efdf0d5743fe8ee5..885a206a394fff783737a412c0b75bf935dd0eca 100644
|
|
--- a/src/GlyphRenderer.ts
|
|
+++ b/src/GlyphRenderer.ts
|
|
@@ -61,6 +61,8 @@ function createFragmentShaderSource(maxFragmentShaderTextureUnits: number): stri
|
|
for (let i = 1; i < maxFragmentShaderTextureUnits; i++) {
|
|
textureConditionals += ` else if (v_texpage == ${i}) { outColor = texture(u_texture[${i}], v_texcoord); }`;
|
|
}
|
|
+ // A v_texpage beyond the sampler budget matches no branch above. Leaving outColor unwritten
|
|
+ // is undefined behaviour in GLSL ES and paints garbage, so fall through to transparent.
|
|
return (`#version 300 es
|
|
precision lowp float;
|
|
|
|
@@ -74,7 +76,7 @@ out vec4 outColor;
|
|
void main() {
|
|
if (v_texpage == 0) {
|
|
outColor = texture(u_texture[0], v_texcoord);
|
|
- } ${textureConditionals}
|
|
+ } ${textureConditionals} else { outColor = vec4(0.0, 0.0, 0.0, 0.0); }
|
|
}`);
|
|
}
|
|
|
|
diff --git a/src/TextureAtlas.ts b/src/TextureAtlas.ts
|
|
index 4977ad741065e26bbfd35bc50e7558a033b13340..30046bf5ec5100d6bc04025ebf097c4c42a8aa33 100644
|
|
--- a/src/TextureAtlas.ts
|
|
+++ b/src/TextureAtlas.ts
|
|
@@ -3,6 +3,7 @@
|
|
* @license MIT
|
|
*/
|
|
|
|
+import { FontWeight } from '@xterm/xterm';
|
|
import { IColorContrastCache } from 'browser/Types';
|
|
import { DIM_OPACITY, TEXT_BASELINE } from './Constants';
|
|
import { tryDrawCustomGlyph } from './customGlyphs/CustomGlyphRasterizer';
|
|
@@ -42,7 +43,8 @@ const enum Constants {
|
|
* is enforced to ensure uploading the texture still finishes in a reasonable amount of time. A
|
|
* 4096 squared image takes up 16MB of GPU memory.
|
|
*/
|
|
- FORCED_MAX_TEXTURE_SIZE = 4096
|
|
+ FORCED_MAX_TEXTURE_SIZE = 4096,
|
|
+ EMPTY_GLYPH_CACHE_LIMIT = 4096
|
|
}
|
|
|
|
interface ICharAtlasActiveRow {
|
|
@@ -60,6 +62,10 @@ export class TextureAtlas implements ITextureAtlas {
|
|
private _cacheMap: FourKeyMap<number, number, number, number, IRasterizedGlyph> = new FourKeyMap();
|
|
private _cacheMapCombined: FourKeyMap<string, number, number, number, IRasterizedGlyph> = new FourKeyMap();
|
|
|
|
+ // Every empty glyph is the same NULL_RASTERIZED_GLYPH, so the key set is the whole cache. A Set
|
|
+ // iterates in insertion order, which is what lets the oldest admission be evicted on its own.
|
|
+ private _emptyGlyphKeys: Set<string> = new Set();
|
|
+
|
|
// The texture that the atlas is drawn to
|
|
private _pages: AtlasPage[] = [];
|
|
public get pages(): { canvas: HTMLCanvasElement, version: number }[] { return this._pages; }
|
|
@@ -135,21 +141,24 @@ export class TextureAtlas implements ITextureAtlas {
|
|
private _pageLayoutVersion = 0;
|
|
public get pageLayoutVersion(): number { return this._pageLayoutVersion; }
|
|
|
|
+ // Orca diagnostics: read by terminal-render-desync-weight-probe.ts to tell a real
|
|
+ // bold-collapse from a repaint problem. Canvas silently keeps its previous font when an
|
|
+ // assignment fails to parse, which rasterizes glyphs at a stale weight.
|
|
+ public fontProbeMismatchCount = 0;
|
|
+ public fontProbeLastMismatch: { desired: string, actual: string } | undefined;
|
|
+
|
|
public clearTexture(): void {
|
|
- if (this._pages[0].currentRow.x === 0 && this._pages[0].currentRow.y === 0) {
|
|
+ this._emptyGlyphKeys.clear();
|
|
+ // Guard on every page rather than pages[0]: a merged page is never written through
|
|
+ // currentRow, so once one lands at index 0 the old check made every later clear a no-op.
|
|
+ if (this._pages.every(page => page.glyphs.length === 0 && page.currentRow.x === 0 && page.currentRow.y === 0)) {
|
|
return;
|
|
}
|
|
- for (const page of this._pages) {
|
|
- page.clear();
|
|
- }
|
|
- this._cacheMap.clear();
|
|
- this._cacheMapCombined.clear();
|
|
- this._didWarmUp = false;
|
|
-
|
|
- // Invalidate renderer models so all texture pages are refreshed. The atlas may be shared, in
|
|
- // which case the clearing renderer has cleared only its own model and every other owner still
|
|
- // holds texture coords into the rows just wiped.
|
|
- this._pageLayoutVersion++;
|
|
+ // Return the atlas to its constructor state instead of clearing in place: page.clear() leaves
|
|
+ // page.glyphs populated, which would keep the guard above from ever firing again. Eviction
|
|
+ // also bumps _pageLayoutVersion, so every renderer sharing this atlas rebuilds its model.
|
|
+ this._evictAllPages();
|
|
+ this._createNewPage();
|
|
}
|
|
|
|
private _createNewPage(): AtlasPage {
|
|
@@ -273,6 +282,7 @@ export class TextureAtlas implements ITextureAtlas {
|
|
this._overflowSizePage = undefined;
|
|
this._cacheMap.clear();
|
|
this._cacheMapCombined.clear();
|
|
+ this._emptyGlyphKeys.clear();
|
|
this._didWarmUp = false;
|
|
this._pageLayoutVersion++;
|
|
this._logService.debug(`Evicted ${pageCount} WebGL atlas pages in ${(performance.now() - startTime).toFixed(2)}ms`);
|
|
@@ -300,8 +310,27 @@ export class TextureAtlas implements ITextureAtlas {
|
|
): IRasterizedGlyph {
|
|
$glyph = cacheMap.get(key, bg, fg, ext);
|
|
if (!$glyph) {
|
|
+ // Tag the key type so char code 49 and the combined string '1' cannot share an entry.
|
|
+ const emptyKey = `${typeof key === 'number' ? 'n' : 's'}${key}_${bg}_${fg}_${ext}`;
|
|
+ if (this._emptyGlyphKeys.has(emptyKey)) {
|
|
+ return NULL_RASTERIZED_GLYPH;
|
|
+ }
|
|
$glyph = this._drawToCache(key, bg, fg, ext, restrictToCellHeight, domContainer);
|
|
- cacheMap.set(key, bg, fg, ext, $glyph);
|
|
+ if ($glyph === NULL_RASTERIZED_GLYPH) {
|
|
+ // Empty glyphs consume no atlas pixels, so page eviction cannot bound their metadata.
|
|
+ // Evict only the oldest admission. Clearing the whole cache made any workload that stays
|
|
+ // above the limit re-rasterize all 4096 entries every limit-th miss, and _drawToCache
|
|
+ // costs a canvas draw plus a getImageData readback on the renderer thread.
|
|
+ if (this._emptyGlyphKeys.size >= Constants.EMPTY_GLYPH_CACHE_LIMIT) {
|
|
+ for (const oldest of this._emptyGlyphKeys) {
|
|
+ this._emptyGlyphKeys.delete(oldest);
|
|
+ break;
|
|
+ }
|
|
+ }
|
|
+ this._emptyGlyphKeys.add(emptyKey);
|
|
+ } else {
|
|
+ cacheMap.set(key, bg, fg, ext, $glyph);
|
|
+ }
|
|
}
|
|
return $glyph;
|
|
}
|
|
@@ -465,6 +494,36 @@ export class TextureAtlas implements ITextureAtlas {
|
|
return this._config.colors.contrastCache;
|
|
}
|
|
|
|
+ /**
|
|
+ * Orca diagnostic. Canvas ignores a font assignment it cannot parse and silently keeps the
|
|
+ * previous value, so a bad family or weight rasterizes every glyph at a stale weight. Record
|
|
+ * the mismatch rather than correcting it: the goal is to tell that failure apart from a
|
|
+ * repaint bug when a terminal renders bold-collapsed.
|
|
+ */
|
|
+ private _probeRasterizationFontWeight(fontWeight: FontWeight): void {
|
|
+ const desired = String(fontWeight);
|
|
+ // Only numeric weights are comparable; keywords round-trip through Canvas unchanged.
|
|
+ if (!/^(?:[1-8]\d{2}|900)$/.test(desired)) {
|
|
+ return;
|
|
+ }
|
|
+ // Canvas normalizes font serialization: Chromium omits 400 and emits the keyword bold for 700.
|
|
+ const token = this._tmpCtx.font.match(
|
|
+ /(?:^|\s)(normal|bold|[1-9]\d{0,3})(?=\s+\d+(?:\.\d+)?px(?:\s|$))/
|
|
+ )?.[1] ?? '400';
|
|
+ const actual = token === 'normal' ? '400' : token === 'bold' ? '700' : token;
|
|
+ if (actual === desired) {
|
|
+ return;
|
|
+ }
|
|
+ this.fontProbeMismatchCount++;
|
|
+ this.fontProbeLastMismatch = { desired, actual: this._tmpCtx.font };
|
|
+ try {
|
|
+ (globalThis as { __orcaAtlasFontProbe?: (mismatch: { desired: string, actual: string }) => void })
|
|
+ .__orcaAtlasFontProbe?.(this.fontProbeLastMismatch);
|
|
+ } catch {
|
|
+ // Diagnostics only; a throwing listener must never break rasterization.
|
|
+ }
|
|
+ }
|
|
+
|
|
private _drawToCache(codeOrChars: number | string, bg: number, fg: number, ext: number, restrictToCellHeight: boolean, domContainer: HTMLElement | undefined): IRasterizedGlyph {
|
|
const chars = typeof codeOrChars === 'number' ? String.fromCharCode(codeOrChars) : codeOrChars;
|
|
|
|
@@ -536,6 +595,7 @@ export class TextureAtlas implements ITextureAtlas {
|
|
const fontStyle = italic ? 'italic' : '';
|
|
this._tmpCtx.font =
|
|
`${fontStyle} ${fontWeight} ${this._config.fontSize * this._config.devicePixelRatio}px ${this._config.fontFamily}`;
|
|
+ this._probeRasterizationFontWeight(fontWeight);
|
|
this._tmpCtx.textBaseline = TEXT_BASELINE;
|
|
|
|
const powerlineGlyph = chars.length === 1 && isPowerlineGlyph(chars.charCodeAt(0));
|
|
diff --git a/src/WebglRenderer.ts b/src/WebglRenderer.ts
|
|
index a951efba5c75e82735cd39b6b22c4b5d5fad5928..e7f80c3a8c14dd65a16a97f1d17e3da1d65ed8bf 100644
|
|
--- a/src/WebglRenderer.ts
|
|
+++ b/src/WebglRenderer.ts
|
|
@@ -386,7 +386,10 @@ export class WebglRenderer extends Disposable implements IRenderer {
|
|
// page's version, so re-run the update and force a full texture rebind.
|
|
let merged = false;
|
|
let mergeRetries = 0;
|
|
- while (this._charAtlas && this._glyphRenderer.value.beginFrame() && mergeRetries++ < Constants.MERGE_RETRY_LIMIT) {
|
|
+ // Test the retry budget before beginFrame: beginFrame latches the page layout version it
|
|
+ // observed, so tripping the limit after consuming it would strand a stale model with no
|
|
+ // later frame able to notice it needs rebuilding.
|
|
+ while (this._charAtlas && mergeRetries++ < Constants.MERGE_RETRY_LIMIT && this._glyphRenderer.value.beginFrame()) {
|
|
merged = true;
|
|
this._clearModel(true);
|
|
this._updateModel(0, this._terminal.rows - 1);
|