Harden worker page rendering: error/timeout recovery and awaited flip prewarm

Two robustness gaps from the worker migration, both raised in review:

- The raster worker had no failure recovery: a thrown createImageBitmap/font error or a
  dropped message would leave the draw promise pending forever, stalling the serialized draw
  chain and hanging prepare/playback. Added worker.onerror and a per-job timeout; both settle
  the in-flight draw to a logged miss (texture-worker-error / -timeout) so the pipeline
  degrades to last-good per the spec instead of hanging. A single settleRasterization path
  clears the timer and resolves.
- prepareSpreadTextureRecordsForFlip() called drawSpread() without awaiting it. That was safe
  when drawSpread was synchronous, but now that it is async (worker) the flip could race ahead
  of the worker draw and miss the resident texture. prewarmFlipTextures now awaits both spread
  draws before the resident-texture lookup.

Suite 168 (added assertions for worker error/timeout recovery and the awaited prewarm).
Normal draw path is behaviorally unchanged from the verified worker commit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-19 19:29:20 +02:00
parent 0e4d9e89d7
commit b0175b7cdc
3 changed files with 34 additions and 10 deletions
+26 -6
View File
@@ -125,20 +125,33 @@ class BookTextureRendererModule extends BaseModule {
this.rasterWorker = new Worker(`/js/book-texture-worker.js${version}`);
this.pendingRasterizations = new Map();
this.rasterRequestId = 0;
this.rasterTimeoutMs = 4000;
this.rasterChain = Promise.resolve();
this.rasterWorker.onmessage = (event) => {
const data = event.data || {};
if (data.type !== 'drawn') return;
const resolve = this.pendingRasterizations.get(data.requestId);
if (resolve) {
this.pendingRasterizations.delete(data.requestId);
resolve(data.results);
}
this.settleRasterization(data.requestId, data.results);
};
// A worker crash or load failure must never leave a draw promise pending (that would
// stall the serialized draw chain and hang prepare/playback). Surface it and settle any
// in-flight draws to a logged miss so the pipeline degrades to last-good, not a hang.
this.rasterWorker.onerror = (event) => {
this.pageCache?.recordProblem?.({ type: 'texture-worker-error', message: event?.message || String(event) });
const pending = Array.from(this.pendingRasterizations.keys());
pending.forEach(id => this.settleRasterization(id, null));
};
// Warm the worker's fonts immediately so the first real page render is not delayed.
this.rasterWorker.postMessage({ type: 'warm-fonts' });
}
settleRasterization(requestId, results) {
const pending = this.pendingRasterizations.get(requestId);
if (!pending) return;
this.pendingRasterizations.delete(requestId);
clearTimeout(pending.timer);
pending.resolve(results);
}
// Plain, structured-cloneable subset of metrics the worker needs to draw a page.
buildWorkerMetrics() {
const m = this.metrics || {};
@@ -190,7 +203,14 @@ class BookTextureRendererModule extends BaseModule {
}
};
return new Promise((resolve) => {
this.pendingRasterizations.set(requestId, resolve);
// Bound every job so a dropped/stuck worker response can never leave this promise
// pending and stall the draw chain; on timeout, settle to a logged miss (last-good).
const timer = setTimeout(() => {
if (!this.pendingRasterizations.has(requestId)) return;
this.pageCache?.recordProblem?.({ type: 'texture-worker-timeout', requestId, sides: sidesToDraw });
this.settleRasterization(requestId, null);
}, this.rasterTimeoutMs || 4000);
this.pendingRasterizations.set(requestId, { resolve, timer });
this.rasterWorker.postMessage(job);
});
}
+6 -4
View File
@@ -2442,8 +2442,10 @@ async function prewarmFlipTextures(direction, targetSpread = null) {
const nextSpread = Number.isFinite(Number(targetSpread))
? Math.max(0, Math.round(Number(targetSpread)))
: Math.max(0, currentSpread + Math.sign(Number(direction || 0)));
prepareSpreadTextureRecordsForFlip(currentSpread);
prepareSpreadTextureRecordsForFlip(nextSpread);
// Await the (now async, worker-backed) draws so the spreads are resident before the cache
// lookup below — otherwise the flip can race ahead and find a missing texture.
await prepareSpreadTextureRecordsForFlip(currentSpread);
await prepareSpreadTextureRecordsForFlip(nextSpread);
const windowMap = await prewarmNavigationTextureWindow('flip-prewarm', { targetSpread: nextSpread });
const current = windowMap?.[currentSpread] || await prewarmSpreadTextures(currentSpread);
const next = windowMap?.[nextSpread] || await prewarmSpreadTextures(nextSpread);
@@ -2453,11 +2455,11 @@ async function prewarmFlipTextures(direction, targetSpread = null) {
};
}
function prepareSpreadTextureRecordsForFlip(spreadIndex) {
async function prepareSpreadTextureRecordsForFlip(spreadIndex) {
const spread = getPaginationSpread(spreadIndex);
if (!spread || typeof window.BookTextureRenderer?.drawSpread !== 'function') return false;
if (spreadTextureRecordsReady(spread)) return true;
window.BookTextureRenderer.drawSpread(spread, ['left', 'right'], {
await window.BookTextureRenderer.drawSpread(spread, ['left', 'right'], {
phase: 'prepare'
});
return true;