Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ describe("isTransientBrowserError", () => {
"Failed to launch the browser process! TROUBLESHOOTING: https://pptr.dev/troubleshooting",
"connect ECONNREFUSED 127.0.0.1:9222",
"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
// concurrently); a fresh browser session on retry usually clears it.
"[FrameCapture] Composition has zero duration.\n Runtime ready: false, __player: true, __hf.seek: true, GSAP timeline: true, data-duration: 53.3s",
])("returns true for transient error: %s", (message) => {
expect(isTransientBrowserError(new Error(message))).toBe(true);
});
Expand All @@ -25,6 +29,10 @@ describe("isTransientBrowserError", () => {
"Composition duration is 0",
"SYSTEM_FONT_USED: -apple-system",
"",
// The runtime finished initializing (renderReady: true) and still reports
// zero duration — a genuine authoring bug (no timeline, no data-duration),
// not a transient host hiccup. Must keep fast-failing without a retry.
"[FrameCapture] Composition has zero duration.\n Runtime ready: true, __player: true, __hf.seek: true, GSAP timeline: false, data-duration: not set",
])("returns false for non-transient error: %s", (message) => {
expect(isTransientBrowserError(new Error(message))).toBe(false);
});
Expand Down
8 changes: 8 additions & 0 deletions packages/engine/src/services/frameCapture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1975,6 +1975,14 @@ const TRANSIENT_BROWSER_ERROR_PATTERNS = [
/Failed to launch the browser process/i,
/Navigation timeout of \d+ ms exceeded/i,
/ECONNREFUSED/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
// concurrent renders), which a fresh session usually clears on retry. This
// is distinct from the "Runtime ready: true" fast-fail case a few lines up
// in pollHfReady (no timeline + no data-duration) — that's a genuine
// authoring bug and intentionally NOT matched here, so it still fails fast.
/Composition has zero duration[\s\S]*Runtime ready: false/,
];

export function isTransientBrowserError(error: unknown): boolean {
Expand Down
44 changes: 44 additions & 0 deletions packages/producer/src/services/render/stages/probeStage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ mock.module("@hyperframes/engine", () => ({
// live in frameCapture-transientErrors.test.ts — update both if patterns change.
isTransientBrowserError: (error: unknown) => {
const msg = error instanceof Error ? error.message : String(error);
if (/Composition has zero duration[\s\S]*Runtime ready: false/.test(msg)) return true;
return /Navigating frame was detached|Target closed|Session closed|browser has disconnected|Page crashed|Execution context was destroyed|Cannot find context with specified id|Failed to launch the browser process|Navigation timeout of \d+ ms exceeded|ECONNREFUSED/i.test(
msg,
);
Expand Down Expand Up @@ -337,6 +338,49 @@ describe("runProbeStage — transient browser error retry (#1687)", () => {
expect(closeCaptureSessionCallCount).toBe(2);
});

it("retries once on a pollHfReady zero-duration timeout (renderReady: false) and succeeds", async () => {
resetRetryMocks();
capturedCfgs.length = 0;
initializeSessionError = new Error(
"[FrameCapture] Composition has zero duration.\n Runtime ready: false, __player: true, __hf.seek: true, GSAP timeline: true, data-duration: 53.3s",
);
initializeSessionFailUntilAttempt = 1;

const { runProbeStage } = await import("./probeStage.js");
const input = makeProbeInput({ cfgForceScreenshot: false, stageForceScreenshot: false });

const result = await runProbeStage(input);

expect(initializeSessionCallCount).toBe(2);
expect(closeCaptureSessionCallCount).toBe(1);
expect(result.duration).toBe(5);
expect(result.probeSession).not.toBeNull();
});

it("throws immediately on a permanent zero-duration error (renderReady: true — genuine authoring bug)", async () => {
resetRetryMocks();
capturedCfgs.length = 0;
initializeSessionError = new Error(
"[FrameCapture] Composition has zero duration.\n Runtime ready: true, __player: true, __hf.seek: true, GSAP timeline: false, data-duration: not set",
);
initializeSessionFailUntilAttempt = 999;

const { runProbeStage } = await import("./probeStage.js");
const input = makeProbeInput({ cfgForceScreenshot: false, stageForceScreenshot: false });

let caught: unknown;
try {
await runProbeStage(input);
} catch (err) {
caught = err;
}

expect(caught).toBeInstanceOf(Error);
expect((caught as Error).message).toContain("Runtime ready: true");
expect(initializeSessionCallCount).toBe(1);
expect(closeCaptureSessionCallCount).toBe(1);
});

it("retries on a transient browser LAUNCH failure (createCaptureSession throws)", async () => {
resetRetryMocks();
capturedCfgs.length = 0;
Expand Down
Loading