Skip to content

Commit 7dd4686

Browse files
committed
refactor(engine): manage child process lifecycles
1 parent 406ba14 commit 7dd4686

15 files changed

Lines changed: 638 additions & 559 deletions

packages/engine/src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,12 @@ export {
230230
type RunFfmpegOptions,
231231
type RunFfmpegResult,
232232
} from "./utils/runFfmpeg.js";
233+
export {
234+
ManagedChildProcess,
235+
type ManagedChildProcessOptions,
236+
type ManagedChildProcessOutcome,
237+
type ManagedProcessTerminationReason,
238+
} from "./utils/managedChildProcess.js";
233239
export {
234240
assertConfiguredFfmpegBinariesExist,
235241
getFfmpegBinary,

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,12 @@ async function flushMuxCodecResolution(): Promise<void> {
9494
await Promise.resolve();
9595
}
9696

97+
async function flushManagedProcessResolution(): Promise<void> {
98+
await Promise.resolve();
99+
await Promise.resolve();
100+
await Promise.resolve();
101+
}
102+
97103
describe("ENCODER_PRESETS", () => {
98104
it("has draft, standard, and high presets", () => {
99105
expect(ENCODER_PRESETS).toHaveProperty("draft");
@@ -310,7 +316,7 @@ describe("encodeFramesChunkedConcat ffmpegEncodeTimeout", () => {
310316

311317
expect(calls).toHaveLength(1);
312318
emitClose(calls[0]!.proc, 0);
313-
await Promise.resolve();
319+
await flushManagedProcessResolution();
314320

315321
expect(calls).toHaveLength(2);
316322
const concatProc = calls[1]!.proc;
@@ -353,7 +359,7 @@ describe("encodeFramesChunkedConcat ffmpegEncodeTimeout", () => {
353359
expect(chunkProc.kill).not.toHaveBeenCalled();
354360

355361
emitClose(chunkProc, 0);
356-
await Promise.resolve();
362+
await flushManagedProcessResolution();
357363

358364
expect(calls).toHaveLength(2);
359365
const concatProc = calls[1]!.proc;

packages/engine/src/services/chunkEncoder.ts

Lines changed: 53 additions & 152 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,8 @@
66
* Supports CPU (libx264) and GPU encoding.
77
*/
88

9-
import { spawn } from "child_process";
109
import { copyFileSync, existsSync, mkdirSync, readdirSync, statSync, writeFileSync } from "fs";
1110
import { join, dirname, extname } from "path";
12-
import { trackChildProcess } from "../utils/processTracker.js";
1311
import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
1412
import {
1513
type GpuEncoder,
@@ -20,7 +18,6 @@ import {
2018
import { type HdrTransfer, getHdrEncoderColorParams } from "../utils/hdr.js";
2119
import { withEvenDimensionPad } from "../utils/evenDimensions.js";
2220
import { formatFfmpegError, runFfmpeg } from "../utils/runFfmpeg.js";
23-
import { getFfmpegBinary } from "../utils/ffmpegBinaries.js";
2421
import { extractAudioMetadata } from "../utils/ffprobe.js";
2522
import { type Fps, fpsToFfmpegArg } from "@hyperframes/core";
2623
import type { EncoderOptions, EncodeResult, MuxResult } from "./chunkEncoder.types.js";
@@ -484,82 +481,40 @@ export async function encodeFramesFromDir(
484481
const inputPath = join(framesDir, framePattern);
485482
const inputArgs = ["-framerate", fpsToFfmpegArg(options.fps), "-i", inputPath];
486483
const args = buildEncoderArgs(options, inputArgs, outputPath, gpuEncoder);
487-
488-
return new Promise((resolve) => {
489-
const ffmpeg = spawn(getFfmpegBinary(), args);
490-
trackChildProcess(ffmpeg);
491-
let stderr = "";
492-
const onAbort = () => {
493-
ffmpeg.kill("SIGTERM");
484+
const encodeTimeout = config?.ffmpegEncodeTimeout ?? DEFAULT_CONFIG.ffmpegEncodeTimeout;
485+
const result = await runFfmpeg(args, { signal, timeout: encodeTimeout });
486+
if (result.terminationReason === "abort") {
487+
return {
488+
success: false,
489+
outputPath,
490+
durationMs: result.durationMs,
491+
framesEncoded: 0,
492+
fileSize: 0,
493+
error: "FFmpeg encode cancelled",
494494
};
495-
if (signal) {
496-
if (signal.aborted) {
497-
ffmpeg.kill("SIGTERM");
498-
} else {
499-
signal.addEventListener("abort", onAbort, { once: true });
500-
}
501-
}
502-
503-
const encodeTimeout = config?.ffmpegEncodeTimeout ?? DEFAULT_CONFIG.ffmpegEncodeTimeout;
504-
let timedOut = false;
505-
const timer = setTimeout(() => {
506-
timedOut = true;
507-
ffmpeg.kill("SIGTERM");
508-
}, encodeTimeout);
509-
510-
ffmpeg.stderr.on("data", (data) => {
511-
stderr += data.toString();
512-
});
513-
514-
ffmpeg.on("close", (code) => {
515-
clearTimeout(timer);
516-
if (signal) signal.removeEventListener("abort", onAbort);
517-
const durationMs = Date.now() - startTime;
518-
if (signal?.aborted && !timedOut) {
519-
resolve({
520-
success: false,
521-
outputPath,
522-
durationMs,
523-
framesEncoded: 0,
524-
fileSize: 0,
525-
error: "FFmpeg encode cancelled",
526-
});
527-
return;
528-
}
529-
530-
if (code !== 0 || timedOut) {
531-
resolve({
532-
success: false,
533-
outputPath,
534-
durationMs,
535-
framesEncoded: 0,
536-
fileSize: 0,
537-
error: appendEncodeTimeoutMessage(
538-
formatFfmpegError(code, stderr),
539-
timedOut,
540-
encodeTimeout,
541-
),
542-
});
543-
return;
544-
}
545-
546-
const fileSize = existsSync(outputPath) ? statSync(outputPath).size : 0;
547-
resolve({ success: true, outputPath, durationMs, framesEncoded: frameCount, fileSize });
548-
});
549-
550-
ffmpeg.on("error", (err) => {
551-
clearTimeout(timer);
552-
if (signal) signal.removeEventListener("abort", onAbort);
553-
resolve({
554-
success: false,
555-
outputPath,
556-
durationMs: Date.now() - startTime,
557-
framesEncoded: 0,
558-
fileSize: 0,
559-
error: appendEncodeTimeoutMessage(`[FFmpeg] ${err.message}`, timedOut, encodeTimeout),
560-
});
561-
});
562-
});
495+
}
496+
if (!result.success) {
497+
return {
498+
success: false,
499+
outputPath,
500+
durationMs: result.durationMs,
501+
framesEncoded: 0,
502+
fileSize: 0,
503+
error: appendEncodeTimeoutMessage(
504+
formatFfmpegError(result.exitCode, result.stderr),
505+
result.terminationReason === "deadline",
506+
encodeTimeout,
507+
),
508+
};
509+
}
510+
const fileSize = existsSync(outputPath) ? statSync(outputPath).size : 0;
511+
return {
512+
success: true,
513+
outputPath,
514+
durationMs: Date.now() - startTime,
515+
framesEncoded: frameCount,
516+
fileSize,
517+
};
563518
}
564519

565520
export async function encodeFramesChunkedConcat(
@@ -624,45 +579,18 @@ export async function encodeFramesChunkedConcat(
624579
let gpuEncoder: GpuEncoder = null;
625580
if (options.useGpu) gpuEncoder = await getCachedGpuEncoder();
626581
const args = buildEncoderArgs(options, inputArgs, chunkPath, gpuEncoder);
627-
const chunkResult = await new Promise<{ success: boolean; error?: string }>((resolve) => {
628-
const ffmpeg = spawn(getFfmpegBinary(), args);
629-
trackChildProcess(ffmpeg);
630-
let stderr = "";
631-
const encodeTimeout = config?.ffmpegEncodeTimeout ?? DEFAULT_CONFIG.ffmpegEncodeTimeout;
632-
let timedOut = false;
633-
const timer = setTimeout(() => {
634-
timedOut = true;
635-
ffmpeg.kill("SIGTERM");
636-
}, encodeTimeout);
637-
ffmpeg.stderr.on("data", (d) => {
638-
stderr += d.toString();
639-
});
640-
ffmpeg.on("close", (code) => {
641-
clearTimeout(timer);
642-
if (code === 0 && !timedOut) resolve({ success: true });
643-
else {
644-
resolve({
645-
success: false,
646-
error: appendEncodeTimeoutMessage(
647-
`Chunk ${i} encode failed: ${stderr.slice(-400)}`,
648-
timedOut,
649-
encodeTimeout,
650-
),
651-
});
652-
}
653-
});
654-
ffmpeg.on("error", (err) => {
655-
clearTimeout(timer);
656-
resolve({
657-
success: false,
658-
error: appendEncodeTimeoutMessage(
659-
`Chunk ${i} encode error: ${err.message}`,
660-
timedOut,
582+
const encodeTimeout = config?.ffmpegEncodeTimeout ?? DEFAULT_CONFIG.ffmpegEncodeTimeout;
583+
const processResult = await runFfmpeg(args, { signal, timeout: encodeTimeout });
584+
const chunkResult = {
585+
success: processResult.success,
586+
error: processResult.success
587+
? undefined
588+
: appendEncodeTimeoutMessage(
589+
`Chunk ${i} encode failed: ${processResult.stderr.slice(-400)}`,
590+
processResult.terminationReason === "deadline",
661591
encodeTimeout,
662592
),
663-
});
664-
});
665-
});
593+
};
666594
if (!chunkResult.success) {
667595
return {
668596
success: false,
@@ -692,45 +620,18 @@ export async function encodeFramesChunkedConcat(
692620
"-y",
693621
outputPath,
694622
];
695-
const concatResult = await new Promise<{ success: boolean; error?: string }>((resolve) => {
696-
const ffmpeg = spawn(getFfmpegBinary(), concatArgs);
697-
trackChildProcess(ffmpeg);
698-
let stderr = "";
699-
const encodeTimeout = config?.ffmpegEncodeTimeout ?? DEFAULT_CONFIG.ffmpegEncodeTimeout;
700-
let timedOut = false;
701-
const timer = setTimeout(() => {
702-
timedOut = true;
703-
ffmpeg.kill("SIGTERM");
704-
}, encodeTimeout);
705-
ffmpeg.stderr.on("data", (d) => {
706-
stderr += d.toString();
707-
});
708-
ffmpeg.on("close", (code) => {
709-
clearTimeout(timer);
710-
if (code === 0 && !timedOut) resolve({ success: true });
711-
else {
712-
resolve({
713-
success: false,
714-
error: appendEncodeTimeoutMessage(
715-
`Chunk concat failed: ${stderr.slice(-400)}`,
716-
timedOut,
717-
encodeTimeout,
718-
),
719-
});
720-
}
721-
});
722-
ffmpeg.on("error", (err) => {
723-
clearTimeout(timer);
724-
resolve({
725-
success: false,
726-
error: appendEncodeTimeoutMessage(
727-
`Chunk concat error: ${err.message}`,
728-
timedOut,
623+
const encodeTimeout = config?.ffmpegEncodeTimeout ?? DEFAULT_CONFIG.ffmpegEncodeTimeout;
624+
const concatProcessResult = await runFfmpeg(concatArgs, { signal, timeout: encodeTimeout });
625+
const concatResult = {
626+
success: concatProcessResult.success,
627+
error: concatProcessResult.success
628+
? undefined
629+
: appendEncodeTimeoutMessage(
630+
`Chunk concat failed: ${concatProcessResult.stderr.slice(-400)}`,
631+
concatProcessResult.terminationReason === "deadline",
729632
encodeTimeout,
730633
),
731-
});
732-
});
733-
});
634+
};
734635

735636
if (!concatResult.success) {
736637
return {

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -818,7 +818,7 @@ describe("spawnStreamingEncoder lifecycle and cleanup", () => {
818818
await expect(resolveWithin(writePromise)).resolves.toBe(false);
819819
expect(encoder.getExitStatus()).toBe("error");
820820
expect(proc.stdin.listenerCount("drain")).toBe(0);
821-
expect(proc.listenerCount("close")).toBe(baselineCloseListeners);
821+
expect(proc.listenerCount("close")).toBeLessThanOrEqual(baselineCloseListeners);
822822

823823
const result = await encoder.close();
824824
expect(result.success).toBe(false);

0 commit comments

Comments
 (0)