Skip to content

Commit 35d5ea0

Browse files
authored
fix(matrix): handle stdout/stderr stream errors in dependency commands (#101597)
* fix(matrix): handle stdout/stderr stream errors in dependency commands * fix(matrix): type stream-error test process kill * fix(matrix): harden dependency stream errors * test(matrix): fix dependency stream test typings
1 parent 039f8fb commit 35d5ea0

2 files changed

Lines changed: 178 additions & 3 deletions

File tree

extensions/matrix/src/matrix/deps.test.ts

Lines changed: 142 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
// Matrix tests cover deps plugin behavior.
2+
import type { ChildProcessWithoutNullStreams, SpawnOptions } from "node:child_process";
23
import fs from "node:fs";
34
import os from "node:os";
45
import path from "node:path";
5-
import { describe, expect, it, vi } from "vitest";
6+
import { afterEach, describe, expect, it, type MockInstance, vi } from "vitest";
67
import {
78
ensureMatrixCryptoRuntime,
89
ensureMatrixSdkInstalled,
@@ -12,6 +13,72 @@ import {
1213

1314
const logStub = vi.fn();
1415

16+
type ChildKill = (signal?: NodeJS.Signals | number) => boolean;
17+
18+
async function importDepsWithSpawnMock(
19+
spawnMock: ReturnType<typeof vi.fn>,
20+
): Promise<typeof import("./deps.js")> {
21+
vi.resetModules();
22+
vi.doMock("node:child_process", async () => {
23+
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
24+
return {
25+
...actual,
26+
spawn: spawnMock,
27+
};
28+
});
29+
return await import("./deps.js");
30+
}
31+
32+
function waitForChildClose(proc: ChildProcessWithoutNullStreams) {
33+
return new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => {
34+
const timer = setTimeout(() => {
35+
reject(new Error("timed out waiting for matrix command child close"));
36+
}, 5_000);
37+
timer.unref?.();
38+
proc.once("close", (code, signal) => {
39+
clearTimeout(timer);
40+
resolve({ code, signal });
41+
});
42+
});
43+
}
44+
45+
function waitForReadableData(stream: NodeJS.ReadableStream) {
46+
return new Promise<void>((resolve, reject) => {
47+
let cleanup = () => {};
48+
const timer = setTimeout(() => {
49+
cleanup();
50+
reject(new Error("timed out waiting for matrix command child output"));
51+
}, 5_000);
52+
timer.unref?.();
53+
cleanup = () => {
54+
clearTimeout(timer);
55+
stream.off("data", onData);
56+
stream.off("error", onError);
57+
};
58+
const onData = () => {
59+
cleanup();
60+
resolve();
61+
};
62+
const onError = (error: Error) => {
63+
cleanup();
64+
reject(error);
65+
};
66+
stream.once("data", onData);
67+
stream.once("error", onError);
68+
});
69+
}
70+
71+
function waitForReadableErrorDispatch() {
72+
return new Promise<void>((resolve) => {
73+
setImmediate(resolve);
74+
});
75+
}
76+
77+
afterEach(() => {
78+
vi.useRealTimers();
79+
vi.doUnmock("node:child_process");
80+
});
81+
1582
function resolveTestNativeBindingFilename(): string | null {
1683
switch (process.platform) {
1784
case "darwin":
@@ -185,6 +252,80 @@ describe("runFixedCommandWithTimeout", () => {
185252
expect(result.stdout).toBe("a".repeat(MATRIX_COMMAND_OUTPUT_TAIL_BYTES));
186253
expect(result.stderr).toBe("b".repeat(MATRIX_COMMAND_OUTPUT_TAIL_BYTES));
187254
});
255+
256+
it("settles real child stream errors after child close and terminates once", async () => {
257+
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
258+
259+
for (const streamName of ["stdout", "stderr"] as const) {
260+
let proc: ChildProcessWithoutNullStreams | undefined;
261+
let killSpy: MockInstance<ChildKill> | undefined;
262+
try {
263+
const spawnMock = vi.fn(
264+
(command: string, args: string[] | undefined, options: SpawnOptions) => {
265+
proc = actual.spawn(command, args ?? [], options) as ChildProcessWithoutNullStreams;
266+
return proc;
267+
},
268+
);
269+
const { runFixedCommandWithTimeout: runWithMockedSpawn } =
270+
await importDepsWithSpawnMock(spawnMock);
271+
const exitListenersBefore = process.listenerCount("exit");
272+
273+
const resultPromise = runWithMockedSpawn({
274+
argv: [
275+
process.execPath,
276+
"-e",
277+
[
278+
"process.stdin.resume();",
279+
'process.on("SIGTERM", () => {});',
280+
'process.stdout.write("stdout ready\\n");',
281+
'process.stderr.write("stderr ready\\n");',
282+
"setInterval(() => {}, 1000);",
283+
].join(""),
284+
],
285+
cwd: process.cwd(),
286+
timeoutMs: 10_000,
287+
});
288+
if (!proc) {
289+
throw new Error("expected matrix command helper to spawn a child process");
290+
}
291+
killSpy = vi.spyOn(proc, "kill");
292+
const closePromise = waitForChildClose(proc);
293+
await Promise.all([waitForReadableData(proc.stdout), waitForReadableData(proc.stderr)]);
294+
let settled = false;
295+
void resultPromise.then(() => {
296+
settled = true;
297+
});
298+
const message = `synthetic parent ${streamName} read failure`;
299+
300+
proc[streamName].destroy(new Error(message));
301+
await waitForReadableErrorDispatch();
302+
expect(settled).toBe(false);
303+
expect(process.listenerCount("exit")).toBe(exitListenersBefore + 1);
304+
expect(killSpy).toHaveBeenCalledTimes(1);
305+
expect(killSpy).toHaveBeenCalledWith("SIGTERM");
306+
307+
const duplicateStreamName = streamName === "stdout" ? "stderr" : "stdout";
308+
proc[duplicateStreamName].destroy(new Error("duplicate parent readable failure"));
309+
await waitForReadableErrorDispatch();
310+
expect(killSpy).toHaveBeenCalledTimes(1);
311+
312+
const result = await resultPromise;
313+
const close = await closePromise;
314+
315+
expect(result.code).toBe(1);
316+
expect(result.stderr).toContain(`${streamName} stream failed: ${message}`);
317+
expect(result.stderr).not.toContain("duplicate parent readable failure");
318+
expect(close).toStrictEqual({ code: null, signal: "SIGKILL" });
319+
expect(killSpy).toHaveBeenLastCalledWith("SIGKILL");
320+
expect(process.listenerCount("exit")).toBe(exitListenersBefore);
321+
} finally {
322+
killSpy?.mockRestore();
323+
if (proc && proc.exitCode === null && !proc.killed) {
324+
proc.kill("SIGKILL");
325+
}
326+
}
327+
}
328+
});
188329
});
189330

190331
describe("ensureMatrixSdkInstalled", () => {

extensions/matrix/src/matrix/deps.ts

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const REQUIRED_MATRIX_PACKAGES = [
1313
];
1414
const MIN_MATRIX_CRYPTO_NATIVE_BINDING_BYTES = 1_000_000;
1515
export const MATRIX_COMMAND_OUTPUT_TAIL_BYTES = 64 * 1024;
16+
const MATRIX_STREAM_ERROR_KILL_GRACE_MS = 1_000;
1617

1718
type MatrixCryptoRuntimeDeps = {
1819
requireFn?: (id: string) => unknown;
@@ -99,6 +100,8 @@ export async function runFixedCommandWithTimeout(params: {
99100
let stderr = "";
100101
let settled = false;
101102
let timer: NodeJS.Timeout | null = null;
103+
let streamKillTimer: NodeJS.Timeout | null = null;
104+
let streamErrorMessage: string | null = null;
102105
const killChildOnExit = () => {
103106
if (!settled && proc.exitCode === null) {
104107
proc.kill("SIGTERM");
@@ -113,6 +116,9 @@ export async function runFixedCommandWithTimeout(params: {
113116
if (timer) {
114117
clearTimeout(timer);
115118
}
119+
if (streamKillTimer) {
120+
clearTimeout(streamKillTimer);
121+
}
116122
process.off("exit", killChildOnExit);
117123
resolve(result);
118124
};
@@ -124,9 +130,29 @@ export async function runFixedCommandWithTimeout(params: {
124130
proc.stderr?.on("data", (chunk: Buffer | string) => {
125131
stderr = appendBoundedOutputTail(stderr, chunk);
126132
});
133+
const failReadableStream = (streamName: "stdout" | "stderr") => (error: Error) => {
134+
if (settled || streamErrorMessage) {
135+
return;
136+
}
137+
streamErrorMessage = `${streamName} stream failed: ${formatErrorMessage(error)}`;
138+
if (proc.exitCode === null) {
139+
proc.kill("SIGTERM");
140+
}
141+
streamKillTimer = setTimeout(() => {
142+
if (!settled && proc.exitCode === null) {
143+
proc.kill("SIGKILL");
144+
}
145+
}, MATRIX_STREAM_ERROR_KILL_GRACE_MS);
146+
streamKillTimer.unref?.();
147+
};
148+
proc.stdout?.on("error", failReadableStream("stdout"));
149+
proc.stderr?.on("error", failReadableStream("stderr"));
127150

128151
timer = setTimeout(() => {
129152
proc.kill("SIGKILL");
153+
if (streamErrorMessage) {
154+
return;
155+
}
130156
finalize({
131157
code: 124,
132158
stdout,
@@ -135,6 +161,9 @@ export async function runFixedCommandWithTimeout(params: {
135161
}, params.timeoutMs);
136162

137163
proc.on("error", (err) => {
164+
if (streamErrorMessage) {
165+
return;
166+
}
138167
finalize({
139168
code: 1,
140169
stdout,
@@ -143,10 +172,15 @@ export async function runFixedCommandWithTimeout(params: {
143172
});
144173

145174
proc.on("close", (code) => {
175+
const streamErrorStderr = streamErrorMessage
176+
? stderr
177+
? appendBoundedOutputTail(stderr, `\n${streamErrorMessage}`)
178+
: streamErrorMessage
179+
: stderr;
146180
finalize({
147-
code: code ?? 1,
181+
code: streamErrorMessage ? 1 : (code ?? 1),
148182
stdout,
149-
stderr,
183+
stderr: streamErrorStderr,
150184
});
151185
});
152186
});

0 commit comments

Comments
 (0)