Skip to content
Open
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
48 changes: 43 additions & 5 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Subsystems, 'shutdown'>,
server: Pick<Server, 'close'>,
exit: (code: number) => void = (code) => process.exit(code),
): () => Promise<void> {
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<void> {
const subs = await initSubsystems();
const server = createMcpServer(subs);
Expand All @@ -565,12 +601,14 @@ export async function startServer(): Promise<void> {
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());
}
53 changes: 53 additions & 0 deletions tests/unit/server/server-factory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});