From 73cdb9f4994c01d8b5e5fdb0c22f272e83fe610a Mon Sep 17 00:00:00 2001 From: Rhys Sullivan Date: Tue, 18 Aug 2026 09:08:39 -0700 Subject: [PATCH] Kill stdio MCP children when a dial is interrupted --- .changeset/stdio-interrupt-child-cleanup.md | 7 ++ packages/plugins/mcp/src/sdk/connection.ts | 7 +- packages/plugins/mcp/src/sdk/discover.ts | 53 +++++---- .../src/sdk/stdio-interrupt-cleanup.test.ts | 112 ++++++++++++++++++ .../src/sdk/stdio-interrupt-test-server.ts | 65 ++++++++++ 5 files changed, 222 insertions(+), 22 deletions(-) create mode 100644 .changeset/stdio-interrupt-child-cleanup.md create mode 100644 packages/plugins/mcp/src/sdk/stdio-interrupt-cleanup.test.ts create mode 100644 packages/plugins/mcp/src/sdk/stdio-interrupt-test-server.ts diff --git a/.changeset/stdio-interrupt-child-cleanup.md b/.changeset/stdio-interrupt-child-cleanup.md new file mode 100644 index 0000000000..687598ade8 --- /dev/null +++ b/.changeset/stdio-interrupt-child-cleanup.md @@ -0,0 +1,7 @@ +--- +"@executor-js/plugin-mcp": patch +--- + +**Interrupted stdio dials no longer strand the spawned child process** + +Cancelling an in-flight health check or tool discovery (a UI refresh aborting the request, or the 15s discovery timeout) abandoned the MCP connect handshake without closing the transport, leaving the spawned stdio child running indefinitely: for `docker run -i --rm` integrations, one stranded container per interrupted dial. The connect handshake now aborts on interruption (the SDK closes the transport, ending stdin and escalating to SIGTERM/SIGKILL), and tool discovery closes the connection even when the interrupt lands between the handshake completing and discovery starting. diff --git a/packages/plugins/mcp/src/sdk/connection.ts b/packages/plugins/mcp/src/sdk/connection.ts index ad9eabbf36..3035716858 100644 --- a/packages/plugins/mcp/src/sdk/connection.ts +++ b/packages/plugins/mcp/src/sdk/connection.ts @@ -257,7 +257,12 @@ const connectClient = (input: { const transportInstance = input.createTransport(); yield* Effect.tryPromise({ - try: () => client.connect(transportInstance), + // Interruption (an HTTP 499 cancelling a health check, the discovery + // timeout) aborts this signal; the SDK then fails the in-flight + // handshake and closes the transport. Without it the abandoned connect + // kept the spawned stdio child alive forever; `docker run -i --rm` + // integrations stranded a container per interrupted dial (#1631). + try: (signal) => client.connect(transportInstance, { signal }), catch: (cause) => connectionFailure(input.transport, `Failed connecting via ${input.transport}`, cause), }).pipe( diff --git a/packages/plugins/mcp/src/sdk/discover.ts b/packages/plugins/mcp/src/sdk/discover.ts index 2734e73f54..5e965b9f44 100644 --- a/packages/plugins/mcp/src/sdk/discover.ts +++ b/packages/plugins/mcp/src/sdk/discover.ts @@ -91,34 +91,45 @@ const listAllTools = ( * forever. On timeout, any connection that DID get established is closed * before the timeout error is raised (`Effect.onExit` still fires for an * interrupted fiber). + * + * Interruption-safe: the connect phase cleans up after itself (the connector + * aborts the handshake and closes the transport, killing any spawned stdio + * child; #1631), and the mask below removes the window between the + * connector succeeding and `onExit` attaching, where an interrupt would + * leak the connection. The connector and listTools stay `restore`d so a 499 + * or the timeout above can still cancel them promptly. */ export const discoverTools = ( connector: McpConnector, timeoutMs: number = Duration.toMillis(DEFAULT_DISCOVER_TIMEOUT), ): Effect.Effect => - Effect.gen(function* () { - // Acquire connection - const connection = yield* connector.pipe( - Effect.mapError((failure) => { - // Preserve the handshake HTTP status (401/403 = auth wall) so the - // liveness health check can classify structurally. - const httpStatus = Predicate.isTagged(failure, "McpConnectionError") - ? failure.httpStatus - : undefined; - return new McpToolDiscoveryError({ - stage: "connect", - message: `Failed connecting to MCP server: ${failure.message}`, - ...(httpStatus !== undefined ? { httpStatus } : {}), - }); - }), - ); + Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + // Acquire connection + const connection = yield* restore( + connector.pipe( + Effect.mapError((failure) => { + // Preserve the handshake HTTP status (401/403 = auth wall) so the + // liveness health check can classify structurally. + const httpStatus = Predicate.isTagged(failure, "McpConnectionError") + ? failure.httpStatus + : undefined; + return new McpToolDiscoveryError({ + stage: "connect", + message: `Failed connecting to MCP server: ${failure.message}`, + ...(httpStatus !== undefined ? { httpStatus } : {}), + }); + }), + ), + ); - const manifest = yield* listAllTools(connection).pipe( - Effect.onExit(() => closeConnection(connection)), - ); + const manifest = yield* restore(listAllTools(connection)).pipe( + Effect.onExit(() => closeConnection(connection)), + ); - return manifest; - }).pipe( + return manifest; + }), + ).pipe( Effect.timeoutOrElse({ duration: Duration.millis(timeoutMs), orElse: () => diff --git a/packages/plugins/mcp/src/sdk/stdio-interrupt-cleanup.test.ts b/packages/plugins/mcp/src/sdk/stdio-interrupt-cleanup.test.ts new file mode 100644 index 0000000000..2031c0caa7 --- /dev/null +++ b/packages/plugins/mcp/src/sdk/stdio-interrupt-cleanup.test.ts @@ -0,0 +1,112 @@ +// Regression coverage for #1631: interrupting a fiber mid-dial (an HTTP 499 +// cancelling a health check on app refresh, or the discovery timeout) must +// tear down the stdio child the transport spawned. Before the fix the +// abandoned `client.connect` promise kept the child alive forever; every +// interrupted health check stranded one `docker run -i --rm` container. +// +// `it.live`: these tests measure real child-process lifetime, so they need +// the wall clock; under the TestClock the fixture's delayed initialize +// reply and the discovery timeout would never fire. + +import { describe, expect, it } from "@effect/vitest"; +import { Duration, Effect, Fiber } from "effect"; +import { existsSync, mkdtempSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { createMcpConnector } from "./connection"; +import { discoverTools } from "./discover"; + +const fixture = fileURLToPath(new URL("./stdio-interrupt-test-server.ts", import.meta.url)); + +const isAlive = (pid: number): boolean => { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: process.kill(pid, 0) reports "process gone" only by throwing ESRCH + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +}; + +const killQuietly = (pid: number): void => { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: kill throws ESRCH when the child already exited, which is the desired state + try { + process.kill(pid, "SIGKILL"); + } catch { + // already gone + } +}; + +const waitUntil = (predicate: () => boolean, timeoutMs: number) => + Effect.gen(function* () { + const deadline = Date.now() + timeoutMs; + while (!predicate()) { + if (Date.now() > deadline) return false; + yield* Effect.sleep(Duration.millis(50)); + } + return true; + }); + +const makeFixture = (mode: "fast" | "slow" | "never") => { + const pidFile = join(mkdtempSync(join(tmpdir(), "mcp-stdio-interrupt-")), "pid"); + const connector = createMcpConnector({ + transport: "stdio", + command: "bun", + args: ["run", fixture, pidFile, mode], + }); + const spawned = waitUntil(() => existsSync(pidFile), 10_000); + const readPid = () => Number(readFileSync(pidFile, "utf8")); + return { connector, spawned, readPid }; +}; + +// The transport's teardown ends stdin first and escalates to SIGTERM only +// after 2s, so a cleaned-up child can legitimately take a moment to exit. +const exitsAfterCleanup = (pid: number) => + Effect.gen(function* () { + const exited = yield* waitUntil(() => !isAlive(pid), 5_000); + killQuietly(pid); + return exited; + }); + +describe("stdio child cleanup on interruption (#1631)", () => { + it.live("uninterrupted discovery closes the child", () => + Effect.gen(function* () { + const { connector, spawned, readPid } = makeFixture("fast"); + const manifest = yield* discoverTools(connector); + expect(manifest.tools).toEqual([]); + expect(yield* spawned).toBe(true); + expect(yield* exitsAfterCleanup(readPid())).toBe(true); + }), + ); + + it.live("interrupting mid-handshake kills the child", () => + Effect.gen(function* () { + const { connector, spawned, readPid } = makeFixture("slow"); + const fiber = yield* discoverTools(connector).pipe(Effect.forkDetach); + + expect(yield* spawned).toBe(true); + const pid = readPid(); + expect(isAlive(pid)).toBe(true); + + // The initialize reply arrives at t+3s, so the handshake is still in + // flight; cancel the fiber the way the HTTP layer does on a 499. + yield* Effect.sleep(Duration.millis(200)); + yield* Fiber.interrupt(fiber); + + expect(yield* exitsAfterCleanup(pid)).toBe(true); + }), + ); + + it.live("the discovery timeout kills the child", () => + Effect.gen(function* () { + const { connector, spawned, readPid } = makeFixture("never"); + const failure = yield* discoverTools(connector, 1_000).pipe(Effect.flip); + expect(failure.message).toContain("timed out"); + + expect(yield* spawned).toBe(true); + expect(yield* exitsAfterCleanup(readPid())).toBe(true); + }), + ); +}); diff --git a/packages/plugins/mcp/src/sdk/stdio-interrupt-test-server.ts b/packages/plugins/mcp/src/sdk/stdio-interrupt-test-server.ts new file mode 100644 index 0000000000..3258ccb19c --- /dev/null +++ b/packages/plugins/mcp/src/sdk/stdio-interrupt-test-server.ts @@ -0,0 +1,65 @@ +// Fixture for stdio-interrupt-cleanup.test.ts. A minimal legacy-handshake MCP +// server that stands in for a `docker run -i --rm` stdio integration: it +// writes its PID to the file named by argv so the test can observe process +// lifetime, and it exits only when stdin closes or it is signalled (the same +// exit contract as the docker CLI). The mode argument controls the initialize +// reply: "fast" answers immediately, "slow" answers after 3s (keeps the +// handshake in flight so the test can interrupt mid-connect), "never" withholds +// it (a wedged server, for the discovery-timeout path). + +import { writeFileSync } from "node:fs"; + +const pidFile = process.argv[2]; +const mode = process.argv[3] ?? "fast"; +if (pidFile === undefined) { + process.stderr.write("usage: stdio-interrupt-test-server.ts [fast|slow|never]\n"); + process.exit(2); +} +writeFileSync(pidFile, String(process.pid)); + +const respond = (message: object): void => { + process.stdout.write(`${JSON.stringify(message)}\n`); +}; + +const handle = (line: string): void => { + if (!line.trim()) return; + let request: { id?: number; method?: string; params?: { protocolVersion?: string } }; + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: standalone non-Effect fixture process; a malformed frame is silently dropped like a real server would + try { + // oxlint-disable-next-line executor/no-json-parse -- boundary: hand-rolled JSON-RPC framing is the fixture's entire purpose (it must control handshake timing below the SDK) + request = JSON.parse(line); + } catch { + return; + } + if (request.method === "initialize") { + const reply = () => + respond({ + jsonrpc: "2.0", + id: request.id, + result: { + protocolVersion: request.params?.protocolVersion, + capabilities: { tools: {} }, + serverInfo: { name: "stdio-interrupt-test-server", version: "0.0.0" }, + }, + }); + if (mode === "slow") setTimeout(reply, 3_000); + else if (mode !== "never") reply(); + } else if (request.method === "tools/list") { + respond({ jsonrpc: "2.0", id: request.id, result: { tools: [] } }); + } else if (request.id !== undefined) { + respond({ jsonrpc: "2.0", id: request.id, result: {} }); + } +}; + +let buffer = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk: string) => { + buffer += chunk; + let newline = buffer.indexOf("\n"); + while (newline >= 0) { + handle(buffer.slice(0, newline)); + buffer = buffer.slice(newline + 1); + newline = buffer.indexOf("\n"); + } +}); +process.stdin.on("end", () => process.exit(0));