From 9e2afbcce58bd87c6febd552c433609db3b4546f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 14 Jul 2026 18:10:03 -0400 Subject: [PATCH] fix(engine): consolidate capture readiness and retries (#2404) * fix(engine): await dynamic CSS backgrounds before capture * fix(render): retry transient network changes * fix(engine): parse CSS URLs without backtracking * fix(engine): decode CSS backgrounds in batch capture --- .../engine/src/services/drawElementService.ts | 2 + .../frameCapture-cssBackgroundReady.test.ts | 154 ++++++++++++++++++ .../frameCapture-transientErrors.test.ts | 1 + packages/engine/src/services/frameCapture.ts | 94 +++++++++++ 4 files changed, 251 insertions(+) create mode 100644 packages/engine/src/services/frameCapture-cssBackgroundReady.test.ts diff --git a/packages/engine/src/services/drawElementService.ts b/packages/engine/src/services/drawElementService.ts index 00528369f..99b126424 100644 --- a/packages/engine/src/services/drawElementService.ts +++ b/packages/engine/src/services/drawElementService.ts @@ -941,6 +941,7 @@ export async function produceDrawElementFrameBatch( __hf_accel_canvases?: HTMLCanvasElement[]; __hf3d?: { update: () => void }; __hf?: { seek?: (t: number) => void }; + __hfDecodeDynamicCssBackgroundImages?: () => Promise; __hfDeInvalidate?: () => boolean; __HF_ROOT_PROPS__?: boolean; __HF_ROOT_BASE_OPACITY__?: number; @@ -975,6 +976,7 @@ export async function produceDrawElementFrameBatch( const { t, fid } = frame; try { if (aw.__hf && typeof aw.__hf.seek === "function") aw.__hf.seek(t); + await aw.__hfDecodeDynamicCssBackgroundImages?.(); aw.__hf3d?.update(); const accel = (aw.__hf_accel_canvases ?? []).filter((c) => root.contains(c)); for (const c of accel) { diff --git a/packages/engine/src/services/frameCapture-cssBackgroundReady.test.ts b/packages/engine/src/services/frameCapture-cssBackgroundReady.test.ts new file mode 100644 index 000000000..f9ee0deea --- /dev/null +++ b/packages/engine/src/services/frameCapture-cssBackgroundReady.test.ts @@ -0,0 +1,154 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { afterEach, describe, expect, it } from "vitest"; +import type { Page } from "puppeteer-core"; +import { decodeDynamicCssBackgroundImages } from "./frameCapture.js"; + +function makeMockPage( + getBackgroundImage: () => string, + decoded: string[], + decodeImage?: (src: string) => Promise, +): Page { + return { + evaluate: async (fn: () => unknown) => { + const previousDocument = globalThis.document; + const previousImage = globalThis.Image; + const previousWindow = globalThis.window; + + const element = { + style: { + get backgroundImage() { + return getBackgroundImage(); + }, + }, + }; + + class MockImage { + src = ""; + + async decode(): Promise { + decoded.push(this.src); + await decodeImage?.(this.src); + } + } + + Object.assign(globalThis, { + document: { + querySelectorAll: () => [element], + }, + Image: MockImage, + window: previousWindow ?? {}, + }); + + try { + return await fn(); + } finally { + Object.assign(globalThis, { + document: previousDocument, + Image: previousImage, + window: previousWindow, + }); + } + }, + } as unknown as Page; +} + +afterEach(() => { + const root = globalThis as { + __hf_css_background_decoded?: Set; + __hfDecodeDynamicCssBackgroundImages?: () => Promise; + }; + delete root.__hf_css_background_decoded; + delete root.__hfDecodeDynamicCssBackgroundImages; +}); + +describe("decodeDynamicCssBackgroundImages", () => { + it("decodes each newly assigned inline background URL before capture", async () => { + let backgroundImage = 'url("/assets/row-0.jpg")'; + const decoded: string[] = []; + const page = makeMockPage(() => backgroundImage, decoded); + + await decodeDynamicCssBackgroundImages(page); + await decodeDynamicCssBackgroundImages(page); + + backgroundImage = 'url("/assets/row-1.jpg")'; + await decodeDynamicCssBackgroundImages(page); + + expect(decoded).toEqual(["/assets/row-0.jpg", "/assets/row-1.jpg"]); + }); + + it("decodes every URL in a layered inline background", async () => { + const decoded: string[] = []; + const page = makeMockPage( + () => "linear-gradient(#000, #fff), url(\"/assets/plate.png\"), url('/assets/grain.webp')", + decoded, + ); + + await decodeDynamicCssBackgroundImages(page); + + expect(decoded).toEqual(["/assets/plate.png", "/assets/grain.webp"]); + }); + + it("rejects malformed quoted URLs without exponential backtracking", async () => { + const decoded: string[] = []; + const malformed = `url("${"\\!".repeat(27)}`; + const page = makeMockPage(() => malformed, decoded); + const startedAt = performance.now(); + + await decodeDynamicCssBackgroundImages(page); + + expect(performance.now() - startedAt).toBeLessThan(250); + expect(decoded).toEqual([]); + }); + + it("retries a URL after a transient decode failure", async () => { + const decoded: string[] = []; + let attempts = 0; + const page = makeMockPage( + () => 'url("/assets/late.jpg")', + decoded, + async () => { + attempts += 1; + if (attempts === 1) throw new Error("not ready"); + }, + ); + + await decodeDynamicCssBackgroundImages(page); + await decodeDynamicCssBackgroundImages(page); + + expect(decoded).toEqual(["/assets/late.jpg", "/assets/late.jpg"]); + }); + + it("installs a page-local decoder that can run after an in-page seek", async () => { + let backgroundImage = 'url("/assets/row-0.jpg")'; + const decoded: string[] = []; + const page = makeMockPage(() => backgroundImage, decoded); + + await decodeDynamicCssBackgroundImages(page); + backgroundImage = 'url("/assets/row-1.jpg")'; + + await page.evaluate(async () => { + const decodeAfterSeek = ( + globalThis as { __hfDecodeDynamicCssBackgroundImages?: () => Promise } + ).__hfDecodeDynamicCssBackgroundImages; + expect(decodeAfterSeek).toBeTypeOf("function"); + await decodeAfterSeek?.(); + }); + + expect(decoded).toEqual(["/assets/row-0.jpg", "/assets/row-1.jpg"]); + }); + + it("awaits the page-local decoder after every seek in drawElement batch capture", () => { + const drawElementSource = readFileSync( + fileURLToPath(new URL("./drawElementService.ts", import.meta.url)), + "utf8", + ); + const batchSource = drawElementSource.slice( + drawElementSource.indexOf("export async function produceDrawElementFrameBatch"), + ); + + expect(batchSource).toMatch( + /aw\.__hf\.seek\(t\);\s*await aw\.__hfDecodeDynamicCssBackgroundImages\?\.\(\);/, + ); + }); +}); diff --git a/packages/engine/src/services/frameCapture-transientErrors.test.ts b/packages/engine/src/services/frameCapture-transientErrors.test.ts index 82cda5e21..c3497c523 100644 --- a/packages/engine/src/services/frameCapture-transientErrors.test.ts +++ b/packages/engine/src/services/frameCapture-transientErrors.test.ts @@ -14,6 +14,7 @@ describe("isTransientBrowserError", () => { "Cannot find context with specified id", "Failed to launch the browser process! TROUBLESHOOTING: https://pptr.dev/troubleshooting", "connect ECONNREFUSED 127.0.0.1:9222", + "net::ERR_NETWORK_CHANGED at http://127.0.0.1:4173/index.html", "Navigation timeout of 60000 ms exceeded", // pollHfReady timed out before window.__renderReady flipped true — the // classic symptom of a slow/contended host (e.g. several renders running diff --git a/packages/engine/src/services/frameCapture.ts b/packages/engine/src/services/frameCapture.ts index 7bd0cf4ce..a443e8abe 100644 --- a/packages/engine/src/services/frameCapture.ts +++ b/packages/engine/src/services/frameCapture.ts @@ -222,6 +222,90 @@ export function isDrawElementVerificationError(err: unknown): boolean { return false; } +/** Wait for inline CSS background images introduced by the latest seek. */ +export async function decodeDynamicCssBackgroundImages(page: Page): Promise { + await page.evaluate(async () => { + const root = globalThis as typeof globalThis & { + __hf_css_background_decoded?: Set; + __hfDecodeDynamicCssBackgroundImages?: () => Promise; + }; + const decode = (root.__hfDecodeDynamicCssBackgroundImages ??= async () => { + const decoded = (root.__hf_css_background_decoded ??= new Set()); + const urls: string[] = []; + const parseBackgroundUrls = (value: string): string[] => { + const found: string[] = []; + let cursor = 0; + + while (cursor < value.length) { + const start = value.indexOf("url(", cursor); + if (start < 0) break; + + let index = start + 4; + while (index < value.length && /\s/.test(value[index] ?? "")) index += 1; + + const quote = value[index] === '"' || value[index] === "'" ? value[index] : null; + if (quote) index += 1; + const contentStart = index; + let contentEnd = -1; + + while (index < value.length) { + const char = value[index]; + if (char === "\\") { + index = Math.min(index + 2, value.length); + continue; + } + if ((quote && char === quote) || (!quote && char === ")")) { + contentEnd = index; + break; + } + index += 1; + } + + if (contentEnd < 0) break; + if (quote) { + index += 1; + while (index < value.length && /\s/.test(value[index] ?? "")) index += 1; + if (value[index] !== ")") { + cursor = index; + continue; + } + } + + const url = value.slice(contentStart, contentEnd).trim(); + if (url) found.push(url); + cursor = index + 1; + } + + return found; + }; + + for (const element of document.querySelectorAll('[style*="background"]')) { + const backgroundImage = element.style.backgroundImage; + if (!backgroundImage || backgroundImage === "none") continue; + + for (const url of parseBackgroundUrls(backgroundImage)) { + if (!decoded.has(url)) urls.push(url); + } + } + + await Promise.all( + [...new Set(urls)].map(async (url) => { + const image = new Image(); + image.src = url; + try { + await image.decode(); + decoded.add(url); + } catch { + // Keep existing capture behavior for missing assets; request diagnostics report them. + } + }), + ); + }); + + await decode(); + }); +} + // Circular buffer for browser console messages dumped on render failure diagnostics. // Complex compositions produce 100+ messages; 50 was too small to capture relevant errors. const BROWSER_CONSOLE_BUFFER_SIZE = 200; @@ -712,6 +796,9 @@ async function finalizeDrawElementInit( opts: { transparent: boolean; forceDE: boolean }, ): Promise { const { transparent, forceDE } = opts; + // Install the page-local decoder before batch drawElement capture begins; + // the batch loop re-runs it after every in-page seek. + await decodeDynamicCssBackgroundImages(page); // Self-verification ground truth: must run pre-injection — after the canvas // wraps the root, a page screenshot shows the canvas's last-drawn bitmap, // not the live DOM (see the Lim 6 boundary-screenshot note). @@ -2005,6 +2092,8 @@ async function prepareFrameForCapture( .__hf_page_composite_pending; }, quantizedTime); + await decodeDynamicCssBackgroundImages(page); + const seekMs = Date.now() - seekStart; // Before-capture hook (e.g. video frame injection) — runs before @@ -3406,6 +3495,11 @@ const TRANSIENT_BROWSER_ERROR_PATTERNS = [ /Failed to launch the browser process/i, /Navigation timeout of \d+ ms exceeded/i, /ECONNREFUSED/i, + // Chromium can briefly invalidate even a localhost connection when Windows + // reports an adapter/route change. A fresh capture session succeeds once the + // network stack settles, so treat this like the other bounded navigation + // retries instead of failing the render immediately. + /net::ERR_NETWORK_CHANGED/i, // pollHfReady's own timeout — thrown when window.__renderReady never flips // true within playerReadyTimeout. "Runtime ready: false" means init simply // didn't finish in time (commonly a slow/contended host, e.g. several