mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 02:06:43 +00:00
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:
co-authored by
Peter Steinberger
parent
92cfceac09
commit
b6c7a77d3f
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user