Files
hyperframes/packages/engine/src/utils/runFfmpeg.test.ts
T
James Russo 0f7eebd7e4 fix(encoder): signal host interruptions for retry (#3578)
* fix(encoder): signal host interruptions for retry

* fix(encoder): cover all render interruption paths

* fix(encoder): classify HDR pre-extraction drains
2026-08-31 22:07:43 -04:00

178 lines
6.2 KiB
TypeScript

import { EventEmitter } from "node:events";
import { resolve } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { formatFfmpegError, isExternalFfmpegInterruption } from "./runFfmpeg.js";
describe("isExternalFfmpegInterruption", () => {
const base = {
exitCode: 255,
signal: null,
stderr: "",
terminationReason: "exit" as const,
};
it("recognizes a direct external termination signal", () => {
expect(isExternalFfmpegInterruption({ ...base, exitCode: null, signal: "SIGTERM" })).toBe(true);
});
it("does not broaden the retry signal to SIGKILL", () => {
expect(isExternalFfmpegInterruption({ ...base, exitCode: null, signal: "SIGKILL" })).toBe(
false,
);
});
it("recognizes ffmpeg's handled SIGTERM exit-255 signature", () => {
expect(
isExternalFfmpegInterruption({
...base,
stderr: "frame= 42\nExiting normally, received signal 15.\n",
}),
).toBe(true);
});
it("does not classify an ordinary exit 255", () => {
expect(isExternalFfmpegInterruption({ ...base, stderr: "Encoder initialization failed" })).toBe(
false,
);
});
it("requires exit 255 when classification relies on ffmpeg stderr", () => {
expect(
isExternalFfmpegInterruption({
...base,
exitCode: 1,
stderr: "Exiting normally, received signal 15.",
}),
).toBe(false);
});
it.each(["abort", "deadline", "inactivity"] as const)(
"keeps a managed %s termination non-retryable",
(terminationReason) => {
expect(
isExternalFfmpegInterruption({
...base,
signal: "SIGTERM",
stderr: "Exiting normally, received signal 15.",
terminationReason,
}),
).toBe(false);
},
);
});
describe("formatFfmpegError", () => {
const originalPlatform = process.platform;
const originalFfmpegPath = process.env.HYPERFRAMES_FFMPEG_PATH;
afterEach(() => {
Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true });
if (originalFfmpegPath === undefined) delete process.env.HYPERFRAMES_FFMPEG_PATH;
else process.env.HYPERFRAMES_FFMPEG_PATH = originalFfmpegPath;
});
it("reports exit code alone when stderr is empty", () => {
expect(formatFfmpegError(-22, "")).toBe("FFmpeg exited with code -22");
});
it("appends stderr tail when present", () => {
const stderr =
"ffmpeg version 8.1\nbuilt with gcc 13.2.0\n" +
"[h264_nvenc @ 0x7f] Error applying encoder options: Invalid argument\n" +
"Error while opening encoder\n";
const message = formatFfmpegError(-22, stderr);
expect(message).toContain("FFmpeg exited with code -22");
expect(message).toContain("ffmpeg stderr (tail):");
expect(message).toContain("Error applying encoder options: Invalid argument");
expect(message).toContain("Error while opening encoder");
});
it("keeps only the last N non-empty lines in the tail", () => {
const lines = Array.from({ length: 30 }, (_, i) => `line-${i}`).join("\n");
const message = formatFfmpegError(1, lines, 5);
expect(message).toContain("line-29");
expect(message).toContain("line-25");
expect(message).not.toContain("line-24");
});
it("strips blank lines from the tail so real signal isn't hidden", () => {
const stderr = "\n\nError applying encoder options: Invalid argument\n\n\n";
const message = formatFfmpegError(-22, stderr);
expect(message).toContain("Error applying encoder options: Invalid argument");
// Only one non-empty stderr line should appear in the tail.
const tailPart = message.split("ffmpeg stderr (tail):\n")[1] ?? "";
expect(tailPart.trim().split(/\r?\n/).length).toBe(1);
});
it("falls back to a process-error string when exit code is null and stderr is empty", () => {
expect(formatFfmpegError(null, "")).toBe("[FFmpeg] process error");
});
it("wraps stderr in [FFmpeg] prefix when exit code is null (spawn failure)", () => {
expect(formatFfmpegError(null, "spawn ffmpeg ENOENT")).toBe("[FFmpeg] spawn ffmpeg ENOENT");
});
it("maps Windows invalid-image exit codes to an actionable architecture hint", () => {
Object.defineProperty(process, "platform", { value: "win32", configurable: true });
expect(formatFfmpegError(3221225595, "")).toContain("wrong architecture");
});
it.each([3221225781, -1073741515])(
"maps Windows DLL-not-found exit code %s to selected-path guidance",
(exitCode) => {
Object.defineProperty(process, "platform", { value: "win32", configurable: true });
process.env.HYPERFRAMES_FFMPEG_PATH = "/tools/ffmpeg.exe";
const message = formatFfmpegError(exitCode, "");
expect(message).toContain("0xC0000135 (STATUS_DLL_NOT_FOUND)");
expect(message).toContain(resolve("/tools/ffmpeg.exe"));
expect(message).toContain("working 64-bit Windows FFmpeg build");
},
);
});
function createSpawnSpy() {
const calls: Array<{ command: string; args: string[] }> = [];
const spawn = vi.fn((command: string, args: string[]) => {
calls.push({ command, args });
const proc = new EventEmitter() as EventEmitter & {
stderr: EventEmitter;
kill: ReturnType<typeof vi.fn>;
killed: boolean;
};
proc.stderr = new EventEmitter();
proc.kill = vi.fn();
proc.killed = false;
process.nextTick(() => proc.emit("close", 0));
return proc;
});
return { spawn, calls };
}
describe("runFfmpeg binary resolution", () => {
const originalFfmpegPath = process.env.HYPERFRAMES_FFMPEG_PATH;
afterEach(() => {
vi.resetModules();
vi.doUnmock("child_process");
if (originalFfmpegPath === undefined) delete process.env.HYPERFRAMES_FFMPEG_PATH;
else process.env.HYPERFRAMES_FFMPEG_PATH = originalFfmpegPath;
});
it("spawns the configured absolute FFmpeg path when HYPERFRAMES_FFMPEG_PATH is set", async () => {
process.env.HYPERFRAMES_FFMPEG_PATH = "/tools/ffmpeg.exe";
const { spawn, calls } = createSpawnSpy();
vi.resetModules();
vi.doMock("child_process", () => ({ spawn }));
const { runFfmpeg } = await import("./runFfmpeg.js");
const result = await runFfmpeg(["-version"]);
expect(result.success).toBe(true);
expect(calls[0]).toEqual({ command: resolve("/tools/ffmpeg.exe"), args: ["-version"] });
});
});