mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 17:27:09 +00:00
fix(engine): keep ffmpeg's exit reason when it dies under a parked write
The field "write EPIPE" / "write EOF" render errors are this path: writeFrame waits on once(stdin, "drain") for back-pressure to clear, ffmpeg dies first, and Node rejects the drain wait with the stream's own error. That rejection escaped writeFrame as the render error, so ffmpeg's exit code and stderr — captured, but read only from the `false`-return path — were discarded. With N workers feeding one encoder the writer is parked most of the time, which is why the parallel cohort reads EPIPE where a single-worker render reads "Streaming encoder exited before frame N: FFmpeg exited with code …". Wait for the exit to settle, then report it as the close race does. Both halves are mutation-checked: rethrowing again fails the test, and returning "exit" without awaiting settlement fails the synchronous getExitStatus read. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
80d12f8c9e
commit
69024e17d1
@@ -951,6 +951,44 @@ describe("spawnStreamingEncoder lifecycle and cleanup", () => {
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("writeFrame resolves false with the exit reason readable when stdin errors while parked on drain", async () => {
|
||||
// This is the field `write EPIPE`: ffmpeg dies while the writer waits for
|
||||
// back-pressure to clear. Node's once(stdin,"drain") rejects with the
|
||||
// stream error, which used to escape writeFrame as the render error and
|
||||
// bury ffmpeg's exit code and stderr.
|
||||
const { spawn, calls } = createSpawnSpy();
|
||||
vi.resetModules();
|
||||
vi.doMock("child_process", () => ({ spawn }));
|
||||
|
||||
const { spawnStreamingEncoder } = await import("./streamingEncoder.js");
|
||||
const dir = mkdtempSync(join(tmpdir(), "se-drain-epipe-"));
|
||||
const encoder = await spawnStreamingEncoder(join(dir, "out.mp4"), baseOptions);
|
||||
|
||||
const proc = calls[0]!.proc;
|
||||
proc.stdin.write = (_chunk: Buffer): boolean => false;
|
||||
proc.stderr.emit("data", Buffer.from("x264 [error]: malformed input\n"));
|
||||
|
||||
const writePromise = encoder.writeFrame(validJpeg);
|
||||
await Promise.resolve();
|
||||
expect(proc.stdin.listenerCount("drain")).toBe(1);
|
||||
|
||||
// stdin errors first; the child's close lands a tick later, as it does
|
||||
// in the field (the OS closes the pipe before Node delivers `close`).
|
||||
proc.stdin.emit("error", Object.assign(new Error("write EPIPE"), { code: "EPIPE" }));
|
||||
setTimeout(() => proc.emit("close", 1), 5);
|
||||
|
||||
// Resolves, never rejects — and by the time it does, the exit has settled
|
||||
// so ensureFrameWritten can read the reason synchronously.
|
||||
await expect(writePromise).resolves.toBe(false);
|
||||
expect(encoder.getExitStatus()).toBe("error");
|
||||
expect(encoder.getExitError()).toMatch(/code 1/);
|
||||
expect(encoder.getExitError()).toContain("malformed input");
|
||||
expect(proc.stdin.listenerCount("drain")).toBe(0);
|
||||
|
||||
const result = await encoder.close();
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("writeFrame resolves false when close fires after write returns false before await attaches listeners", async () => {
|
||||
const { spawn, calls } = createSpawnSpy();
|
||||
vi.resetModules();
|
||||
|
||||
@@ -541,11 +541,20 @@ export async function spawnStreamingEncoder(
|
||||
const closePromise = once(ffmpeg, "close", { signal: abortController.signal }).then(
|
||||
() => "exit" as const,
|
||||
);
|
||||
const racePromise = Promise.race([drainPromise, closePromise]).catch((err: unknown) => {
|
||||
const racePromise = Promise.race([drainPromise, closePromise]).catch(async (err: unknown) => {
|
||||
if (err instanceof Error && err.name === "AbortError") {
|
||||
return "exit" as const;
|
||||
}
|
||||
throw err;
|
||||
// `once(stdin, "drain")` rejects with the stream's own error when
|
||||
// ffmpeg's read end closes first — a bare `write EPIPE` (darwin/
|
||||
// linux) or `write EOF` (win32). Rethrowing it here made that the
|
||||
// render error, with ffmpeg's exit code and stderr discarded, so a
|
||||
// parked write observed the same death uninformatively that an
|
||||
// unparked one reports through `getExitError()`. Wait for the exit
|
||||
// to settle so the caller's `ensureFrameWritten` reads the reason,
|
||||
// then report the exit like the `close` race does.
|
||||
await exitPromise;
|
||||
return "exit" as const;
|
||||
});
|
||||
|
||||
if (managed.isSettled || exitStatus !== "running") {
|
||||
|
||||
Reference in New Issue
Block a user