diff --git a/src/client/stdio.ts b/src/client/stdio.ts index 62292ce10..c37720eb4 100644 --- a/src/client/stdio.ts +++ b/src/client/stdio.ts @@ -138,7 +138,6 @@ export class StdioClientTransport implements Transport { this._process.on("error", (error) => { if (error.name === "AbortError") { // Expected when close() is called. - this.onclose?.(); return; } @@ -214,8 +213,33 @@ export class StdioClientTransport implements Transport { } async close(): Promise { - this._abortController.abort(); - this._process = undefined; + if (this._process) { + const processToClose = this._process; + this._process = undefined; + + const closePromise = new Promise((resolve) => { + processToClose.once("close", () => { + resolve(); + }); + }); + + this._abortController.abort(); + + // waits the underlying process to exit cleanly otherwise after 1s kills it + await Promise.race([ + closePromise, + new Promise((resolve) => setTimeout(resolve, 1_000).unref()), + ]); + + if (processToClose.exitCode === null) { + try { + processToClose.kill("SIGKILL"); + } catch { + // we did our best + } + } + } + this._readBuffer.clear(); } diff --git a/src/integration-tests/process-cleanup.test.ts b/src/integration-tests/process-cleanup.test.ts index 0dd7861a4..62d1c12b7 100644 --- a/src/integration-tests/process-cleanup.test.ts +++ b/src/integration-tests/process-cleanup.test.ts @@ -1,10 +1,13 @@ +import { execSync } from "node:child_process"; +import { Client } from "../client/index.js"; +import { StdioClientTransport } from "../client/stdio.js"; import { Server } from "../server/index.js"; import { StdioServerTransport } from "../server/stdio.js"; describe("Process cleanup", () => { jest.setTimeout(5000); // 5 second timeout - it("should exit cleanly after closing transport", async () => { + it("server should exit cleanly after closing transport", async () => { const server = new Server( { name: "test-server", @@ -25,4 +28,62 @@ describe("Process cleanup", () => { // The test runner will fail if the process hangs expect(true).toBe(true); }); -}); \ No newline at end of file + + it("client should exit cleanly after closing transport", async () => { + const isProcessRunning = (pid: number) => { + try { + execSync(`ps -p ${pid}`, { stdio: "ignore", windowsHide: true }); + return true; + } catch { + return false; + } + }; + + const client = new Client({ + name: "test-client", + version: "1.0.0", + }); + + const transport = new StdioClientTransport({ + command: process.argv0, + args: ["server-that-hangs.js"], + cwd: __dirname, + }); + + await client.connect(transport); + const pid = transport.pid; + + await client.close(); + + // delay so OS can cleanup the process + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(isProcessRunning(pid!)).toBe(false); + }); + + it("onclose should be called exactly once", async () => { + const client = new Client({ + name: "test-client", + version: "1.0.0", + }); + + const transport = new StdioClientTransport({ + command: process.argv0, + args: ["test-server.js"], + cwd: __dirname, + }); + + let onCloseWasCalled = 0; + client.onclose = () => { + onCloseWasCalled++; + }; + + await client.connect(transport); + await client.close(); + + // A short delay to allow the close event to propagate + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(onCloseWasCalled).toBe(1); + }); +}); diff --git a/src/integration-tests/server-that-hangs.js b/src/integration-tests/server-that-hangs.js new file mode 100644 index 000000000..72ef77bfa --- /dev/null +++ b/src/integration-tests/server-that-hangs.js @@ -0,0 +1,21 @@ +import { setTimeout } from 'node:timers' +import process from 'node:process' +import { McpServer } from "../../dist/esm/server/mcp.js"; +import { StdioServerTransport } from "../../dist/esm/server/stdio.js"; + +const transport = new StdioServerTransport(); + +const server = new McpServer({ + name: "server-that-hangs", + version: "1.0.0" +}); + +await server.connect(transport); + +const doNotExitImmediately = async () => { + setTimeout(() => process.exit(0), 30 * 1000); +}; + +process.stdin.on('close', doNotExitImmediately); +process.on('SIGINT', doNotExitImmediately); +process.on('SIGTERM', doNotExitImmediately); diff --git a/src/integration-tests/test-server.js b/src/integration-tests/test-server.js new file mode 100644 index 000000000..3f3f2243c --- /dev/null +++ b/src/integration-tests/test-server.js @@ -0,0 +1,11 @@ +import { McpServer } from "../../dist/esm/server/mcp.js"; +import { StdioServerTransport } from "../../dist/esm/server/stdio.js"; + +const transport = new StdioServerTransport(); + +const server = new McpServer({ + name: "test-server", + version: "1.0.0", +}); + +await server.connect(transport);