diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 461606101..d58690351 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -83,6 +83,11 @@ export { augmentProtocolTimeoutError, isProtocolTimeoutError, } from "./services/protocolTimeoutErrorHint.js"; +export { + augmentPageNavigationTimeoutError, + isPageNavigationTimeoutError, + type NavigationTimeoutHintContext, +} from "./services/pageNavigationTimeoutErrorHint.js"; // ── Frame capture pipeline ────────────────────────────────────────────────────── export { diff --git a/packages/engine/src/services/pageNavigationTimeoutErrorHint.test.ts b/packages/engine/src/services/pageNavigationTimeoutErrorHint.test.ts new file mode 100644 index 000000000..8f0531aa2 --- /dev/null +++ b/packages/engine/src/services/pageNavigationTimeoutErrorHint.test.ts @@ -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); + }); +}); diff --git a/packages/engine/src/services/pageNavigationTimeoutErrorHint.ts b/packages/engine/src/services/pageNavigationTimeoutErrorHint.ts new file mode 100644 index 000000000..2f997a0f5 --- /dev/null +++ b/packages/engine/src/services/pageNavigationTimeoutErrorHint.ts @@ -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= (milliseconds)\n` + + ` CLI: --browser-timeout (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=\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` + ); +} diff --git a/packages/producer/src/services/renderOrchestrator.ts b/packages/producer/src/services/renderOrchestrator.ts index 1ca68f8ba..45401ff67 100644 --- a/packages/producer/src/services/renderOrchestrator.ts +++ b/packages/producer/src/services/renderOrchestrator.ts @@ -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);