diff --git a/src/server.ts b/src/server.ts index 92a61bc9c..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,12 +601,14 @@ export async function startServer(): Promise { log.warn('search engine bootstrap failed', { error: String(err) }); }); - const shutdown = async () => { - await subs.shutdown(); - await server.close(); - process.exit(0); - }; + const shutdown = createShutdownHandler(subs, server); 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()); } 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); + }); +});