fix(memory-host-sdk): handle stdout/stderr stream errors in runCliCommand (#101402)

* fix(memory-host-sdk): handle stdout/stderr stream errors in runCliCommand

Register error handlers on stdout and stderr streams in
runCliCommand() to prevent uncaught exceptions when a pipe
breaks (e.g. EPIPE after child process exit).

Without these listeners, Node.js throws an uncaught exception
that crashes the process.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(memory-host-sdk): extract listenForChildOutputErrors helper

Refactor the inline stream error handling into a reusable
listenForChildOutputErrors helper, matching the pattern
established in the merged #101370 (agent-core).

The helper registers error listeners on stdout/stderr via a
shared loop, keeping the pattern consistent across the codebase.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(memory-host-sdk): harden qmd stream failures

* fix(memory-host-sdk): harden qmd stream failures

* docs(changelog): note qmd stream hardening

* chore: keep release changelog out of contributor PR

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
wings1029 2026-07-07 15:37:21 +08:00 committed by GitHub
parent 1aeed5ec26
commit 5c4b63964c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 68 additions and 0 deletions

View file

@ -524,4 +524,52 @@ describe("runCliCommand", () => {
});
expect(child.kill).not.toHaveBeenCalledWith("SIGKILL");
});
it.each(["stdout", "stderr"] as const)(
"rejects when %s stream emits an error",
async (streamName) => {
const child = createMockChild();
spawnMock.mockReturnValueOnce(child);
const streamError = new Error(`${streamName} EPIPE`);
const pending = runCliCommand({
commandSummary: "qmd query test",
spawnInvocation: { command: "qmd", argv: ["query", "test", "--json"] },
env: process.env,
cwd: tempDir,
maxOutputChars: 10_000,
});
child[streamName].emit("error", streamError);
await expect(pending).rejects.toMatchObject({
message: `qmd query test ${streamName} error: ${streamName} EPIPE`,
cause: streamError,
});
expect(child.kill).toHaveBeenCalledOnce();
expect(child.kill).toHaveBeenCalledWith("SIGKILL");
},
);
it("keeps the other stream guarded after stdout error", async () => {
const child = createMockChild();
spawnMock.mockReturnValueOnce(child);
const pending = runCliCommand({
commandSummary: "qmd query test",
spawnInvocation: { command: "qmd", argv: ["query", "test", "--json"] },
env: process.env,
cwd: tempDir,
maxOutputChars: 10_000,
});
child.stdout.emit("error", new Error("stdout EPIPE"));
expect(() => {
child.stderr.emit("error", new Error("stderr later"));
}).not.toThrow();
await expect(pending).rejects.toThrow("qmd query test stdout error: stdout EPIPE");
expect(child.kill).toHaveBeenCalledOnce();
});
});

View file

@ -246,6 +246,26 @@ export async function runCliCommand(params: {
stderr = next.text;
stderrTruncated = stderrTruncated || next.truncated;
});
// Guard stdout/stderr against stream errors (e.g. EPIPE when the
// child exits before all pipe data is consumed). Without listeners,
// Node.js throws an uncaught exception that crashes the process.
for (const streamName of ["stdout", "stderr"] as const) {
child[streamName].on("error", (error: Error) => {
if (settled) {
return;
}
signalQmdProcessTree(child, "SIGKILL");
settle(() =>
reject(
new Error(`${params.commandSummary} ${streamName} error: ${error.message}`, {
cause: error,
}),
),
);
});
}
child.on("error", (err) => {
if (timer) {
clearTimeout(timer);