Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/stdio-interrupt-child-cleanup.md
Original file line numberDiff line numberDiff line change
@@ -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.
7 changes: 6 additions & 1 deletion packages/plugins/mcp/src/sdk/connection.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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(
Expand Down
53 changes: 32 additions & 21 deletions packages/plugins/mcp/src/sdk/discover.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<McpToolManifest, McpToolDiscoveryError> =>
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: () =>
Expand Down
112 changes: 112 additions & 0 deletions packages/plugins/mcp/src/sdk/stdio-interrupt-cleanup.test.ts
Original file line numberDiff line numberDiff line change
@@ -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);
}),
);
});
65 changes: 65 additions & 0 deletions packages/plugins/mcp/src/sdk/stdio-interrupt-test-server.ts
Original file line numberDiff line numberDiff line change
@@ -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 <pid-file> [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));
Loading