fix(discord): handle ffmpeg stderr stream errors in voice playback (#101088)

* fix(discord): handle ffmpeg stderr stream errors in voice playback

createDiscordOpusPlaybackStream guards ffmpeg stdout and stdin against raw
stream 'error' events (an unhandled stream error throws and crashes the
gateway via uncaughtException), but the stderr stream on the same child was
left unguarded. Add the symmetric stderr error handler, routing it through
opusStream.destroy like stdout. Add a regression test covering stdout and
stderr stream errors.

* refactor(discord): unify ffmpeg stream errors

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Masato Hoshino
2026-07-06 19:43:10 +01:00
committed by GitHub
co-authored by Peter Steinberger
parent 92cfceac09
commit b6c7a77d3f
2 changed files with 57 additions and 3 deletions
+53 -2
View File
@@ -1,12 +1,39 @@
// Discord tests cover audio plugin behavior.
import { Readable } from "node:stream";
import { describe, expect, it, vi } from "vitest";
import { EventEmitter } from "node:events";
import { PassThrough, Readable } from "node:stream";
import { beforeEach, describe, expect, it, vi } from "vitest";
const spawnMock = vi.hoisted(() => vi.fn());
vi.mock("node:child_process", async (importOriginal) => ({
...(await importOriginal<typeof import("node:child_process")>()),
spawn: spawnMock,
}));
vi.mock("openclaw/plugin-sdk/media-runtime", async (importOriginal) => ({
...(await importOriginal<typeof import("openclaw/plugin-sdk/media-runtime")>()),
resolveFfmpegBin: () => "ffmpeg",
}));
import {
createDiscordOpusEncodeStream,
createDiscordOpusPlaybackStream,
decodeOpusStream,
decodeOpusStreamChunks,
} from "./audio.js";
function createFakeFfmpeg() {
const child = new EventEmitter() as EventEmitter & {
stdout: PassThrough;
stderr: PassThrough;
stdin: PassThrough;
kill: ReturnType<typeof vi.fn>;
};
child.stdout = new PassThrough();
child.stderr = new PassThrough();
child.stdin = new PassThrough();
child.kill = vi.fn();
return child;
}
async function collectBuffers(stream: Readable): Promise<Buffer[]> {
const chunks: Buffer[] = [];
for await (const chunk of stream) {
@@ -76,3 +103,27 @@ describe("discord voice opus codec", () => {
expect(onError).toHaveBeenCalledWith(err);
});
});
describe("createDiscordOpusPlaybackStream child stream errors", () => {
beforeEach(() => {
spawnMock.mockReset();
});
it.each(["stdout", "stderr"] as const)(
"routes a %s stream error to the playback stream instead of crashing",
async (streamName) => {
const ffmpeg = createFakeFfmpeg();
spawnMock.mockReturnValue(ffmpeg);
const playback = createDiscordOpusPlaybackStream("input.mp3");
const errorSeen = new Promise<Error>((resolve) => {
playback.once("error", resolve);
});
const streamError = new Error(`${streamName} broke`);
expect(() => ffmpeg[streamName].emit("error", streamError)).not.toThrow();
await expect(errorSeen).resolves.toBe(streamError);
},
);
});
+4 -1
View File
@@ -133,7 +133,10 @@ export function createDiscordOpusPlaybackStream(input: Readable | string): Reada
}
});
ffmpeg.stdout.on("error", (err) => opusStream.destroy(err));
// Both readable child pipes need listeners; an unhandled stream error terminates Node.
for (const readable of [ffmpeg.stdout, ffmpeg.stderr]) {
readable.on("error", (err) => opusStream.destroy(err));
}
ffmpeg.stdin.on("error", (err) => {
if ((err as NodeJS.ErrnoException).code !== "EPIPE") {
opusStream.destroy(err);