diff --git a/src/core/text-rendering/SdfFontHandler.ts b/src/core/text-rendering/SdfFontHandler.ts index 0c7b555..47882e2 100644 --- a/src/core/text-rendering/SdfFontHandler.ts +++ b/src/core/text-rendering/SdfFontHandler.ts @@ -109,6 +109,12 @@ export interface SdfFont { maxCharHeight: number; } +// Number of times a failed font load is automatically retried before +// loadFont() finally rejects. Counts reloads *after* the initial attempt +// (matching the `maxRetryCount` = retries convention used for textures), so +// the load is attempted up to MAX_FONT_LOAD_RETRIES + 1 times in total. +export const MAX_FONT_LOAD_RETRIES = 3; + //global state variables for SdfFontHandler const fontCache = new Map(); const fontLoadPromises = new Map>(); @@ -320,10 +326,18 @@ export const loadFont = ( ); } - const nwff: CoreTextNode[] = (nodesWaitingForFont[fontFamily] = []); - // Create loading promise - const loadPromise = (async (): Promise => { - const fontData = await new Promise((resolve, reject) => { + // Reuse an existing waiter list. A previous load attempt for this font may + // have failed and left nodes parked here; overwriting the list would strand + // them, so a successful retry could never wake them. The list is consumed + // (and deleted) on the next successful load. + let nwff = nodesWaitingForFont[fontFamily]; + if (nwff === undefined) { + nwff = nodesWaitingForFont[fontFamily] = []; + } + // One attempt at fetching + decoding the JSON atlas description. A fresh + // XHR runs per attempt so a transient network/parse failure can recover. + const fetchFontData = (): Promise => + new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('GET', atlasDataUrl, true); xhr.responseType = 'json'; @@ -352,16 +366,23 @@ export const loadFont = ( }; xhr.send(null); }); + + // One attempt at loading the atlas texture for the given font data. On + // success it processes + caches the font and wakes parked nodes. On failure + // it drops the dead atlas texture (createTexture caches by `src`, so the + // next attempt must evict it to build a fresh one) and rejects. + const loadAtlas = (fontData: SdfFontData): Promise => { if (!fontData || !fontData.chars) { - throw new Error('Invalid SDF font data format'); + return Promise.reject(new Error('Invalid SDF font data format')); } // Atlas texture should be provided externally if (!atlasUrl) { - throw new Error('Atlas texture must be provided for SDF fonts'); + return Promise.reject( + new Error('Atlas texture must be provided for SDF fonts'), + ); } - // Wait for atlas texture to load return new Promise((resolve, reject) => { // create new atlas texture using ImageTexture const atlasTexture = stage.txManager.createTexture('ImageTexture', { @@ -372,46 +393,72 @@ export const loadFont = ( atlasTexture.setRenderableOwner(fontFamily, true); atlasTexture.preventCleanup = true; // Prevent automatic cleanup - if (atlasTexture.state === 'loaded') { - // If already loaded, process immediately - processFontData(fontFamily, fontData, atlasTexture, metrics); - fontLoadPromises.delete(fontFamily); - - for (let key in nwff) { - nwff[key]!.setUpdateType(UpdateType.Local); - } - delete nodesWaitingForFont[fontFamily]; - return resolve(); - } - - atlasTexture.on('loaded', () => { + const onLoaded = () => { // Process and cache font data processFontData(fontFamily, fontData, atlasTexture, metrics); - // remove from promises - fontLoadPromises.delete(fontFamily); - for (let key in nwff) { nwff[key]!.setUpdateType(UpdateType.Local); } delete nodesWaitingForFont[fontFamily]; resolve(); - }); + }; + + if (atlasTexture.state === 'loaded') { + // If already loaded, process immediately + onLoaded(); + return; + } + + atlasTexture.on('loaded', onLoaded); // EventEmitter invokes listeners as (target, data), so the error payload // is the SECOND argument. The first arg is the Texture that emitted the // event. Reading it as the only param (the previous behavior) rejected // and logged the Texture instead of the actual TextureError. atlasTexture.on('failed', (_target, error: TextureError) => { - // Cleanup on error - fontLoadPromises.delete(fontFamily); - if (fontCache[fontFamily]) { - delete fontCache[fontFamily]; - } - console.error(`Failed to load SDF font: ${fontFamily}`, error); + // Drop the failed atlas so a retry builds a fresh texture rather than + // getting this dead instance back from the createTexture key-cache. + atlasTexture.setRenderableOwner(fontFamily, false); + stage.txManager.removeTextureFromCache(atlasTexture); reject(error); }); }); + }; + + // Initial attempt plus up to MAX_FONT_LOAD_RETRIES automatic reloads. + const loadPromise = (async (): Promise => { + let lastError: unknown; + for (let attempt = 0; attempt <= MAX_FONT_LOAD_RETRIES; attempt++) { + try { + await loadAtlas(await fetchFontData()); + // Success: clear the in-flight marker (the font now lives in fontCache) + // — parked nodes were already woken inside loadAtlas. + fontLoadPromises.delete(fontFamily); + return; + } catch (error) { + lastError = error; + if (attempt < MAX_FONT_LOAD_RETRIES) { + console.warn( + `SDF font "${fontFamily}" failed to load (attempt ${ + attempt + 1 + } of ${MAX_FONT_LOAD_RETRIES + 1}), retrying.`, + error, + ); + } + } + } + + // Every attempt failed. Clear the in-flight marker so the font can be + // requested again and drop any partial cache entry. nodesWaitingForFont + // is deliberately kept: nodes parked here must survive so a later + // loadFont() (which reuses the list) can still wake them if the font + // eventually loads. The list shrinks as nodes self-remove via + // stopWaitingForFont on destroy. + fontLoadPromises.delete(fontFamily); + fontCache.delete(fontFamily); + console.error(`Failed to load SDF font: ${fontFamily}`, lastError); + throw lastError; })(); fontLoadPromises.set(fontFamily, loadPromise); diff --git a/src/core/text-rendering/tests/SdfFontHandler.test.ts b/src/core/text-rendering/tests/SdfFontHandler.test.ts index d0e8726..f295fa0 100644 --- a/src/core/text-rendering/tests/SdfFontHandler.test.ts +++ b/src/core/text-rendering/tests/SdfFontHandler.test.ts @@ -1,5 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { loadFont } from '../SdfFontHandler.js'; +import { + loadFont, + waitingForFont, + isFontLoaded, + MAX_FONT_LOAD_RETRIES, +} from '../SdfFontHandler.js'; import { EventEmitter } from '../../../common/EventEmitter.js'; import { TextureError, TextureErrorCode } from '../../TextureError.js'; import type { Stage } from '../../Stage.js'; @@ -14,7 +19,19 @@ class FakeXHR { onerror: (() => void) | null = null; open(): void {} send(): void { - this.response = { chars: [{}] }; + // Enough shape for processFontData to run on the success path without + // throwing: a chars array, an (empty) kernings array, and metrics so it + // skips the atlas-derived cap/x-height branches. + this.response = { + chars: [{}], + kernings: [], + lightningMetrics: { + ascender: 800, + descender: -200, + lineGap: 200, + unitsPerEm: 1000, + }, + }; if (this.onload !== null) { this.onload(); } @@ -34,15 +51,59 @@ function makeFakeTexture() { return tex; } +type FakeTexture = ReturnType; + +// A stage whose txManager hands out a fresh fake texture per loadFont attempt +// (so each retry has its own texture to fail/succeed) and records them. +function makeStage(): { stage: Stage; textures: FakeTexture[] } { + const textures: FakeTexture[] = []; + const stage = { + txManager: { + createTexture: () => { + const t = makeFakeTexture(); + textures.push(t); + return t; + }, + // loadFont evicts a failed atlas before retrying; a no-op is enough here. + removeTextureFromCache: () => {}, + }, + } as unknown as Stage; + return { stage, textures }; +} + +const opts = (fontFamily: string) => + ({ + fontFamily, + atlasUrl: 'atlas.png', + atlasDataUrl: 'atlas.json', + } as Parameters[1]); + // Drain microtasks + one macrotask so the async loader reaches listener setup. const flush = (): Promise => new Promise((r) => setTimeout(r, 0)); -describe('SdfFontHandler loadFont — failed event argument', () => { +// Fail every attempt: each iteration waits for the next attempt to wire up its +// atlas listener, then fires 'failed' on that attempt's texture. +async function failAllAttempts( + textures: FakeTexture[], + error: TextureError, + attempts: number, +): Promise { + for (let i = 0; i < attempts; i++) { + await flush(); + textures[i]!.emit('failed', error); + } +} + +const TOTAL_ATTEMPTS = MAX_FONT_LOAD_RETRIES + 1; // initial + retries + +describe('SdfFontHandler loadFont — failure after exhausting retries', () => { let errSpy: ReturnType; + let warnSpy: ReturnType; let originalXHR: unknown; beforeEach(() => { errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); originalXHR = (globalThis as unknown as { XMLHttpRequest: unknown }) .XMLHttpRequest; (globalThis as unknown as { XMLHttpRequest: unknown }).XMLHttpRequest = @@ -51,62 +112,147 @@ describe('SdfFontHandler loadFont — failed event argument', () => { afterEach(() => { errSpy.mockRestore(); + warnSpy.mockRestore(); (globalThis as unknown as { XMLHttpRequest: unknown }).XMLHttpRequest = originalXHR; }); - it('rejects with the TextureError (second emit arg), not the emitting texture', async () => { - const tex = makeFakeTexture(); - const stage = { - txManager: { createTexture: () => tex }, - } as unknown as Stage; + it('rejects with the last TextureError (second emit arg), not the emitting texture', async () => { + const { stage, textures } = makeStage(); + const promise = loadFont(stage, opts('TestSdfFailFont')); - const promise = loadFont(stage, { - fontFamily: 'TestSdfFailFont', - atlasUrl: 'atlas.png', - atlasDataUrl: 'atlas.json', - } as Parameters[1]); + const error = new TextureError( + TextureErrorCode.TEXTURE_UPLOAD_FAILED, + 'boom', + ); - await flush(); + // Attach the rejection assertion before driving failures. + const assertion = expect(promise).rejects.toBe(error); + await failAllAttempts(textures, error, TOTAL_ATTEMPTS); + await assertion; + }); + + it('logs the error, not the texture, after the final attempt', async () => { + const { stage, textures } = makeStage(); + const promise = loadFont(stage, opts('TestSdfFailFontLog')); const error = new TextureError( TextureErrorCode.TEXTURE_UPLOAD_FAILED, 'boom', ); + const rejected = promise.catch(() => {}); + await failAllAttempts(textures, error, TOTAL_ATTEMPTS); + await rejected; - // Attach the rejection assertion before emitting so the handler is ready. - const assertion = expect(promise).rejects.toBe(error); + const lastCall = errSpy.mock.calls[errSpy.mock.calls.length - 1]!; + expect(lastCall[1]).toBe(error); + expect(lastCall[1]).not.toBe(textures[textures.length - 1]); + }); - // EventEmitter calls listeners as (target, data) -> (tex, error). - tex.emit('failed', error); + it('attempts the load exactly initial + MAX_FONT_LOAD_RETRIES times', async () => { + const { stage, textures } = makeStage(); + const promise = loadFont(stage, opts('TestSdfAttemptCount')); - await assertion; + const error = new TextureError( + TextureErrorCode.TEXTURE_UPLOAD_FAILED, + 'boom', + ); + const rejected = promise.catch(() => {}); + await failAllAttempts(textures, error, TOTAL_ATTEMPTS); + await rejected; + + // One texture is created per attempt; no further attempts after exhaustion. + expect(textures.length).toBe(TOTAL_ATTEMPTS); + // A warning per retried attempt, error only on the final failure. + expect(warnSpy).toHaveBeenCalledTimes(MAX_FONT_LOAD_RETRIES); + expect(errSpy).toHaveBeenCalledTimes(1); + }); +}); + +describe('SdfFontHandler loadFont — automatic retry', () => { + let errSpy: ReturnType; + let warnSpy: ReturnType; + let originalXHR: unknown; + + beforeEach(() => { + errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + originalXHR = (globalThis as unknown as { XMLHttpRequest: unknown }) + .XMLHttpRequest; + (globalThis as unknown as { XMLHttpRequest: unknown }).XMLHttpRequest = + FakeXHR; + }); + + afterEach(() => { + errSpy.mockRestore(); + warnSpy.mockRestore(); + (globalThis as unknown as { XMLHttpRequest: unknown }).XMLHttpRequest = + originalXHR; }); - it('logs the error, not the texture, on failure', async () => { - const tex = makeFakeTexture(); - const stage = { - txManager: { createTexture: () => tex }, - } as unknown as Stage; + it('recovers when a retry succeeds and wakes nodes parked before the failure', async () => { + const { stage, textures } = makeStage(); + const fontFamily = 'RetryRecoverFont'; + + const promise = loadFont(stage, opts(fontFamily)); - const promise = loadFont(stage, { - fontFamily: 'TestSdfFailFontLog', - atlasUrl: 'atlas.png', - atlasDataUrl: 'atlas.json', - } as Parameters[1]); + // Park a node while the font is loading (waiter list exists synchronously). + const node = { id: 1, setUpdateType: vi.fn() }; + waitingForFont( + fontFamily, + node as unknown as Parameters[1], + ); + // First attempt fails... await flush(); + textures[0]!.emit( + 'failed', + new TextureError(TextureErrorCode.TEXTURE_UPLOAD_FAILED, 'boom'), + ); + // ...the automatic retry succeeds. + await flush(); + textures[1]!.emit('loaded'); + await promise; + + expect(isFontLoaded(fontFamily)).toBe(true); + expect(node.setUpdateType).toHaveBeenCalledTimes(1); + // Only two attempts were needed. + expect(textures.length).toBe(2); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(errSpy).not.toHaveBeenCalled(); + }); + + it('keeps parked nodes after every retry fails so a later loadFont still wakes them', async () => { + const { stage, textures } = makeStage(); + const fontFamily = 'RetryExhaustReuseFont'; const error = new TextureError( TextureErrorCode.TEXTURE_UPLOAD_FAILED, 'boom', ); - const rejected = promise.catch(() => {}); - tex.emit('failed', error); - await rejected; - const lastCall = errSpy.mock.calls[errSpy.mock.calls.length - 1]!; - expect(lastCall[1]).toBe(error); - expect(lastCall[1]).not.toBe(tex); + const first = loadFont(stage, opts(fontFamily)); + const firstRejected = first.catch(() => {}); + + const node = { id: 1, setUpdateType: vi.fn() }; + waitingForFont( + fontFamily, + node as unknown as Parameters[1], + ); + + await failAllAttempts(textures, error, TOTAL_ATTEMPTS); + await firstRejected; + + expect(isFontLoaded(fontFamily)).toBe(false); + expect(node.setUpdateType).not.toHaveBeenCalled(); + + // A fresh load reuses the still-parked node and wakes it on success. + const second = loadFont(stage, opts(fontFamily)); + await flush(); + textures[TOTAL_ATTEMPTS]!.emit('loaded'); + await second; + + expect(isFontLoaded(fontFamily)).toBe(true); + expect(node.setUpdateType).toHaveBeenCalledTimes(1); }); });