From 232254b6d90b246dd5e07151ecada24021af6085 Mon Sep 17 00:00:00 2001 From: Haoqian Li Date: Thu, 25 Jun 2026 01:20:07 +0800 Subject: [PATCH] fix(mcp): arm liveness watchdog in local proxy --- __tests__/mcp-daemon.test.ts | 12 +++++++----- src/mcp/proxy.ts | 6 ++++++ 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/__tests__/mcp-daemon.test.ts b/__tests__/mcp-daemon.test.ts index c00d528f6..c63a5d929 100644 --- a/__tests__/mcp-daemon.test.ts +++ b/__tests__/mcp-daemon.test.ts @@ -193,7 +193,10 @@ describe('Shared MCP daemon (issue #411)', () => { }); it('two invocations share ONE detached daemon; both attach as proxies', async () => { - const env = { CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS: '15000' }; + const env = { + CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS: '15000', + CODEGRAPH_MCP_DEBUG: '1', + }; const first = spawnServer(tempDir, env); servers.push(first); @@ -203,6 +206,9 @@ describe('Shared MCP daemon (issue #411)', () => { // The launcher is a PROXY (not the daemon itself) — that's the detach fix. await waitFor(() => first.stderr.some((l) => l.includes('Attached to shared daemon')), 8000); + // The local-handshake proxy also needs the main-thread liveness watchdog + // (#943), matching direct and detached-daemon mode. + await waitFor(() => first.stderr.some((l) => l.includes('[CodeGraph watchdog] armed')), 8000); // A detached daemon came up and recorded itself. await waitFor(() => fs.existsSync(path.join(realRoot, '.codegraph', 'daemon.pid')), 8000); @@ -416,10 +422,6 @@ describe('Shared MCP daemon (issue #411)', () => { await waitFor(() => (readLockPid(realRoot) ?? 0) > 0, 8000); const daemonPid = readLockPid(realRoot)!; - // A warm call goes through the daemon. - sendMessage(server.child, { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'codegraph_status', arguments: {} } }); - await waitFor(() => findResponse(server.stdout, 2), 10000); - // Kill the daemon out from under the live proxy. process.kill(daemonPid, 'SIGTERM'); expect(await waitProcessExit(daemonPid, 8000)).toBe(true); diff --git a/src/mcp/proxy.ts b/src/mcp/proxy.ts index b7b538dd4..346d76970 100644 --- a/src/mcp/proxy.ts +++ b/src/mcp/proxy.ts @@ -29,6 +29,7 @@ import { SERVER_INFO, PROTOCOL_VERSION } from './session'; import { SERVER_INSTRUCTIONS } from './server-instructions'; import { getStaticTools } from './tools'; import { getTelemetry, ClientInfo } from '../telemetry'; +import { installMainThreadWatchdog, WatchdogHandle } from './liveness-watchdog'; import type { MCPEngine } from './engine'; /** Default poll cadence for the PPID watchdog (same as the direct server). */ @@ -202,6 +203,10 @@ export interface LocalHandshakeDeps { * never costs the old fall-back-to-direct robustness. */ export async function runLocalHandshakeProxy(deps: LocalHandshakeDeps): Promise { + // The proxy is long-lived and can serve fallback tool calls in-process. Match + // direct/daemon mode by killing this launcher if its main thread wedges, so an + // MCP host retry cannot accumulate abandoned `serve --mcp` wrapper processes. + const livenessWatchdog: WatchdogHandle | null = installMainThreadWatchdog(); let daemonStatus: 'connecting' | 'ready' | 'failed' = 'connecting'; let daemonSocket: net.Socket | null = null; let clientInitId: unknown = undefined; // suppress the daemon's reply to the forwarded initialize @@ -232,6 +237,7 @@ export async function runLocalHandshakeProxy(deps: LocalHandshakeDeps): Promise< }; const shutdown = (): void => { if (shuttingDown) return; shuttingDown = true; + try { livenessWatchdog?.stop(); } catch { /* ignore */ } try { daemonSocket?.destroy(); } catch { /* ignore */ } try { engine?.stop(); } catch { /* ignore */ } process.exit(0);