fix(engine): a background decode timeout is reported at every caller, url redacted

This commit is contained in:
Miguel Angel Simon Sierra
2026-11-03 18:48:19 -05:00
parent 014b4b87f2
commit 7540d84ae0
4 changed files with 76 additions and 14 deletions
@@ -1002,7 +1002,13 @@ export async function produceDrawElementFrameBatch(
width: number,
height: number,
quality = 80,
): Promise<{ encodeResults: Array<Promise<Buffer>>; failedAt: number | null; error?: string }> {
): Promise<{
encodeResults: Array<Promise<Buffer>>;
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<void>;
__hfDecodeDynamicCssBackgroundImages?: () => Promise<void>;
__hfDecodeDynamicCssBackgroundImages?: () => Promise<string[] | void>;
__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 };
}
@@ -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\);/,
);
});
});
@@ -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(
+27 -8
View File
@@ -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.