Skip to content

Commit 5c4b639

Browse files
wings1029claudesteipete
authored
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 <[email protected]> * 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 <[email protected]> * 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 <[email protected]> Co-authored-by: Peter Steinberger <[email protected]>
1 parent 1aeed5e commit 5c4b639

2 files changed

Lines changed: 68 additions & 0 deletions

File tree

packages/memory-host-sdk/src/host/qmd-process.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -524,4 +524,52 @@ describe("runCliCommand", () => {
524524
});
525525
expect(child.kill).not.toHaveBeenCalledWith("SIGKILL");
526526
});
527+
528+
it.each(["stdout", "stderr"] as const)(
529+
"rejects when %s stream emits an error",
530+
async (streamName) => {
531+
const child = createMockChild();
532+
spawnMock.mockReturnValueOnce(child);
533+
const streamError = new Error(`${streamName} EPIPE`);
534+
535+
const pending = runCliCommand({
536+
commandSummary: "qmd query test",
537+
spawnInvocation: { command: "qmd", argv: ["query", "test", "--json"] },
538+
env: process.env,
539+
cwd: tempDir,
540+
maxOutputChars: 10_000,
541+
});
542+
543+
child[streamName].emit("error", streamError);
544+
545+
await expect(pending).rejects.toMatchObject({
546+
message: `qmd query test ${streamName} error: ${streamName} EPIPE`,
547+
cause: streamError,
548+
});
549+
expect(child.kill).toHaveBeenCalledOnce();
550+
expect(child.kill).toHaveBeenCalledWith("SIGKILL");
551+
},
552+
);
553+
554+
it("keeps the other stream guarded after stdout error", async () => {
555+
const child = createMockChild();
556+
spawnMock.mockReturnValueOnce(child);
557+
558+
const pending = runCliCommand({
559+
commandSummary: "qmd query test",
560+
spawnInvocation: { command: "qmd", argv: ["query", "test", "--json"] },
561+
env: process.env,
562+
cwd: tempDir,
563+
maxOutputChars: 10_000,
564+
});
565+
566+
child.stdout.emit("error", new Error("stdout EPIPE"));
567+
568+
expect(() => {
569+
child.stderr.emit("error", new Error("stderr later"));
570+
}).not.toThrow();
571+
572+
await expect(pending).rejects.toThrow("qmd query test stdout error: stdout EPIPE");
573+
expect(child.kill).toHaveBeenCalledOnce();
574+
});
527575
});

packages/memory-host-sdk/src/host/qmd-process.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,26 @@ export async function runCliCommand(params: {
246246
stderr = next.text;
247247
stderrTruncated = stderrTruncated || next.truncated;
248248
});
249+
250+
// Guard stdout/stderr against stream errors (e.g. EPIPE when the
251+
// child exits before all pipe data is consumed). Without listeners,
252+
// Node.js throws an uncaught exception that crashes the process.
253+
for (const streamName of ["stdout", "stderr"] as const) {
254+
child[streamName].on("error", (error: Error) => {
255+
if (settled) {
256+
return;
257+
}
258+
signalQmdProcessTree(child, "SIGKILL");
259+
settle(() =>
260+
reject(
261+
new Error(`${params.commandSummary} ${streamName} error: ${error.message}`, {
262+
cause: error,
263+
}),
264+
),
265+
);
266+
});
267+
}
268+
249269
child.on("error", (err) => {
250270
if (timer) {
251271
clearTimeout(timer);

0 commit comments

Comments
 (0)