Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions packages/engine/src/services/captureFailure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);
});
});
63 changes: 38 additions & 25 deletions packages/engine/src/services/captureFailure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
}
}
Expand Down Expand Up @@ -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,
});
}

Expand Down
86 changes: 86 additions & 0 deletions packages/engine/src/services/parallelCoordinator-peerAbort.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
10 changes: 10 additions & 0 deletions packages/engine/src/services/parallelCoordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,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;
}

Expand Down Expand Up @@ -940,6 +942,7 @@ async function executeWorkerTask(
frameStride: task.frameStride,
durationMs: Date.now() - startTime,
perf,
pageErrorLines: pageErrorLinesOf(activeSession),
};
} catch (error) {
const diagnostics = session ? selectWorkerDiagnostics(session.browserConsoleBuffer) : [];
Expand All @@ -953,6 +956,7 @@ async function executeWorkerTask(
const failure = classifyCaptureFailure(error, {
signal,
workerDiagnostics: [workerDiagnostic],
pageErrorLines: session ? pageErrorLinesOf(session) : undefined,
});
onFailure?.(failure);
return {
Expand All @@ -965,6 +969,7 @@ async function executeWorkerTask(
perf,
error: failure.message,
diagnostics: diagnostics.length > 0 ? diagnostics : undefined,
pageErrorLines: session ? pageErrorLinesOf(session) : undefined,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

blocker: this failed-worker pageErrorLines is never read. executeParallelCapture throws a CaptureFailure as soon as any result has failure/error (:1157-1167) and discards results. So pushWorkerPageErrors in executeDiskCaptureWithAdaptiveRetry and in the streaming stage never runs. Example: a script throws at load in every worker, one worker then times out, and the render fails. The orchestrator builds the summary from the probe's console plus getCaptureStageBrowserConsole(error), which is empty because the parallel branch doesn't wrap its error, so it still reports pageErrors: 0. Fix: carry the lines on the failure (alongside workerDiagnostics), or have runCaptureStage / the streaming stage catch, merge, and rethrow via wrapCaptureStageError(err, withWorkerPageErrors(...)). Add a test where executeParallelCapture rejects.

failure,
};
} finally {
Expand Down Expand Up @@ -1015,6 +1020,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
Expand Down Expand Up @@ -1156,6 +1165,7 @@ export async function executeParallelCapture(
message: `[Parallel] Capture failed: ${errorMessages}`,
cause: representative,
workerDiagnostics,
pageErrorLines: [...new Set(results.flatMap((result) => result.pageErrorLines ?? []))],
});
}

Expand Down
21 changes: 21 additions & 0 deletions packages/producer/src/services/render/captureStageError.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
7 changes: 6 additions & 1 deletion packages/producer/src/services/render/captureStageError.ts
Original file line number Diff line number Diff line change
@@ -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[];
Expand All @@ -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;
}
Expand All @@ -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 [];
}
15 changes: 15 additions & 0 deletions packages/producer/src/services/render/perfSummary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading