Skip to content
Open
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
27 changes: 26 additions & 1 deletion packages/engine/src/utils/ffprobe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,12 +118,15 @@ interface SpawnCall {
interface FakeProc extends EventEmitter {
stdout: EventEmitter;
stderr: EventEmitter;
kill: ReturnType<typeof vi.fn>;
}

type SpawnOutcome =
| { kind: "missing" }
| { kind: "error"; message: string; code?: string }
| { kind: "exit"; code: number; stdout?: string; stderr?: string };
| { kind: "exit"; code: number; stdout?: string; stderr?: string }
// never settles on its own — only kill() ends it (simulates a hung probe)
| { kind: "hang" };

function createSpawnSpy(outcomes: SpawnOutcome[]): {
spawn: (command: string, args: readonly string[]) => FakeProc;
Expand All @@ -139,9 +142,16 @@ function createSpawnSpy(outcomes: SpawnOutcome[]): {
const proc = new EventEmitter() as FakeProc;
proc.stdout = new EventEmitter();
proc.stderr = new EventEmitter();
// A real SIGTERM ends the process, firing 'close' with a null code; mirror
// that so the runner settles after a timeout kill.
proc.kill = vi.fn((_signal?: string) => {
process.nextTick(() => proc.emit("close", null));
return true;
});

process.nextTick(() => {
if (!outcome) return;
if (outcome.kind === "hang") return; // never settles until kill()
if (outcome.kind === "missing") {
const err = new Error("spawn ffprobe ENOENT") as NodeJS.ErrnoException;
err.code = "ENOENT";
Expand All @@ -168,12 +178,27 @@ describe("ffprobe missing-binary fallback", () => {
const originalFfprobePath = process.env.HYPERFRAMES_FFPROBE_PATH;

afterEach(() => {
vi.useRealTimers();
vi.resetModules();
vi.doUnmock("child_process");
if (originalFfprobePath === undefined) delete process.env.HYPERFRAMES_FFPROBE_PATH;
else process.env.HYPERFRAMES_FFPROBE_PATH = originalFfprobePath;
});

it("kills and rejects a hung ffprobe after the timeout instead of leaking it", async () => {
vi.useFakeTimers();
const { spawn } = createSpawnSpy([{ kind: "hang" }]);
vi.resetModules();
vi.doMock("child_process", () => ({ spawn }));

const { extractMediaMetadata } = await import("./ffprobe.js");
const probe = extractMediaMetadata("/tmp/hangs-forever.mp4");
const assertion = expect(probe).rejects.toThrow(/timed out/i);
// Before the timeout fires nothing settles; advancing past it kills + rejects.
await vi.advanceTimersByTimeAsync(120_000);
await assertion;
});

it("spawns the configured absolute FFprobe path when HYPERFRAMES_FFPROBE_PATH is set", async () => {
process.env.HYPERFRAMES_FFPROBE_PATH = "/tools/ffprobe.exe";
const { spawn, calls } = createSpawnSpy([
Expand Down
23 changes: 22 additions & 1 deletion packages/engine/src/utils/ffprobe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,28 +3,49 @@ import { spawn } from "child_process";
import { readFileSync } from "fs";
import { extname } from "path";
import { FFPROBE_PATH_ENV, getFfprobeBinary } from "./ffmpegBinaries.js";
import { trackChildProcess } from "./processTracker.js";

/**
* Upper bound for any single ffprobe invocation. ffprobe metadata returns in
* milliseconds; the keyframe-interval probe is the slowest but still seconds.
* A hung probe (malformed container, stalled network FS, fifo) would otherwise
* never settle, so cap it generously and reject.
*/
const FFPROBE_TIMEOUT_MS = 120_000;

/** Spawn ffprobe with given args, return stdout. Throws on non-zero exit or missing binary. */
function runFfprobe(args: string[]): Promise<string> {
return new Promise((resolve, reject) => {
const command = getFfprobeBinary();
const proc = spawn(command, args);
// Track the child so render abort/teardown can reap it (every other engine
// spawn does this); without it killTrackedProcesses() can't kill a hung probe.
trackChildProcess(proc);
let stdout = "";
let stderr = "";
let timedOut = false;
const timer = setTimeout(() => {
timedOut = true;
proc.kill("SIGTERM");
}, FFPROBE_TIMEOUT_MS);
proc.stdout.on("data", (data) => {
stdout += data.toString();
});
proc.stderr.on("data", (data) => {
stderr += data.toString();
});
proc.on("close", (code) => {
if (code !== 0) {
clearTimeout(timer);
if (timedOut) {
reject(new Error(`[FFmpeg] ffprobe timed out after ${FFPROBE_TIMEOUT_MS}ms`));
} else if (code !== 0) {
reject(new Error(`[FFmpeg] ffprobe exited with code ${code}: ${stderr}`));
} else {
resolve(stdout);
}
});
proc.on("error", (err) => {
clearTimeout(timer);
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
const configured = process.env[FFPROBE_PATH_ENV]?.trim();
reject(
Expand Down
Loading