diff --git a/src/transports/stdio/server.ts b/src/transports/stdio/server.ts index e4bf72d..cc69f46 100644 --- a/src/transports/stdio/server.ts +++ b/src/transports/stdio/server.ts @@ -37,8 +37,23 @@ export class StdioServerTransport implements BaseTransport { async start(): Promise { await this.transport.start(); this.running = true; + + // Exit cleanly when the parent process disconnects (stdin EOF). Without + // this, the underlying SDK readline poll spins on null reads after the + // pipe closes, leaving the server reparented to init at ~99% CPU until + // killed manually. Observed daily on macOS Darwin 25.x when the parent + // (Claude CLI / Claude Desktop) is hard-killed. + process.stdin.on("end", this.handleStdinClose); + process.stdin.on("close", this.handleStdinClose); } + private handleStdinClose = (): void => { + // Stdio transport has no work to do once stdin is gone. Best-effort + // cleanup of the SDK transport, then exit so launchd / parent never sees + // a CPU-spinning zombie. + this.transport.close().finally(() => process.exit(0)); + }; + async send(message: ExtendedJSONRPCMessage): Promise { try { if (hasImageContent(message)) { @@ -73,6 +88,8 @@ export class StdioServerTransport implements BaseTransport { } async close(): Promise { + process.stdin.off("end", this.handleStdinClose); + process.stdin.off("close", this.handleStdinClose); await this.transport.close(); this.running = false; } diff --git a/tests/transports/stdio/server.test.ts b/tests/transports/stdio/server.test.ts new file mode 100644 index 0000000..90b0c94 --- /dev/null +++ b/tests/transports/stdio/server.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, jest, afterEach, beforeEach } from "@jest/globals"; +import { StdioServerTransport } from "../../../src/transports/stdio/server.js"; + +/** + * Regression tests for stdin-EOF handling. + * + * Without the listeners installed in StdioServerTransport.start(), the + * underlying SDK readline poll spins on null reads after the parent process + * disconnects, leaving the MCP server reparented to init at ~99% CPU. + * + * These tests verify: + * 1. process.stdin "close" → process.exit(0) + * 2. process.stdin "end" → process.exit(0) + * 3. close() removes the listeners (no leak / re-instantiation safety) + */ +describe("StdioServerTransport — stdin EOF handling", () => { + let transport: StdioServerTransport | undefined; + let exitSpy: ReturnType; + + beforeEach(() => { + // Mock process.exit so the test runner survives — record the call instead. + exitSpy = jest.spyOn(process, "exit").mockImplementation((() => { + // Intentionally a no-op. Real behavior is verified via the spy assertion. + }) as never); + }); + + afterEach(async () => { + if (transport?.isRunning()) { + await transport.close(); + } + transport = undefined; + exitSpy.mockRestore(); + }); + + it('exits with code 0 when process.stdin emits "close"', async () => { + transport = new StdioServerTransport(); + await transport.start(); + + process.stdin.emit("close"); + // Wait one microtask + macrotask for transport.close().finally chain. + await new Promise((resolve) => setImmediate(resolve)); + + expect(exitSpy).toHaveBeenCalledWith(0); + }); + + it('exits with code 0 when process.stdin emits "end"', async () => { + transport = new StdioServerTransport(); + await transport.start(); + + process.stdin.emit("end"); + await new Promise((resolve) => setImmediate(resolve)); + + expect(exitSpy).toHaveBeenCalledWith(0); + }); + + it("removes stdin listeners on close() so re-instantiation is leak-free", async () => { + const baselineEnd = process.stdin.listenerCount("end"); + const baselineClose = process.stdin.listenerCount("close"); + + transport = new StdioServerTransport(); + await transport.start(); + + expect(process.stdin.listenerCount("end")).toBe(baselineEnd + 1); + expect(process.stdin.listenerCount("close")).toBe(baselineClose + 1); + + await transport.close(); + transport = undefined; + + expect(process.stdin.listenerCount("end")).toBe(baselineEnd); + expect(process.stdin.listenerCount("close")).toBe(baselineClose); + }); +});