diff --git a/src/lifecycle.ts b/src/lifecycle.ts index 2fcd25b..dc8a721 100644 --- a/src/lifecycle.ts +++ b/src/lifecycle.ts @@ -1,16 +1,19 @@ /** * lifecycle — Process lifecycle guard for MCP server. * - * Detects parent process death, stdin close, and OS signals to prevent + * Detects parent process death (ppid polling) and OS signals to prevent * orphaned MCP server processes consuming 100% CPU (issue #103). * + * Stdin close is NOT used as a shutdown signal — the MCP stdio transport + * owns stdin and transient pipe events cause spurious -32000 errors (#236). + * * Cross-platform: macOS, Linux, Windows. */ export interface LifecycleGuardOptions { /** Interval in ms to check parent liveness. Default: 30_000 */ checkIntervalMs?: number; - /** Called when parent death or stdin close is detected. */ + /** Called when parent death or OS signal is detected. */ onShutdown: () => void; /** Injectable parent-alive check (for testing). Default: ppid-based check. */ isParentAlive?: () => boolean; @@ -54,14 +57,6 @@ export function startLifecycleGuard(opts: LifecycleGuardOptions): () => void { }, interval); timer.unref(); - // P0: Stdin close — parent pipe broken - // Must resume stdin to receive close/end events (Node starts paused) - const onStdinClose = () => shutdown(); - process.stdin.resume(); - process.stdin.on("end", onStdinClose); - process.stdin.on("close", onStdinClose); - process.stdin.on("error", onStdinClose); - // P0: OS signals — terminal close, kill, ctrl+c const signals: NodeJS.Signals[] = ["SIGTERM", "SIGINT"]; if (process.platform !== "win32") signals.push("SIGHUP"); @@ -70,9 +65,6 @@ export function startLifecycleGuard(opts: LifecycleGuardOptions): () => void { return () => { stopped = true; clearInterval(timer); - process.stdin.removeListener("end", onStdinClose); - process.stdin.removeListener("close", onStdinClose); - process.stdin.removeListener("error", onStdinClose); for (const sig of signals) process.removeListener(sig, shutdown); }; } diff --git a/tests/lifecycle.test.ts b/tests/lifecycle.test.ts index 2756163..2ab7bf3 100644 --- a/tests/lifecycle.test.ts +++ b/tests/lifecycle.test.ts @@ -128,18 +128,31 @@ describe("Lifecycle Guard", () => { const isWindows = process.platform === "win32"; describe.skipIf(isWindows)("Lifecycle Guard — Integration (real process)", () => { - test("child exits when stdin is closed", async () => { + test("child does NOT exit when stdin is closed (#236)", async () => { const { child, ready } = spawnGuardChild(42); await ready; child.stdin!.end(); - const code = await new Promise((resolve) => { + let exited = false; + let exitCode: number | null = null; + child.on("close", (code) => { exited = true; exitCode = code; }); + + // Give the guard 500ms — if stdin-close still triggered shutdown, it + // would have fired by now (previous implementation exited within ~1ms). + await new Promise((r) => setTimeout(r, 500)); + + assert.equal(exited, false, `Child must stay alive after stdin.end(); exited with code ${exitCode}`); + assert.equal(child.killed, false, "Child.killed should still be false"); + + // Clean up: SIGTERM the still-alive child so the test runner doesn't leak. + const closed = new Promise((resolve) => { + if (exited) return resolve(exitCode); child.on("close", resolve); - setTimeout(() => { child.kill("SIGKILL"); resolve(null); }, 5000); + setTimeout(() => { child.kill("SIGKILL"); resolve(null); }, 3000); }); - - assert.equal(code, 42, "Child should exit with code 42 when stdin closes"); + child.kill("SIGTERM"); + await closed; }, 10_000); test("child exits on SIGTERM", async () => {