Skip to content
Merged
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
18 changes: 5 additions & 13 deletions src/lifecycle.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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");
Expand All @@ -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);
};
}
23 changes: 18 additions & 5 deletions tests/lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number | null>((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<number | null>((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 () => {
Expand Down
Loading