Surface ffmpeg startup failures

This commit is contained in:
2026-06-22 12:16:21 +02:00
parent 1a77896c32
commit d10dae1b53
4 changed files with 118 additions and 3 deletions
+6
View File
@@ -108,6 +108,12 @@ export default class AudioManager {
const resolvedSource = this.resolveSource();
this.stopCurrentPlayback();
this.ffmpeg = startFfmpeg(resolvedSource.input, this.options.ffmpeg);
try {
await this.ffmpeg.ready;
} catch (error) {
this.stopCurrentPlayback();
throw error;
}
this.resource = createAudioResource(this.ffmpeg.process.stdout, {
inputType: StreamType.Raw,
inlineVolume: this.options.volume?.enabled === true,
+51 -2
View File
@@ -3,7 +3,7 @@ import type { ChildProcessByStdio } from 'node:child_process';
import { createRequire } from 'node:module';
import type { Readable } from 'node:stream';
import { AudioManagerConfigError } from './errors';
import { AudioManagerConfigError, FfmpegProcessError } from './errors';
import type { FfmpegOptions } from './types';
const requireFromCurrentModule = createRequire(__filename);
@@ -11,9 +11,11 @@ const requireFromCurrentModule = createRequire(__filename);
const DEFAULT_INPUT_ARGS = ['-hide_banner', '-loglevel', 'error', '-nostdin'] as const;
const DEFAULT_OUTPUT_ARGS = ['-vn', '-f', 's16le', '-ar', '48000', '-ac', '2', 'pipe:1'] as const;
const FORCE_KILL_TIMEOUT_MS = 2_000;
const STDERR_TAIL_BYTES = 4_096;
export type FfmpegProcessHandle = {
process: ChildProcessByStdio<null, Readable, Readable>;
ready: Promise<void>;
stop(): void;
};
@@ -50,18 +52,65 @@ 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);
childProcess.once('error', () => undefined);
childProcess.stderr.resume();
return {
process: childProcess,
ready,
stop: (): void => {
stopProcess(childProcess);
},
};
}
function waitForFfmpegOutput(childProcess: ChildProcessByStdio<null, Readable, Readable>): Promise<void> {
let stderrTail = '';
const appendStderr = (chunk: Buffer | string): void => {
stderrTail = (stderrTail + String(chunk)).slice(-STDERR_TAIL_BYTES);
};
childProcess.stderr.on('data', appendStderr);
return new Promise((resolve, reject) => {
const cleanup = (): void => {
childProcess.off('error', onError);
childProcess.off('exit', onExit);
childProcess.stdout.off('readable', onReadable);
};
const fail = (message: string, cause?: unknown): void => {
cleanup();
reject(new FfmpegProcessError(addStderrTail(message, stderrTail), cause));
};
const onError = (error: Error): void => {
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 onReadable = (): void => {
cleanup();
resolve();
};
childProcess.once('error', onError);
childProcess.once('exit', onExit);
childProcess.stdout.once('readable', onReadable);
});
}
function addStderrTail(message: string, stderrTail: string): string {
const trimmedTail = stderrTail.trim();
return trimmedTail ? `${message} stderr: ${trimmedTail}` : message;
}
function stopProcess(childProcess: ChildProcessByStdio<null, Readable, Readable>): void {
childProcess.stdout.destroy();
childProcess.stderr.destroy();
+22 -1
View File
@@ -4,7 +4,7 @@ import { resolve } from 'node:path';
import { PassThrough } from 'node:stream';
import AudioManager from '../src/audio-manager';
import { AudioManagerConfigError, AudioManagerStateError } from '../src';
import { AudioManagerConfigError, AudioManagerStateError, FfmpegProcessError } from '../src';
import { startFfmpeg } from '../src/ffmpeg';
import type { AudioSource, VoiceConnectionOptions } from '../src';
@@ -31,6 +31,7 @@ const mockFfmpegHandle = {
process: {
stdout: new PassThrough(),
},
ready: Promise.resolve(),
stop: jest.fn(),
};
@@ -163,6 +164,26 @@ describe('AudioManager', () => {
expect(manager.state).toBe('playing');
});
it('does not report playback when ffmpeg startup fails', async () => {
const startupError = new FfmpegProcessError('Unable to start ffmpeg.');
jest.mocked(startFfmpeg).mockReturnValueOnce({
...mockFfmpegHandle,
ready: Promise.reject(startupError),
});
const manager = new AudioManager({
connection: connectionOptions,
source: liveStreamSource,
renewIntervalMs: false,
});
await manager.connect();
await expect(manager.play()).rejects.toBe(startupError);
expect(manager.state).toBe('ready');
expect(mockAudioPlayer.play).not.toHaveBeenCalled();
expect(mockFfmpegHandle.stop).toHaveBeenCalledTimes(1);
});
it('replaces active playback when a new source is played', async () => {
const manager = new AudioManager({
connection: connectionOptions,
+39
View File
@@ -1,6 +1,7 @@
import { jest, describe, beforeEach, afterEach, it, expect } from '@jest/globals';
import { spawn } from 'node:child_process';
import { FfmpegProcessError } from '../src';
import { resolveFfmpegExecutable, startFfmpeg } from '../src/ffmpeg';
jest.mock('node:child_process', () => ({
@@ -12,12 +13,16 @@ const mockSpawn = jest.mocked(spawn);
type MockChildProcess = {
stdout: {
destroy: jest.Mock;
once: jest.Mock;
off: jest.Mock;
};
stderr: {
destroy: jest.Mock;
on: jest.Mock;
resume: jest.Mock;
};
once: jest.Mock;
off: jest.Mock;
removeAllListeners: jest.Mock;
kill: jest.Mock;
killed: boolean;
@@ -29,12 +34,16 @@ function createMockChildProcess(): MockChildProcess {
return {
stdout: {
destroy: jest.fn(),
once: jest.fn(),
off: jest.fn(),
},
stderr: {
destroy: jest.fn(),
on: jest.fn(),
resume: jest.fn(),
},
once: jest.fn(),
off: jest.fn(),
removeAllListeners: jest.fn(),
kill: jest.fn(),
killed: false,
@@ -88,9 +97,39 @@ describe('ffmpeg helpers', () => {
{ stdio: ['ignore', 'pipe', 'pipe'] },
);
expect(childProcess.once).toHaveBeenCalledWith('error', expect.any(Function));
expect(childProcess.once).toHaveBeenCalledWith('exit', expect.any(Function));
expect(childProcess.stdout.once).toHaveBeenCalledWith('readable', expect.any(Function));
expect(childProcess.stderr.on).toHaveBeenCalledWith('data', expect.any(Function));
expect(childProcess.stderr.resume).toHaveBeenCalledTimes(1);
});
it('rejects readiness when ffmpeg startup emits an error', async () => {
const childProcess = createMockChildProcess();
mockSpawn.mockReturnValue(childProcess);
const handle = startFfmpeg('tests/audio.mp3');
const errorHandler = childProcess.once.mock.calls.find(([event]) => event === 'error')?.[1];
errorHandler(new Error('spawn ENOENT'));
await expect(handle.ready).rejects.toThrow(FfmpegProcessError);
await expect(handle.ready).rejects.toThrow('spawn ENOENT');
});
it('includes stderr when ffmpeg exits before producing audio', async () => {
const childProcess = createMockChildProcess();
mockSpawn.mockReturnValue(childProcess);
const handle = startFfmpeg('tests/audio.mp3');
const stderrHandler = childProcess.stderr.on.mock.calls.find(([event]) => event === 'data')?.[1];
const exitHandler = childProcess.once.mock.calls.find(([event]) => event === 'exit')?.[1];
stderrHandler('invalid input');
exitHandler(1, null);
await expect(handle.ready).rejects.toThrow('invalid input');
});
it('uses custom executable and argument overrides when provided', () => {
const childProcess = createMockChildProcess();
mockSpawn.mockReturnValue(childProcess);