Skip to content

Commit b7d7030

Browse files
committed
fix(imessage): handle CLI child stdout/stderr stream errors
1 parent f643c9b commit b7d7030

4 files changed

Lines changed: 157 additions & 0 deletions

File tree

extensions/imessage/src/actions.runtime.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,23 @@ function mockSpawnJsonResponse(payload: Record<string, unknown> = { success: tru
4141
});
4242
}
4343

44+
function mockSpawnWithStreamError(stream: "stdout" | "stderr", error: Error) {
45+
spawnMock.mockImplementationOnce(() => {
46+
const child = new EventEmitter() as EventEmitter & {
47+
stdout: EventEmitter & { setEncoding: (encoding: string) => void };
48+
stderr: EventEmitter & { setEncoding: (encoding: string) => void };
49+
kill: (signal: string) => void;
50+
};
51+
child.stdout = Object.assign(new EventEmitter(), { setEncoding: vi.fn() });
52+
child.stderr = Object.assign(new EventEmitter(), { setEncoding: vi.fn() });
53+
child.kill = vi.fn();
54+
queueMicrotask(() => {
55+
child[stream].emit("error", error);
56+
});
57+
return child;
58+
});
59+
}
60+
4461
function mockRpcChatList(chats: Array<Record<string, unknown>>) {
4562
const request = vi.fn().mockResolvedValue({ chats });
4663
const stop = vi.fn().mockResolvedValue(undefined);
@@ -83,6 +100,38 @@ describe("imessage actions runtime", () => {
83100
);
84101
});
85102

103+
it("rejects on stdout stream error", async () => {
104+
mockSpawnWithStreamError("stdout", new Error("stdout pipe broken"));
105+
106+
await expect(
107+
imessageActionsRuntime.sendReaction({
108+
chatGuid: "iMessage;+;chat0000",
109+
messageId: "message-guid",
110+
reaction: "like",
111+
options: {
112+
cliPath: "imsg",
113+
chatGuid: "iMessage;+;chat0000",
114+
},
115+
}),
116+
).rejects.toThrow("iMessage CLI stdout stream error: stdout pipe broken");
117+
});
118+
119+
it("rejects on stderr stream error", async () => {
120+
mockSpawnWithStreamError("stderr", new Error("stderr pipe broken"));
121+
122+
await expect(
123+
imessageActionsRuntime.sendReaction({
124+
chatGuid: "iMessage;+;chat0000",
125+
messageId: "message-guid",
126+
reaction: "like",
127+
options: {
128+
cliPath: "imsg",
129+
chatGuid: "iMessage;+;chat0000",
130+
},
131+
}),
132+
).rejects.toThrow("iMessage CLI stderr stream error: stderr pipe broken");
133+
});
134+
86135
it("drops cached chats.list entries when the current clock is not a valid date timestamp", async () => {
87136
vi.spyOn(Date, "now").mockReturnValueOnce(1_700_000_000_000).mockReturnValueOnce(Number.NaN);
88137
const firstClient = mockRpcChatList([{ id: 1, guid: "iMessage;+;first" }]);

extensions/imessage/src/actions.runtime.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,9 +243,29 @@ async function runIMessageCliJson(
243243
}
244244
stdout = appended.value;
245245
});
246+
child.stdout.on("error", (error) => {
247+
if (settled) {
248+
return;
249+
}
250+
fail(
251+
new Error(
252+
`iMessage CLI stdout stream error: ${error instanceof Error ? error.message : String(error)}`,
253+
),
254+
);
255+
});
246256
child.stderr.on("data", (chunk) => {
247257
stderr = appendIMessageCliStderrTail(stderr, chunk);
248258
});
259+
child.stderr.on("error", (error) => {
260+
if (settled) {
261+
return;
262+
}
263+
fail(
264+
new Error(
265+
`iMessage CLI stderr stream error: ${error instanceof Error ? error.message : String(error)}`,
266+
),
267+
);
268+
});
249269
child.on("error", (error) => {
250270
if (settled) {
251271
clearTimers();

extensions/imessage/src/send.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
// Imessage tests cover send plugin behavior.
2+
import { EventEmitter } from "node:events";
23
import fs from "node:fs";
34
import os from "node:os";
45
import path from "node:path";
@@ -29,6 +30,13 @@ const IMESSAGE_TEST_CFG = {
2930
},
3031
};
3132

33+
const spawnMock = vi.hoisted(() => vi.fn());
34+
35+
vi.mock("node:child_process", async (importOriginal) => ({
36+
...(await importOriginal<typeof import("node:child_process")>()),
37+
spawn: spawnMock,
38+
}));
39+
3240
function createClient(result: Record<string, unknown>): IMessageRpcClient {
3341
return {
3442
request: vi.fn(async () => result),
@@ -79,6 +87,7 @@ describe("sendMessageIMessage receipts", () => {
7987
vi.restoreAllMocks();
8088
vi.unstubAllEnvs();
8189
vi.useRealTimers();
90+
spawnMock.mockReset();
8291
});
8392

8493
it("attaches a text receipt for native send ids", async () => {
@@ -1265,3 +1274,62 @@ describe("sendMessageIMessage receipts", () => {
12651274
expect(runCliJson).not.toHaveBeenCalled();
12661275
});
12671276
});
1277+
1278+
function mockSpawnWithStreamError(stream: "stdout" | "stderr", error: Error) {
1279+
spawnMock.mockImplementationOnce(() => {
1280+
const child = new EventEmitter() as EventEmitter & {
1281+
stdout: EventEmitter & { setEncoding: (encoding: string) => void };
1282+
stderr: EventEmitter & { setEncoding: (encoding: string) => void };
1283+
kill: (signal: string) => void;
1284+
};
1285+
child.stdout = Object.assign(new EventEmitter(), { setEncoding: vi.fn() });
1286+
child.stderr = Object.assign(new EventEmitter(), { setEncoding: vi.fn() });
1287+
child.kill = vi.fn();
1288+
queueMicrotask(() => {
1289+
child[stream].emit("error", error);
1290+
});
1291+
return child;
1292+
});
1293+
}
1294+
1295+
describe("sendMessageIMessage CLI stream errors", () => {
1296+
beforeEach(() => {
1297+
installIMessageStateRuntimeForTest();
1298+
resetIMessageShortIdState();
1299+
resetPersistedIMessageEchoCacheForTest();
1300+
});
1301+
1302+
afterEach(() => {
1303+
clearIMessageApprovalReactionTargetsForTest();
1304+
resetIMessageShortIdState();
1305+
resetPersistedIMessageEchoCacheForTest();
1306+
vi.restoreAllMocks();
1307+
vi.unstubAllEnvs();
1308+
vi.useRealTimers();
1309+
spawnMock.mockReset();
1310+
});
1311+
1312+
it("rejects on stdout stream error during attachment send", async () => {
1313+
mockSpawnWithStreamError("stdout", new Error("stdout pipe broken"));
1314+
1315+
await expect(
1316+
sendMessageIMessage("chat_guid:chat-1", "", {
1317+
config: IMESSAGE_TEST_CFG,
1318+
mediaUrl: "/tmp/image.png",
1319+
resolveAttachmentImpl: async () => ({ path: "/tmp/image.png", contentType: "image/png" }),
1320+
}),
1321+
).rejects.toThrow("iMessage CLI stdout stream error: stdout pipe broken");
1322+
});
1323+
1324+
it("rejects on stderr stream error during attachment send", async () => {
1325+
mockSpawnWithStreamError("stderr", new Error("stderr pipe broken"));
1326+
1327+
await expect(
1328+
sendMessageIMessage("chat_guid:chat-1", "", {
1329+
config: IMESSAGE_TEST_CFG,
1330+
mediaUrl: "/tmp/image.png",
1331+
resolveAttachmentImpl: async () => ({ path: "/tmp/image.png", contentType: "image/png" }),
1332+
}),
1333+
).rejects.toThrow("iMessage CLI stderr stream error: stderr pipe broken");
1334+
});
1335+
});

extensions/imessage/src/send.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -644,9 +644,29 @@ async function runIMessageCliJson(
644644
}
645645
stdout = appended.value;
646646
});
647+
child.stdout.on("error", (error) => {
648+
if (settled) {
649+
return;
650+
}
651+
fail(
652+
new Error(
653+
`iMessage CLI stdout stream error: ${error instanceof Error ? error.message : String(error)}`,
654+
),
655+
);
656+
});
647657
child.stderr.on("data", (chunk) => {
648658
stderr = appendIMessageCliStderrTail(stderr, chunk);
649659
});
660+
child.stderr.on("error", (error) => {
661+
if (settled) {
662+
return;
663+
}
664+
fail(
665+
new Error(
666+
`iMessage CLI stderr stream error: ${error instanceof Error ? error.message : String(error)}`,
667+
),
668+
);
669+
});
650670
child.on("error", (error) => {
651671
if (settled) {
652672
clearTimers();

0 commit comments

Comments
 (0)