Skip to content

Commit 9606fe4

Browse files
cxbAsDevvincentkoc
andauthored
fix(cli): handle stdout/stderr stream errors in execFileUtf8Tail (#100850)
* fix(cli): handle stdout/stderr stream errors in execFileUtf8Tail * chore(proof): add real behavior proof for logs-cli runtime stream errors * chore(proof): fix promise executor lint in logs-cli proof * fix(cli): terminate log tail children on stream errors --------- Co-authored-by: Vincent Koc <[email protected]>
1 parent a327cec commit 9606fe4

2 files changed

Lines changed: 69 additions & 2 deletions

File tree

src/cli/logs-cli.runtime.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import type { ChildProcess } from "node:child_process";
2+
import { EventEmitter } from "node:events";
3+
import { beforeEach, describe, expect, it, vi } from "vitest";
4+
import { execFileUtf8Tail } from "./logs-cli.runtime.js";
5+
6+
const { spawnMock } = vi.hoisted(() => ({ spawnMock: vi.fn() }));
7+
8+
vi.mock("node:child_process", async () => {
9+
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
10+
return {
11+
...actual,
12+
spawn: spawnMock,
13+
};
14+
});
15+
16+
describe("execFileUtf8Tail", () => {
17+
beforeEach(() => {
18+
spawnMock.mockReset();
19+
});
20+
21+
it.each(["stdout", "stderr"] as const)(
22+
"terminates the child when %s emits an error",
23+
async (streamName) => {
24+
const stdout = new EventEmitter();
25+
const stderr = new EventEmitter();
26+
const kill = vi.fn(() => true);
27+
const child = Object.assign(new EventEmitter(), { kill, stderr, stdout });
28+
spawnMock.mockReturnValue(child as unknown as ChildProcess);
29+
30+
const resultPromise = execFileUtf8Tail("journalctl", ["--no-pager"], { maxBytes: 1024 });
31+
stdout.emit("data", Buffer.from("partial output"));
32+
const streamError = new Error(`${streamName} read failed`);
33+
(streamName === "stdout" ? stdout : stderr).emit("error", streamError);
34+
35+
await expect(resultPromise).resolves.toEqual({
36+
code: 1,
37+
stderr: streamError.message,
38+
stdout: "partial output",
39+
truncated: false,
40+
});
41+
expect(kill).toHaveBeenCalledOnce();
42+
},
43+
);
44+
45+
it("does not kill the child when spawning fails", async () => {
46+
const child = new EventEmitter();
47+
const kill = vi.fn(() => true);
48+
Object.assign(child, { kill, stderr: new EventEmitter(), stdout: new EventEmitter() });
49+
spawnMock.mockReturnValue(child as unknown as ChildProcess);
50+
51+
const resultPromise = execFileUtf8Tail("journalctl", ["--no-pager"], { maxBytes: 1024 });
52+
child.emit("error", new Error("spawn failed"));
53+
54+
await expect(resultPromise).resolves.toMatchObject({ code: 1, stderr: "spawn failed" });
55+
expect(kill).not.toHaveBeenCalled();
56+
});
57+
});

src/cli/logs-cli.runtime.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,18 +56,28 @@ export async function execFileUtf8Tail(
5656
}
5757
}
5858
});
59-
child.on("error", (error) => {
59+
60+
const resolveWithError = (error: unknown, terminateChild = false) => {
6061
if (settled) {
6162
return;
6263
}
6364
settled = true;
65+
if (terminateChild) {
66+
// Journal output is only useful when fully readable. Stop the child so
67+
// a failed pipe cannot leave a live command holding the CLI open.
68+
child.kill();
69+
}
6470
resolve({
6571
stdout: Buffer.concat(stdoutChunks).toString("utf8"),
6672
stderr: error instanceof Error ? error.message : String(error),
6773
code: 1,
6874
truncated,
6975
});
70-
});
76+
};
77+
78+
child.stdout?.on("error", (error) => resolveWithError(error, true));
79+
child.stderr?.on("error", (error) => resolveWithError(error, true));
80+
child.on("error", resolveWithError);
7181
child.on("close", (code) => {
7282
if (settled) {
7383
return;

0 commit comments

Comments
 (0)