From 1f152376cd49a0bfd5c52615304c6ceadbcb59eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sun, 1 Nov 2026 22:11:05 -0500 Subject: [PATCH 1/4] fix(engine): a background image that never finishes decoding no longer stalls the frame --- .../frameCapture-cssBackgroundReady.test.ts | 28 ++++++++-- packages/engine/src/services/frameCapture.ts | 51 +++++++++++++++---- 2 files changed, 65 insertions(+), 14 deletions(-) diff --git a/packages/engine/src/services/frameCapture-cssBackgroundReady.test.ts b/packages/engine/src/services/frameCapture-cssBackgroundReady.test.ts index 34e60a45952..d12a89d430f 100644 --- a/packages/engine/src/services/frameCapture-cssBackgroundReady.test.ts +++ b/packages/engine/src/services/frameCapture-cssBackgroundReady.test.ts @@ -1,8 +1,8 @@ import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { Page } from "puppeteer-core"; -import { decodeDynamicCssBackgroundImages } from "./frameCapture.js"; +import { BACKGROUND_DECODE_TIMEOUT_MS, decodeDynamicCssBackgroundImages } from "./frameCapture.js"; function makeMockPage( getBackgroundImage: () => string, @@ -10,7 +10,7 @@ function makeMockPage( decodeImage?: (src: string) => Promise, ): Page { return { - evaluate: async (fn: () => unknown) => { + evaluate: async (fn: (arg?: unknown) => unknown, arg?: unknown) => { const previousDocument = globalThis.document; const previousImage = globalThis.Image; const previousWindow = globalThis.window; @@ -41,7 +41,7 @@ function makeMockPage( }); try { - return await fn(); + return await fn(arg); } finally { Object.assign(globalThis, { document: previousDocument, @@ -56,9 +56,12 @@ function makeMockPage( afterEach(() => { const root = globalThis as { __hf_css_background_decoded?: Set; + __hf_css_background_timed_out?: Set; __hfDecodeDynamicCssBackgroundImages?: () => Promise; }; delete root.__hf_css_background_decoded; + delete root.__hf_css_background_timed_out; + vi.useRealTimers(); delete root.__hfDecodeDynamicCssBackgroundImages; }); @@ -119,6 +122,23 @@ describe("decodeDynamicCssBackgroundImages", () => { expect(decoded).toEqual(["/assets/late.jpg", "/assets/late.jpg"]); }); + it("stops waiting for a url whose decode never settles and names it", async () => { + vi.useFakeTimers(); + const decoded: string[] = []; + const page = makeMockPage( + () => 'url("/assets/stuck.jpg")', + decoded, + () => new Promise(() => {}), + ); + + const first = decodeDynamicCssBackgroundImages(page); + await vi.advanceTimersByTimeAsync(BACKGROUND_DECODE_TIMEOUT_MS); + expect(await first).toEqual(["/assets/stuck.jpg"]); + + expect(await decodeDynamicCssBackgroundImages(page)).toEqual([]); + expect(decoded).toEqual(["/assets/stuck.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[] = []; diff --git a/packages/engine/src/services/frameCapture.ts b/packages/engine/src/services/frameCapture.ts index 247006d5392..851af40ff30 100644 --- a/packages/engine/src/services/frameCapture.ts +++ b/packages/engine/src/services/frameCapture.ts @@ -457,15 +457,25 @@ export function getDrawElementVerificationDetails( return undefined; } -/** Wait for inline CSS background images introduced by the latest seek. */ -export async function decodeDynamicCssBackgroundImages(page: Page): Promise { - await page.evaluate(async () => { +/** Longest the capture waits for one inline background image to decode; it is then left out. */ +export const BACKGROUND_DECODE_TIMEOUT_MS = 15_000; + +/** + * Wait for inline CSS background images introduced by the latest seek. + * Resolves with the urls that did not settle in time: they are captured as they stand and not waited on again. + */ +export async function decodeDynamicCssBackgroundImages(page: Page): Promise { + return page.evaluate(async (timeoutMs: number) => { const root = globalThis as typeof globalThis & { __hf_css_background_decoded?: Set; - __hfDecodeDynamicCssBackgroundImages?: () => Promise; + __hf_css_background_timed_out?: Set; + __hf_css_background_timeout_ms?: number; + __hfDecodeDynamicCssBackgroundImages?: () => Promise; }; const decode = (root.__hfDecodeDynamicCssBackgroundImages ??= async () => { + const limitMs = root.__hf_css_background_timeout_ms ?? 0; const decoded = (root.__hf_css_background_decoded ??= new Set()); + const timedOut = (root.__hf_css_background_timed_out ??= new Set()); const urls: string[] = []; const parseBackgroundUrls = (value: string): string[] => { const found: string[] = []; @@ -519,26 +529,42 @@ export async function decodeDynamicCssBackgroundImages(page: Page): Promise { const image = new Image(); image.src = url; + let timer: ReturnType | undefined; + const expired = new Promise<"late">((resolve) => { + timer = setTimeout(() => resolve("late"), limitMs); + }); try { - await image.decode(); - decoded.add(url); + const outcome = await Promise.race([ + image.decode().then(() => "ready" as const), + expired, + ]); + if (outcome === "ready") decoded.add(url); + else { + timedOut.add(url); + late.push(url); + } } catch { // Keep existing capture behavior for missing assets; request diagnostics report them. + } finally { + clearTimeout(timer); } }), ); + return late; }); - await decode(); - }); + root.__hf_css_background_timeout_ms = timeoutMs; + return decode(); + }, BACKGROUND_DECODE_TIMEOUT_MS); } // Circular buffer for browser console messages dumped on render failure diagnostics. @@ -3355,7 +3381,12 @@ async function prepareFrameForCapture( const hasPendingComposite = await seekPageTimeline(page, quantizedTime, seekOptions); session.captureStep = "decoding CSS background images"; - await decodeDynamicCssBackgroundImages(page); + for (const url of await decodeDynamicCssBackgroundImages(page)) { + appendBrowserDiagnostic( + session, + `[FrameCapture] background image still decoding after ${BACKGROUND_DECODE_TIMEOUT_MS}ms, captured without it: ${url}`, + ); + } const seekMs = Date.now() - seekStart; From 014b4b87f20c98acfcada12c043f6459f37e5704 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sun, 1 Nov 2026 22:33:43 -0500 Subject: [PATCH 2/4] fix(engine): capture tolerates a page that returns no list of late background images --- packages/engine/src/services/frameCapture.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/engine/src/services/frameCapture.ts b/packages/engine/src/services/frameCapture.ts index 851af40ff30..bfe2d429694 100644 --- a/packages/engine/src/services/frameCapture.ts +++ b/packages/engine/src/services/frameCapture.ts @@ -3381,7 +3381,8 @@ async function prepareFrameForCapture( const hasPendingComposite = await seekPageTimeline(page, quantizedTime, seekOptions); session.captureStep = "decoding CSS background images"; - for (const url of await decodeDynamicCssBackgroundImages(page)) { + // Mocked pages answer evaluate() with undefined, so tolerate a missing list. + for (const url of (await decodeDynamicCssBackgroundImages(page)) ?? []) { appendBrowserDiagnostic( session, `[FrameCapture] background image still decoding after ${BACKGROUND_DECODE_TIMEOUT_MS}ms, captured without it: ${url}`, From 7540d84ae07b719a699a7ec4543bd0bd65bb4cce Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Tue, 3 Nov 2026 18:47:48 -0500 Subject: [PATCH 3/4] fix(engine): a background decode timeout is reported at every caller, url redacted --- .../engine/src/services/drawElementService.ts | 20 ++++++++--- .../frameCapture-cssBackgroundReady.test.ts | 33 +++++++++++++++-- .../frameCapture-freshFallback.test.ts | 2 ++ packages/engine/src/services/frameCapture.ts | 35 ++++++++++++++----- 4 files changed, 76 insertions(+), 14 deletions(-) diff --git a/packages/engine/src/services/drawElementService.ts b/packages/engine/src/services/drawElementService.ts index 085de072556..8f95e956ebd 100644 --- a/packages/engine/src/services/drawElementService.ts +++ b/packages/engine/src/services/drawElementService.ts @@ -1002,7 +1002,13 @@ export async function produceDrawElementFrameBatch( width: number, height: number, quality = 80, -): Promise<{ encodeResults: Array>; failedAt: number | null; error?: string }> { +): Promise<{ + encodeResults: Array>; + failedAt: number | null; + error?: string; + /** Background urls whose decode timed out during this batch. */ + lateBackgrounds: string[]; +}> { const state = workerEncodeStates.get(page); if (!state) { throw new Error( @@ -1070,7 +1076,8 @@ export async function produceDrawElementFrameBatch( __hf3d?: { update: () => void }; __hf?: { seek?: (t: number) => void }; __hfWaitForSeekCompletion?: () => Promise; - __hfDecodeDynamicCssBackgroundImages?: () => Promise; + __hfDecodeDynamicCssBackgroundImages?: () => Promise; + __hf_css_background_late?: string[]; __hfDeInvalidate?: () => boolean; __HF_ROOT_PROPS__?: boolean; __HF_ROOT_BASE_OPACITY__?: number; @@ -1106,7 +1113,8 @@ export async function produceDrawElementFrameBatch( try { if (aw.__hf && typeof aw.__hf.seek === "function") aw.__hf.seek(t); await aw.__hfWaitForSeekCompletion?.(); - await aw.__hfDecodeDynamicCssBackgroundImages?.(); + const late = await aw.__hfDecodeDynamicCssBackgroundImages?.(); + if (late?.length) (aw.__hf_css_background_late ??= []).push(...late); aw.__hf3d?.update(); const accel = (aw.__hf_accel_canvases ?? []).filter((c) => root.contains(c)); for (const c of accel) { @@ -1233,5 +1241,9 @@ export async function produceDrawElementFrameBatch( } } - return { encodeResults, failedAt: outcome.failedAt, error: outcome.error }; + const lateBackgrounds = await page.evaluate(() => { + const w = window as Window & { __hf_css_background_late?: string[] }; + return w.__hf_css_background_late?.splice(0) ?? []; + }); + return { encodeResults, failedAt: outcome.failedAt, error: outcome.error, lateBackgrounds }; } diff --git a/packages/engine/src/services/frameCapture-cssBackgroundReady.test.ts b/packages/engine/src/services/frameCapture-cssBackgroundReady.test.ts index d12a89d430f..e6b92c46743 100644 --- a/packages/engine/src/services/frameCapture-cssBackgroundReady.test.ts +++ b/packages/engine/src/services/frameCapture-cssBackgroundReady.test.ts @@ -2,7 +2,12 @@ import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { Page } from "puppeteer-core"; -import { BACKGROUND_DECODE_TIMEOUT_MS, decodeDynamicCssBackgroundImages } from "./frameCapture.js"; +import { + BACKGROUND_DECODE_TIMEOUT_MS, + decodeDynamicCssBackgroundImages, + recordBackgroundDecodeTimeouts, + type CaptureSession, +} from "./frameCapture.js"; function makeMockPage( getBackgroundImage: () => string, @@ -139,6 +144,30 @@ describe("decodeDynamicCssBackgroundImages", () => { expect(decoded).toEqual(["/assets/stuck.jpg"]); }); + it("reports a timed-out url without its credentials or query, in the text and the warning", () => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + const session = { warnings: [], browserConsoleBuffer: [] } as unknown as CaptureSession; + const secret = "https://user:pw@cdn.test/bg.jpg?token=SECRET#frag"; + + recordBackgroundDecodeTimeouts(session, [secret]); + recordBackgroundDecodeTimeouts(session, [secret]); + + expect(session.browserConsoleBuffer.join("\n")).toContain("https://cdn.test/bg.jpg"); + expect(JSON.stringify([session.browserConsoleBuffer, session.warnings])).not.toMatch( + /SECRET|user|pw@|frag/, + ); + expect(session.warnings).toEqual([ + expect.objectContaining({ + code: "media_readiness_timeout", + details: { + mediaType: "image", + sources: ["https://cdn.test/bg.jpg"], + timeoutMs: BACKGROUND_DECODE_TIMEOUT_MS, + }, + }), + ]); + }); + 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[] = []; @@ -168,7 +197,7 @@ describe("decodeDynamicCssBackgroundImages", () => { ); expect(batchSource).toMatch( - /aw\.__hf\.seek\(t\);\s*await aw\.__hfWaitForSeekCompletion\?\.\(\);\s*await aw\.__hfDecodeDynamicCssBackgroundImages\?\.\(\);/, + /aw\.__hf\.seek\(t\);\s*await aw\.__hfWaitForSeekCompletion\?\.\(\);\s*const late = await aw\.__hfDecodeDynamicCssBackgroundImages\?\.\(\);\s*if \(late\?\.length\) \(aw\.__hf_css_background_late \?\?= \[\]\)\.push\(\.\.\.late\);/, ); }); }); diff --git a/packages/engine/src/services/frameCapture-freshFallback.test.ts b/packages/engine/src/services/frameCapture-freshFallback.test.ts index ea7209b775e..f3c13170328 100644 --- a/packages/engine/src/services/frameCapture-freshFallback.test.ts +++ b/packages/engine/src/services/frameCapture-freshFallback.test.ts @@ -125,6 +125,7 @@ describe("fresh screenshot fallback", () => { vi.mocked(produceDrawElementFrameBatch).mockImplementationOnce(async () => ({ encodeResults: [Promise.resolve(Buffer.from("ok")), Promise.reject(timedOut(8))], failedAt: null, + lateBackgrounds: [], })); const results = await captureFramesBatchPipelined(await session(), [7, 8], [7 / 30, 8 / 30]); expect((await results[0]!.encodeResult).toString()).toBe("ok"); @@ -255,6 +256,7 @@ describe("fresh screenshot fallback", () => { vi.mocked(produceDrawElementFrameBatch).mockResolvedValueOnce({ encodeResults: [prefix], failedAt: 1, + lateBackgrounds: [], error, }); await expect( diff --git a/packages/engine/src/services/frameCapture.ts b/packages/engine/src/services/frameCapture.ts index bfe2d429694..6dd6adb49e3 100644 --- a/packages/engine/src/services/frameCapture.ts +++ b/packages/engine/src/services/frameCapture.ts @@ -1385,7 +1385,7 @@ async function finalizeDrawElementInit( 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); + recordBackgroundDecodeTimeouts(session, 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). @@ -2495,6 +2495,29 @@ function recordCaptureWarnings(session: CaptureSession, warnings: readonly Captu } } +/** Report background urls that timed out decoding; the shown url drops credentials and query. */ +export function recordBackgroundDecodeTimeouts( + session: CaptureSession, + urls: readonly string[] | undefined, +): void { + // Mocked pages answer evaluate() with undefined, so tolerate a missing list. + if (!urls?.length) return; + const sources = [...new Set(urls.map(sanitizeDiagnosticUrl))].sort(); + for (const source of sources) { + appendBrowserDiagnostic( + session, + `[FrameCapture] background image still decoding after ${BACKGROUND_DECODE_TIMEOUT_MS}ms, captured without it: ${source}`, + ); + } + recordCaptureWarnings(session, [ + { + code: "media_readiness_timeout", + message: `image media did not finish decoding within ${BACKGROUND_DECODE_TIMEOUT_MS}ms`, + details: { mediaType: "image", sources, timeoutMs: BACKGROUND_DECODE_TIMEOUT_MS }, + }, + ]); +} + export function recordSubTimelineWarning(session: CaptureSession, timeoutMs: number): void { const outcome = session.subTimelineWaitOutcome; if (outcome === "ready" || !outcome) return; @@ -3382,12 +3405,7 @@ async function prepareFrameForCapture( session.captureStep = "decoding CSS background images"; // Mocked pages answer evaluate() with undefined, so tolerate a missing list. - for (const url of (await decodeDynamicCssBackgroundImages(page)) ?? []) { - appendBrowserDiagnostic( - session, - `[FrameCapture] background image still decoding after ${BACKGROUND_DECODE_TIMEOUT_MS}ms, captured without it: ${url}`, - ); - } + recordBackgroundDecodeTimeouts(session, await decodeDynamicCssBackgroundImages(page)); const seekMs = Date.now() - seekStart; @@ -5243,13 +5261,14 @@ export async function captureFramesBatchPipelined( const quantized = times.map((t) => quantizeTimeToFrame(t, fps)); session.captureStep = "capturing the frame"; - const { encodeResults, failedAt, error } = await produceDrawElementFrameBatch( + const { encodeResults, failedAt, error, lateBackgrounds } = await produceDrawElementFrameBatch( page, quantized, options.width, options.height, options.quality ?? 80, ); + recordBackgroundDecodeTimeouts(session, lateBackgrounds); // A later batch failure abandons this prefix. Its asynchronous encodes can // still reject while session cleanup shuts the worker down. From 194f9a6078c2be505db668d4a2b61a389c588455 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Tue, 3 Nov 2026 19:09:52 -0500 Subject: [PATCH 4/4] fix(engine): a relative background url keeps only its path in the decode timeout report --- .../frameCapture-cssBackgroundReady.test.ts | 12 ++++++++++++ packages/engine/src/services/frameCapture.test.ts | 8 ++++++++ packages/engine/src/services/frameCapture.ts | 15 ++++++--------- 3 files changed, 26 insertions(+), 9 deletions(-) diff --git a/packages/engine/src/services/frameCapture-cssBackgroundReady.test.ts b/packages/engine/src/services/frameCapture-cssBackgroundReady.test.ts index e6b92c46743..7efce0d175d 100644 --- a/packages/engine/src/services/frameCapture-cssBackgroundReady.test.ts +++ b/packages/engine/src/services/frameCapture-cssBackgroundReady.test.ts @@ -168,6 +168,18 @@ describe("decodeDynamicCssBackgroundImages", () => { ]); }); + it("reports a timed-out relative url without its query or fragment", () => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + const session = { warnings: [], browserConsoleBuffer: [] } as unknown as CaptureSession; + + recordBackgroundDecodeTimeouts(session, ["./assets/bg.webp?example=REDACT_ME#fragment"]); + + expect(JSON.stringify([session.browserConsoleBuffer, session.warnings])).not.toMatch( + /REDACT_ME|fragment/, + ); + expect(session.warnings[0]?.details).toMatchObject({ sources: ["/assets/bg.webp"] }); + }); + 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[] = []; diff --git a/packages/engine/src/services/frameCapture.test.ts b/packages/engine/src/services/frameCapture.test.ts index e9f6d28b10b..6550e951dc2 100644 --- a/packages/engine/src/services/frameCapture.test.ts +++ b/packages/engine/src/services/frameCapture.test.ts @@ -453,6 +453,14 @@ describe("navigation diagnostics", () => { ); }); + it("keeps only the path of bare and dot-relative URLs", () => { + expect(sanitizeDiagnosticUrl("./assets/bg.webp?example=REDACT_ME#fragment")).toBe( + "/assets/bg.webp", + ); + expect(sanitizeDiagnosticUrl("assets/bg.webp?example=REDACT_ME")).toBe("/assets/bg.webp"); + expect(sanitizeDiagnosticUrl("//user:pw@cdn.test/bg.jpg?t=1")).toBe("/bg.jpg"); + }); + it("formats page.goto failures with mode, timeout, elapsed time, and sanitized URL", () => { const diagnostic = formatNavigationFailureDiagnostic({ captureMode: "screenshot", diff --git a/packages/engine/src/services/frameCapture.ts b/packages/engine/src/services/frameCapture.ts index 6dd6adb49e3..6e1b812130e 100644 --- a/packages/engine/src/services/frameCapture.ts +++ b/packages/engine/src/services/frameCapture.ts @@ -661,14 +661,6 @@ export function sanitizeDiagnosticUrl(input: string): string { if (!input) return "(empty)"; if (input.startsWith("data:")) return "data:"; if (input.startsWith("blob:")) return "blob:"; - if (input.startsWith("/")) { - try { - const url = new URL(input, "http://hyperframes.local"); - return url.pathname; - } catch { - return input; - } - } try { const url = new URL(input); @@ -678,7 +670,12 @@ export function sanitizeDiagnosticUrl(input: string): string { url.hash = ""; return url.toString(); } catch { - return input; + // A relative reference keeps its path only, and raw input never comes back. + try { + return new URL(input, "http://hyperframes.local").pathname; + } catch { + return input.split(/[?#]/, 1)[0] ?? ""; + } } }