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:
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -164,6 +164,8 @@ const checks = [
|
||||
['3D overflow reveal commits the spread then requests a timeline flip via event before activating', /requiresSpreadTransition\(segment\)/.test(bookPlaybackTimelineSource) && /this\.commitSegmentSpread\(segment, sentence\)/.test(bookPlaybackTimelineSource) && /this\.requestPageFlip\(1, \{[\s\S]*targetSpread: segment\.targetSpreadIndex/.test(bookPlaybackTimelineSource) && /this\.activatePreparedSegment\(segment, sentence\)/.test(bookPlaybackTimelineSource) && /dispatchEvent\(new CustomEvent\('webgl-book:request-page-flip'/.test(bookPlaybackTimelineSource) && /addEventListener\('webgl-book:request-page-flip'/.test(source) && /startPageFlip\(direction, \{/.test(source)],
|
||||
['texture worker paints inline bold and italic styles off the main thread', /getInlineStyleState/.test(textureWorkerSource) && /updateInlineStyleState/.test(textureWorkerSource) && /getCanvasFont/.test(textureWorkerSource) && /segment\.style/.test(textureWorkerSource) && !/drawLine\(ctx/.test(textureRendererSource)],
|
||||
['texture renderer delegates page rasterization to an OffscreenCanvas worker and blits the result', /book-texture-worker\.js/.test(textureRendererSource) && /rasterizeSpread/.test(textureRendererSource) && /ctx\.drawImage\(result\.pageBitmap, 0, 0\)/.test(textureRendererSource) && /OffscreenCanvas/.test(textureWorkerSource) && /createImageBitmap/.test(textureWorkerSource)],
|
||||
['texture renderer recovers from worker error/timeout so a draw promise never hangs the chain', /this\.rasterWorker\.onerror/.test(textureRendererSource) && /texture-worker-timeout/.test(textureRendererSource) && /settleRasterization/.test(textureRendererSource) && /clearTimeout\(pending\.timer\)/.test(textureRendererSource)],
|
||||
['flip prewarm awaits the async worker draw before the resident-texture lookup', /await prepareSpreadTextureRecordsForFlip\(currentSpread\)/.test(source) && /await window\.BookTextureRenderer\.drawSpread\(spread, \['left', 'right'\]/.test(source)],
|
||||
['webgl lab can preload page textures without swapping visible page material through texture store', /preparePageTexture\(side = 'left'/.test(webglPageCacheSource) && /takePreparedPageTexture\(side = 'left'/.test(webglPageCacheSource) && /renderer\.initTexture\(texture\)/.test(webglPageCacheSource) && /takePreparedPageTexture/.test(source) && !/const preparedPageTextures/.test(source)],
|
||||
['webgl page text textures avoid mipmap generation', /function configurePageCanvasTexture/.test(source) && /texture\.minFilter = THREE\.LinearFilter/.test(source) && /texture\.generateMipmaps = false/.test(source)],
|
||||
['webgl reveal shader masks against a base-page texture instead of flat color blocks', /bookRevealBaseMap/.test(source) && /bookRevealUseBaseMap/.test(source) && /revealBaseColor/.test(source) && /baseCanvas/.test(textureRendererSource)],
|
||||
|
||||
Reference in New Issue
Block a user