From 58cff5f6d5dc8a136617df1a1b1712b142ec0986 Mon Sep 17 00:00:00 2001 From: Via Date: Wed, 15 Jul 2026 22:33:37 +0000 Subject: [PATCH] feat(engine): surface escape hatches in page.goto Nav timeout errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Field signal ts=1784146416 (darwin/arm64, CLI 0.7.58, 7/10): host page.goto hit Navigation timeout of 60000ms twice on a CSS 3D + audio composition; Docker rendered the same composition successfully. Puppeteer's stock "Navigation timeout of 60000 ms exceeded" text names none of HyperFrames' existing escape hatches, so the reporter had no signal that the failure had knobs. Wraps main-render Puppeteer `page.goto` errors matching /Navigation timeout|net::ERR_TIMED_OUT/i with an augmented message that names: - The effective timeout currently applied (`cfg.pageNavigationTimeout`). - Raise-the-timeout: `PRODUCER_PAGE_NAVIGATION_TIMEOUT_MS` env, `--browser-timeout` CLI flag (seconds). - Browser-binary escape hatch: `HYPERFRAMES_BROWSER_PATH` env. - Field-signal shape: darwin/arm64 + CSS 3D + audio compound Docker hint — gated on all three inputs being explicitly true; falls back to generic hints when any input is unknown. Mirrors #2443's HYPERFRAMES_BROWSER_PATH surfacing pattern (which covered download-time failures) at the runtime `page.goto` layer. Non-matching errors flow through unchanged. Original error preserved via `err.cause`. Wired into `renderOrchestrator.executeRenderJob`'s top-level catch, composed after `augmentProtocolTimeoutError` so the two augmenters never both fire on the same error (mutually exclusive regexes). Current wire-up passes no `hasCss3D` / `hasAudio` context — no compile-time CSS-3D signal is threaded through the render pipeline, and `hasAudio` is block-scoped inside the try. Per the helper's fallback docs, unknown flags route to the generic env + browser-path hints. A future compile-time CSS-3D scan can thread both flags to enable the full compound Docker hint without touching this helper's signature. Stack: PR #3 of 9 (base via/win32-streaming-encode-autodisable). Signed-off-by: Via --- packages/engine/src/index.ts | 5 + .../pageNavigationTimeoutErrorHint.test.ts | 163 ++++++++++++++++++ .../pageNavigationTimeoutErrorHint.ts | 153 ++++++++++++++++ .../src/services/renderOrchestrator.ts | 26 ++- 4 files changed, 346 insertions(+), 1 deletion(-) create mode 100644 packages/engine/src/services/pageNavigationTimeoutErrorHint.test.ts create mode 100644 packages/engine/src/services/pageNavigationTimeoutErrorHint.ts 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);