fix(cli): a failed render names its failure code for the Desktop app (#6725)

This commit is contained in:
Miguel Ángel
2026-10-29 07:36:32 +00:00
committed by GitHub
parent cf15892425
commit ba8ffffa0b
2 changed files with 115 additions and 13 deletions
+53 -2
View File
@@ -2,10 +2,19 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { processCompositionAudio, type AudioFailureDiagnostic } from "@hyperframes/engine";
import {
CaptureFailure,
processCompositionAudio,
type AudioFailureDiagnostic,
} from "@hyperframes/engine";
import { applyDistributedAudioWarningPolicy } from "../../../producer/src/services/distributed/plan.js";
import { buildSyntheticRenderJob } from "../../../producer/src/services/distributed/shared.js";
import { createRenderJob, executeRenderJob, type RenderJob } from "@hyperframes/producer";
import {
createRenderJob,
executeRenderJob,
RenderQualityError,
type RenderJob,
} from "@hyperframes/producer";
import { runAudioStage } from "../../../producer/src/services/render/stages/audioStage.js";
import { renderFailureDiagnostic, renderMachineDiagnostic } from "./renderDiagnostic.js";
@@ -91,6 +100,48 @@ describe("render diagnostic wire", () => {
expect(line).not.toContain(PRIVATE);
});
it.each([
[
new RenderQualityError([
{ code: "audio_processing_failed", message: PRIVATE, stage: "capture-readiness" },
]),
"AUDIO_PROCESSING_FAILED",
],
[
new Error(`Video extraction failed [VIDEO_SOURCE_UNRENDERABLE; source_missing=1] ${PRIVATE}`),
"VIDEO_SOURCE_UNRENDERABLE",
],
[
Object.assign(new Error(`ENOENT: no such file, open '${PRIVATE}'`), { code: "ENOENT" }),
"ENOENT",
],
[new Error(`EPERM: operation not permitted, rename '${PRIVATE}'`), "EPERM"],
[
new CaptureFailure({
kind: "transient_browser",
message: `[Parallel] Capture failed: Worker 0: [FrameCapture] Composition has zero duration. ${PRIVATE}`,
}),
"COMPOSITION_ZERO_DURATION",
],
[
new CaptureFailure({ kind: "authoring", message: PRIVATE, pageErrorLines: [PRIVATE] }),
"PAGE_ERROR",
],
[
new CaptureFailure({ kind: "protocol_timeout", message: PRIVATE }),
"CAPTURE_PROTOCOL_TIMEOUT",
],
[new Error(PRIVATE), "RENDER_FAILED"],
])("names a failed render's code without its words (%#)", (error, code) => {
const line = capture(() => renderFailureDiagnostic(undefined, error, false));
expect(JSON.parse(line.slice("@hf-diagnostic ".length))).toEqual({
version: 1,
operation: "render",
code,
});
expect(line).not.toContain(PRIVATE);
});
it("stays silent for a terminal or a container attached to a terminal", () => {
expect(capture([failure], true)).toBe("");
vi.unstubAllEnvs();
+62 -11
View File
@@ -1,14 +1,62 @@
import type { RenderJob } from "@hyperframes/producer";
import { audioFailureDiagnostics } from "@hyperframes/engine";
import { RenderQualityError, type RenderJob } from "@hyperframes/producer";
import { CaptureFailure, audioFailureDiagnostics } from "@hyperframes/engine";
const FILE_ERRORS = [
"ENOENT",
"EPERM",
"EACCES",
"EBUSY",
"ENOSPC",
"EMFILE",
"EISDIR",
"ENOTDIR",
"EEXIST",
"EXDEV",
];
const FILE_ERROR = new RegExp(`\\b(${FILE_ERRORS.join("|")})\\b`);
const CLI_CODE = /\[(?!(?:DEBUG|INFO|WARN|ERROR)\b)([A-Z][A-Z0-9_]{2,63})[\];]/;
function fileErrorOf(error: unknown, message: string): string | undefined {
let seen: unknown = error;
for (let depth = 0; depth < 5 && seen instanceof Error; depth++) {
const code = (seen as NodeJS.ErrnoException).code;
if (code && FILE_ERRORS.includes(code)) return code;
seen = seen.cause;
}
return FILE_ERROR.exec(message)?.[1];
}
function captureCodeOf(error: unknown, message: string): string | undefined {
if (/Composition has zero duration/.test(message)) return "COMPOSITION_ZERO_DURATION";
if (!(error instanceof CaptureFailure)) return undefined;
return error.pageErrorLines.length > 0 ? "PAGE_ERROR" : `CAPTURE_${error.kind.toUpperCase()}`;
}
/** A failed render's code: never its words, which can name the person's files and folders. */
function renderFailureCode(error: unknown): string {
const message = error instanceof Error ? error.message : String(error);
if (error instanceof RenderQualityError && error.warnings[0])
return error.warnings[0].code.toUpperCase();
return (
CLI_CODE.exec(message)?.[1] ??
fileErrorOf(error, message) ??
captureCodeOf(error, message) ??
"RENDER_FAILED"
);
}
/** Versioned, content-free failure line for Desktop's piped CLI, separate from human error output. */
export function renderMachineDiagnostic(values: readonly unknown[]): void {
export function renderMachineDiagnostic(values: readonly unknown[], code?: string): void {
if (process.stdout.isTTY || process.env.HYPERFRAMES_STDOUT_IS_TTY === "1") return;
const failures = audioFailureDiagnostics(values);
if (failures.length === 0) return;
process.stdout.write(
`@hf-diagnostic ${JSON.stringify({ version: 1, operation: "render", audioFailures: failures })}\n`,
);
if (failures.length === 0 && !code) return;
const line = {
version: 1,
operation: "render",
...(code ? { code } : {}),
...(failures.length > 0 ? { audioFailures: failures } : {}),
};
process.stdout.write(`@hf-diagnostic ${JSON.stringify(line)}\n`);
}
export function renderFailureDiagnostic(
@@ -24,8 +72,11 @@ export function renderFailureDiagnostic(
Array.isArray(error.audioFailures)
? error.audioFailures
: [];
renderMachineDiagnostic([
...interruptedFailures,
...(job?.warnings.flatMap((warning) => warning.details?.audioFailures ?? []) ?? []),
]);
renderMachineDiagnostic(
[
...interruptedFailures,
...(job?.warnings.flatMap((warning) => warning.details?.audioFailures ?? []) ?? []),
],
renderFailureCode(error),
);
}