112 lines · 4.1 KB · TypeScript
RawDownload
// 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);    }),  );});