Skip to content

Commit f45586b

Browse files
committed
#547 kill the child process if it's not properly terminated by stdio.close()
1 parent 0c4b308 commit f45586b

File tree

2 files changed

+114
-15
lines changed

2 files changed

+114
-15
lines changed

src/client/stdio.test.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { JSONRPCMessage } from "../types.js";
22
import { StdioClientTransport, StdioServerParameters } from "./stdio.js";
3+
import { ChildProcess } from "node:child_process";
34

45
const serverParameters: StdioServerParameters = {
56
command: "/usr/bin/tee",
@@ -22,6 +23,77 @@ test("should start then close cleanly", async () => {
2223
expect(didClose).toBeTruthy();
2324
});
2425

26+
test("should gracefully terminate the process", async () => {
27+
const killSpy = jest.spyOn(ChildProcess.prototype, "kill");
28+
29+
jest.spyOn(global, "setTimeout").mockImplementationOnce((callback) => {
30+
if (typeof callback === "function") {
31+
callback();
32+
}
33+
return 1 as unknown as NodeJS.Timeout;
34+
});
35+
36+
const client = new StdioClientTransport(serverParameters);
37+
38+
const mockProcess = {
39+
kill: jest.fn(),
40+
exitCode: null,
41+
once: jest.fn().mockImplementation((event, handler) => {
42+
if (
43+
mockProcess.kill.mock.calls.length === 2 &&
44+
(event === "exit" || event === "close")
45+
) {
46+
setTimeout(() => handler(), 0);
47+
}
48+
return mockProcess;
49+
}),
50+
};
51+
52+
// @ts-expect-error accessing private property for testing
53+
client._process = mockProcess;
54+
55+
await client.close();
56+
57+
expect(mockProcess.kill).toHaveBeenNthCalledWith(1, "SIGTERM");
58+
expect(mockProcess.kill).toHaveBeenNthCalledWith(2, "SIGKILL");
59+
expect(mockProcess.kill).toHaveBeenCalledTimes(2);
60+
61+
killSpy.mockRestore();
62+
});
63+
64+
test("should exit cleanly if SIGTERM works", async () => {
65+
const client = new StdioClientTransport(serverParameters);
66+
67+
const callbacks: Record<string, Function> = {};
68+
69+
const mockProcess = {
70+
kill: jest.fn(),
71+
exitCode: null,
72+
once: jest.fn((event, callback) => {
73+
callbacks[event] = callback;
74+
return mockProcess;
75+
}),
76+
} as unknown as ChildProcess;
77+
78+
// @ts-expect-error accessing private property for testing
79+
client._process = mockProcess;
80+
81+
// @ts-expect-error accessing private property for testing
82+
client._abortController = { abort: jest.fn() };
83+
84+
const closePromise = client.close();
85+
86+
expect(mockProcess.kill).toHaveBeenCalledWith("SIGTERM");
87+
expect(mockProcess.once).toHaveBeenCalledWith("exit", expect.any(Function));
88+
89+
callbacks.exit && callbacks.exit();
90+
91+
await closePromise;
92+
93+
expect(mockProcess.kill).toHaveBeenCalledWith("SIGTERM");
94+
expect(mockProcess.kill).toHaveBeenCalledTimes(1);
95+
});
96+
2597
test("should read messages", async () => {
2698
const client = new StdioClientTransport(serverParameters);
2799
client.onerror = (error) => {

src/client/stdio.ts

Lines changed: 42 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -45,20 +45,20 @@ export type StdioServerParameters = {
4545
export const DEFAULT_INHERITED_ENV_VARS =
4646
process.platform === "win32"
4747
? [
48-
"APPDATA",
49-
"HOMEDRIVE",
50-
"HOMEPATH",
51-
"LOCALAPPDATA",
52-
"PATH",
53-
"PROCESSOR_ARCHITECTURE",
54-
"SYSTEMDRIVE",
55-
"SYSTEMROOT",
56-
"TEMP",
57-
"USERNAME",
58-
"USERPROFILE",
59-
]
48+
"APPDATA",
49+
"HOMEDRIVE",
50+
"HOMEPATH",
51+
"LOCALAPPDATA",
52+
"PATH",
53+
"PROCESSOR_ARCHITECTURE",
54+
"SYSTEMDRIVE",
55+
"SYSTEMROOT",
56+
"TEMP",
57+
"USERNAME",
58+
"USERPROFILE",
59+
]
6060
: /* list inspired by the default env inheritance of sudo */
61-
["HOME", "LOGNAME", "PATH", "SHELL", "TERM", "USER"];
61+
["HOME", "LOGNAME", "PATH", "SHELL", "TERM", "USER"];
6262

6363
/**
6464
* Returns a default environment object including only environment variables deemed safe to inherit.
@@ -112,7 +112,7 @@ export class StdioClientTransport implements Transport {
112112
async start(): Promise<void> {
113113
if (this._process) {
114114
throw new Error(
115-
"StdioClientTransport already started! If using Client class, note that connect() calls start() automatically."
115+
"StdioClientTransport already started! If using Client class, note that connect() calls start() automatically.",
116116
);
117117
}
118118

@@ -127,7 +127,7 @@ export class StdioClientTransport implements Transport {
127127
signal: this._abortController.signal,
128128
windowsHide: process.platform === "win32" && isElectron(),
129129
cwd: this._serverParams.cwd,
130-
}
130+
},
131131
);
132132

133133
this._process.on("error", (error) => {
@@ -201,8 +201,35 @@ export class StdioClientTransport implements Transport {
201201

202202
async close(): Promise<void> {
203203
this._abortController.abort();
204+
205+
const taskProcess = this._process;
204206
this._process = undefined;
205207
this._readBuffer.clear();
208+
209+
if (!taskProcess || taskProcess.exitCode !== null) {
210+
return;
211+
}
212+
213+
taskProcess.kill("SIGTERM");
214+
215+
const exited = await Promise.race([
216+
new Promise<boolean>((resolve) => {
217+
const onExit = () => resolve(true);
218+
taskProcess.once("exit", onExit);
219+
taskProcess.once("close", onExit);
220+
}),
221+
new Promise<boolean>((resolve) => {
222+
setTimeout(() => resolve(false), 1000);
223+
}),
224+
]);
225+
226+
if (!exited) {
227+
taskProcess.kill("SIGKILL");
228+
await new Promise<void>((resolve) => {
229+
taskProcess.once("exit", resolve);
230+
taskProcess.once("close", resolve);
231+
});
232+
}
206233
}
207234

208235
send(message: JSONRPCMessage): Promise<void> {

0 commit comments

Comments
 (0)