fix(engine): a render stall names the step Chrome hung in and says what to do (#5450)

* fix(engine): a capture stall names the step Chrome stopped answering in

* fix(engine): a stall on a motion-blurred frame names the seek or injection it hung in
This commit is contained in:
Miguel Ángel
2026-10-11 07:51:45 +00:00
committed by GitHub
parent 8bca7c4417
commit bb34e301ec
5 changed files with 90 additions and 15 deletions
@@ -591,3 +591,47 @@ describe("computeStaticFrameSet's real page-side property matching (not a canned
expect(result.nonSpatialOnlyFrameSet.has(0)).toBe(true);
});
});
describe("a stall on a motion-blurred frame names the step in progress", () => {
it("names the frame seek, the injection and the restore seek, not the previous frame's step", async () => {
const steps: string[] = [];
const session = makeSession({
captureStep: "capturing the frame",
onBeforeCapture: async () => {
steps.push(`inject:${session.captureStep}`);
},
});
const page = globalThis as unknown as {
window: { __hf: { seek: (time: number, options?: { subFrameDivisions?: number }) => void } };
};
page.window.__hf.seek = (_time, options) => {
if (options?.subFrameDivisions === undefined) steps.push(`seek:${session.captureStep}`);
};
await captureFrameToBuffer(session, 10, 10 / 30);
expect(steps[0]).toBe("seek:seeking the timeline");
expect(steps[1]).toBe("inject:injecting video frames");
expect(steps[steps.length - 1]).toBe("seek:seeking the timeline");
});
it("names the seek and the injection on a frame without motion blur too", async () => {
const steps: string[] = [];
const session = makeSession({
motionBlur: undefined,
captureStep: "capturing the frame",
onBeforeCapture: async () => {
steps.push(`inject:${session.captureStep}`);
},
});
const page = globalThis as unknown as { window: { __hf: { seek: () => void } } };
page.window.__hf.seek = () => {
steps.push(`seek:${session.captureStep}`);
};
await captureFrameToBuffer(session, 10, 10 / 30);
expect(steps).toEqual(["seek:seeking the timeline", "inject:injecting video frames"]);
expect(session.captureStep).toBe("capturing the frame");
});
});
@@ -108,6 +108,16 @@ export type { CaptureOptions, CaptureResult, CaptureBufferResult, CapturePerfSum
* both times shows its frame for `heldVideoTime`, so footage does not smear. */
export type BeforeCaptureHook = (page: Page, time: number, heldVideoTime?: number) => Promise<void>;
/** The per-frame wait a capture is in, worded for the stall error a user reads. */
type CaptureStep =
| "seeking the timeline"
| "decoding CSS background images"
| "injecting video frames"
| "waiting for page seek handlers"
| "loading color-grading LUTs"
| "compositing the page"
| "capturing the frame";
export interface CaptureSession {
browser: Browser;
/** Exact ownership token for this browser acquisition. */
@@ -298,6 +308,7 @@ export interface CaptureSession {
* retries via screenshot.
*/
deFrameTimeouts?: number;
captureStep?: CaptureStep;
/**
* drawElement init passed every gate but stopped before verification +
* canvas injection: the session has no video-frame injector yet (probe
@@ -2856,8 +2867,10 @@ async function prepareFrameForCapture(
);
const seekStart = Date.now();
session.captureStep = "seeking the timeline";
const hasPendingComposite = await seekPageTimeline(page, quantizedTime, seekOptions);
session.captureStep = "decoding CSS background images";
await decodeDynamicCssBackgroundImages(page);
const seekMs = Date.now() - seekStart;
@@ -2867,9 +2880,12 @@ async function prepareFrameForCapture(
// replacements for <video> elements.
const beforeCaptureStart = Date.now();
if (session.onBeforeCapture) {
session.captureStep = "injecting video frames";
await session.onBeforeCapture(page, quantizedTime, heldVideoTime);
}
session.captureStep = "waiting for page seek handlers";
await waitForPendingSeekCompletion(page);
session.captureStep = "loading color-grading LUTs";
await page.evaluate(async () => {
const runtime = (
window as Window & {
@@ -2889,6 +2905,7 @@ async function prepareFrameForCapture(
session.captureMode !== "beginframe" &&
session.captureMode !== "drawelement"
) {
session.captureStep = "compositing the page";
await page.evaluate(async () => {
const w = window as unknown as { __hf_page_composite_prepare?: () => Promise<boolean> };
if (typeof w.__hf_page_composite_prepare === "function") {
@@ -2909,6 +2926,7 @@ async function prepareFrameForCapture(
});
}
session.captureStep = "capturing the frame";
return { quantizedTime, seekMs, beforeCaptureMs };
}
@@ -4189,12 +4207,14 @@ async function captureAccumulatedFrame(
const frameTime = quantizeSeekTime(absFrameIndex / fps, fps);
const eventfulSeekStart = Date.now();
session.captureStep = "seeking the timeline";
await seekPageTimeline(session.page, frameTime, undefined);
const totals = { seekMs: Date.now() - eventfulSeekStart, beforeCaptureMs: 0, screenshotMs: 0 };
if (session.onBeforeCapture) {
// The injector copies a video's own style only when its frame changes: inject at frameTime
// first so the held frame carries frameTime's style, not the first sample's.
const injectStart = Date.now();
session.captureStep = "injecting video frames";
await session.onBeforeCapture(session.page, frameTime, frameTime);
totals.beforeCaptureMs += Date.now() - injectStart;
}
@@ -4237,6 +4257,7 @@ async function captureAccumulatedFrame(
}
const restoreSeekStart = Date.now();
session.captureStep = "seeking the timeline";
await seekPageTimeline(session.page, frameTime, { suppressEvents: true });
totals.seekMs += Date.now() - restoreSeekStart;
@@ -4603,6 +4624,7 @@ export async function captureFramesBatchPipelined(
const fps = fpsToNumber(options.fps);
const quantized = times.map((t) => quantizeTimeToFrame(t, fps));
session.captureStep = "capturing the frame";
const { encodeResults, failedAt, error } = await produceDrawElementFrameBatch(
page,
quantized,
@@ -203,7 +203,7 @@ async function captureSegmentFrames(
ctx.captureFrame(ctx.session, i, time),
ctx.stallTimeoutMs - (Date.now() - lastProgressAt),
{
captureMode: ctx.session.captureMode,
session: ctx.session,
frameIndex: i,
totalFrames: ctx.totalFrames,
stallTimeoutMs: ctx.stallTimeoutMs,
@@ -61,11 +61,12 @@ mock.module("@hyperframes/engine", () => ({
}
return [];
},
captureFrameToBuffer: async () => {
captureFrameToBuffer: async (session: { captureStep?: string }) => {
if (failCaptureFrameToBuffer) {
throw new Error("captureFrameToBuffer failed");
}
if (hangSequentialUntilStall) {
session.captureStep = "capturing the frame";
return new Promise(() => {});
}
return { buffer: Buffer.from("frame"), captureTimeMs: 1 };
@@ -600,6 +601,7 @@ describe("runCaptureStreamingStage", () => {
expect(caught).toBeInstanceOf(Error);
expect((caught as Error & { cause?: Error }).cause?.name).toBe("SequentialCaptureStallError");
expect((caught as Error).message).toContain("Sequential screenshot capture stalled");
expect((caught as Error).message).toContain("(stuck at frame 0/10 while capturing the frame)");
expect((caught as Error).message).not.toContain("drawElement");
expect(closeCaptureSession).toHaveBeenCalledTimes(1);
});
@@ -93,6 +93,9 @@ import type { SdrStreamingCapturePlan } from "../capturePlan.js";
*/
const DEFAULT_CAPTURE_STALL_MS = 60_000;
const DE_STALL_POLL_MS = 5_000;
const STALL_ADVICE =
"Chrome stopped answering. If another app is keeping the GPU or CPU busy " +
"(a 3D render, a game, another export), let it finish or render one job at a time, then retry.";
/**
* The parallel no-progress watchdog tripped. Typed like the sequential stall
@@ -116,12 +119,12 @@ class ParallelCaptureStallError extends Error {
}) {
const phases = args.phaseSummary ? ` Last worker phases: ${args.phaseSummary}.` : "";
super(
args.variant === "drain"
(args.variant === "drain"
? `[Render] Parallel ${args.captureLabel} capture stalled: no frame progress for ` +
`${args.stallTimeoutMs}ms (stuck at ${args.lastCapturedFrames}/${args.totalFrames}).${phases}`
`${args.stallTimeoutMs}ms (stuck at ${args.lastCapturedFrames}/${args.totalFrames}).${phases}`
: `[Render] Parallel ${args.captureLabel} capture stalled after ${args.stallTimeoutMs}ms ` +
`with no frame progress (last frame ${args.lastCapturedFrames}/${args.totalFrames}); ` +
`retrying on a fresh screenshot session.${phases}`,
`with no frame progress (last frame ${args.lastCapturedFrames}/${args.totalFrames}); ` +
`retrying on a fresh screenshot session.${phases}`) + ` ${STALL_ADVICE}`,
);
this.name = "ParallelCaptureStallError";
this.variant = args.variant;
@@ -160,21 +163,25 @@ function captureModeLabel(mode: CaptureSession["captureMode"]): string {
return "screenshot";
}
type StalledSession = Pick<CaptureSession, "captureMode" | "captureStep">;
class SequentialCaptureStallError extends Error {
readonly captureMode: CaptureSession["captureMode"];
constructor(
captureMode: CaptureSession["captureMode"],
session: StalledSession,
stallTimeoutMs: number,
frameIndex: number,
totalFrames: number,
) {
const step = session.captureStep ? ` while ${session.captureStep}` : "";
super(
`[Render] Sequential ${captureModeLabel(captureMode)} capture stalled: ` +
`no frame progress for ${stallTimeoutMs}ms (stuck at frame ${frameIndex}/${totalFrames}).`,
`[Render] Sequential ${captureModeLabel(session.captureMode)} capture stalled: ` +
`no frame progress for ${stallTimeoutMs}ms (stuck at frame ${frameIndex}/${totalFrames}${step}). ` +
STALL_ADVICE,
);
this.name = "SequentialCaptureStallError";
this.captureMode = captureMode;
this.captureMode = session.captureMode;
}
}
@@ -182,7 +189,7 @@ export function raceAgainstStall<T>(
promise: Promise<T>,
deadlineMs: number,
input: {
captureMode: CaptureSession["captureMode"];
session: StalledSession;
frameIndex: number;
totalFrames: number;
stallTimeoutMs: number;
@@ -195,11 +202,11 @@ export function raceAgainstStall<T>(
reject(
signal?.aborted
? new Error(
`[Render] Sequential ${captureModeLabel(input.captureMode)} capture aborted ` +
`[Render] Sequential ${captureModeLabel(input.session.captureMode)} capture aborted ` +
`while a capture call was in flight (frame ${input.frameIndex}/${input.totalFrames}).`,
)
: new SequentialCaptureStallError(
input.captureMode,
input.session,
input.stallTimeoutMs,
input.frameIndex,
input.totalFrames,
@@ -473,7 +480,7 @@ async function runWorkerEncodePipelineLoop(
raceAgainstStall(
promise,
stallTimeoutMs - (Date.now() - lastProgressAt),
{ captureMode: session.captureMode, frameIndex: idx, totalFrames, stallTimeoutMs },
{ session, frameIndex: idx, totalFrames, stallTimeoutMs },
abortSignal,
);
@@ -953,7 +960,7 @@ export async function runCaptureStreamingStage(
const { buffer } = await raceAgainstStall(
captureFrameToBuffer(session, i, time),
stallTimeoutMs - (Date.now() - lastProgressAt),
{ captureMode: session.captureMode, frameIndex: i, totalFrames, stallTimeoutMs },
{ session, frameIndex: i, totalFrames, stallTimeoutMs },
abortSignal,
);
await reorderBuffer.waitForFrame(i);