Skip to content

Commit 156a1cd

Browse files
mksgluclaude
andcommitted
fix(lifecycle): remove stdin listeners causing spurious MCP -32000 errors (#236)
Cherry-pick from main (bfbf654). Original PR #255 by contributor, superseding #237 by @ponythewhite. Removes process.stdin.resume() and end/close/error listeners from lifecycle guard. These conflicted with StdioServerTransport causing false-positive parent-death detection and -32000 errors. ppid polling + OS signals remain as orphan detection mechanisms. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d89a34f commit 156a1cd

2 files changed

Lines changed: 23 additions & 18 deletions

File tree

src/lifecycle.ts

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,19 @@
11
/**
22
* lifecycle — Process lifecycle guard for MCP server.
33
*
4-
* Detects parent process death, stdin close, and OS signals to prevent
4+
* Detects parent process death (ppid polling) and OS signals to prevent
55
* orphaned MCP server processes consuming 100% CPU (issue #103).
66
*
7+
* Stdin close is NOT used as a shutdown signal — the MCP stdio transport
8+
* owns stdin and transient pipe events cause spurious -32000 errors (#236).
9+
*
710
* Cross-platform: macOS, Linux, Windows.
811
*/
912

1013
export interface LifecycleGuardOptions {
1114
/** Interval in ms to check parent liveness. Default: 30_000 */
1215
checkIntervalMs?: number;
13-
/** Called when parent death or stdin close is detected. */
16+
/** Called when parent death or OS signal is detected. */
1417
onShutdown: () => void;
1518
/** Injectable parent-alive check (for testing). Default: ppid-based check. */
1619
isParentAlive?: () => boolean;
@@ -54,14 +57,6 @@ export function startLifecycleGuard(opts: LifecycleGuardOptions): () => void {
5457
}, interval);
5558
timer.unref();
5659

57-
// P0: Stdin close — parent pipe broken
58-
// Must resume stdin to receive close/end events (Node starts paused)
59-
const onStdinClose = () => shutdown();
60-
process.stdin.resume();
61-
process.stdin.on("end", onStdinClose);
62-
process.stdin.on("close", onStdinClose);
63-
process.stdin.on("error", onStdinClose);
64-
6560
// P0: OS signals — terminal close, kill, ctrl+c
6661
const signals: NodeJS.Signals[] = ["SIGTERM", "SIGINT"];
6762
if (process.platform !== "win32") signals.push("SIGHUP");
@@ -70,9 +65,6 @@ export function startLifecycleGuard(opts: LifecycleGuardOptions): () => void {
7065
return () => {
7166
stopped = true;
7267
clearInterval(timer);
73-
process.stdin.removeListener("end", onStdinClose);
74-
process.stdin.removeListener("close", onStdinClose);
75-
process.stdin.removeListener("error", onStdinClose);
7668
for (const sig of signals) process.removeListener(sig, shutdown);
7769
};
7870
}

tests/lifecycle.test.ts

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -128,18 +128,31 @@ describe("Lifecycle Guard", () => {
128128
const isWindows = process.platform === "win32";
129129

130130
describe.skipIf(isWindows)("Lifecycle Guard — Integration (real process)", () => {
131-
test("child exits when stdin is closed", async () => {
131+
test("child does NOT exit when stdin is closed (#236)", async () => {
132132
const { child, ready } = spawnGuardChild(42);
133133

134134
await ready;
135135
child.stdin!.end();
136136

137-
const code = await new Promise<number | null>((resolve) => {
137+
let exited = false;
138+
let exitCode: number | null = null;
139+
child.on("close", (code) => { exited = true; exitCode = code; });
140+
141+
// Give the guard 500ms — if stdin-close still triggered shutdown, it
142+
// would have fired by now (previous implementation exited within ~1ms).
143+
await new Promise((r) => setTimeout(r, 500));
144+
145+
assert.equal(exited, false, `Child must stay alive after stdin.end(); exited with code ${exitCode}`);
146+
assert.equal(child.killed, false, "Child.killed should still be false");
147+
148+
// Clean up: SIGTERM the still-alive child so the test runner doesn't leak.
149+
const closed = new Promise<number | null>((resolve) => {
150+
if (exited) return resolve(exitCode);
138151
child.on("close", resolve);
139-
setTimeout(() => { child.kill("SIGKILL"); resolve(null); }, 5000);
152+
setTimeout(() => { child.kill("SIGKILL"); resolve(null); }, 3000);
140153
});
141-
142-
assert.equal(code, 42, "Child should exit with code 42 when stdin closes");
154+
child.kill("SIGTERM");
155+
await closed;
143156
}, 10_000);
144157

145158
test("child exits on SIGTERM", async () => {

0 commit comments

Comments
 (0)