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:
Vance Ingalls
2026-07-15 17:22:01 -07:00
committed by GitHub
4 changed files with 346 additions and 1 deletions
+5
View File
@@ -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);