From 1b7a225ed7db10d183095c07261f121991c1fb12 Mon Sep 17 00:00:00 2001 From: Carlos Alcaraz <193642530+calcarazgre646@users.noreply.github.com> Date: Thu, 18 Jun 2026 21:49:39 -0300 Subject: [PATCH] fix(engine): track and time-bound ffprobe spawns runFfprobe was the only engine child-process spawn without trackChildProcess or a timeout. Every other spawn (runFfmpeg, the chunk and streaming encoders, the frame extractor) registers with the process tracker and bounds itself with a timeout. A hung ffprobe (malformed container, stalled network FS, a fifo) therefore never settled, and killTrackedProcesses() on render abort/teardown could not reap it since it was never in the tracked set. analyzeKeyframeIntervals is the worst case: it probes every keyframe and can run long. Register the child with trackChildProcess and add a 120s timeout that SIGTERMs and rejects, mirroring runFfmpeg. --- packages/engine/src/utils/ffprobe.test.ts | 27 ++++++++++++++++++++++- packages/engine/src/utils/ffprobe.ts | 23 ++++++++++++++++++- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/packages/engine/src/utils/ffprobe.test.ts b/packages/engine/src/utils/ffprobe.test.ts index 5a5150384..b266fdf43 100644 --- a/packages/engine/src/utils/ffprobe.test.ts +++ b/packages/engine/src/utils/ffprobe.test.ts @@ -118,12 +118,15 @@ interface SpawnCall { interface FakeProc extends EventEmitter { stdout: EventEmitter; stderr: EventEmitter; + kill: ReturnType; } 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; @@ -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"; @@ -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([ diff --git a/packages/engine/src/utils/ffprobe.ts b/packages/engine/src/utils/ffprobe.ts index abe4c3540..6ed9e6337 100644 --- a/packages/engine/src/utils/ffprobe.ts +++ b/packages/engine/src/utils/ffprobe.ts @@ -3,14 +3,31 @@ 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 { 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(); }); @@ -18,13 +35,17 @@ function runFfprobe(args: string[]): Promise { 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(