diff --git a/packages/engine/src/services/captureFailure.test.ts b/packages/engine/src/services/captureFailure.test.ts index 4d27b3b5fe..4b1dc81f08 100644 --- a/packages/engine/src/services/captureFailure.test.ts +++ b/packages/engine/src/services/captureFailure.test.ts @@ -102,3 +102,22 @@ describe("classifyCaptureFailure", () => { ).toBe(false); }); }); + +describe("classifyCaptureFailure page errors", () => { + it("carries the page's error lines onto the classified failure", () => { + const failure = classifyCaptureFailure(new Error("Target closed"), { + pageErrorLines: ["[Browser:PAGEERROR] script threw"], + }); + expect(failure.pageErrorLines).toEqual(["[Browser:PAGEERROR] script threw"]); + }); + + it("keeps the lines when an existing failure is reclassified", () => { + const original = new CaptureFailure({ + kind: "transient_browser", + message: "Target closed", + pageErrorLines: ["[Browser:PAGEERROR] script threw"], + }); + const failure = classifyCaptureFailure(original, { workerDiagnostics: [] }); + expect(failure.pageErrorLines).toEqual(["[Browser:PAGEERROR] script threw"]); + }); +}); diff --git a/packages/engine/src/services/captureFailure.ts b/packages/engine/src/services/captureFailure.ts index b252fc16cc..be67b98c45 100644 --- a/packages/engine/src/services/captureFailure.ts +++ b/packages/engine/src/services/captureFailure.ts @@ -19,12 +19,14 @@ export class CaptureFailure extends Error { readonly kind: CaptureFailureKind; readonly cause: unknown; readonly workerDiagnostics: readonly CaptureWorkerDiagnostic[]; + readonly pageErrorLines: readonly string[]; constructor(input: { kind: CaptureFailureKind; message: string; cause?: unknown; workerDiagnostics?: readonly CaptureWorkerDiagnostic[]; + pageErrorLines?: readonly string[]; }) { super(input.message); this.name = "CaptureFailure"; @@ -35,6 +37,7 @@ export class CaptureFailure extends Error { Object.freeze({ ...diagnostic, lines: Object.freeze([...diagnostic.lines]) }), ), ); + this.pageErrorLines = Object.freeze([...(input.pageErrorLines ?? [])]); if (input.cause instanceof Error && input.cause.stack) this.stack = input.cause.stack; } } @@ -147,44 +150,54 @@ function ioError(error: unknown, message: string): boolean { ); } +const ORDERED_KIND_RULES: readonly (readonly [CaptureFailureKind, (message: string) => boolean])[] = + [ + [ + "memory_exhaustion", + (message) => + BUN_MEMORY_EXHAUSTION_EXACT_MESSAGE.test(message.trim()) || + BUN_MEMORY_EXHAUSTION_WRAPPED_WORKER_MESSAGE.test(message) || + matchesAny(message, MEMORY_EXHAUSTION_ERROR_PATTERNS), + ], + ["verification", (message) => matchesAny(message, VERIFICATION_ERROR_PATTERNS)], + ["protocol_timeout", (message) => matchesAny(message, PROTOCOL_TIMEOUT_PATTERNS)], + ["transient_browser", (message) => matchesAny(message, TRANSIENT_BROWSER_ERROR_PATTERNS)], + ["authoring", (message) => matchesAny(message, AUTHORING_ERROR_PATTERNS)], + ]; + +function classifyKind( + error: unknown, + message: string, + signal: AbortSignal | undefined, +): CaptureFailureKind { + if (signal?.aborted || /(?:render|capture)?_?cancelled|AbortError/i.test(message)) { + return "cancelled"; + } + const rule = ORDERED_KIND_RULES.find(([, matches]) => matches(message)); + if (rule) return rule[0]; + return ioError(error, message) ? "io" : "authoring"; +} + export function classifyCaptureFailure( error: unknown, options: { signal?: AbortSignal; workerDiagnostics?: readonly CaptureWorkerDiagnostic[]; + pageErrorLines?: readonly string[]; } = {}, ): CaptureFailure { - if (error instanceof CaptureFailure && !options.workerDiagnostics && !options.signal?.aborted) { - return error; + const prior = error instanceof CaptureFailure ? error : undefined; + if (prior && !(options.workerDiagnostics || options.pageErrorLines || options.signal?.aborted)) { + return prior; } const message = messageOf(error); - let kind: CaptureFailureKind; - if (options.signal?.aborted || /(?:render|capture)?_?cancelled|AbortError/i.test(message)) { - kind = "cancelled"; - } else if ( - BUN_MEMORY_EXHAUSTION_EXACT_MESSAGE.test(message.trim()) || - BUN_MEMORY_EXHAUSTION_WRAPPED_WORKER_MESSAGE.test(message) || - matchesAny(message, MEMORY_EXHAUSTION_ERROR_PATTERNS) - ) { - kind = "memory_exhaustion"; - } else if (matchesAny(message, VERIFICATION_ERROR_PATTERNS)) { - kind = "verification"; - } else if (matchesAny(message, PROTOCOL_TIMEOUT_PATTERNS)) { - kind = "protocol_timeout"; - } else if (matchesAny(message, TRANSIENT_BROWSER_ERROR_PATTERNS)) { - kind = "transient_browser"; - } else if (matchesAny(message, AUTHORING_ERROR_PATTERNS)) { - kind = "authoring"; - } else { - kind = ioError(error, message) ? "io" : "authoring"; - } + const kind = classifyKind(error, message, options.signal); return new CaptureFailure({ kind, message, cause: error, - workerDiagnostics: - options.workerDiagnostics ?? - (error instanceof CaptureFailure ? error.workerDiagnostics : undefined), + workerDiagnostics: options.workerDiagnostics ?? prior?.workerDiagnostics, + pageErrorLines: options.pageErrorLines ?? prior?.pageErrorLines, }); } diff --git a/packages/engine/src/services/parallelCoordinator-peerAbort.test.ts b/packages/engine/src/services/parallelCoordinator-peerAbort.test.ts index 88aa7c3b50..388e1c8adb 100644 --- a/packages/engine/src/services/parallelCoordinator-peerAbort.test.ts +++ b/packages/engine/src/services/parallelCoordinator-peerAbort.test.ts @@ -62,6 +62,92 @@ describe("executeParallelCapture peer abort", () => { } }); + it("carries every worker's page-error lines on the failure, including a worker that did not fail", async () => { + const root = mkdtempSync(join(tmpdir(), "hf-failure-page-errors-")); + vi.doMock("./frameCapture.js", () => ({ + createCaptureSession: vi.fn(async (_url: string, outputDir: string) => { + const workerId = outputDir.endsWith("worker-0") ? 0 : 1; + return { + workerId, + browserConsoleBuffer: [`[Browser:PAGEERROR] worker ${workerId} script threw`], + } as unknown as CaptureSession & { workerId: number }; + }), + initializeSession: vi.fn(async (session: CaptureSession & { workerId: number }) => { + if (session.workerId === 0) throw new Error("Composition has zero duration."); + await Promise.resolve(); + }), + captureFrame: vi.fn().mockResolvedValue(undefined), + captureFrameToBuffer: vi.fn(), + captureFrameToBufferPipelined: vi.fn(), + closeCaptureSession: vi.fn().mockResolvedValue(undefined), + getCapturePerfSummary: vi.fn(() => ({ frames: 0 })), + })); + + try { + const { executeParallelCapture } = await import("./parallelCoordinator.js"); + const result = executeParallelCapture( + "http://127.0.0.1", + root, + [ + { workerId: 0, startFrame: 0, endFrame: 1, outputDir: join(root, "worker-0") }, + { workerId: 1, startFrame: 1, endFrame: 2, outputDir: join(root, "worker-1") }, + ], + { width: 320, height: 180, fps: { num: 30, den: 1 } }, + () => null, + ); + + await expect(result).rejects.toMatchObject({ + name: "CaptureFailure", + pageErrorLines: [ + "[Browser:PAGEERROR] worker 0 script threw", + "[Browser:PAGEERROR] worker 1 script threw", + ], + }); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("hands the pool the failed worker's own page-error lines", async () => { + const root = mkdtempSync(join(tmpdir(), "hf-worker-failure-lines-")); + vi.doMock("./frameCapture.js", () => ({ + createCaptureSession: vi.fn(async () => ({ + browserConsoleBuffer: ["[Browser:PAGEERROR] script threw at load"], + })), + initializeSession: vi.fn(async () => { + throw new Error("Protocol error (Target.createTarget): Target closed"); + }), + captureFrame: vi.fn(), + captureFrameToBuffer: vi.fn(), + captureFrameToBufferPipelined: vi.fn(), + closeCaptureSession: vi.fn().mockResolvedValue(undefined), + getCapturePerfSummary: vi.fn(() => ({ frames: 0 })), + })); + + try { + const { executeParallelCapture } = await import("./parallelCoordinator.js"); + const failures: { pageErrorLines: readonly string[] }[] = []; + await executeParallelCapture( + "http://127.0.0.1", + root, + [{ workerId: 0, startFrame: 0, endFrame: 1, outputDir: join(root, "worker-0") }], + { width: 320, height: 180, fps: { num: 30, den: 1 } }, + () => null, + undefined, + undefined, + async () => {}, + undefined, + { onWorkerFailure: (failure) => failures.push(failure) }, + ).catch(() => {}); + + expect(failures.map((failure) => failure.pageErrorLines)).toEqual([ + ["[Browser:PAGEERROR] script threw at load"], + ]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + it("closes a browser whose launch finishes after the cancel before the render rejects", async () => { const root = mkdtempSync(join(tmpdir(), "hf-late-launch-")); const lateSession = {} as CaptureSession; diff --git a/packages/engine/src/services/parallelCoordinator.ts b/packages/engine/src/services/parallelCoordinator.ts index 5ad4489f06..5ef6bd08e8 100644 --- a/packages/engine/src/services/parallelCoordinator.ts +++ b/packages/engine/src/services/parallelCoordinator.ts @@ -79,6 +79,8 @@ export interface WorkerResult { perf?: CapturePerfSummary; error?: string; diagnostics?: string[]; + /** The worker session's `[Browser:PAGEERROR]` console lines, kept so the render's error count sees them. */ + pageErrorLines?: string[]; failure?: CaptureFailure; } @@ -941,6 +943,7 @@ async function executeWorkerTask( frameStride: task.frameStride, durationMs: Date.now() - startTime, perf, + pageErrorLines: pageErrorLinesOf(activeSession), }; } catch (error) { const diagnostics = session ? selectWorkerDiagnostics(session.browserConsoleBuffer) : []; @@ -954,6 +957,7 @@ async function executeWorkerTask( const failure = classifyCaptureFailure(error, { signal, workerDiagnostics: [workerDiagnostic], + pageErrorLines: session ? pageErrorLinesOf(session) : undefined, }); onFailure?.(failure); return { @@ -966,6 +970,7 @@ async function executeWorkerTask( perf, error: failure.message, diagnostics: diagnostics.length > 0 ? diagnostics : undefined, + pageErrorLines: session ? pageErrorLinesOf(session) : undefined, failure, }; } finally { @@ -1016,6 +1021,10 @@ export interface ParallelCaptureHooks { onWorkerFailure?: (failure: CaptureFailure) => void; } +function pageErrorLinesOf(session: CaptureSession): string[] { + return session.browserConsoleBuffer.filter((line) => line.includes("PAGEERROR")); +} + /** * The pool's single failure gate. Both halves of the contract the streaming * stage relies on live here so they can be pinned without a browser: the @@ -1157,6 +1166,7 @@ export async function executeParallelCapture( message: `[Parallel] Capture failed: ${errorMessages}`, cause: representative, workerDiagnostics, + pageErrorLines: [...new Set(results.flatMap((result) => result.pageErrorLines ?? []))], }); } diff --git a/packages/producer/src/services/render/captureStageError.test.ts b/packages/producer/src/services/render/captureStageError.test.ts new file mode 100644 index 0000000000..d05ac17ead --- /dev/null +++ b/packages/producer/src/services/render/captureStageError.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { CaptureFailure } from "@hyperframes/engine"; +import { getCaptureStageBrowserConsole } from "./captureStageError.js"; + +describe("getCaptureStageBrowserConsole", () => { + it("returns the page-error lines a failed parallel capture carried", () => { + const failure = new CaptureFailure({ + kind: "authoring", + message: "[Parallel] Capture failed: Worker 0: boom", + pageErrorLines: ["[Browser:PAGEERROR] Identifier 'dup' has already been declared"], + }); + + expect(getCaptureStageBrowserConsole(failure)).toEqual([ + "[Browser:PAGEERROR] Identifier 'dup' has already been declared", + ]); + }); + + it("returns nothing for an error that carries no console", () => { + expect(getCaptureStageBrowserConsole(new Error("plain"))).toEqual([]); + }); +}); diff --git a/packages/producer/src/services/render/captureStageError.ts b/packages/producer/src/services/render/captureStageError.ts index a45a6829a7..5dca599c62 100644 --- a/packages/producer/src/services/render/captureStageError.ts +++ b/packages/producer/src/services/render/captureStageError.ts @@ -1,6 +1,7 @@ import { normalizeErrorMessage } from "../../utils/errorMessage.js"; import { CaptureFailure, classifyCaptureFailure } from "@hyperframes/engine"; import { EncoderInterruptedError } from "./encoderInterruption.js"; +import { withWorkerPageErrors } from "./perfSummary.js"; export class CaptureStageError extends CaptureFailure { readonly browserConsole: string[]; @@ -12,9 +13,12 @@ export class CaptureStageError extends CaptureFailure { message: normalizeErrorMessage(input.cause), cause: input.cause, workerDiagnostics: classified.workerDiagnostics, + pageErrorLines: classified.pageErrorLines, }); this.name = "CaptureStageError"; - this.browserConsole = input.browserConsole.slice(); + this.browserConsole = withWorkerPageErrors(input.browserConsole, [ + ...classified.pageErrorLines, + ]); if (input.cause instanceof Error && input.cause.stack) { this.stack = input.cause.stack; } @@ -31,5 +35,6 @@ export function wrapCaptureStageError( export function getCaptureStageBrowserConsole(error: unknown): string[] { if (error instanceof CaptureStageError) return error.browserConsole; + if (error instanceof CaptureFailure) return [...error.pageErrorLines]; return []; } diff --git a/packages/producer/src/services/render/perfSummary.ts b/packages/producer/src/services/render/perfSummary.ts index f6160f6a58..f8704ba0ae 100644 --- a/packages/producer/src/services/render/perfSummary.ts +++ b/packages/producer/src/services/render/perfSummary.ts @@ -54,6 +54,21 @@ export function pushWorkerDedupPerfs( } } +/** Adds each worker's page-error console lines to `sink`, once per distinct line. */ +export function pushWorkerPageErrors( + results: ReadonlyArray<{ pageErrorLines?: string[] }>, + sink: string[], +): void { + for (const result of results) { + for (const line of result.pageErrorLines ?? []) if (!sink.includes(line)) sink.push(line); + } +} + +/** The console lines plus the workers' page-error lines the console does not already hold. */ +export function withWorkerPageErrors(consoleLines: string[], workerLines: string[]): string[] { + return [...consoleLines, ...workerLines.filter((line) => !consoleLines.includes(line))]; +} + /** * Collapse per-session/per-worker static-dedup perf into one render-level * outcome. enabled/armed = OR across workers (they run the same gates on the diff --git a/packages/producer/src/services/render/stages/captureStage.test.ts b/packages/producer/src/services/render/stages/captureStage.test.ts index c8b4d94432..bd82efef7e 100644 --- a/packages/producer/src/services/render/stages/captureStage.test.ts +++ b/packages/producer/src/services/render/stages/captureStage.test.ts @@ -2,9 +2,11 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { DEFAULT_CONFIG, classifyCaptureFailure } from "@hyperframes/engine"; +import { CaptureFailure, DEFAULT_CONFIG, classifyCaptureFailure } from "@hyperframes/engine"; import { createRenderJob, isTransientCaptureRetryEligible } from "../../renderOrchestrator.js"; import { formatCaptureFrameName } from "../../../utils/paths.js"; +import { CaptureStageError, getCaptureStageBrowserConsole } from "../captureStageError.js"; +import { summarizeBrowserDiagnostics } from "../observability.js"; // Mock only the engine session primitives; `classifyCaptureFailure` stays // real so the retry decision is exercised. @@ -317,6 +319,52 @@ describe("runCaptureStage measured disk projection (both branches)", () => { ); }); + it("parallel: page errors seen only by worker sessions reach the diagnostics count", async () => { + const { workDir, framesDir } = makeDirs(); + freeBytes.mockReturnValue({ bavail: 10_000_000_000, bsize: 1 }); + executeParallelCapture.mockImplementation(async () => { + for (let i = 0; i < 20; i++) { + writeFileSync(join(framesDir, formatCaptureFrameName(i, "jpg")), oneMb); + } + return [ + { pageErrorLines: ["[Browser:PAGEERROR] Identifier 'dup' has already been declared"] }, + { pageErrorLines: ["[Browser:PAGEERROR] Identifier 'dup' has already been declared"] }, + ]; + }); + + const result = await runCaptureStage(makeInput(2, workDir, framesDir)); + + expect(summarizeBrowserDiagnostics(result.lastBrowserConsole).pageErrors).toBe(1); + }); + + it("parallel: a failed capture still reports the page errors its workers saw", async () => { + const { workDir, framesDir } = makeDirs(); + freeBytes.mockReturnValue({ bavail: 10_000_000_000, bsize: 1 }); + executeParallelCapture.mockRejectedValue( + new CaptureFailure({ + kind: "authoring", + message: "Composition has zero duration.", + pageErrorLines: ["[Browser:PAGEERROR] ReferenceError: gsap is not defined"], + }), + ); + + const probeSession = { + browserConsoleBuffer: ["[FrameCapture:ERROR] page.goto failed"], + options: { captureBeyondViewport: false }, + } as never; + + const error = await runCaptureStage({ + ...makeInput(2, workDir, framesDir), + probeSession, + }).catch((e: unknown) => e); + + expect(error).toBeInstanceOf(CaptureStageError); + expect(getCaptureStageBrowserConsole(error)).toEqual([ + "[FrameCapture:ERROR] page.goto failed", + "[Browser:PAGEERROR] ReferenceError: gsap is not defined", + ]); + }); + it("parallel: the progress callback raises the same shortfall from worker dirs", async () => { const { workDir, framesDir } = makeDirs(); freeBytes.mockReturnValue({ bavail: 8_000_000, bsize: 1 }); diff --git a/packages/producer/src/services/render/stages/captureStage.ts b/packages/producer/src/services/render/stages/captureStage.ts index c12482a8a7..9c86d6cb99 100644 --- a/packages/producer/src/services/render/stages/captureStage.ts +++ b/packages/producer/src/services/render/stages/captureStage.ts @@ -68,6 +68,7 @@ import { } from "../../renderOrchestrator.js"; import { wrapCaptureStageError } from "../captureStageError.js"; import { reportFrameProgress, reportWorkerStartup } from "../shared.js"; +import { withWorkerPageErrors } from "../perfSummary.js"; import type { SdrDiskCapturePlan } from "../capturePlan.js"; export interface CaptureStageInput { @@ -315,6 +316,7 @@ export async function runCaptureStage(input: CaptureStageInput): Promise { if (progress.latestWorkerPhase) { if (progress.capturedFrames === 0) reportWorkerStartup(job, progress, onProgress); @@ -396,6 +399,11 @@ export async function runCaptureStage(input: CaptureStageInput): Promise { + throw wrapCaptureStageError( + error, + withWorkerPageErrors(probeSession?.browserConsoleBuffer ?? [], workerPageErrors), + ); }); captureAttempts.push(...attempts); const lastAttempt = attempts[attempts.length - 1]; @@ -408,6 +416,7 @@ export async function runCaptureStage(input: CaptureStageInput): Promise rmSync(fixtureRoot, { recursive: true, force: true })); -import { getCaptureStageBrowserConsole } from "../captureStageError.js"; +import { CaptureStageError, getCaptureStageBrowserConsole } from "../captureStageError.js"; import { createCapturePlan } from "../capturePlan.js"; import type { CaptureHdrStageInput } from "./captureHdrStage.js"; @@ -32,6 +32,7 @@ let hangParallelUntilAbort = false; // a capture call (the watchdog's job); session_init = still booting (not). let hangParallelPhase: "session_init" | "frame_capture" = "frame_capture"; let failWorkerTransient = false; +let workerFailurePageErrors: string[] = []; // Parallel capture that reports this many frames, advancing the clock by parallelFrameMs each. let parallelFrames = 0; let parallelFrameMs = 0; @@ -116,10 +117,12 @@ mock.module("@hyperframes/engine", () => ({ // That ordering is the engine's contract, pinned without a browser by // parallelCoordinator.test.ts "createPoolFailureHandler"; this mock // only replays it. - injectedWorkerFailure = Object.assign( - new Error("Protocol error (Page.captureScreenshot): Target closed"), - { kind: "transient_browser" }, - ); + const { CaptureFailure } = await import("@hyperframes/engine"); + injectedWorkerFailure = new CaptureFailure({ + kind: "transient_browser", + message: "Protocol error (Page.captureScreenshot): Target closed", + pageErrorLines: workerFailurePageErrors, + }); hooks?.onWorkerFailure?.(injectedWorkerFailure); throw new Error( "[Parallel] Capture failed: Worker 1: Protocol error (Page.captureScreenshot): Target closed", @@ -489,14 +492,44 @@ describe("runCaptureStreamingStage", () => { else process.env.HF_DE_STALL_MS = prev; } - // The exact failure object the engine reported — not the pool's flattened - // "[Parallel] Capture failed: …" string, and not a stall. - expect(caught).toBe(injectedWorkerFailure); + // The engine's own failure, not the pool's flattened "[Parallel] Capture failed: …" + // string and not a stall, wrapped so the stage reports the page's console. + expect(caught).toBeInstanceOf(CaptureStageError); + expect((caught as CaptureStageError).cause).toBe(injectedWorkerFailure); + expect((caught as CaptureStageError).kind).toBe("transient_browser"); expect((caught as Error).message).not.toContain("stalled"); // Parked peers were released with that same error. expect(reorderAbortCalls).toEqual([injectedWorkerFailure]); }); + it("reports the script's page errors when a worker dies in a streaming render", async () => { + failWorkerTransient = true; + workerFailurePageErrors = ["[Browser:PAGEERROR] ReferenceError: gsap is not defined"]; + const { runCaptureStreamingStage } = await import("./captureStreamingStage.js"); + const cfg = { forceScreenshot: false, ffmpegStreamingTimeout: 3_600_000 }; + const baseInput = createInput(cfg); + const input = { + ...baseInput, + totalFrames: 100, + plan: { ...baseInput.plan, workerCount: 2, forceParallelStream: true }, + }; + + let caught: unknown; + try { + await runCaptureStreamingStage(input); + } catch (error) { + caught = error; + } finally { + failWorkerTransient = false; + workerFailurePageErrors = []; + } + + expect(caught).toBeInstanceOf(CaptureStageError); + expect(getCaptureStageBrowserConsole(caught)).toContain( + "[Browser:PAGEERROR] ReferenceError: gsap is not defined", + ); + }); + it("does not relabel a genuine parent-abort as a stall", async () => { hangParallelUntilAbort = true; const prev = process.env.HF_DE_STALL_MS; diff --git a/packages/producer/src/services/render/stages/captureStreamingStage.ts b/packages/producer/src/services/render/stages/captureStreamingStage.ts index 9d2ecf433e..3282456242 100644 --- a/packages/producer/src/services/render/stages/captureStreamingStage.ts +++ b/packages/producer/src/services/render/stages/captureStreamingStage.ts @@ -73,7 +73,11 @@ import type { FileServerHandle } from "../../fileServer.js"; import type { ProducerLogger } from "../../../logger.js"; import type { ProgressCallback, RenderJob } from "../../renderOrchestrator.js"; import { wrapCaptureStageError } from "../captureStageError.js"; -import { pushWorkerDedupPerfs } from "../perfSummary.js"; +import { + pushWorkerDedupPerfs, + pushWorkerPageErrors, + withWorkerPageErrors, +} from "../perfSummary.js"; import { ensureFrameWritten } from "./captureHdrFrameShared.js"; import { reportEncodeProgress, reportFrameProgress, reportWorkerStartup } from "../shared.js"; import { encoderFailureError } from "../encoderInterruption.js"; @@ -106,6 +110,11 @@ const STALL_ADVICE = * when no writer was parked, i.e. every worker was wedged inside a capture * call. The two are distinct field signatures and stay distinguishable. */ +/** A pool failure with the probe session's console (and the failure's own page errors) attached. */ +function poolFailure(error: unknown, probeSession: CaptureSession | null) { + return wrapCaptureStageError(error, probeSession?.browserConsoleBuffer ?? []); +} + class ParallelCaptureStallError extends Error { readonly variant: "drain" | "pool"; @@ -867,7 +876,7 @@ export async function runCaptureStreamingStage( // Surface the TYPED drain error (DrawElementVerificationError) so the // orchestrator's verify-retry handler recognizes it — the worker pool // flattens worker errors into a plain message string. - if (parallelDrainError) throw parallelDrainError; + if (parallelDrainError) throw poolFailure(parallelDrainError, probeSession); // A watchdog trip aborts the pool via stallController, so the pool // rejects with a generic "[Parallel] Capture failed" string. Replace // it with a clear, non-cancellation stall error (the parent abort did @@ -883,13 +892,15 @@ export async function runCaptureStreamingStage( phaseSummary: phaseSummary(), }); } - throw err; + throw poolFailure(err, probeSession); } finally { clearInterval(stallTimer); abortSignal?.removeEventListener("abort", forwardParentAbort); } - if (parallelDrainError) throw parallelDrainError; + if (parallelDrainError) throw poolFailure(parallelDrainError, probeSession); pushWorkerDedupPerfs(workerResults, dedupPerfs); + const workerPageErrors: string[] = []; + pushWorkerPageErrors(workerResults, workerPageErrors); if (parallelGuardRan) { deDrainStats = parallelStats; } @@ -900,6 +911,7 @@ export async function runCaptureStreamingStage( await closeCaptureSession(probeSession); probeSession = null; } + lastBrowserConsole = withWorkerPageErrors(lastBrowserConsole, workerPageErrors); } else { // Sequential capture → streaming encode diff --git a/packages/producer/src/services/renderOrchestrator.ts b/packages/producer/src/services/renderOrchestrator.ts index d1f9160c74..e43e9d05f6 100644 --- a/packages/producer/src/services/renderOrchestrator.ts +++ b/packages/producer/src/services/renderOrchestrator.ts @@ -171,6 +171,7 @@ import { findRenderHdrAutoPromotionTrigger, resolveEffectiveHdrMode } from "./re import { buildRenderPerfSummary, pushWorkerDedupPerfs, + pushWorkerPageErrors, roundDb, worstSubTimelineWaitOutcome, } from "./render/perfSummary.js"; @@ -1201,6 +1202,8 @@ export async function executeDiskCaptureWithAdaptiveRetry(options: { frameRangeStart?: number; /** Mutated in place — replaced each attempt so only the final attempt's worker perf survives (see retry reset below). */ dedupPerfs: CapturePerfSummary[]; + /** Mutated in place: the workers' page-error console lines, for the render's error count. */ + pageErrorLines?: string[]; }): Promise { const attempts: CaptureAttemptSummary[] = []; let currentWorkers = options.initialWorkerCount; @@ -1271,6 +1274,7 @@ export async function executeDiskCaptureWithAdaptiveRetry(options: { options.cfg, ); pushWorkerDedupPerfs(workerResults, options.dedupPerfs); + if (options.pageErrorLines) pushWorkerPageErrors(workerResults, options.pageErrorLines); } finally { await mergeWorkerFrames(attemptWorkDir, tasks, options.framesDir); }