mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
Merge pull request #2506 from heygen-com/via/darwin-goto-nav-timeout-hint
feat(engine): surface escape hatches in page.goto Nav timeout errors
This commit is contained in:
@@ -83,6 +83,11 @@ export {
|
||||
augmentProtocolTimeoutError,
|
||||
isProtocolTimeoutError,
|
||||
} from "./services/protocolTimeoutErrorHint.js";
|
||||
export {
|
||||
augmentPageNavigationTimeoutError,
|
||||
isPageNavigationTimeoutError,
|
||||
type NavigationTimeoutHintContext,
|
||||
} from "./services/pageNavigationTimeoutErrorHint.js";
|
||||
|
||||
// ── Frame capture pipeline ──────────────────────────────────────────────────────
|
||||
export {
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
augmentPageNavigationTimeoutError,
|
||||
isPageNavigationTimeoutError,
|
||||
} from "./pageNavigationTimeoutErrorHint.js";
|
||||
|
||||
describe("augmentPageNavigationTimeoutError", () => {
|
||||
it("passes non-Navigation-timeout errors through unchanged (same instance)", () => {
|
||||
const original = new Error("Runtime.callFunctionOn timed out");
|
||||
const result = augmentPageNavigationTimeoutError(original, 60_000);
|
||||
expect(result).toBe(original);
|
||||
expect(result.message).toBe("Runtime.callFunctionOn timed out");
|
||||
});
|
||||
|
||||
it("augments 'Navigation timeout of Xms exceeded' with the effective timeout", () => {
|
||||
const original = new Error("Navigation timeout of 60000 ms exceeded");
|
||||
const result = augmentPageNavigationTimeoutError(original, 60_000);
|
||||
expect(result).not.toBe(original);
|
||||
expect(result.message).toContain(original.message);
|
||||
expect(result.message).toContain(
|
||||
"HyperFrames effective page.goto navigation timeout: 60000 ms",
|
||||
);
|
||||
});
|
||||
|
||||
it("includes the env + CLI + browser-path hints in the generic augmentation", () => {
|
||||
const original = new Error("Navigation timeout of 60000 ms exceeded");
|
||||
const result = augmentPageNavigationTimeoutError(original, 60_000);
|
||||
expect(result.message).toContain("PRODUCER_PAGE_NAVIGATION_TIMEOUT_MS");
|
||||
expect(result.message).toContain("--browser-timeout");
|
||||
expect(result.message).toContain("HYPERFRAMES_BROWSER_PATH");
|
||||
});
|
||||
|
||||
it("preserves err.cause on the augmented error", () => {
|
||||
const original = new Error("Navigation timeout of 60000 ms exceeded");
|
||||
const result = augmentPageNavigationTimeoutError(original, 60_000);
|
||||
expect((result as Error & { cause?: unknown }).cause).toBe(original);
|
||||
});
|
||||
|
||||
it("augments net::ERR_TIMED_OUT errors as well", () => {
|
||||
const original = new Error("net::ERR_TIMED_OUT at http://127.0.0.1:4173/index.html");
|
||||
const result = augmentPageNavigationTimeoutError(original, 120_000);
|
||||
expect(result).not.toBe(original);
|
||||
expect(result.message).toContain("HyperFrames effective page.goto navigation timeout: 120000");
|
||||
});
|
||||
|
||||
it("coerces non-Error thrown values into Error without augmenting", () => {
|
||||
const result = augmentPageNavigationTimeoutError("plain string failure", 60_000);
|
||||
expect(result).toBeInstanceOf(Error);
|
||||
expect(result.message).toBe("plain string failure");
|
||||
// Not augmented: coerced string doesn't match the Nav-timeout regex.
|
||||
expect(result.message).not.toContain("HyperFrames effective page.goto navigation timeout");
|
||||
});
|
||||
|
||||
it("fires the darwin/arm64 + CSS 3D + audio Docker hint only when all three match", () => {
|
||||
const original = new Error("Navigation timeout of 60000 ms exceeded");
|
||||
const result = augmentPageNavigationTimeoutError(original, 60_000, {
|
||||
platform: "darwin",
|
||||
arch: "arm64",
|
||||
hasCss3D: true,
|
||||
hasAudio: true,
|
||||
});
|
||||
expect(result.message).toContain("ts=1784146416");
|
||||
expect(result.message).toContain("--docker");
|
||||
expect(result.message).toContain("CSS 3D rendering context");
|
||||
});
|
||||
|
||||
it("does not surface the Docker hint on non-darwin platforms even when CSS 3D + audio are true", () => {
|
||||
const original = new Error("Navigation timeout of 60000 ms exceeded");
|
||||
const result = augmentPageNavigationTimeoutError(original, 60_000, {
|
||||
platform: "linux",
|
||||
arch: "x64",
|
||||
hasCss3D: true,
|
||||
hasAudio: true,
|
||||
});
|
||||
expect(result.message).not.toContain("ts=1784146416");
|
||||
expect(result.message).not.toContain("--docker");
|
||||
// Generic hints still fire.
|
||||
expect(result.message).toContain("PRODUCER_PAGE_NAVIGATION_TIMEOUT_MS");
|
||||
expect(result.message).toContain("HYPERFRAMES_BROWSER_PATH");
|
||||
});
|
||||
|
||||
it("does not surface the Docker hint on darwin/x64 (Intel Macs)", () => {
|
||||
const original = new Error("Navigation timeout of 60000 ms exceeded");
|
||||
const result = augmentPageNavigationTimeoutError(original, 60_000, {
|
||||
platform: "darwin",
|
||||
arch: "x64",
|
||||
hasCss3D: true,
|
||||
hasAudio: true,
|
||||
});
|
||||
expect(result.message).not.toContain("ts=1784146416");
|
||||
expect(result.message).not.toContain("--docker");
|
||||
});
|
||||
|
||||
it("does not surface the Docker hint on darwin/arm64 without CSS 3D", () => {
|
||||
const original = new Error("Navigation timeout of 60000 ms exceeded");
|
||||
const result = augmentPageNavigationTimeoutError(original, 60_000, {
|
||||
platform: "darwin",
|
||||
arch: "arm64",
|
||||
hasCss3D: false,
|
||||
hasAudio: true,
|
||||
});
|
||||
expect(result.message).not.toContain("ts=1784146416");
|
||||
expect(result.message).not.toContain("--docker");
|
||||
});
|
||||
|
||||
it("does not surface the Docker hint on darwin/arm64 without audio", () => {
|
||||
const original = new Error("Navigation timeout of 60000 ms exceeded");
|
||||
const result = augmentPageNavigationTimeoutError(original, 60_000, {
|
||||
platform: "darwin",
|
||||
arch: "arm64",
|
||||
hasCss3D: true,
|
||||
hasAudio: false,
|
||||
});
|
||||
expect(result.message).not.toContain("ts=1784146416");
|
||||
expect(result.message).not.toContain("--docker");
|
||||
});
|
||||
|
||||
it("does not surface the Docker hint when CSS 3D / audio inputs are unknown (fallback documented)", () => {
|
||||
// Current wire-up in renderOrchestrator passes hasCss3D: undefined because
|
||||
// no compile-time CSS-3D signal is threaded through the pipeline. The
|
||||
// Docker hint is intentionally strict about `=== true` — this test locks
|
||||
// that behaviour so a future compile-time hasCss3D scan can flip it on
|
||||
// by supplying the flag, without accidentally firing before then.
|
||||
const original = new Error("Navigation timeout of 60000 ms exceeded");
|
||||
const result = augmentPageNavigationTimeoutError(original, 60_000, {
|
||||
platform: "darwin",
|
||||
arch: "arm64",
|
||||
// hasCss3D + hasAudio omitted (undefined).
|
||||
});
|
||||
expect(result.message).not.toContain("ts=1784146416");
|
||||
expect(result.message).not.toContain("--docker");
|
||||
// Generic hints still fire.
|
||||
expect(result.message).toContain("PRODUCER_PAGE_NAVIGATION_TIMEOUT_MS");
|
||||
expect(result.message).toContain("HYPERFRAMES_BROWSER_PATH");
|
||||
});
|
||||
|
||||
it("defaults platform/arch to the current process when context omits them", () => {
|
||||
// Regression: earlier draft required an explicit platform. Make sure the
|
||||
// helper still augments (with generic hints) when no context is passed.
|
||||
const original = new Error("Navigation timeout of 60000 ms exceeded");
|
||||
const result = augmentPageNavigationTimeoutError(original, 60_000);
|
||||
expect(result).not.toBe(original);
|
||||
expect(result.message).toContain("PRODUCER_PAGE_NAVIGATION_TIMEOUT_MS");
|
||||
expect(result.message).toContain("HYPERFRAMES_BROWSER_PATH");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isPageNavigationTimeoutError", () => {
|
||||
it("returns true for matching messages", () => {
|
||||
expect(isPageNavigationTimeoutError(new Error("Navigation timeout of 60000 ms exceeded"))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isPageNavigationTimeoutError("net::ERR_TIMED_OUT")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for non-matching messages", () => {
|
||||
expect(isPageNavigationTimeoutError(new Error("Runtime.callFunctionOn timed out"))).toBe(false);
|
||||
expect(isPageNavigationTimeoutError(new Error("Target closed"))).toBe(false);
|
||||
expect(isPageNavigationTimeoutError(null)).toBe(false);
|
||||
expect(isPageNavigationTimeoutError(undefined)).toBe(false);
|
||||
expect(isPageNavigationTimeoutError(42)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Augment Puppeteer `page.goto` navigation-timeout errors with actionable
|
||||
* guidance that names the HyperFrames-specific knobs. Puppeteer's stock error
|
||||
* text ("Navigation timeout of 60000 ms exceeded") doesn't tell the user
|
||||
* which env var / CLI flag raises this timeout in HyperFrames, or which
|
||||
* browser-binary override lets them route around a slow pinned build.
|
||||
*
|
||||
* Sibling of `augmentProtocolTimeoutError` (surfaces
|
||||
* `PRODUCER_PUPPETEER_PROTOCOL_TIMEOUT_MS` / `--protocol-timeout` on the
|
||||
* `Runtime.callFunctionOn timed out` class), and mirrors the surfacing
|
||||
* pattern from #2443 (which surfaces `HYPERFRAMES_BROWSER_PATH` on
|
||||
* download-time failures). This helper covers the runtime `page.goto` layer
|
||||
* instead:
|
||||
*
|
||||
* 1. Raise-the-timeout: `PRODUCER_PAGE_NAVIGATION_TIMEOUT_MS` env,
|
||||
* `--browser-timeout` CLI flag (SECONDS, not ms).
|
||||
* 2. Escape-hatch browser binary: `HYPERFRAMES_BROWSER_PATH` env, points
|
||||
* at a system Chrome / chrome-headless-shell path.
|
||||
* 3. Field-signal shape: darwin/arm64 CSS 3D + audio compound
|
||||
* (ts=1784146416) that succeeded under Docker — gated on all three
|
||||
* inputs being explicitly true.
|
||||
*
|
||||
* Design is conservative — non-matching errors flow through unchanged (same
|
||||
* instance). Non-Error inputs are coerced with `new Error(String(err))` so
|
||||
* callers always receive a well-typed `Error`. Original error preserved via
|
||||
* `err.cause` for downstream logging / observability.
|
||||
*
|
||||
* ─────────────────────────────────────────────────────────────────────────
|
||||
* Compound-hint fallback (documented per stack-review guardrails)
|
||||
* ─────────────────────────────────────────────────────────────────────────
|
||||
* The field signal cites the darwin/arm64 + CSS-3D + audio-track compound as
|
||||
* the shape where the Docker fallback rendered identically. The Docker hint
|
||||
* therefore fires ONLY when all three are true. When any one is unknown
|
||||
* (`undefined`) the helper falls back to the generic env + browser-path
|
||||
* hints — the Docker fallback is not universally applicable and surfacing
|
||||
* it outside the known-good compound risks recommending Docker on shapes it
|
||||
* hasn't been verified for.
|
||||
*
|
||||
* At the current wire-up in `renderOrchestrator.executeRenderJob`'s
|
||||
* top-level catch, `hasAudio` is in scope (computed in the `audio_process`
|
||||
* stage) but a CSS-3D compile-time signal isn't threaded through the
|
||||
* pipeline: grep `packages/producer/src/services/` and
|
||||
* `packages/engine/src/services/` — no compile-time `hasCss3D` boolean
|
||||
* exists; `parseTransformMatrix` in `alphaBlit.ts` detects 3D matrices at
|
||||
* engine-init runtime, AFTER `page.goto` has already succeeded. So the
|
||||
* current wire-up passes `hasCss3D: undefined`, and the Docker hint does
|
||||
* NOT fire in production today. The helper accepts both flags so a future
|
||||
* PR that lands a compile-time `hasCss3D` scan (e.g. an htmlCompiler.ts
|
||||
* pass over `transform-style: preserve-3d`, `perspective:`, `rotateX(`,
|
||||
* `rotateY(`, `matrix3d(`) can enable the full compound hint without
|
||||
* touching this helper's signature.
|
||||
*/
|
||||
|
||||
const NAVIGATION_TIMEOUT_MATCHER = /Navigation timeout|net::ERR_TIMED_OUT/i;
|
||||
|
||||
export interface NavigationTimeoutHintContext {
|
||||
/** `process.platform` at the catch site. Defaults to the current process. */
|
||||
platform?: NodeJS.Platform;
|
||||
/** `process.arch` at the catch site. Defaults to the current process. */
|
||||
arch?: NodeJS.Architecture | string;
|
||||
/**
|
||||
* Whether the composition uses a CSS 3D rendering context. Callers pass
|
||||
* `undefined` when this signal isn't threaded through — the Docker hint
|
||||
* then does not fire (see fallback docs above).
|
||||
*/
|
||||
hasCss3D?: boolean;
|
||||
/**
|
||||
* Whether the composition has an audio track. Callers pass `undefined`
|
||||
* when the signal isn't threaded through — the Docker hint then does not
|
||||
* fire.
|
||||
*/
|
||||
hasAudio?: boolean;
|
||||
}
|
||||
|
||||
export function augmentPageNavigationTimeoutError(
|
||||
err: unknown,
|
||||
effectiveTimeoutMs: number,
|
||||
context: NavigationTimeoutHintContext = {},
|
||||
): Error {
|
||||
if (!(err instanceof Error)) return new Error(String(err));
|
||||
if (!NAVIGATION_TIMEOUT_MATCHER.test(err.message)) return err;
|
||||
|
||||
const platform = context.platform ?? process.platform;
|
||||
const arch = context.arch ?? process.arch;
|
||||
|
||||
const dockerHint = shouldSurfaceDockerHint({
|
||||
platform,
|
||||
arch,
|
||||
hasCss3D: context.hasCss3D,
|
||||
hasAudio: context.hasAudio,
|
||||
})
|
||||
? buildDockerHintBlock()
|
||||
: "";
|
||||
|
||||
const augmented = new Error(
|
||||
`${err.message}\n\n` +
|
||||
`HyperFrames effective page.goto navigation timeout: ${effectiveTimeoutMs} ms.\n\n` +
|
||||
`To raise the timeout:\n` +
|
||||
` Env: PRODUCER_PAGE_NAVIGATION_TIMEOUT_MS=<higher-ms> (milliseconds)\n` +
|
||||
` CLI: --browser-timeout <seconds> (seconds)\n\n` +
|
||||
`To use a different browser binary (e.g. system Chrome instead of the pinned chrome-headless-shell):\n` +
|
||||
` Env: HYPERFRAMES_BROWSER_PATH=<path-to-Chrome-or-chrome-headless-shell>\n` +
|
||||
dockerHint,
|
||||
);
|
||||
(augmented as Error & { cause?: unknown }).cause = err;
|
||||
return augmented;
|
||||
}
|
||||
|
||||
/**
|
||||
* Predicate variant: exposed for callers that only need to classify an
|
||||
* error (e.g. observability, tests) without materialising an augmented
|
||||
* Error. Uses the same matcher as the augmentation path so the two never
|
||||
* drift.
|
||||
*/
|
||||
export function isPageNavigationTimeoutError(err: unknown): boolean {
|
||||
const message = err instanceof Error ? err.message : typeof err === "string" ? err : "";
|
||||
return NAVIGATION_TIMEOUT_MATCHER.test(message);
|
||||
}
|
||||
|
||||
interface DockerHintGate {
|
||||
platform: NodeJS.Platform | string;
|
||||
arch: NodeJS.Architecture | string;
|
||||
hasCss3D?: boolean;
|
||||
hasAudio?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Docker fallback hint fires only on the darwin/arm64 + CSS-3D + audio
|
||||
* compound the field signal exercised. Requiring all three to be
|
||||
* explicitly true (not just truthy — `undefined` is not enough) prevents
|
||||
* the hint from firing on shapes where the Docker fallback hasn't been
|
||||
* verified. Other platforms have different failure modes and different
|
||||
* remediation surfaces (Windows GPU compound → PR #2505, Linux headless
|
||||
* quirks → separate).
|
||||
*/
|
||||
function shouldSurfaceDockerHint(gate: DockerHintGate): boolean {
|
||||
return (
|
||||
gate.platform === "darwin" &&
|
||||
gate.arch === "arm64" &&
|
||||
gate.hasCss3D === true &&
|
||||
gate.hasAudio === true
|
||||
);
|
||||
}
|
||||
|
||||
function buildDockerHintBlock(): string {
|
||||
return (
|
||||
`\nField signal ts=1784146416 (darwin/arm64 host mode, CLI 0.7.58): the compound of\n` +
|
||||
`a CSS 3D rendering context + audio track on macOS arm64 has been reported to hit\n` +
|
||||
`Navigation timeout twice at page.goto while the same composition renders\n` +
|
||||
`identically under Docker. Consider:\n` +
|
||||
` hyperframes render ... --docker\n`
|
||||
);
|
||||
}
|
||||
@@ -79,6 +79,7 @@ import {
|
||||
isDrawElementVerificationError,
|
||||
getDrawElementVerificationDetails,
|
||||
augmentProtocolTimeoutError,
|
||||
augmentPageNavigationTimeoutError,
|
||||
} from "@hyperframes/engine";
|
||||
import { join, dirname, resolve } from "path";
|
||||
import { totalmem } from "node:os";
|
||||
@@ -3155,7 +3156,30 @@ export async function executeRenderJob(
|
||||
// unchanged when the message doesn't match, so non-timeout failures (memory
|
||||
// exhaustion, other CDP errors) flow through with no change.
|
||||
const protocolTimeoutError = augmentProtocolTimeoutError(error, cfg.protocolTimeout);
|
||||
const errorMessage = memoryGuidance ?? normalizeErrorMessage(protocolTimeoutError);
|
||||
// Surface HyperFrames' PRODUCER_PAGE_NAVIGATION_TIMEOUT_MS env +
|
||||
// --browser-timeout CLI + HYPERFRAMES_BROWSER_PATH escape hatch in
|
||||
// Puppeteer `page.goto` navigation-timeout errors. Puppeteer's stock
|
||||
// "Navigation timeout of 60000 ms exceeded" text names none of these
|
||||
// levers. Field signal ts=1784146416 (darwin/arm64, CLI 0.7.58): host
|
||||
// page.goto hit Navigation timeout twice on a CSS 3D + audio composition;
|
||||
// Docker rendered the same composition successfully. Mirrors #2443's
|
||||
// HYPERFRAMES_BROWSER_PATH surfacing at the runtime-navigation layer
|
||||
// (vs download-time). `augmentPageNavigationTimeoutError` returns the
|
||||
// input unchanged when the message doesn't match the Nav-timeout regex,
|
||||
// so protocol-timeout / memory / other CDP errors flow through unchanged.
|
||||
// hasCss3D + hasAudio are both left undefined here — no compile-time
|
||||
// CSS-3D signal is currently threaded through the render pipeline, and
|
||||
// `hasAudio` from the audio_process stage is block-scoped inside the
|
||||
// try. Per the helper's fallback docs, unknown flags route to the
|
||||
// generic env + browser-path hints (Docker compound hint suppressed).
|
||||
// A future compile-time CSS-3D scan (e.g. htmlCompiler.ts pass over
|
||||
// `transform-style: preserve-3d`, `perspective:`, `rotateX(`, etc.) can
|
||||
// thread both flags here to enable the full compound Docker hint.
|
||||
const navigationTimeoutError = augmentPageNavigationTimeoutError(
|
||||
protocolTimeoutError,
|
||||
cfg.pageNavigationTimeout,
|
||||
);
|
||||
const errorMessage = memoryGuidance ?? normalizeErrorMessage(navigationTimeoutError);
|
||||
const carriedBrowserConsole = getCaptureStageBrowserConsole(error);
|
||||
if (carriedBrowserConsole.length > 0) {
|
||||
lastBrowserConsole = [...lastBrowserConsole, ...carriedBrowserConsole].slice(-200);
|
||||
|
||||
Reference in New Issue
Block a user