Skip to content
Open
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
17 changes: 17 additions & 0 deletions src/transports/stdio/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,23 @@ export class StdioServerTransport implements BaseTransport {
async start(): Promise<void> {
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<void> {
try {
if (hasImageContent(message)) {
Expand Down Expand Up @@ -73,6 +88,8 @@ export class StdioServerTransport implements BaseTransport {
}

async close(): Promise<void> {
process.stdin.off("end", this.handleStdinClose);
process.stdin.off("close", this.handleStdinClose);
await this.transport.close();
this.running = false;
}
Expand Down
72 changes: 72 additions & 0 deletions tests/transports/stdio/server.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof jest.spyOn>;

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);
});
});