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:
zw-xysk 2026-07-09 18:06:10 +08:00 committed by GitHub
parent fae5421f81
commit 9fa913740d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 138 additions and 17 deletions

View file

@ -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();
});
});

View file

@ -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