diff --git a/package.json b/package.json index 6dadc36..f2155e9 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "postbuild": "chmod +x dist/index.js", "dev": "tsx watch src/index.ts", "start": "node dist/index.js", - "test": "node -e \"console.log('mcp-local-wp build ok')\"", + "test": "tsx --test tests/lifecycle.test.ts", "prepublishOnly": "npm run build && npm run postbuild", "lint": "eslint \"src/**/*.{ts,js}\"", "lint:fix": "eslint \"src/**/*.{ts,js}\" --fix", diff --git a/src/index.ts b/src/index.ts index aedabc3..c48a61e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ #!/usr/bin/env node import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { installStdioLifecycle } from './lifecycle.js'; import { CallToolRequestSchema, ListToolsRequestSchema, @@ -11,6 +12,9 @@ import { MySQLClient } from './mysql-client.js'; import { getLocalMySQLConfig, listAvailableSites } from './local-detector.js'; import type { SiteSelectionResult } from './types.js'; +// Pin before sync startup (dotenv + Local detection) — process.ppid is dynamic. +const parentPid = process.ppid; + // Load environment variables config(); @@ -314,21 +318,19 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { // Start the server async function main() { const transport = new StdioServerTransport(); + installStdioLifecycle({ + transport, + onCloseAssignable: server, + envName: 'LOCALWP_PARENT_WATCHDOG_MS', + parentPid, + onShutdown: () => { + void mysql.disconnect(); + }, + }); await server.connect(transport); console.error('WordPress Local MCP Server running...'); } -// Cleanup on exit -process.on('SIGINT', async () => { - await mysql.disconnect(); - process.exit(0); -}); - -process.on('SIGTERM', async () => { - await mysql.disconnect(); - process.exit(0); -}); - main().catch((error) => { console.error('Server error:', error); process.exit(1); diff --git a/src/lifecycle.ts b/src/lifecycle.ts new file mode 100644 index 0000000..316b07a --- /dev/null +++ b/src/lifecycle.ts @@ -0,0 +1,128 @@ +/** + * 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). 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. + */ + +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 LOCALWP_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: 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; +} + +/** + * 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)`. + * + * 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 = options.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); + }; + + // 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); + } + for (const sig of ['SIGTERM', 'SIGINT', 'SIGHUP'] as const) { + process.on(sig, () => shutdown(0)); + } + + const envName = options.envName ?? 'LOCALWP_PARENT_WATCHDOG_MS'; + startParentWatchdog( + parentPid, + parseWatchdogIntervalMs(process.env[envName]), + () => shutdown(0) + ); + + return shutdown; +} diff --git a/tests/lifecycle.test.ts b/tests/lifecycle.test.ts new file mode 100644 index 0000000..35e2191 --- /dev/null +++ b/tests/lifecycle.test.ts @@ -0,0 +1,61 @@ +import assert from 'node:assert/strict'; +import { afterEach, describe, it, mock } from 'node:test'; +import { + DEFAULT_PARENT_WATCHDOG_MS, + installStdioLifecycle, + parseWatchdogIntervalMs, + startParentWatchdog, +} from '../src/lifecycle.ts'; + +describe('parseWatchdogIntervalMs', () => { + it('defaults on unset/invalid/non-positive', () => { + assert.equal(parseWatchdogIntervalMs(undefined), DEFAULT_PARENT_WATCHDOG_MS); + assert.equal(parseWatchdogIntervalMs(''), DEFAULT_PARENT_WATCHDOG_MS); + assert.equal(parseWatchdogIntervalMs('nope'), DEFAULT_PARENT_WATCHDOG_MS); + assert.equal(parseWatchdogIntervalMs('0'), DEFAULT_PARENT_WATCHDOG_MS); + assert.equal(parseWatchdogIntervalMs('-1'), DEFAULT_PARENT_WATCHDOG_MS); + }); + + it('honours positive values floored at 100ms', () => { + assert.equal(parseWatchdogIntervalMs('250'), 250); + assert.equal(parseWatchdogIntervalMs('50'), 100); + }); +}); + +describe('startParentWatchdog', () => { + it('fires once when the probe reports parent gone', async () => { + let calls = 0; + const timer = startParentWatchdog(1, 100, () => { + calls += 1; + }, () => true); + assert.equal(calls, 0); + await new Promise((r) => setTimeout(r, 150)); + assert.equal(calls, 1); + await new Promise((r) => setTimeout(r, 250)); + assert.equal(calls, 1); + clearInterval(timer); + }); +}); + +describe('installStdioLifecycle soft stdin EOF', () => { + afterEach(() => { + mock.restoreAll(); + }); + + it('does not close transport or exit on stdin end/close', () => { + const transport = { close: mock.fn() }; + const exitMock = mock.method(process, 'exit', () => undefined as never); + + installStdioLifecycle({ + transport, + parentPid: process.ppid, + envName: 'LOCALWP_TEST_PARENT_WATCHDOG_MS', + }); + + process.stdin.emit('end'); + process.stdin.emit('close'); + + assert.equal(transport.close.mock.callCount(), 0); + assert.equal(exitMock.mock.callCount(), 0); + }); +});