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:
Vance Ingalls
2026-09-18 20:53:09 -07:00
co-authored by Claude Fable 5.1
parent 80d12f8c9e
commit 69024e17d1
2 changed files with 49 additions and 2 deletions
@@ -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") {