From 7ce90d5b17345aafa71466ca32e5f6e734506e94 Mon Sep 17 00:00:00 2001 From: Jack Arturo Date: Wed, 29 Jul 2026 11:06:10 +0200 Subject: [PATCH 1/4] fix: exit orphaned stdio MCP servers on parent death Clients can abandon sessions while a wrapper keeps stdin open, so EOF never arrives and leaked servers thrash swap. Exit on stdin close and add a parent-liveness watchdog (same approach as mcp-automem). --- node_modules | 1 + src/__tests__/lifecycle.test.ts | 34 ++++++++++ src/index.ts | 6 ++ src/lifecycle.ts | 109 ++++++++++++++++++++++++++++++++ 4 files changed, 150 insertions(+) create mode 120000 node_modules create mode 100644 src/__tests__/lifecycle.test.ts create mode 100644 src/lifecycle.ts diff --git a/node_modules b/node_modules new file mode 120000 index 0000000..b6195c8 --- /dev/null +++ b/node_modules @@ -0,0 +1 @@ +/Users/jgarturo/Projects/OpenAI/mcp-servers/mcp-edd/node_modules \ No newline at end of file diff --git a/src/__tests__/lifecycle.test.ts b/src/__tests__/lifecycle.test.ts new file mode 100644 index 0000000..985eb18 --- /dev/null +++ b/src/__tests__/lifecycle.test.ts @@ -0,0 +1,34 @@ +import { + DEFAULT_PARENT_WATCHDOG_MS, + parseWatchdogIntervalMs, + startParentWatchdog, +} from '../lifecycle.js'; + +describe('parseWatchdogIntervalMs', () => { + it('defaults on unset/invalid/non-positive', () => { + expect(parseWatchdogIntervalMs(undefined)).toBe(DEFAULT_PARENT_WATCHDOG_MS); + expect(parseWatchdogIntervalMs('')).toBe(DEFAULT_PARENT_WATCHDOG_MS); + expect(parseWatchdogIntervalMs('nope')).toBe(DEFAULT_PARENT_WATCHDOG_MS); + expect(parseWatchdogIntervalMs('0')).toBe(DEFAULT_PARENT_WATCHDOG_MS); + expect(parseWatchdogIntervalMs('-1')).toBe(DEFAULT_PARENT_WATCHDOG_MS); + }); + + it('honours positive values floored at 100ms', () => { + expect(parseWatchdogIntervalMs('250')).toBe(250); + expect(parseWatchdogIntervalMs('50')).toBe(100); + }); +}); + +describe('startParentWatchdog', () => { + it('fires once when the probe reports parent gone', () => { + jest.useFakeTimers(); + const onDead = jest.fn(); + startParentWatchdog(1, 100, onDead, () => true); + expect(onDead).not.toHaveBeenCalled(); + jest.advanceTimersByTime(100); + expect(onDead).toHaveBeenCalledTimes(1); + jest.advanceTimersByTime(500); + expect(onDead).toHaveBeenCalledTimes(1); + jest.useRealTimers(); + }); +}); diff --git a/src/index.ts b/src/index.ts index 4395067..6165f45 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ import { createRequire } from 'node:module'; import { z } from 'zod'; import { EDDClient, EDDHttpError } from './edd-client.js'; import { loadEnv, validateEnv } from './env.js'; +import { installStdioLifecycle } from './lifecycle.js'; type PackageJson = { version: string }; const require = createRequire(import.meta.url); @@ -647,6 +648,11 @@ server.registerTool( // ============================================================================ async function main() { const transport = new StdioServerTransport(); + installStdioLifecycle({ + transport, + onCloseAssignable: server.server, + envName: 'EDD_PARENT_WATCHDOG_MS', + }); await server.connect(transport); } diff --git a/src/lifecycle.ts b/src/lifecycle.ts new file mode 100644 index 0000000..dfae640 --- /dev/null +++ b/src/lifecycle.ts @@ -0,0 +1,109 @@ +/** + * Parent-liveness watchdog for the stdio MCP server. + * + * When an intermediate wrapper (npx → npm exec → node bin → server) keeps the + * server's stdin write-end open, a dead client never delivers EOF, so the leaf + * sits in the event loop forever and can thrash swap (multi-GB). stdin + * 'end'/'close', transport close, and signals all miss that orphan case. The + * watchdog catches it by noticing the original parent is gone. + * + * Kept side-effect-free on import so unit tests can cover it without spawning + * the full server. + */ + +export type ParentLivenessProbe = (parentPid: number) => boolean; + +/** Poll interval (ms) used when the env override is unset or invalid. */ +export const DEFAULT_PARENT_WATCHDOG_MS = 30_000; + +const MIN_PARENT_WATCHDOG_MS = 100; + +/** + * Parse EDD_PARENT_WATCHDOG_MS into a safe poll interval. + * + * Non-finite / zero / negative → default. No disable value: an unparseable + * knob must never silently turn orphan protection off. Floored at + * MIN_PARENT_WATCHDOG_MS so tiny inputs can't spin the CPU. + */ +export function parseWatchdogIntervalMs(raw: string | undefined): number { + const n = Number(raw); + if (!Number.isFinite(n) || n <= 0) return DEFAULT_PARENT_WATCHDOG_MS; + return Math.max(n, MIN_PARENT_WATCHDOG_MS); +} + +/** + * Default probe: the server has been reparented away from its original parent. + * POSIX only (Windows does not reparent orphans, so this is a no-op there). + */ +export function parentReparented(parentPid: number): boolean { + return process.ppid !== parentPid; +} + +/** + * Poll for the original parent's death and invoke `onDead` exactly once. + * Interval is unref'd so the watchdog alone never keeps the event loop alive. + */ +export function startParentWatchdog( + parentPid: number, + intervalMs: number, + onDead: () => void, + isParentGone: ParentLivenessProbe = parentReparented +): NodeJS.Timeout { + let fired = false; + const timer = setInterval(() => { + if (fired) return; + if (isParentGone(parentPid)) { + fired = true; + onDead(); + } + }, intervalMs); + timer.unref(); + return timer; +} + +/** + * Install stdin/transport/signal/parent-watchdog shutdown hooks. + * Call before `server.connect(transport)`. Captures `process.ppid` immediately. + */ +export function installStdioLifecycle(options: { + transport: { close?: () => unknown }; + onCloseAssignable?: { onclose?: (() => void) | null }; + envName?: string; + onShutdown?: () => void; +}): () => void { + const parentPid = process.ppid; + let shuttingDown = false; + const shutdown = (code = 0) => { + if (shuttingDown) return; + shuttingDown = true; + try { + options.onShutdown?.(); + } catch { + /* best effort */ + } + try { + void options.transport.close?.(); + } catch { + /* best effort */ + } + process.exit(code); + }; + + process.stdin.on('end', () => shutdown(0)); + process.stdin.on('close', () => shutdown(0)); + if (options.onCloseAssignable) { + options.onCloseAssignable.onclose = () => shutdown(0); + } + for (const sig of ['SIGTERM', 'SIGINT', 'SIGHUP'] as const) { + process.on(sig, () => shutdown(0)); + } + + const envName = options.envName ?? 'EDD_PARENT_WATCHDOG_MS'; + startParentWatchdog( + parentPid, + parseWatchdogIntervalMs(process.env[envName]), + () => shutdown(0) + ); + + return shutdown; +} From aba483cc5aee5185ee344431bd2d15073eaf728e Mon Sep 17 00:00:00 2001 From: Jack Arturo Date: Wed, 29 Jul 2026 11:07:02 +0200 Subject: [PATCH 2/4] chore: remove accidental node_modules symlink from PR --- node_modules | 1 - 1 file changed, 1 deletion(-) delete mode 120000 node_modules diff --git a/node_modules b/node_modules deleted file mode 120000 index b6195c8..0000000 --- a/node_modules +++ /dev/null @@ -1 +0,0 @@ -/Users/jgarturo/Projects/OpenAI/mcp-servers/mcp-edd/node_modules \ No newline at end of file From 765e3b5f665afd0c18704411e078abb4418afda5 Mon Sep 17 00:00:00 2001 From: Jack Arturo Date: Wed, 29 Jul 2026 13:35:49 +0200 Subject: [PATCH 3/4] fix: make soft stdin EOF a true no-op MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Do not transport.close() or process.exit on stdin end/close — that trips SDK onclose and breaks one-shot pipe flows. Hard exit only on parent watchdog reparent and SIGTERM/SIGINT/SIGHUP. Capture parentPid before any await; unit tests assert stdin EOF alone does not exit. --- src/index.ts | 3 ++ src/lifecycle.ts | 28 ++++++++++++++----- .../unit}/lifecycle.test.ts | 28 ++++++++++++++++++- 3 files changed, 51 insertions(+), 8 deletions(-) rename {src/__tests__ => tests/unit}/lifecycle.test.ts (63%) diff --git a/src/index.ts b/src/index.ts index 6165f45..b2e7f31 100644 --- a/src/index.ts +++ b/src/index.ts @@ -647,11 +647,14 @@ server.registerTool( // Start Server // ============================================================================ async function main() { + // Capture before any await — process.ppid is dynamic. + const parentPid = process.ppid; const transport = new StdioServerTransport(); installStdioLifecycle({ transport, onCloseAssignable: server.server, envName: 'EDD_PARENT_WATCHDOG_MS', + parentPid, }); await server.connect(transport); } diff --git a/src/lifecycle.ts b/src/lifecycle.ts index dfae640..9125a6a 100644 --- a/src/lifecycle.ts +++ b/src/lifecycle.ts @@ -3,9 +3,14 @@ * * When an intermediate wrapper (npx → npm exec → node bin → server) keeps the * server's stdin write-end open, a dead client never delivers EOF, so the leaf - * sits in the event loop forever and can thrash swap (multi-GB). stdin - * 'end'/'close', transport close, and signals all miss that orphan case. The - * watchdog catches it by noticing the original parent is gone. + * sits in the event loop forever and can thrash swap (multi-GB). Soft stdin + * EOF alone is not enough to catch that orphan case. The watchdog catches it + * by noticing the original parent is gone. + * + * Soft stdin EOF is intentional: do NOT call `transport.close()` and do NOT + * `process.exit` on `end`/`close`. Closing the transport trips the MCP SDK + * `onclose` → hard exit and breaks one-shot `echo … | npm start` flows. + * Hard exit is reserved for parent-watchdog reparent + OS signals. * * Kept side-effect-free on import so unit tests can cover it without spawning * the full server. @@ -63,15 +68,21 @@ export function startParentWatchdog( /** * Install stdin/transport/signal/parent-watchdog shutdown hooks. - * Call before `server.connect(transport)`. Captures `process.ppid` immediately. + * Call before `server.connect(transport)`. + * + * If `main()` awaits anything before this call, pass `parentPid` captured + * synchronously at the top of `main()` — `process.ppid` is dynamic, and a + * late capture after reparent would make the watchdog a no-op (mcp-automem #137). */ export function installStdioLifecycle(options: { transport: { close?: () => unknown }; onCloseAssignable?: { onclose?: (() => void) | null }; envName?: string; onShutdown?: () => void; + /** Prefer a pid captured before any `await` in `main()`. */ + parentPid?: number; }): () => void { - const parentPid = process.ppid; + const parentPid = options.parentPid ?? process.ppid; let shuttingDown = false; const shutdown = (code = 0) => { if (shuttingDown) return; @@ -89,8 +100,11 @@ export function installStdioLifecycle(options: { process.exit(code); }; - process.stdin.on('end', () => shutdown(0)); - process.stdin.on('close', () => shutdown(0)); + // Soft stdin EOF → no-op. Do not transport.close() (SDK onclose → hard exit) + // and do not process.exit. One-shot pipes must drain in-flight handlers; + // orphans that keep stdin open are killed by the parent watchdog / signals. + process.stdin.on('end', () => {}); + process.stdin.on('close', () => {}); if (options.onCloseAssignable) { options.onCloseAssignable.onclose = () => shutdown(0); } diff --git a/src/__tests__/lifecycle.test.ts b/tests/unit/lifecycle.test.ts similarity index 63% rename from src/__tests__/lifecycle.test.ts rename to tests/unit/lifecycle.test.ts index 985eb18..dc82ba3 100644 --- a/src/__tests__/lifecycle.test.ts +++ b/tests/unit/lifecycle.test.ts @@ -1,8 +1,9 @@ import { DEFAULT_PARENT_WATCHDOG_MS, + installStdioLifecycle, parseWatchdogIntervalMs, startParentWatchdog, -} from '../lifecycle.js'; +} from '../../src/lifecycle.js'; describe('parseWatchdogIntervalMs', () => { it('defaults on unset/invalid/non-positive', () => { @@ -32,3 +33,28 @@ describe('startParentWatchdog', () => { jest.useRealTimers(); }); }); + +describe('installStdioLifecycle soft stdin EOF', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('does not close transport or exit on stdin end/close', () => { + const transport = { close: jest.fn() }; + const exitSpy = jest + .spyOn(process, 'exit') + .mockImplementation((() => undefined) as never); + + installStdioLifecycle({ + transport, + parentPid: process.ppid, + envName: 'EDD_PARENT_WATCHDOG_MS', + }); + + process.stdin.emit('end'); + process.stdin.emit('close'); + + expect(transport.close).not.toHaveBeenCalled(); + expect(exitSpy).not.toHaveBeenCalled(); + }); +}); From 9f7a7aa33146453433f5ad4bf9b09af041bf851b Mon Sep 17 00:00:00 2001 From: Jack Arturo Date: Wed, 29 Jul 2026 17:40:24 +0200 Subject: [PATCH 4/4] fix: pin parent pid before sync startup; clarify Windows no-op Capture process.ppid before loadEnv/tool registration. Document that the reparent probe is POSIX-only (matches mcp-automem). --- src/index.ts | 5 +++-- src/lifecycle.ts | 7 ++++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/index.ts b/src/index.ts index b2e7f31..57d4c91 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,9 @@ import { EDDClient, EDDHttpError } from './edd-client.js'; import { loadEnv, validateEnv } from './env.js'; import { installStdioLifecycle } from './lifecycle.js'; +// Pin before sync startup (loadEnv/tool registration) — process.ppid is dynamic. +const parentPid = process.ppid; + type PackageJson = { version: string }; const require = createRequire(import.meta.url); const packageJson = require('../package.json') as PackageJson; @@ -647,8 +650,6 @@ server.registerTool( // Start Server // ============================================================================ async function main() { - // Capture before any await — process.ppid is dynamic. - const parentPid = process.ppid; const transport = new StdioServerTransport(); installStdioLifecycle({ transport, diff --git a/src/lifecycle.ts b/src/lifecycle.ts index 9125a6a..5ba2a3b 100644 --- a/src/lifecycle.ts +++ b/src/lifecycle.ts @@ -38,7 +38,12 @@ export function parseWatchdogIntervalMs(raw: string | undefined): number { /** * Default probe: the server has been reparented away from its original parent. - * POSIX only (Windows does not reparent orphans, so this is a no-op there). + * + * POSIX only: this relies on the kernel reparenting an orphan (to pid 1 or a + * subreaper) when its parent dies. Windows does not reparent orphans, so + * `process.ppid` never changes there and this probe never fires — the watchdog + * is a no-op on win32. Orphan mitigation on Windows would need a different + * mechanism (e.g. a job object or a stdin heartbeat). Matches mcp-automem. */ export function parentReparented(parentPid: number): boolean { return process.ppid !== parentPid;