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] 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 085de07255..8f95e956eb 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 d12a89d430..e6b92c4674 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 ea7209b775..f3c1317032 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 bfe2d42969..6dd6adb49e 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.