diff --git a/packages/engine/src/services/captureFailure.test.ts b/packages/engine/src/services/captureFailure.test.ts index c973a94c5..b580a4aee 100644 --- a/packages/engine/src/services/captureFailure.test.ts +++ b/packages/engine/src/services/captureFailure.test.ts @@ -6,6 +6,7 @@ describe("classifyCaptureFailure", () => { ["Target closed", "transient_browser"], ["Runtime.callFunctionOn timed out after 30000ms", "protocol_timeout"], ["Runtime.evaluate timed out", "protocol_timeout"], + ["drawElement worker encode timed out (frame 42)", "protocol_timeout"], ["Waiting failed: 30000ms exceeded", "protocol_timeout"], ["JavaScript heap out of memory", "memory_exhaustion"], ["drawElement self-verify failed", "verification"], @@ -55,6 +56,14 @@ describe("classifyCaptureFailure", () => { expect(Object.isFrozen(failure.workerDiagnostics[0]?.lines)).toBe(true); }); + it("classifies repeated operation text in linear time", () => { + const repeatedCopy = "copy".repeat(25_000); + + expect(classifyCaptureFailure(new Error(repeatedCopy)).kind).toBe("authoring"); + expect(classifyCaptureFailure(new Error(`${repeatedCopy} failed`)).kind).toBe("io"); + expect(classifyCaptureFailure(new Error("copy\nfailed")).kind).toBe("authoring"); + }); + it("marks structural failures fatal but leaves retryable failures non-fatal", () => { expect( isFatalCaptureFailure(new CaptureFailure({ kind: "authoring", message: "bad source" })), diff --git a/packages/engine/src/services/captureFailure.ts b/packages/engine/src/services/captureFailure.ts index c69e6580d..c0e3567bf 100644 --- a/packages/engine/src/services/captureFailure.ts +++ b/packages/engine/src/services/captureFailure.ts @@ -58,6 +58,7 @@ const PROTOCOL_TIMEOUT_PATTERNS = [ /Runtime\.callFunctionOn timed out/i, /Runtime\.evaluate timed out/i, /HeadlessExperimental\.beginFrame timed out/i, + /drawElement worker encode timed out \(frame \d+\)/i, /Protocol error[\s\S]*tim(?:ed|e) out/i, /Waiting failed:\s*\d+\s*ms exceeded/i, /Waiting failed[\s\S]*timeout/i, @@ -103,11 +104,25 @@ function matchesAny(message: string, patterns: readonly RegExp[]): boolean { return patterns.some((pattern) => pattern.test(message)); } +const IO_OPERATION_TOKENS = ["read", "write", "rename", "copy", "open", "file", "directory"]; + +function hasIoOperationFailure(message: string): boolean { + const normalized = message.toLowerCase(); + return normalized.split(/[\r\n]/).some((line) => + IO_OPERATION_TOKENS.some((operation) => { + const operationIndex = line.indexOf(operation); + if (operationIndex < 0) return false; + const failureStart = operationIndex + operation.length; + return line.indexOf("failed", failureStart) >= 0 || line.indexOf("error", failureStart) >= 0; + }), + ); +} + function ioError(error: unknown, message: string): boolean { const code = error instanceof Error ? (error as NodeJS.ErrnoException).code : undefined; return ( Boolean(code && /^(?:EACCES|EEXIST|EIO|EMFILE|ENFILE|ENOENT|ENOSPC|EPERM|EROFS)$/.test(code)) || - /(?:read|write|rename|copy|open|file|directory).*(?:failed|error)/i.test(message) + hasIoOperationFailure(message) ); } diff --git a/packages/producer/src/services/renderOrchestrator.test.ts b/packages/producer/src/services/renderOrchestrator.test.ts index f2281afaf..e900b68f2 100644 --- a/packages/producer/src/services/renderOrchestrator.test.ts +++ b/packages/producer/src/services/renderOrchestrator.test.ts @@ -1513,6 +1513,13 @@ describe("adaptive missing-frame retry helpers", () => { new Error("[Parallel] Capture failed: Worker 1: HeadlessExperimental.beginFrame timed out"), ), ).toBe(true); + expect( + isRecoverableParallelCaptureError( + new Error( + "[Parallel] Capture failed: Worker 0: drawElement worker encode timed out (frame 42)", + ), + ), + ).toBe(true); expect(isRecoverableParallelCaptureError(new Error("Encoding failed: ffmpeg exited"))).toBe( false, );