From dbb3d63cb1a92d04112dcc9ebb53bea4d1e317fb Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Sat, 3 Oct 2026 05:05:29 +0000 Subject: [PATCH] fix(mcp): wait for a stopping daemon, and for the proxy's fallback engine (#2311) (#2316) * fix(mcp): wait for a stopping daemon, and for the proxy's fallback engine (#2311) Now that QueryPool.destroy() waits up to 15 s for a query worker still starting up (#2307), two callers gave up too early: - stopDaemonAt() gave a daemon 3 s after SIGTERM, then re-probed its identity and reported it still running once the daemon had closed its socket, so a daemon waiting on a starting worker made `codegraph index` abort. A daemon that has closed its socket but still holds its lock is now given 17 s more to exit; nothing is signalled during that wait. - The proxy's in-process fallback called engine.stop() and exited on the next line. It now exits once its engine, and any engine still being retired after a handback to the daemon, has stopped, with a 20 s backstop. Once shutdown starts it ignores client input and starts no new engine. Extracted from bompus/codegraph@cc87ec34daeaacdc54cfbbf8955843733655f15d, a fork merge commit that cannot be cherry-picked whole; only these four files are taken, unchanged. Co-Authored-By: Claude Opus 5.5 * fix(mcp): a stopping daemon is waited for whether it closed its socket or released its lock (#2311) Builds on the previous commit: - stopDaemonAt: one rule after the first 3 s. If the daemon's identity can be re-proven (same lock, socket answers), escalate to SIGKILL as before. Otherwise, whether it has stopped answering its socket or already let go of its lock, it is partway through its own shutdown: signal nothing more and wait, bounded, for the PID to exit. The previous commit reported a daemon that had released its lock but not yet exited as still running, a race at the end of every slow shutdown. The cleanup that follows compares the lock with the one signalled, so a successor's is kept. On Windows, where SIGTERM is TerminateProcess, the same wait covers a termination still settling. - The grace is WORKER_START_SETTLE_MS + 2 s and the proxy's backstop WORKER_START_SETTLE_MS + 5 s, so they move with the settle wait they cover. stopDaemonAt takes `shutdownGraceMs` so a test can bound it. - The proxy says so on stderr when its backstop fires. Tests: the daemon fixture's setup and cleanup are shared, and the fixture process is always killed on cleanup (a failing run used to leak it). New: a daemon that released its lock mid-shutdown is waited for; a daemon that never finishes stopping is still reported after the grace and never SIGKILLed; the proxy waits for an engine being retired at shutdown; and client input arriving once shutdown has begun starts no engine. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Aaron Queen Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/daemon-registry.test.ts | 103 +++++++++++++ __tests__/proxy-liveness-watchdog.test.ts | 7 +- __tests__/proxy-shutdown.test.ts | 172 ++++++++++++++++++++++ src/mcp/daemon-registry.ts | 49 ++++-- src/mcp/proxy.ts | 39 ++++- 6 files changed, 357 insertions(+), 14 deletions(-) create mode 100644 __tests__/proxy-shutdown.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 049af59d4..be4394309 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -71,6 +71,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Claude Code now shows the model the full text of `codegraph_status` and of explore's stale-index refusal. Both results also carried `structuredContent`, and Claude Code shows a result's `structuredContent` in place of its text, so the model saw only a freshness JSON object: no refusal message, no retry guidance and no status report. Both results are now text only. Thanks @bompus. (#2088) - When an agent asks CodeGraph about a separate git repository nested inside an indexed project, one the project's index leaves out (for example because the parent's `.gitignore` excludes it), it now gets the usual "isn't indexed" guidance instead of answers from the parent project's code. Nested repositories the parent does index, such as submodules, work as before. Thanks @wstczyw for the report. (#2110) - An agent session that lost its connection to CodeGraph's shared background server, or couldn't reach it at startup, no longer stops that server from running for the rest of the session. It reconnects on its own once the server can start, so other sessions on the same project get auto-sync back instead of running read-only. Thanks @ijbranch for the report. (#2277) +- `codegraph index` no longer gives up with "Could not verify that the active CodeGraph daemon has stopped" while the background server is still finishing its shutdown, and `codegraph daemon` no longer reports such a server as one it could not stop; both now wait for it to exit, for up to about 20 seconds. An agent session that is serving CodeGraph by itself, because the background server was unavailable, now also waits for that work to stop before it exits instead of exiting partway through, which could crash on Windows. Thanks @bompus. (#2311) #### Indexing and sync diff --git a/__tests__/daemon-registry.test.ts b/__tests__/daemon-registry.test.ts index 0ad4707df..af8c46399 100644 --- a/__tests__/daemon-registry.test.ts +++ b/__tests__/daemon-registry.test.ts @@ -143,6 +143,109 @@ describe('daemon-registry', () => { } }, 15000); + /** + * A daemon fixture whose SIGTERM (intercepted; nothing is delivered) runs + * `onTerm` — the shape of its graceful shutdown — instead of exiting. + * `exitAfter(ms)` ends the process for real, as the end of that shutdown. + */ + async function stoppingDaemon( + root: string, + onTerm: (daemon: { server: net.Server; pidPath: string; exitAfter: (ms: number) => void }) => void, + ): Promise<{ root: string; pid: number; pidPath: string; signalled: (signal: string) => boolean; dispose: () => Promise }> { + fs.mkdirSync(path.join(root, '.codegraph')); + const socketPath = getDaemonSocketPath(root); + const pidPath = getDaemonPidPath(root); + const pid = startDetachedProcess(); + const server = net.createServer((socket) => { + socket.end(`${JSON.stringify({ protocol: 1, codegraph: 'test', pid, socketPath })}\n`); + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(socketPath, resolve); + }); + fs.writeFileSync(pidPath, encodeLockInfo({ pid, version: 'test', socketPath, startedAt: Date.now() })); + const originalKill = process.kill.bind(process); + let exitTimer: NodeJS.Timeout | undefined; + const exitAfter = (ms: number): void => { exitTimer = setTimeout(() => originalKill(pid, 'SIGKILL'), ms); }; + const kill = vi.spyOn(process, 'kill').mockImplementation((target, signal) => { + if (target === pid && signal === 'SIGTERM') { + onTerm({ server, pidPath, exitAfter }); + return true; + } + return originalKill(target, signal); + }); + return { + root, + pid, + pidPath, + signalled: (signal) => kill.mock.calls.some(([target, sent]) => target === pid && sent === signal), + dispose: async () => { + clearTimeout(exitTimer); + kill.mockRestore(); + if (isProcessAlive(pid)) process.kill(pid, 'SIGKILL'); + if (server.listening) await new Promise((resolve) => server.close(() => resolve())); + fs.rmSync(root, { recursive: true, force: true }); + }, + }; + } + + it('waits for a daemon that has closed its socket but is still shutting down', async () => { + // A daemon's shutdown closes its socket first, then waits up to 15 s for + // query workers still starting up before it exits. Past the first 3 s the + // identity probe fails; that is a daemon still stopping, not a stranger. + const daemon = await stoppingDaemon(fs.mkdtempSync(path.join(tmpHome, 'stop-slow-')), ({ server, exitAfter }) => { + server.close(); + exitAfter(4000); + }); + try { + const result = await stopDaemonAt(daemon.root); + expect(result.outcome).toBe('term'); + expect(isProcessAlive(daemon.pid)).toBe(false); + expect(daemon.signalled('SIGKILL')).toBe(false); + expect(fs.existsSync(daemon.pidPath)).toBe(false); + } finally { + await daemon.dispose(); + } + }, 15000); + + it('waits for a daemon that has released its lock but not yet exited', async () => { + // The end of the same shutdown: the lock goes just before the process does. + // A stop that looks then must wait for the exit, not report it running. + const daemon = await stoppingDaemon(fs.mkdtempSync(path.join(tmpHome, 'stop-unlocked-')), ({ server, pidPath, exitAfter }) => { + server.close(); + fs.unlinkSync(pidPath); + exitAfter(4000); + }); + try { + const result = await stopDaemonAt(daemon.root); + expect(result.outcome).toBe('term'); + expect(isProcessAlive(daemon.pid)).toBe(false); + expect(daemon.signalled('SIGKILL')).toBe(false); + } finally { + await daemon.dispose(); + } + }, 15000); + + it('still reports a daemon whose shutdown never finishes, without force-killing it', async () => { + // The wait is bounded: past it, a daemon that closed its socket and never + // exited is reported, and with no socket to re-prove its identity it is + // left alone rather than SIGKILLed. + const daemon = await stoppingDaemon(fs.mkdtempSync(path.join(tmpHome, 'stop-wedged-')), ({ server }) => { + server.close(); + }); + try { + const started = Date.now(); + const result = await stopDaemonAt(daemon.root, { shutdownGraceMs: 300 }); + expect(result.outcome).toBe('still-running'); + expect(Date.now() - started).toBeLessThan(8000); + expect(isProcessAlive(daemon.pid)).toBe(true); + expect(daemon.signalled('SIGKILL')).toBe(false); + expect(fs.existsSync(daemon.pidPath)).toBe(true); + } finally { + await daemon.dispose(); + } + }, 15000); + it('preserves a newer lock installed during a successful stop identity probe', async () => { const root = fs.mkdtempSync(path.join(tmpHome, 'stop-race-')); fs.mkdirSync(path.join(root, '.codegraph')); diff --git a/__tests__/proxy-liveness-watchdog.test.ts b/__tests__/proxy-liveness-watchdog.test.ts index 8e9094ba4..b09bf91c5 100644 --- a/__tests__/proxy-liveness-watchdog.test.ts +++ b/__tests__/proxy-liveness-watchdog.test.ts @@ -45,7 +45,11 @@ async function exercise(mode: 'connecting-wedge' | 'fallback-wedge' | 'slow-fall await delay(2500); return { content: [{ type: 'text', text: 'finished' }] }; } }), - stop: () => {}, + // Slow, like a stop waiting on a query worker still starting up: the + // proxy must not exit before it settles. + stop: () => mode === 'slow-fallback' + ? delay(300).then(() => { require('fs').writeSync(1, 'STOPPED\\n'); }) + : undefined, }), }); `], { @@ -76,6 +80,7 @@ async function exercise(mode: 'connecting-wedge' | 'fallback-wedge' | 'slow-fall await until(exited); expect(child.exitCode, stderr).toBe(0); expect(child.signalCode).toBeNull(); + expect(stdout, stderr).toContain('STOPPED'); } else { await until(() => stdout.includes('WEDGED') || exited()); expect(stdout, stderr).toContain('WEDGED'); diff --git a/__tests__/proxy-shutdown.test.ts b/__tests__/proxy-shutdown.test.ts new file mode 100644 index 000000000..052984594 --- /dev/null +++ b/__tests__/proxy-shutdown.test.ts @@ -0,0 +1,172 @@ +/** + * The local-handshake proxy's shutdown waits for every in-process engine it + * owns, and starts nothing new once it is shutting down (#2311). An engine's + * stop can wait on a query worker still starting up — exiting before it + * settles is the Windows crash QueryPool.destroy avoids — and stopping is + * what releases the writer lock. + * + * Each case runs the real proxy entry point in a child process, with only the + * daemon and engine dependencies replaced. Engines print `ENGINE n` when made + * and `STOPPED n` once a deliberately slow stop settles. + */ +import { describe, it, expect } from 'vitest'; +import { spawn, type ChildProcess } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +const MODULE = path.resolve(__dirname, '../dist/mcp/proxy.js'); +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +async function until(check: () => boolean, what: string, timeout = 10_000): Promise { + const deadline = Date.now() + timeout; + while (!check()) { + if (Date.now() > deadline) throw new Error(`Timed out waiting for ${what}`); + await delay(25); + } +} + +const line = (msg: object): string => JSON.stringify({ jsonrpc: '2.0', ...msg }) + '\n'; +const INITIALIZE = line({ id: 1, method: 'initialize', params: {} }); +const toolCall = (id: number): string => line({ id, method: 'tools/call', params: { name: 'test', arguments: {} } }); + +/** Shared by every scenario: the proxy, with markers on stdout. */ +const PRELUDE = ` + const { runLocalHandshakeProxy } = require(${JSON.stringify(MODULE)}); + const fs = require('fs'); + const net = require('net'); + const say = (s) => fs.writeSync(1, s + '\\n'); + const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + let engines = 0; + const makeEngine = ({ readOnly, callMs, stopMs }) => () => { + const n = ++engines; + say('ENGINE ' + n); + return { + ensureInitialized: async () => {}, + isReadOnly: () => { say('RETRY ' + n); return readOnly; }, + getToolHandler: () => ({ execute: async () => { + await delay(n === 1 ? callMs : 0); + return { content: [{ type: 'text', text: 'finished ' + n }] }; + } }), + stop: () => delay(stopMs).then(() => say('STOPPED ' + n)), + }; + }; +`; + +interface Proxy { + child: ChildProcess; + out: () => string; + err: () => string; + exited: () => boolean; + write: (s: string) => void; + dispose: () => Promise; +} + +function startProxy(script: string, env: Record = {}): Proxy { + const child = spawn(process.execPath, ['-e', PRELUDE + script], { + env: { + ...process.env, + CODEGRAPH_TELEMETRY: '0', DO_NOT_TRACK: '1', CODEGRAPH_NO_PROMPT_HOOK: '1', + CODEGRAPH_NO_WATCHDOG: '1', CODEGRAPH_PPID_POLL_MS: '0', + CODEGRAPH_STARTUP_HANDSHAKE_TIMEOUT_MS: '0', + CODEGRAPH_DAEMON_RETRY_MS: '200', + ...env, + }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + let stdout = '', stderr = ''; + child.stdout!.on('data', (d) => { stdout += d; }); + child.stderr!.on('data', (d) => { stderr += d; }); + // On a proxy that has already exited, a write fails with EPIPE; the + // assertions say what went wrong, not an unhandled stream error. + child.stdin!.on('error', () => { /* proxy gone */ }); + const exited = () => child.exitCode !== null || child.signalCode !== null; + return { + child, + out: () => stdout, + err: () => stderr, + exited, + write: (s) => { if (!exited()) child.stdin!.write(s); }, + dispose: async () => { + if (!exited()) child.kill('SIGKILL'); + await until(exited, 'the proxy to exit'); + child.stdin!.destroy(); + }, + }; +} + +describe('local proxy shutdown (#2311)', () => { + it('waits for an engine being retired before it exits', async () => { + // A writing engine is retired so a daemon can take its lock: the retry + // hands it back once its call answers. stdin closes while the retirement is + // still waiting on that call, so `engine` is already null at shutdown. + const proxy = startProxy(` + runLocalHandshakeProxy({ + root: process.cwd(), + getDaemonSocket: async () => null, + makeEngine: makeEngine({ readOnly: false, callMs: 10000, stopMs: 500 }), + }); + `); + try { + proxy.write(INITIALIZE); + proxy.write(toolCall(2)); + await until(() => proxy.out().includes('RETRY 1') || proxy.exited(), 'the retry to retire the engine'); + expect(proxy.out(), proxy.err()).toContain('RETRY 1'); + proxy.child.stdin!.end(); + await until(proxy.exited, 'the proxy to exit'); + expect(proxy.child.exitCode, proxy.err()).toBe(0); + expect(proxy.out(), proxy.err()).toContain('STOPPED 1'); + expect(proxy.out()).not.toContain('finished 1'); + } finally { + await proxy.dispose(); + } + }, 20_000); + + it('starts no engine for client input that arrives once shutdown has begun', async () => { + // A read-only engine is retired after the daemon comes back; the daemon + // then drops again, so calls are served in-process with no live engine. + // The host pid exiting starts shutdown while stdin is still open, and a + // call written then must not start an engine shutdown() never stops. + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-proxy-stop-')); + const sock = process.platform === 'win32' + ? `\\\\.\\pipe\\cg-proxy-stop-${process.pid}-${Date.now()}` + : path.join(dir, 'd.sock'); + const host = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore' }); + const hostGone = new Promise((resolve) => host.once('exit', () => resolve())); + const proxy = startProxy(` + const conns = []; + const server = net.createServer((c) => { conns.push(c); c.on('data', () => {}); }); + let gets = 0; + server.listen(${JSON.stringify(sock)}, () => runLocalHandshakeProxy({ + root: process.cwd(), + getDaemonSocket: async () => { + if (++gets !== 2) return null; + const s = net.createConnection(${JSON.stringify(sock)}); + await new Promise((resolve, reject) => { s.once('connect', resolve); s.once('error', reject); }); + setTimeout(() => { for (const c of conns) c.destroy(); }, 100); + return s; + }, + makeEngine: makeEngine({ readOnly: true, callMs: 10000, stopMs: 1500 }), + })); + `, { CODEGRAPH_PPID_POLL_MS: '50', CODEGRAPH_HOST_PPID: String(host.pid) }); + try { + proxy.write(INITIALIZE); + proxy.write(toolCall(2)); + await until(() => proxy.err().includes('Shared daemon connection lost') || proxy.exited(), 'the daemon to drop'); + expect(proxy.out(), proxy.err()).toContain('ENGINE 1'); + host.kill('SIGKILL'); + await hostGone; + await until(() => proxy.err().includes('Parent process exited') || proxy.exited(), 'shutdown to begin'); + proxy.write(toolCall(3)); + await until(proxy.exited, 'the proxy to exit'); + expect(proxy.child.exitCode, proxy.err()).toBe(0); + expect(proxy.out(), proxy.err()).toContain('STOPPED 1'); + expect(proxy.out(), proxy.err()).not.toContain('ENGINE 2'); + expect(proxy.out()).not.toContain('"id":3'); + } finally { + if (host.exitCode === null && host.signalCode === null) host.kill('SIGKILL'); + await proxy.dispose(); + fs.rmSync(dir, { recursive: true, force: true }); + } + }, 20_000); +}); diff --git a/src/mcp/daemon-registry.ts b/src/mcp/daemon-registry.ts index c6c4043b6..aabb79139 100644 --- a/src/mcp/daemon-registry.ts +++ b/src/mcp/daemon-registry.ts @@ -32,6 +32,7 @@ import { type DaemonLockInfo, } from './daemon-paths'; import { readWriterLock, releaseWriterLock, tryAcquireWriterLock } from './writer-lock'; +import { WORKER_START_SETTLE_MS } from '../worker-teardown'; export interface DaemonRecord { /** Realpath'd project root the daemon serves. */ @@ -213,6 +214,18 @@ export async function clearStaleDaemonArtifacts(root: string): Promise const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)); +/** How long `stopDaemonAt` gives a daemon to exit on SIGTERM before looking closer. */ +const DAEMON_TERM_WAIT_MS = 3_000; + +/** + * How much longer `stopDaemonAt` waits for a daemon that is partway through its + * own shutdown: it has stopped answering its socket (or let go of its lock) but + * not yet exited. That shutdown waits up to {@link WORKER_START_SETTLE_MS} for a + * query worker still starting up before it exits, so this covers that plus the + * rest of the shutdown (#2311). + */ +const DAEMON_SHUTDOWN_GRACE_MS = WORKER_START_SETTLE_MS + 2_000; + async function waitForDeath(pid: number, timeoutMs: number): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { @@ -235,8 +248,14 @@ export interface StopResult { * keys its socket/lockfile). Resolves the pid from the authoritative lockfile, * falling back to the registry. Rebuild callers preserve unverified live locks * instead of interpreting a failed probe as permission to discard the database. + * A daemon still finishing its own shutdown is waited for, up to + * `shutdownGraceMs` more (default {@link DAEMON_SHUTDOWN_GRACE_MS}; tests + * shorten it), before it is reported `still-running`. */ -export async function stopDaemonAt(root: string, options: { preserveUnverified?: boolean } = {}): Promise { +export async function stopDaemonAt( + root: string, + options: { preserveUnverified?: boolean; shutdownGraceMs?: number } = {}, +): Promise { let pid: number | null = null; let identity: DaemonLockInfo | null = null; let lockContents: string | null = null; @@ -285,17 +304,29 @@ export async function stopDaemonAt(root: string, options: { preserveUnverified?: // (no graceful path), so we always sweep artifacts ourselves below. try { process.kill(pid, 'SIGTERM'); } catch { /* raced to exit */ } let outcome: StopResult['outcome'] = 'term'; - if (!(await waitForDeath(pid, 3000))) { + if (!(await waitForDeath(pid, DAEMON_TERM_WAIT_MS))) { // Re-prove identity before escalating; the old PID may have been reused. - if (!sameLock() || !await probeDaemonIdentity(identity) || !sameLock()) { - return { root, pid, outcome: 'still-running' }; + if (sameLock() && await probeDaemonIdentity(identity) && sameLock()) { + try { process.kill(pid, 'SIGKILL'); } catch { /* raced to exit */ } + if (!(await waitForDeath(pid, 2000))) { + return { root, pid, outcome: 'still-running' }; + } + outcome = 'kill'; + } else { + // No longer answering its socket, or no longer holding the lock it held + // when it was signalled: a daemon partway through its own shutdown, which + // closes the socket first and can then wait on a query worker still + // starting up before it releases the lock and exits (#2311). On Windows, + // where SIGTERM is TerminateProcess, this is a termination still settling. + // Its identity can't be re-proven without the socket, so signal nothing + // more; just wait, bounded, for the PID to go. One that outlives the + // wait is reported, as before. + if (!(await waitForDeath(pid, options.shutdownGraceMs ?? DAEMON_SHUTDOWN_GRACE_MS))) { + return { root, pid, outcome: 'still-running' }; + } } - try { process.kill(pid, 'SIGKILL'); } catch { /* raced to exit */ } - if (!(await waitForDeath(pid, 2000))) { - return { root, pid, outcome: 'still-running' }; - } - outcome = 'kill'; } + // Compares the lock with the one we signalled, so a successor's is kept. cleanupDaemonArtifacts(root, lockContents); return { root, pid, outcome }; } diff --git a/src/mcp/proxy.ts b/src/mcp/proxy.ts index a983c2ebd..52e6b77fd 100644 --- a/src/mcp/proxy.ts +++ b/src/mcp/proxy.ts @@ -35,6 +35,7 @@ import { ExploreSessionState } from './explore-session-state'; import { getTelemetry, ClientInfo } from '../telemetry'; import { installMainThreadWatchdog, WatchdogHandle } from './liveness-watchdog'; import type { MCPEngine } from './engine'; +import { WORKER_START_SETTLE_MS } from '../worker-teardown'; /** Default poll cadence for the PPID watchdog (same as the direct server). */ const DEFAULT_PPID_POLL_MS = 5000; @@ -48,6 +49,15 @@ const DEFAULT_PPID_POLL_MS = 5000; const DEFAULT_DAEMON_RETRY_MS = 5_000; const DEFAULT_DAEMON_RETRY_MAX_MS = 300_000; +/** + * Longest the proxy waits, once it is shutting down, for its in-process engines + * to stop before exiting anyway. An engine's stop waits up to + * {@link WORKER_START_SETTLE_MS} for a query worker still starting up (exiting + * mid-start can crash the process on Windows); this bounds a stop that never + * settles (#2311). + */ +const SHUTDOWN_BACKSTOP_MS = WORKER_START_SETTLE_MS + 5_000; + /** * Env var that opts INTO the "attached to shared daemon" log line. Off by * default: the line is benign INFO, but MCP hosts render any server stderr at @@ -250,6 +260,8 @@ export async function runLocalHandshakeProxy(deps: LocalHandshakeDeps): Promise< let engineReady: Promise | null = null; // Calls being served in-process; the engine is only ever stopped after they finish. const localCalls = new Set>(); + // Engines retired but not yet stopped; shutdown() waits for them too. + const retiring = new Set(); let shuttingDown = false; const retryBaseMs = parseDelayMs(process.env.CODEGRAPH_DAEMON_RETRY_MS, DEFAULT_DAEMON_RETRY_MS); const retryMaxMs = Math.max(retryBaseMs, parseDelayMs(process.env.CODEGRAPH_DAEMON_RETRY_MAX_MS, DEFAULT_DAEMON_RETRY_MAX_MS)); @@ -282,12 +294,23 @@ export async function runLocalHandshakeProxy(deps: LocalHandshakeDeps): Promise< try { livenessWatchdog?.stop(); } catch { /* ignore */ } if (retryTimer) clearTimeout(retryTimer); try { daemonSocket?.destroy(); } catch { /* ignore */ } - try { engine?.stop(); } catch { /* ignore */ } - process.exit(0); + // Exit once every engine has stopped, a retiring one included: exiting while + // a query worker is still starting up can crash the process on Windows (see + // QueryPool.destroy), and stopping releases the writer lock. The backstop + // bounds a stop that never settles; it stays ref'd so the process lives + // until one of the two exits it. + setTimeout(() => { + process.stderr.write(`[CodeGraph MCP] In-process engine did not stop within ${SHUTDOWN_BACKSTOP_MS}ms; exiting anyway.\n`); + process.exit(0); + }, SHUTDOWN_BACKSTOP_MS); + const stopping = [engine, ...retiring].map((e) => Promise.resolve().then(() => e?.stop())); + void Promise.allSettled(stopping).then(() => process.exit(0)); }; // Resolves the engine a call was started on, so a call in flight while the // engine is being retired still finishes on it. const ensureEngine = async (): Promise => { + // shutdown() stops only the engines it can see; never start one after it. + if (!engine && shuttingDown) throw new Error('codegraph is shutting down'); if (!engine) { engine = deps.makeEngine(); engineReady = engine.ensureInitialized(deps.root).catch(() => { /* degraded */ }); @@ -302,8 +325,13 @@ export async function runLocalHandshakeProxy(deps: LocalHandshakeDeps): Promise< const retired = engine; engine = null; engineReady = null; - await Promise.allSettled([...localCalls]); - try { await retired?.stop(); } catch { /* best-effort */ } + if (retired) retiring.add(retired); + try { + await Promise.allSettled([...localCalls]); + try { await retired?.stop(); } catch { /* best-effort */ } + } finally { + if (retired) retiring.delete(retired); + } }; // Daemon-unavailable fallback: serve a client message in-process. const handleLocally = (line: string): Promise => { @@ -352,6 +380,9 @@ export async function runLocalHandshakeProxy(deps: LocalHandshakeDeps): Promise< let stdinBuf = ''; process.stdin.setEncoding('utf8'); process.stdin.on('data', (chunk: string) => { + // Nothing is served once shutdown starts: a call now could start an engine + // shutdown() never sees, and the process exits as soon as the others stop. + if (shuttingDown) return; stdinBuf += chunk; let idx: number; while ((idx = stdinBuf.indexOf('\n')) !== -1) {