Skip to content

Commit 9c35af4

Browse files
committed
test(engine): cover spawnStreamingEncoder lifecycle and cleanup paths
Add unit tests that mock child_process.spawn so we can drive an in-memory 'ffmpeg' through the success/failure paths used by the producer's HDR encoder and by Chunk 5A's defensive close() in renderOrchestrator. Covers: - successful exit after explicit close() - non-zero exit before close() returns a failure result (no throw) - ENOENT on spawn returns a failure result (no throw) - abort signal triggers SIGTERM and a 'cancelled' result - close() is idempotent and never throws on a second call - writeFrame returns false after the encoder has exited - close() detaches the abort listener so post-close aborts don't re-kill ffmpeg These contracts are what the renderOrchestrator try/finally cleanup added in Chunk 5A relies on, and what the ffprobe-unavailable test (Chunk 9B) hinted at for the encoder side.
1 parent 147bb73 commit 9c35af4

1 file changed

Lines changed: 247 additions & 1 deletion

File tree

packages/engine/src/services/streamingEncoder.test.ts

Lines changed: 247 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@
99
* master-display / max-cll and ship as SDR BT.2020 again.
1010
*/
1111

12-
import { describe, expect, it } from "vitest";
12+
import { EventEmitter } from "events";
13+
import { mkdtempSync } from "fs";
14+
import { tmpdir } from "os";
15+
import { join } from "path";
16+
import { afterEach, describe, expect, it, vi } from "vitest";
1317

1418
import {
1519
buildStreamingArgs,
@@ -284,3 +288,245 @@ describe("createFrameReorderBuffer", () => {
284288
await buf.waitForAllDone();
285289
});
286290
});
291+
292+
interface FakeStdin extends EventEmitter {
293+
destroyed: boolean;
294+
end: (cb?: () => void) => void;
295+
write: (chunk: Buffer) => boolean;
296+
}
297+
298+
interface FakeProc extends EventEmitter {
299+
stdin: FakeStdin;
300+
stdout: EventEmitter;
301+
stderr: EventEmitter;
302+
kill: ReturnType<typeof vi.fn>;
303+
}
304+
305+
interface SpawnCall {
306+
command: string;
307+
args: readonly string[];
308+
proc: FakeProc;
309+
}
310+
311+
function createFakeStdin(): FakeStdin {
312+
const state = { destroyed: false };
313+
const stdin = new EventEmitter() as FakeStdin;
314+
Object.defineProperty(stdin, "destroyed", {
315+
get: () => state.destroyed,
316+
set: (v: boolean) => {
317+
state.destroyed = v;
318+
},
319+
});
320+
stdin.end = (cb?: () => void) => {
321+
state.destroyed = true;
322+
if (cb) process.nextTick(cb);
323+
};
324+
stdin.write = (_chunk: Buffer): boolean => !state.destroyed;
325+
return stdin;
326+
}
327+
328+
function createFakeProc(): FakeProc {
329+
const proc = new EventEmitter() as FakeProc;
330+
proc.stdin = createFakeStdin();
331+
proc.stdout = new EventEmitter();
332+
proc.stderr = new EventEmitter();
333+
proc.kill = vi.fn();
334+
return proc;
335+
}
336+
337+
function createSpawnSpy(): {
338+
spawn: (command: string, args: readonly string[]) => FakeProc;
339+
calls: SpawnCall[];
340+
} {
341+
const calls: SpawnCall[] = [];
342+
const spawn = (command: string, args: readonly string[]): FakeProc => {
343+
const proc = createFakeProc();
344+
calls.push({ command, args, proc });
345+
return proc;
346+
};
347+
return { spawn, calls };
348+
}
349+
350+
const baseOptions: StreamingEncoderOptions = {
351+
fps: 30,
352+
width: 100,
353+
height: 100,
354+
codec: "h264",
355+
useGpu: false,
356+
};
357+
358+
describe("spawnStreamingEncoder lifecycle and cleanup", () => {
359+
afterEach(() => {
360+
vi.resetModules();
361+
vi.doUnmock("child_process");
362+
});
363+
364+
it("returns a success result when ffmpeg exits cleanly after close()", async () => {
365+
const { spawn, calls } = createSpawnSpy();
366+
vi.resetModules();
367+
vi.doMock("child_process", () => ({ spawn }));
368+
369+
const { spawnStreamingEncoder } = await import("./streamingEncoder.js");
370+
const dir = mkdtempSync(join(tmpdir(), "se-success-"));
371+
const encoder = await spawnStreamingEncoder(join(dir, "out.mp4"), baseOptions);
372+
373+
expect(calls).toHaveLength(1);
374+
expect(calls[0]?.command).toBe("ffmpeg");
375+
376+
const proc = calls[0]!.proc;
377+
const closePromise = encoder.close();
378+
process.nextTick(() => proc.emit("close", 0));
379+
380+
const result = await closePromise;
381+
expect(result.success).toBe(true);
382+
expect(result.error).toBeUndefined();
383+
expect(result.fileSize).toBe(0); // No real ffmpeg, no file written
384+
});
385+
386+
it("returns a failure result (does NOT throw) when ffmpeg exits non-zero before close()", async () => {
387+
const { spawn, calls } = createSpawnSpy();
388+
vi.resetModules();
389+
vi.doMock("child_process", () => ({ spawn }));
390+
391+
const { spawnStreamingEncoder } = await import("./streamingEncoder.js");
392+
const dir = mkdtempSync(join(tmpdir(), "se-fail-"));
393+
const encoder = await spawnStreamingEncoder(join(dir, "out.mp4"), baseOptions);
394+
395+
const proc = calls[0]!.proc;
396+
proc.stderr.emit("data", Buffer.from("Encoder error\n"));
397+
await new Promise<void>((resolve) => {
398+
process.nextTick(() => {
399+
proc.emit("close", 1);
400+
resolve();
401+
});
402+
});
403+
404+
const result = await encoder.close();
405+
expect(result.success).toBe(false);
406+
expect(result.error).toContain("FFmpeg exited with code 1");
407+
expect(result.error).toContain("Encoder error");
408+
});
409+
410+
it("returns a failure result (does NOT throw) when ffmpeg fails to spawn (ENOENT)", async () => {
411+
const { spawn, calls } = createSpawnSpy();
412+
vi.resetModules();
413+
vi.doMock("child_process", () => ({ spawn }));
414+
415+
const { spawnStreamingEncoder } = await import("./streamingEncoder.js");
416+
const dir = mkdtempSync(join(tmpdir(), "se-enoent-"));
417+
const encoder = await spawnStreamingEncoder(join(dir, "out.mp4"), baseOptions);
418+
419+
const proc = calls[0]!.proc;
420+
await new Promise<void>((resolve) => {
421+
process.nextTick(() => {
422+
const err = new Error("spawn ffmpeg ENOENT") as NodeJS.ErrnoException;
423+
err.code = "ENOENT";
424+
proc.emit("error", err);
425+
resolve();
426+
});
427+
});
428+
429+
const result = await encoder.close();
430+
expect(result.success).toBe(false);
431+
expect(result.error).toMatch(/spawn ffmpeg ENOENT/);
432+
});
433+
434+
it("returns a 'cancelled' result and SIGTERMs ffmpeg when the abort signal fires", async () => {
435+
const { spawn, calls } = createSpawnSpy();
436+
vi.resetModules();
437+
vi.doMock("child_process", () => ({ spawn }));
438+
439+
const { spawnStreamingEncoder } = await import("./streamingEncoder.js");
440+
const controller = new AbortController();
441+
const dir = mkdtempSync(join(tmpdir(), "se-abort-"));
442+
const encoder = await spawnStreamingEncoder(
443+
join(dir, "out.mp4"),
444+
baseOptions,
445+
controller.signal,
446+
);
447+
448+
const proc = calls[0]!.proc;
449+
controller.abort();
450+
expect(proc.kill).toHaveBeenCalledWith("SIGTERM");
451+
452+
process.nextTick(() => proc.emit("close", null));
453+
const result = await encoder.close();
454+
455+
expect(result.success).toBe(false);
456+
expect(result.error).toBe("Streaming encode cancelled");
457+
});
458+
459+
it("close() is idempotent: a second call still resolves to a result and does not throw", async () => {
460+
const { spawn, calls } = createSpawnSpy();
461+
vi.resetModules();
462+
vi.doMock("child_process", () => ({ spawn }));
463+
464+
const { spawnStreamingEncoder } = await import("./streamingEncoder.js");
465+
const dir = mkdtempSync(join(tmpdir(), "se-idempotent-"));
466+
const encoder = await spawnStreamingEncoder(join(dir, "out.mp4"), baseOptions);
467+
468+
const proc = calls[0]!.proc;
469+
process.nextTick(() => proc.emit("close", 0));
470+
471+
const first = await encoder.close();
472+
expect(first.success).toBe(true);
473+
474+
// Defensive cleanup in renderOrchestrator may call close() again after the
475+
// explicit call. Verify the second call doesn't reject — it can return
476+
// either success (cached) or a benign failure result, but must not throw.
477+
let threw = false;
478+
try {
479+
const second = await encoder.close();
480+
expect(typeof second.success).toBe("boolean");
481+
} catch {
482+
threw = true;
483+
}
484+
expect(threw).toBe(false);
485+
});
486+
487+
it("writeFrame returns false after ffmpeg has exited", async () => {
488+
const { spawn, calls } = createSpawnSpy();
489+
vi.resetModules();
490+
vi.doMock("child_process", () => ({ spawn }));
491+
492+
const { spawnStreamingEncoder } = await import("./streamingEncoder.js");
493+
const dir = mkdtempSync(join(tmpdir(), "se-writefail-"));
494+
const encoder = await spawnStreamingEncoder(join(dir, "out.mp4"), baseOptions);
495+
496+
expect(encoder.writeFrame(Buffer.from([0]))).toBe(true);
497+
498+
const proc = calls[0]!.proc;
499+
await new Promise<void>((resolve) => {
500+
process.nextTick(() => {
501+
proc.emit("close", 0);
502+
resolve();
503+
});
504+
});
505+
506+
expect(encoder.writeFrame(Buffer.from([0]))).toBe(false);
507+
});
508+
509+
it("close() removes the abort listener so a post-close abort does not re-kill ffmpeg", async () => {
510+
const { spawn, calls } = createSpawnSpy();
511+
vi.resetModules();
512+
vi.doMock("child_process", () => ({ spawn }));
513+
514+
const { spawnStreamingEncoder } = await import("./streamingEncoder.js");
515+
const controller = new AbortController();
516+
const dir = mkdtempSync(join(tmpdir(), "se-detach-"));
517+
const encoder = await spawnStreamingEncoder(
518+
join(dir, "out.mp4"),
519+
baseOptions,
520+
controller.signal,
521+
);
522+
523+
const proc = calls[0]!.proc;
524+
process.nextTick(() => proc.emit("close", 0));
525+
await encoder.close();
526+
527+
expect(proc.kill).not.toHaveBeenCalled();
528+
529+
controller.abort();
530+
expect(proc.kill).not.toHaveBeenCalled();
531+
});
532+
});

0 commit comments

Comments
 (0)