mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-10 00:11:19 +00:00
fix(tts-local-cli): handle stdout/stderr stream errors in speech provider (#102347)
* fix(tts-local-cli): handle stdout/stderr stream errors in speech provider CLI TTS speech provider spawns a child process to generate audio and registers stdout/stderr data listeners but omits stream error handlers. stdout carries synthesized audio data. A pipe error mid-generation must reject the promise so the caller does not silently receive truncated audio when the child later exits zero. stderr carries diagnostic logs only — errors there are benign and should not crash the provider. Apply separate strategies matching vincentkoc's requirement: - stdout (audio): error → reject(Promise) — surface the failure - stderr (diagnostic): error → ignore — does not affect audio output * fix lint: remove unused signal param from mock kill() * fix(tts-local-cli): contain child stream failures Co-authored-by: 赵旺0668001248 <zhao.wang1@xydigit.com> * chore(tts-local-cli): keep release notes in PR body --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
parent
fae5421f81
commit
9fa913740d
2 changed files with 138 additions and 17 deletions
|
|
@ -0,0 +1,80 @@
|
|||
import { EventEmitter } from "node:events";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { afterEach, 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", () => ({
|
||||
runFfmpeg: vi.fn(),
|
||||
}));
|
||||
|
||||
import { buildCliSpeechProvider } from "./speech-provider.js";
|
||||
|
||||
type MockChild = EventEmitter & {
|
||||
stdout: EventEmitter;
|
||||
stderr: EventEmitter;
|
||||
stdin: EventEmitter & { end: () => void; write: (data: string) => void };
|
||||
kill: ReturnType<typeof vi.fn<(signal?: NodeJS.Signals) => boolean>>;
|
||||
};
|
||||
|
||||
function createMockChild(): MockChild {
|
||||
const child = new EventEmitter() as MockChild;
|
||||
child.stdout = new EventEmitter();
|
||||
child.stderr = new EventEmitter();
|
||||
const stdin = new EventEmitter() as EventEmitter & { end: () => void; write: () => void };
|
||||
stdin.end = () => {};
|
||||
stdin.write = () => {};
|
||||
child.stdin = stdin;
|
||||
child.kill = vi.fn(() => true);
|
||||
return child;
|
||||
}
|
||||
|
||||
const TEST_CFG = {} as OpenClawConfig;
|
||||
|
||||
async function synthesize(child: MockChild) {
|
||||
spawnMock.mockReturnValue(child);
|
||||
const promise = buildCliSpeechProvider().synthesize({
|
||||
text: "hello",
|
||||
cfg: TEST_CFG,
|
||||
providerConfig: { command: "/fake/tts", outputFormat: "wav", timeoutMs: 5000 },
|
||||
providerOverrides: {},
|
||||
timeoutMs: 5000,
|
||||
target: "audio-file",
|
||||
});
|
||||
await vi.waitUntil(() => spawnMock.mock.calls.length > 0, { timeout: 2000 });
|
||||
return { promise };
|
||||
}
|
||||
|
||||
describe("CLI TTS provider stream error handling", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("rejects on stdout stream error instead of silently returning truncated audio", async () => {
|
||||
const child = createMockChild();
|
||||
const { promise } = await synthesize(child);
|
||||
|
||||
child.stdout.emit("error", new Error("EPIPE: audio stream broken"));
|
||||
child.emit("close", null, "SIGTERM");
|
||||
|
||||
await expect(promise).rejects.toThrow("CLI TTS stdout stream error");
|
||||
expect(child.kill).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("keeps synthesized audio when only the diagnostic stream errors", async () => {
|
||||
const child = createMockChild();
|
||||
const { promise } = await synthesize(child);
|
||||
|
||||
child.stdout.emit("data", Buffer.from("audio"));
|
||||
child.stderr.emit("error", new Error("EPIPE: diagnostics stream broken"));
|
||||
child.emit("close", 0);
|
||||
|
||||
await expect(promise).resolves.toMatchObject({ audioBuffer: Buffer.from("audio") });
|
||||
expect(child.kill).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -204,46 +204,85 @@ async function runCli(params: {
|
|||
const args = baseArgs.map((a) => applyTemplate(a, ctx));
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let timedOut = false;
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
proc.kill();
|
||||
// Escalate to SIGKILL if child ignores SIGTERM
|
||||
setTimeout(() => proc.kill("SIGKILL"), 5000).unref();
|
||||
}, params.timeoutMs);
|
||||
|
||||
const env = params.env ? { ...process.env, ...params.env } : process.env;
|
||||
const proc = spawn(cmd, args, { cwd: params.cwd, env, stdio: ["pipe", "pipe", "pipe"] });
|
||||
let settled = false;
|
||||
let failure: Error | undefined;
|
||||
let forceKillTimer: NodeJS.Timeout | undefined;
|
||||
|
||||
const clearTimers = () => {
|
||||
clearTimeout(timer);
|
||||
clearTimeout(forceKillTimer);
|
||||
};
|
||||
const rejectOnce = (error: Error) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimers();
|
||||
reject(error);
|
||||
};
|
||||
const terminate = () => {
|
||||
proc.kill();
|
||||
forceKillTimer = setTimeout(() => proc.kill("SIGKILL"), 5000);
|
||||
forceKillTimer.unref();
|
||||
};
|
||||
const terminateFor = (error: Error) => {
|
||||
// Keep one terminal cause across stream, process, and close events. Node
|
||||
// may emit several of them for the same failed child lifecycle.
|
||||
if (settled || failure) {
|
||||
return;
|
||||
}
|
||||
failure = error;
|
||||
clearTimeout(timer);
|
||||
terminate();
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
terminateFor(new Error(`CLI TTS timed out after ${params.timeoutMs}ms`));
|
||||
}, params.timeoutMs);
|
||||
|
||||
const stdoutChunks: Buffer[] = [];
|
||||
const stderrChunks: Buffer[] = [];
|
||||
proc.stdout.on("data", (c) => stdoutChunks.push(c));
|
||||
// stdout carries synthesized audio data. A stream error means the audio
|
||||
// pipe broke mid-generation — reject so the caller does not silently
|
||||
// receive truncated audio when the child later exits zero.
|
||||
proc.stdout.on("error", (e) => {
|
||||
terminateFor(new Error(`CLI TTS stdout stream error: ${e.message}`));
|
||||
});
|
||||
proc.stderr.on("data", (c) => stderrChunks.push(c));
|
||||
// stderr carries diagnostic logs only. Stream errors here are benign and
|
||||
// should not crash the provider or affect audio output.
|
||||
proc.stderr.on("error", () => {});
|
||||
|
||||
proc.on("error", (e) => {
|
||||
clearTimeout(timer);
|
||||
reject(new Error(`CLI TTS failed: ${e.message}`));
|
||||
rejectOnce(failure ?? new Error(`CLI TTS failed: ${e.message}`));
|
||||
});
|
||||
|
||||
proc.on("close", (code) => {
|
||||
clearTimeout(timer);
|
||||
if (timedOut) {
|
||||
return reject(new Error(`CLI TTS timed out after ${params.timeoutMs}ms`));
|
||||
if (settled) {
|
||||
clearTimers();
|
||||
return;
|
||||
}
|
||||
if (failure) {
|
||||
return rejectOnce(failure);
|
||||
}
|
||||
if (code !== 0) {
|
||||
const stderr = Buffer.concat(stderrChunks).toString("utf8");
|
||||
return reject(new Error(`CLI TTS exit ${code}: ${stderr}`));
|
||||
return rejectOnce(new Error(`CLI TTS exit ${code}: ${stderr}`));
|
||||
}
|
||||
|
||||
const audioFile = findAudioFile(params.outputDir, params.filePrefix);
|
||||
if (audioFile) {
|
||||
if (!existsSync(audioFile)) {
|
||||
return reject(new Error(`CLI TTS: output file not found at ${audioFile}`));
|
||||
return rejectOnce(new Error(`CLI TTS: output file not found at ${audioFile}`));
|
||||
}
|
||||
const format = detectFormat(audioFile);
|
||||
if (!format) {
|
||||
return reject(new Error(`CLI TTS: unknown format for ${audioFile}`));
|
||||
return rejectOnce(new Error(`CLI TTS: unknown format for ${audioFile}`));
|
||||
}
|
||||
settled = true;
|
||||
clearTimers();
|
||||
return resolve({
|
||||
buffer: readFileSync(audioFile),
|
||||
actualFormat: format,
|
||||
|
|
@ -254,9 +293,11 @@ async function runCli(params: {
|
|||
const stdout = Buffer.concat(stdoutChunks);
|
||||
if (stdout.length > 0) {
|
||||
// Assume WAV for stdout output; could be MP3 but caller should convert if needed
|
||||
settled = true;
|
||||
clearTimers();
|
||||
return resolve({ buffer: stdout, actualFormat: "wav" });
|
||||
}
|
||||
reject(new Error("CLI TTS produced no output"));
|
||||
rejectOnce(new Error("CLI TTS produced no output"));
|
||||
});
|
||||
|
||||
proc.stdin?.on("error", () => {}); // suppress EPIPE if child ignores stdin
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue