fix(ffmpeg): make process shutdown race-safe

This commit is contained in:
2026-08-24 14:12:40 +02:00
parent d750e5fe3f
commit f48424e198
2 changed files with 100 additions and 44 deletions
+71 -34
View File
@@ -14,8 +14,8 @@ const FORCE_KILL_TIMEOUT_MS = 2_000;
const STDERR_TAIL_BYTES = 4_096;
export type FfmpegProcessHandle = {
process: ChildProcessByStdio<null, Readable, Readable>;
ready: Promise<void>;
readonly process: ChildProcessByStdio<null, Readable, Readable>;
readonly ready: Promise<void>;
stop(): void;
};
@@ -36,7 +36,8 @@ export function resolveFfmpegExecutable(options: FfmpegOptions = {}): string {
}
} catch (error) {
throw new AudioManagerConfigError(
`Unable to resolve ffmpeg-static. Install it or pass ffmpeg.executablePath. Cause: ${String(error)}`,
'Unable to resolve ffmpeg-static. Install it or pass ffmpeg.executablePath.',
{ cause: error },
);
}
@@ -52,7 +53,8 @@ export function startFfmpeg(input: string, options: FfmpegOptions = {}): FfmpegP
...(options.outputArgs ?? DEFAULT_OUTPUT_ARGS),
];
const childProcess = spawn(executable, args, { stdio: ['ignore', 'pipe', 'pipe'] });
const ready = waitForFfmpegOutput(childProcess);
const abortController = new AbortController();
const ready = waitForFfmpegOutput(childProcess, abortController.signal);
childProcess.stderr.resume();
@@ -60,49 +62,76 @@ export function startFfmpeg(input: string, options: FfmpegOptions = {}): FfmpegP
process: childProcess,
ready,
stop: (): void => {
stopProcess(childProcess);
stopProcess(childProcess, abortController);
},
};
}
function waitForFfmpegOutput(childProcess: ChildProcessByStdio<null, Readable, Readable>): Promise<void> {
function waitForFfmpegOutput(
childProcess: ChildProcessByStdio<null, Readable, Readable>,
signal: AbortSignal,
): Promise<void> {
const { promise, resolve, reject } = Promise.withResolvers<void>();
let stderrTail = '';
let settled = false;
const appendStderr = (chunk: Buffer | string): void => {
stderrTail = (stderrTail + String(chunk)).slice(-STDERR_TAIL_BYTES);
};
childProcess.stderr.on('data', appendStderr);
const cleanup = (): void => {
childProcess.off('exit', onExit);
childProcess.stdout.off('readable', onReadable);
childProcess.stderr.off('data', appendStderr);
signal.removeEventListener('abort', onAbort);
};
return new Promise((resolve, reject) => {
const cleanup = (): void => {
childProcess.off('error', onError);
childProcess.off('exit', onExit);
childProcess.stdout.off('readable', onReadable);
};
const settle = (complete: () => void): void => {
if (settled) {
return;
}
const fail = (message: string, cause?: unknown): void => {
cleanup();
reject(new FfmpegProcessError(addStderrTail(message, stderrTail), cause));
};
settled = true;
cleanup();
complete();
};
const onError = (error: Error): void => {
const fail = (message: string, cause?: unknown): void => {
settle(() => reject(new FfmpegProcessError(addStderrTail(message, stderrTail), cause)));
};
const onError = (error: Error): void => {
if (!settled) {
fail(`Unable to start ffmpeg. Cause: ${error.message}`, error);
};
}
};
const onExit = (code: number | null, signal: NodeJS.Signals | null): void => {
fail(`ffmpeg exited before producing audio. Exit code: ${code ?? 'none'}, signal: ${signal ?? 'none'}.`);
};
const onExit = (code: number | null, exitSignal: NodeJS.Signals | null): void => {
fail(`ffmpeg exited before producing audio. Exit code: ${code ?? 'none'}, signal: ${exitSignal ?? 'none'}.`);
};
const onReadable = (): void => {
cleanup();
resolve();
};
const onReadable = (): void => {
settle(() => resolve());
};
childProcess.once('error', onError);
childProcess.once('exit', onExit);
childProcess.stdout.once('readable', onReadable);
});
const onAbort = (): void => {
const reason: unknown = signal.reason;
settle(() =>
reject(
reason instanceof Error
? reason
: new FfmpegProcessError('ffmpeg was stopped before producing audio.', reason),
),
);
};
childProcess.stderr.on('data', appendStderr);
childProcess.on('error', onError);
childProcess.once('exit', onExit);
childProcess.stdout.once('readable', onReadable);
signal.addEventListener('abort', onAbort, { once: true });
return promise;
}
function addStderrTail(message: string, stderrTail: string): string {
@@ -111,22 +140,30 @@ function addStderrTail(message: string, stderrTail: string): string {
return trimmedTail ? `${message} stderr: ${trimmedTail}` : message;
}
function stopProcess(childProcess: ChildProcessByStdio<null, Readable, Readable>): void {
function stopProcess(
childProcess: ChildProcessByStdio<null, Readable, Readable>,
abortController: AbortController,
): void {
if (abortController.signal.aborted) {
return;
}
abortController.abort(new FfmpegProcessError('ffmpeg was stopped before producing audio.'));
childProcess.stdout.destroy();
childProcess.stderr.destroy();
childProcess.removeAllListeners();
if (childProcess.killed || childProcess.exitCode !== null || childProcess.signalCode !== null) {
if (childProcess.exitCode !== null || childProcess.signalCode !== null) {
return;
}
childProcess.kill('SIGTERM');
const forceKillTimeout = setTimeout(() => {
if (!childProcess.killed && childProcess.exitCode === null && childProcess.signalCode === null) {
if (childProcess.exitCode === null && childProcess.signalCode === null) {
childProcess.kill('SIGKILL');
}
}, FORCE_KILL_TIMEOUT_MS);
childProcess.once('close', () => clearTimeout(forceKillTimeout));
forceKillTimeout.unref();
}