From 24328a36ab7457e6fe06613cc96492ea7e798956 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yi=C4=9Fit=20Budak?= Date: Thu, 20 Aug 2026 09:28:43 +0300 Subject: [PATCH 1/2] fix(server): exit when the MCP client closes stdin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit StdioServerTransport never watches stdin for EOF, so when the MCP client exits without delivering SIGINT/SIGTERM (e.g. the parent is killed or crashes), the server lingers forever as an orphan — along with any headless browsers it launched. Over weeks this piles up dozens of zombie node/Chromium processes and fills swap. Treat a closed stdin pipe as a shutdown request, guard shutdown against double invocation, and force-exit after 10s if subsystem teardown hangs (e.g. a wedged browser), so the process can never outlive its client indefinitely. Co-Authored-By: Claude Fable 5 --- src/server.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/server.ts b/src/server.ts index 92a61bc9c..77e6df407 100644 --- a/src/server.ts +++ b/src/server.ts @@ -565,7 +565,13 @@ export async function startServer(): Promise { log.warn('search engine bootstrap failed', { error: String(err) }); }); + let shuttingDown = false; const shutdown = async () => { + if (shuttingDown) return; + shuttingDown = true; + // If subsystem teardown hangs (e.g. a wedged browser), force-exit rather + // than linger as an orphan. + setTimeout(() => process.exit(1), 10_000).unref(); await subs.shutdown(); await server.close(); process.exit(0); @@ -573,4 +579,10 @@ export async function startServer(): Promise { process.on('SIGINT', shutdown); process.on('SIGTERM', shutdown); + + // StdioServerTransport never watches stdin for EOF, so when the MCP client + // exits without signalling us the server (and its headless browsers) would + // outlive it forever. Treat a closed stdin pipe as a shutdown request. + process.stdin.on('end', () => void shutdown()); + process.stdin.on('close', () => void shutdown()); } From 8294cbe475ab4183d28e89d446aa34a771398d62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yi=C4=9Fit=20Budak?= Date: Thu, 20 Aug 2026 09:46:46 +0300 Subject: [PATCH 2/2] fix(server): harden shutdown against cleanup failures and early loop drain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review feedback on the stdin-EOF shutdown path: - Keep the force-exit watchdog referenced so the event loop cannot drain mid-cleanup and exit before server.close() and the explicit exit code; clear it once teardown completes. - Isolate each cleanup phase: a rejected subs.shutdown() no longer skips server.close() or surfaces as an unhandled rejection — both phases always run and any failure exits with status 1. - Extract the handler into createShutdownHandler() with an injectable exit so the flow is unit-testable; add tests for the success path, per-phase failures, and idempotency under concurrent triggers. Co-Authored-By: Claude Fable 5 --- src/server.ts | 48 ++++++++++++++++----- tests/unit/server/server-factory.test.ts | 53 ++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 11 deletions(-) diff --git a/src/server.ts b/src/server.ts index 77e6df407..a3f5d4ce7 100644 --- a/src/server.ts +++ b/src/server.ts @@ -550,6 +550,42 @@ export function createMcpServer(subsystems: Subsystems): Server { return server; } +/** + * Builds the process-wide shutdown handler: runs each cleanup phase even if + * an earlier one fails, force-exits if teardown hangs, and is safe to invoke + * from multiple triggers (signals, stdin EOF). + */ +export function createShutdownHandler( + subs: Pick, + server: Pick, + exit: (code: number) => void = (code) => process.exit(code), +): () => Promise { + let shuttingDown = false; + return async () => { + if (shuttingDown) return; + shuttingDown = true; + // If teardown hangs (e.g. a wedged browser), force-exit rather than + // linger as an orphan. Kept ref'd so the event loop can't drain + // mid-cleanup and exit before the explicit exit below. + const watchdog = setTimeout(() => exit(1), 10_000); + let failed = false; + try { + await subs.shutdown(); + } catch (err) { + failed = true; + log.error('subsystem shutdown failed', { error: String(err) }); + } + try { + await server.close(); + } catch (err) { + failed = true; + log.error('server close failed', { error: String(err) }); + } + clearTimeout(watchdog); + exit(failed ? 1 : 0); + }; +} + export async function startServer(): Promise { const subs = await initSubsystems(); const server = createMcpServer(subs); @@ -565,17 +601,7 @@ export async function startServer(): Promise { log.warn('search engine bootstrap failed', { error: String(err) }); }); - let shuttingDown = false; - const shutdown = async () => { - if (shuttingDown) return; - shuttingDown = true; - // If subsystem teardown hangs (e.g. a wedged browser), force-exit rather - // than linger as an orphan. - setTimeout(() => process.exit(1), 10_000).unref(); - await subs.shutdown(); - await server.close(); - process.exit(0); - }; + const shutdown = createShutdownHandler(subs, server); process.on('SIGINT', shutdown); process.on('SIGTERM', shutdown); diff --git a/tests/unit/server/server-factory.test.ts b/tests/unit/server/server-factory.test.ts index c997700bf..7f745080b 100644 --- a/tests/unit/server/server-factory.test.ts +++ b/tests/unit/server/server-factory.test.ts @@ -209,3 +209,56 @@ describe('createMcpServer', () => { expect(typeof startServer).toBe('function'); }); }); + +describe('createShutdownHandler', () => { + it('runs both cleanup phases and exits 0 on success', async () => { + const { createShutdownHandler } = await import('../../../src/server.js'); + const subsShutdown = vi.fn().mockResolvedValue(undefined); + const serverClose = vi.fn().mockResolvedValue(undefined); + const exit = vi.fn(); + + await createShutdownHandler({ shutdown: subsShutdown }, { close: serverClose }, exit)(); + + expect(subsShutdown).toHaveBeenCalledTimes(1); + expect(serverClose).toHaveBeenCalledTimes(1); + expect(exit).toHaveBeenCalledWith(0); + }); + + it('still closes the server and exits 1 when subsystem shutdown rejects', async () => { + const { createShutdownHandler } = await import('../../../src/server.js'); + const subsShutdown = vi.fn().mockRejectedValue(new Error('cleanup failed')); + const serverClose = vi.fn().mockResolvedValue(undefined); + const exit = vi.fn(); + + await createShutdownHandler({ shutdown: subsShutdown }, { close: serverClose }, exit)(); + + expect(serverClose).toHaveBeenCalledTimes(1); + expect(exit).toHaveBeenCalledWith(1); + }); + + it('exits 1 when server.close rejects', async () => { + const { createShutdownHandler } = await import('../../../src/server.js'); + const subsShutdown = vi.fn().mockResolvedValue(undefined); + const serverClose = vi.fn().mockRejectedValue(new Error('close failed')); + const exit = vi.fn(); + + await createShutdownHandler({ shutdown: subsShutdown }, { close: serverClose }, exit)(); + + expect(subsShutdown).toHaveBeenCalledTimes(1); + expect(exit).toHaveBeenCalledWith(1); + }); + + it('is idempotent — concurrent triggers run cleanup only once', async () => { + const { createShutdownHandler } = await import('../../../src/server.js'); + const subsShutdown = vi.fn().mockResolvedValue(undefined); + const serverClose = vi.fn().mockResolvedValue(undefined); + const exit = vi.fn(); + + const shutdown = createShutdownHandler({ shutdown: subsShutdown }, { close: serverClose }, exit); + await Promise.all([shutdown(), shutdown(), shutdown()]); + + expect(subsShutdown).toHaveBeenCalledTimes(1); + expect(serverClose).toHaveBeenCalledTimes(1); + expect(exit).toHaveBeenCalledTimes(1); + }); +});