diff --git a/public/js/book-texture-renderer-module.js b/public/js/book-texture-renderer-module.js index ff22642..16b5c6e 100644 --- a/public/js/book-texture-renderer-module.js +++ b/public/js/book-texture-renderer-module.js @@ -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); }); } diff --git a/public/js/webgl-book-lab.js b/public/js/webgl-book-lab.js index 0541247..054ada7 100644 --- a/public/js/webgl-book-lab.js +++ b/public/js/webgl-book-lab.js @@ -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; diff --git a/scripts/check-webgl-book-lab.js b/scripts/check-webgl-book-lab.js index 1ff1a12..ae107c6 100644 --- a/scripts/check-webgl-book-lab.js +++ b/scripts/check-webgl-book-lab.js @@ -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)],