|
9 | 9 | * master-display / max-cll and ship as SDR BT.2020 again. |
10 | 10 | */ |
11 | 11 |
|
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"; |
13 | 17 |
|
14 | 18 | import { |
15 | 19 | buildStreamingArgs, |
@@ -284,3 +288,245 @@ describe("createFrameReorderBuffer", () => { |
284 | 288 | await buf.waitForAllDone(); |
285 | 289 | }); |
286 | 290 | }); |
| 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