Merge pull request #72 from FrauJulian/66-67-68-69-70-71-multiple-bugs

improvements and bug fixes
This commit is contained in:
2026-06-22 12:43:27 +02:00
committed by GitHub
4 changed files with 487 additions and 22 deletions
+50 -11
View File
@@ -82,14 +82,27 @@ export default class AudioManager {
this.clearRenewTimer();
this.playbackState = 'connecting';
this.connection?.destroy();
this.connection = joinVoiceChannel({
const connection = joinVoiceChannel({
guildId: this.connectionOptions.guildId,
channelId: this.connectionOptions.channelId,
adapterCreator: this.connectionOptions.adapterCreator,
});
this.connection.subscribe(this.audioPlayer);
this.connection = connection;
connection.subscribe(this.audioPlayer);
await entersState(this.connection, VoiceConnectionStatus.Ready, this.options.connectTimeoutMs);
try {
await entersState(connection, VoiceConnectionStatus.Ready, this.options.connectTimeoutMs);
} catch (error) {
connection.destroy();
if (this.connection === connection) {
this.connection = undefined;
}
this.playbackState = 'stopped';
throw error;
}
if (this.connection !== connection) {
throw new AudioManagerStateError('Voice connection was stopped before it became ready.');
}
this.playbackState = 'ready';
this.scheduleRenewal();
}
@@ -108,17 +121,35 @@ export default class AudioManager {
const resolvedSource = this.resolveSource();
this.stopCurrentPlayback();
this.ffmpeg = startFfmpeg(resolvedSource.input, this.options.ffmpeg);
this.resource = createAudioResource(this.ffmpeg.process.stdout, {
inputType: StreamType.Raw,
inlineVolume: this.options.volume?.enabled === true,
});
const ffmpeg = this.ffmpeg;
try {
await ffmpeg.ready;
if (this.ffmpeg !== ffmpeg) {
throw new AudioManagerStateError('Playback was stopped before ffmpeg became ready.');
}
this.resource = createAudioResource(ffmpeg.process.stdout, {
inputType: StreamType.Raw,
inlineVolume: this.options.volume?.enabled === true,
});
} catch (error) {
if (this.ffmpeg === ffmpeg) {
this.stopCurrentPlayback();
}
throw error;
}
if (this.options.volume?.enabled === true && this.options.volume.initialPercent !== undefined) {
this.setVolume(this.options.volume.initialPercent);
}
this.audioPlayer.play(this.resource);
this.playbackState = 'playing';
try {
this.audioPlayer.play(this.resource);
this.playbackState = 'playing';
} catch (error) {
this.stopCurrentPlayback();
this.playbackState = 'ready';
throw error;
}
}
public async start(): Promise<void> {
@@ -169,7 +200,7 @@ export default class AudioManager {
throw new AudioManagerStateError('Volume control requires volume.enabled to be true.');
}
if (volumeInPercent < 0 || volumeInPercent > 100) {
if (!Number.isFinite(volumeInPercent) || volumeInPercent < 0 || volumeInPercent > 100) {
throw new AudioManagerConfigError('Volume must be between 0 and 100 percent.');
}
@@ -227,7 +258,15 @@ export default class AudioManager {
}
this.renewTimer = setTimeout(() => {
void this.start();
void this.start().catch(() => {
this.clearRenewTimer();
this.stopCurrentPlayback();
this.audioPlayer.stop(true);
this.connection?.disconnect();
this.connection?.destroy();
this.connection = undefined;
this.playbackState = 'stopped';
});
}, renewIntervalMs);
if (typeof this.renewTimer.unref === 'function') {
+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();
+294 -5
View File
@@ -1,11 +1,14 @@
import { jest, describe, beforeEach, afterEach, it, expect } from '@jest/globals';
import { createAudioResource, entersState, joinVoiceChannel } from '@discordjs/voice';
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 { FfmpegProcessHandle } from '../src/ffmpeg';
import type { AudioSource, VoiceConnectionOptions } from '../src';
import type { VoiceConnection } from '@discordjs/voice';
const mockAudioPlayer = {
play: jest.fn(),
@@ -18,6 +21,13 @@ const mockConnection = {
disconnect: jest.fn(),
destroy: jest.fn(),
};
const mockSecondConnection = {
subscribe: jest.fn(),
disconnect: jest.fn(),
destroy: jest.fn(),
};
const mockVoiceConnection = mockConnection as unknown as VoiceConnection;
const mockSecondVoiceConnection = mockSecondConnection as unknown as VoiceConnection;
const mockAudioResource = {
playStream: {
destroy: jest.fn(),
@@ -26,10 +36,11 @@ const mockAudioResource = {
setVolume: jest.fn(),
},
};
const mockFfmpegHandle = {
const mockFfmpegHandle: FfmpegProcessHandle = {
process: {
stdout: new PassThrough(),
},
} as unknown as FfmpegProcessHandle['process'],
ready: Promise.resolve(),
stop: jest.fn(),
};
@@ -45,8 +56,8 @@ jest.mock('@discordjs/voice', () => ({
},
createAudioPlayer: jest.fn(() => mockAudioPlayer),
createAudioResource: jest.fn(() => mockAudioResource),
entersState: jest.fn(() => Promise.resolve(mockConnection)),
joinVoiceChannel: jest.fn(() => mockConnection),
entersState: jest.fn(() => Promise.resolve(mockVoiceConnection)),
joinVoiceChannel: jest.fn(() => mockVoiceConnection),
}));
jest.mock('../src/ffmpeg', () => ({
@@ -66,6 +77,26 @@ const liveStreamSource: AudioSource = {
const fileSourcePath = 'tests/audio.mp3';
const resolvedFileSourcePath = resolve(process.cwd(), fileSourcePath);
type MockedEntersStateReturn = ReturnType<typeof entersState>;
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
let resolve!: (value: T) => void;
const promise = new Promise<T>((promiseResolve) => {
resolve = promiseResolve;
});
return { promise, resolve };
}
function createMockFfmpegHandle(): FfmpegProcessHandle {
return {
process: {
stdout: new PassThrough(),
} as unknown as FfmpegProcessHandle['process'],
ready: Promise.resolve(),
stop: jest.fn(),
};
}
describe('AudioManager', () => {
beforeEach(() => {
@@ -99,6 +130,91 @@ describe('AudioManager', () => {
expect(mockAudioPlayer.play).toHaveBeenCalledWith(mockAudioResource);
});
it('cleans up when connection startup fails', async () => {
jest.useFakeTimers();
const manager = new AudioManager({
connection: connectionOptions,
renewIntervalMs: 10_000,
});
jest.mocked(entersState).mockRejectedValueOnce(new Error('voice connection timeout'));
await expect(manager.connect()).rejects.toThrow('voice connection timeout');
expect(manager.state).toBe('stopped');
expect(manager.isConnected).toBe(false);
expect(mockConnection.subscribe).toHaveBeenCalledWith(mockAudioPlayer);
expect(mockConnection.destroy).toHaveBeenCalledTimes(1);
expect(jest.getTimerCount()).toBe(0);
});
it('does not become ready when stopped during connection startup', async () => {
jest.useFakeTimers();
const ready = deferred<VoiceConnection>();
jest.mocked(entersState).mockReturnValueOnce(ready.promise as unknown as MockedEntersStateReturn);
const manager = new AudioManager({
connection: connectionOptions,
renewIntervalMs: 10_000,
});
const connectPromise = manager.connect();
await manager.stop();
ready.resolve(mockVoiceConnection);
await expect(connectPromise).rejects.toThrow(AudioManagerStateError);
expect(manager.state).toBe('stopped');
expect(manager.isConnected).toBe(false);
expect(jest.getTimerCount()).toBe(0);
});
it('keeps the latest connection when concurrent connects resolve out of order', async () => {
const firstReady = deferred<VoiceConnection>();
const secondReady = deferred<VoiceConnection>();
jest.mocked(joinVoiceChannel)
.mockReturnValueOnce(mockVoiceConnection)
.mockReturnValueOnce(mockSecondVoiceConnection);
jest.mocked(entersState)
.mockReturnValueOnce(firstReady.promise as unknown as MockedEntersStateReturn)
.mockReturnValueOnce(secondReady.promise as unknown as MockedEntersStateReturn);
const manager = new AudioManager({
connection: connectionOptions,
renewIntervalMs: false,
});
const firstConnect = manager.connect();
const secondConnect = manager.connect();
firstReady.resolve(mockVoiceConnection);
secondReady.resolve(mockSecondVoiceConnection);
await expect(firstConnect).rejects.toThrow(AudioManagerStateError);
await expect(secondConnect).resolves.toBeUndefined();
expect(manager.state).toBe('ready');
expect(manager.isConnected).toBe(true);
expect(mockConnection.destroy).toHaveBeenCalledTimes(1);
expect(mockSecondConnection.destroy).not.toHaveBeenCalled();
});
it('does not become ready when disposed during connection startup', async () => {
const ready = deferred<VoiceConnection>();
jest.mocked(entersState).mockReturnValueOnce(ready.promise as unknown as MockedEntersStateReturn);
const manager = new AudioManager({
connection: connectionOptions,
renewIntervalMs: false,
});
const connectPromise = manager.connect();
manager.dispose();
ready.resolve(mockVoiceConnection);
await expect(connectPromise).rejects.toThrow(AudioManagerStateError);
expect(manager.state).toBe('disposed');
expect(manager.isConnected).toBe(false);
expect(mockConnection.destroy).toHaveBeenCalledTimes(1);
});
it('starts playback with the committed mp3 test file', async () => {
const manager = new AudioManager({
connection: connectionOptions,
@@ -144,6 +260,138 @@ 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('cleans up when audio resource creation fails', async () => {
const resourceError = new Error('resource failed');
jest.mocked(createAudioResource).mockImplementationOnce(() => {
throw resourceError;
});
const manager = new AudioManager({
connection: connectionOptions,
source: liveStreamSource,
renewIntervalMs: false,
});
await manager.connect();
await expect(manager.play()).rejects.toBe(resourceError);
expect(manager.state).toBe('ready');
expect(mockFfmpegHandle.stop).toHaveBeenCalledTimes(1);
expect(mockAudioPlayer.play).not.toHaveBeenCalled();
});
it('cleans up when the audio player rejects playback', async () => {
const playerError = new Error('player failed');
mockAudioPlayer.play.mockImplementationOnce(() => {
throw playerError;
});
const manager = new AudioManager({
connection: connectionOptions,
source: liveStreamSource,
renewIntervalMs: false,
});
await manager.connect();
await expect(manager.play()).rejects.toBe(playerError);
expect(manager.state).toBe('ready');
expect(mockFfmpegHandle.stop).toHaveBeenCalledTimes(1);
expect(mockAudioResource.playStream.destroy).toHaveBeenCalledTimes(1);
});
it('does not report playback when stopped before ffmpeg is ready', async () => {
const ready = deferred<void>();
jest.mocked(startFfmpeg).mockReturnValueOnce({
...mockFfmpegHandle,
ready: ready.promise,
});
const manager = new AudioManager({
connection: connectionOptions,
source: liveStreamSource,
renewIntervalMs: false,
});
await manager.connect();
const playPromise = manager.play();
await manager.stop();
ready.resolve();
await expect(playPromise).rejects.toThrow(AudioManagerStateError);
expect(manager.state).toBe('stopped');
expect(mockAudioPlayer.play).not.toHaveBeenCalled();
});
it('does not report playback when disposed before ffmpeg is ready', async () => {
const ready = deferred<void>();
jest.mocked(startFfmpeg).mockReturnValueOnce({
...mockFfmpegHandle,
ready: ready.promise,
});
const manager = new AudioManager({
connection: connectionOptions,
source: liveStreamSource,
renewIntervalMs: false,
});
await manager.connect();
const playPromise = manager.play();
manager.dispose();
ready.resolve();
await expect(playPromise).rejects.toThrow(AudioManagerStateError);
expect(manager.state).toBe('disposed');
expect(mockAudioPlayer.play).not.toHaveBeenCalled();
});
it('keeps only the latest concurrent playback', async () => {
const firstReady = deferred<void>();
const firstHandle = {
...createMockFfmpegHandle(),
ready: firstReady.promise,
};
const secondHandle = createMockFfmpegHandle();
jest.mocked(startFfmpeg).mockReturnValueOnce(firstHandle).mockReturnValueOnce(secondHandle);
const manager = new AudioManager({
connection: connectionOptions,
source: liveStreamSource,
renewIntervalMs: false,
});
await manager.connect();
const firstPlay = manager.play();
const secondPlay = manager.play({ type: 'file', path: fileSourcePath });
firstReady.resolve();
await expect(firstPlay).rejects.toThrow(AudioManagerStateError);
await expect(secondPlay).resolves.toBeUndefined();
expect(manager.state).toBe('playing');
expect(mockAudioPlayer.play).toHaveBeenCalledTimes(1);
expect(firstHandle.stop).toHaveBeenCalledTimes(1);
expect(secondHandle.stop).not.toHaveBeenCalled();
});
it('replaces active playback when a new source is played', async () => {
const manager = new AudioManager({
connection: connectionOptions,
@@ -233,6 +481,12 @@ describe('AudioManager', () => {
expect(() => manager.setVolume(-1)).toThrow(AudioManagerConfigError);
expect(() => manager.setVolume(101)).toThrow(AudioManagerConfigError);
expect(() => manager.setVolume(Number.NaN)).toThrow(AudioManagerConfigError);
expect(() => manager.setVolume(Number.POSITIVE_INFINITY)).toThrow(AudioManagerConfigError);
expect(() => manager.setVolume(Number.NEGATIVE_INFINITY)).toThrow(AudioManagerConfigError);
expect(() => manager.setVolume(0)).not.toThrow();
expect(() => manager.setVolume(42)).not.toThrow();
expect(() => manager.setVolume(100)).not.toThrow();
});
it('rejects volume changes before any resource exists', () => {
@@ -281,6 +535,20 @@ describe('AudioManager', () => {
expect(manager.state).toBe('stopped');
});
it('rejects pause and resume after stop', async () => {
const manager = new AudioManager({
connection: connectionOptions,
source: liveStreamSource,
renewIntervalMs: false,
});
await manager.start();
await manager.stop();
expect(() => manager.pause()).toThrow(AudioManagerStateError);
expect(() => manager.resume()).toThrow(AudioManagerStateError);
});
it('restarts playback when the renewal timer fires', async () => {
jest.useFakeTimers();
@@ -298,6 +566,27 @@ describe('AudioManager', () => {
expect(startSpy).toHaveBeenCalledTimes(1);
});
it('cleans up when renewal restart fails', async () => {
jest.useFakeTimers();
const manager = new AudioManager({
connection: connectionOptions,
source: liveStreamSource,
renewIntervalMs: 10_000,
});
const startSpy = jest.spyOn(manager, 'start').mockRejectedValue(new Error('renewal failed'));
await manager.connect();
jest.advanceTimersByTime(10_000);
await Promise.resolve();
expect(startSpy).toHaveBeenCalledTimes(1);
expect(mockAudioPlayer.stop).toHaveBeenCalledWith(true);
expect(mockConnection.destroy).toHaveBeenCalled();
expect(manager.state).toBe('stopped');
expect(jest.getTimerCount()).toBe(0);
});
it('clears existing renewal timer before reconnecting', async () => {
jest.useFakeTimers();
+92 -4
View File
@@ -1,6 +1,8 @@
import { jest, describe, beforeEach, afterEach, it, expect } from '@jest/globals';
import { spawn } from 'node:child_process';
import type { ChildProcess } from 'node:child_process';
import { FfmpegProcessError } from '../src';
import { resolveFfmpegExecutable, startFfmpeg } from '../src/ffmpeg';
jest.mock('node:child_process', () => ({
@@ -12,12 +14,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 +35,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,
@@ -43,6 +53,26 @@ function createMockChildProcess(): MockChildProcess {
};
}
function mockSpawnReturn(childProcess: MockChildProcess): void {
mockSpawn.mockReturnValue(childProcess as unknown as ChildProcess);
}
function getProcessHandler(childProcess: MockChildProcess, eventName: string): (...args: unknown[]) => void {
return childProcess.once.mock.calls.find(([event]) => event === eventName)?.[1] as (...args: unknown[]) => void;
}
function getStdoutHandler(childProcess: MockChildProcess, eventName: string): (...args: unknown[]) => void {
return childProcess.stdout.once.mock.calls.find(([event]) => event === eventName)?.[1] as (
...args: unknown[]
) => void;
}
function getStderrHandler(childProcess: MockChildProcess, eventName: string): (...args: unknown[]) => void {
return childProcess.stderr.on.mock.calls.find(([event]) => event === eventName)?.[1] as (
...args: unknown[]
) => void;
}
describe('ffmpeg helpers', () => {
beforeEach(() => {
jest.clearAllMocks();
@@ -63,7 +93,7 @@ describe('ffmpeg helpers', () => {
it('spawns ffmpeg with the default argument set', () => {
const childProcess = createMockChildProcess();
mockSpawn.mockReturnValue(childProcess);
mockSpawnReturn(childProcess);
startFfmpeg('https://synradiode.stream.laut.fm/synradiode');
@@ -88,12 +118,70 @@ 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();
mockSpawnReturn(childProcess);
const handle = startFfmpeg('tests/audio.mp3');
const errorHandler = getProcessHandler(childProcess, 'error');
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();
mockSpawnReturn(childProcess);
const handle = startFfmpeg('tests/audio.mp3');
const stderrHandler = getStderrHandler(childProcess, 'data');
const exitHandler = getProcessHandler(childProcess, 'exit');
stderrHandler('invalid input');
exitHandler(1, null);
await expect(handle.ready).rejects.toThrow('invalid input');
});
it('keeps the useful tail of long ffmpeg stderr output', async () => {
const childProcess = createMockChildProcess();
mockSpawnReturn(childProcess);
const handle = startFfmpeg('tests/audio.mp3');
const stderrHandler = getStderrHandler(childProcess, 'data');
const exitHandler = getProcessHandler(childProcess, 'exit');
stderrHandler(`${'x'.repeat(5_000)}final diagnostic`);
exitHandler(1, null);
await expect(handle.ready).rejects.toThrow('final diagnostic');
});
it('keeps readiness resolved when ffmpeg exits after producing audio', async () => {
const childProcess = createMockChildProcess();
mockSpawnReturn(childProcess);
const handle = startFfmpeg('tests/audio.mp3');
const readableHandler = getStdoutHandler(childProcess, 'readable');
const exitHandler = getProcessHandler(childProcess, 'exit');
readableHandler();
exitHandler(1, null);
await expect(handle.ready).resolves.toBeUndefined();
});
it('uses custom executable and argument overrides when provided', () => {
const childProcess = createMockChildProcess();
mockSpawn.mockReturnValue(childProcess);
mockSpawnReturn(childProcess);
startFfmpeg('tests/audio.mp3', {
executablePath: '/custom/ffmpeg',
@@ -111,7 +199,7 @@ describe('ffmpeg helpers', () => {
it('stops a running child process and schedules a force kill fallback', () => {
jest.useFakeTimers();
const childProcess = createMockChildProcess();
mockSpawn.mockReturnValue(childProcess);
mockSpawnReturn(childProcess);
const handle = startFfmpeg('tests/audio.mp3');
handle.stop();
@@ -129,7 +217,7 @@ describe('ffmpeg helpers', () => {
it('does not signal a process that already exited', () => {
const childProcess = createMockChildProcess();
childProcess.exitCode = 0;
mockSpawn.mockReturnValue(childProcess);
mockSpawnReturn(childProcess);
const handle = startFfmpeg('tests/audio.mp3');
handle.stop();