index.ts 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567
  1. /**
  2. * CodeGraph MCP Server
  3. *
  4. * Model Context Protocol server that exposes CodeGraph functionality
  5. * as tools for AI assistants like Claude.
  6. *
  7. * @module mcp
  8. *
  9. * @example
  10. * ```typescript
  11. * import { MCPServer } from 'codegraph';
  12. *
  13. * const server = new MCPServer('/path/to/project');
  14. * await server.start();
  15. * ```
  16. *
  17. * Runtime modes (decided in {@link MCPServer.start}):
  18. *
  19. * - **Direct** — one process serves one MCP client over stdio. The pre-#411
  20. * behavior; used when the user opts out (`CODEGRAPH_NO_DAEMON=1`), no
  21. * `.codegraph/` is reachable, or the daemon machinery fails for any reason.
  22. * - **Proxy** — what an MCP host actually talks to when sharing is on: a thin
  23. * stdio↔socket pipe to the shared daemon. The proxy carries the #277 PPID
  24. * watchdog, so a SIGKILL'd host reaps its proxy promptly. See {@link ./proxy.ts}.
  25. * - **Daemon** — a *detached* background process (its own session/process
  26. * group) that serves N proxies over a Unix-domain socket / named pipe,
  27. * sharing one CodeGraph + watcher + SQLite handle. Spawned on demand; never a
  28. * child of any host, so it survives individual sessions and is reaped by
  29. * client-refcount + idle timeout. See {@link ./daemon.ts} and issue #411.
  30. *
  31. * The detached-daemon + always-proxy split is the fix for the review finding
  32. * that the original in-process daemon (a) was the first host's child, so closing
  33. * that terminal severed every other client, and (b) disabled the PPID watchdog,
  34. * regressing #277 (orphaned daemons on host SIGKILL).
  35. */
  36. import * as fs from 'fs';
  37. import * as path from 'path';
  38. import { spawn, StdioOptions } from 'child_process';
  39. import { resolveServerRoot, getCodeGraphDir } from '../directory';
  40. import { StdioTransport } from './transport';
  41. import { MCPEngine } from './engine';
  42. import { MCPSession } from './session';
  43. import {
  44. Daemon,
  45. clearStaleDaemonLock,
  46. isProcessAlive,
  47. tryAcquireDaemonLock,
  48. } from './daemon';
  49. import { connectWithHello, runLocalHandshakeProxy } from './proxy';
  50. import { releaseWriterLock, tryAcquireWriterLock, writerLockHeldMessage } from './writer-lock';
  51. import { getDaemonSocketCandidates, probeDaemonIdentity } from './daemon-paths';
  52. import { getTelemetry } from '../telemetry';
  53. import { checkForUpdateInBackground } from '../upgrade/update-check';
  54. import { EARLY_PPID } from './early-ppid';
  55. import { supervisionLostReason, parsePpidPollMs, parseHostPpid } from './ppid-watchdog';
  56. import { installMainThreadWatchdog, WatchdogHandle } from './liveness-watchdog';
  57. import { armStartupHandshakeTimeout } from './startup-handshake';
  58. import { treatStdinFailureAsShutdown } from './stdin-teardown';
  59. import { HOST_PPID_ENV } from '../extraction/wasm-runtime-flags';
  60. /**
  61. * Env var that marks a process as the *detached daemon* itself (set by
  62. * {@link spawnDetachedDaemon} when it re-invokes the CLI). Without it a
  63. * `serve --mcp` invocation is a launcher that connects-or-spawns; with it, the
  64. * process IS the daemon and must never try to spawn another (infinite spawn).
  65. */
  66. const DAEMON_INTERNAL_ENV = 'CODEGRAPH_DAEMON_INTERNAL';
  67. /**
  68. * Retries for the detached daemon arbitrating the O_EXCL lock against a racing
  69. * sibling. Tiny — the lock resolves on the first round in practice; the retries
  70. * only cover clearing a genuinely stale (dead-pid) lockfile.
  71. */
  72. const TAKEOVER_MAX_RETRIES = 5;
  73. const TAKEOVER_RETRY_DELAY_MS = 100;
  74. /**
  75. * How long a launcher waits for a freshly-spawned daemon to bind its socket
  76. * before giving up and running in-process. The daemon binds the socket *before*
  77. * the (backgrounded) engine/grammar warm-up, so this only needs to cover node
  78. * process startup. 60 × 100ms = 6s of headroom for a cold/slow box; on the
  79. * common path the socket appears within a few rounds.
  80. */
  81. // Poll finely (25ms) so the proxy attaches the instant the freshly-spawned
  82. // daemon binds, instead of waiting up to a coarse 100ms after — shaves the
  83. // cold-start handshake (the window the headless agent races). Same ~6s total
  84. // give-up budget (240 × 25ms), just finer granularity; socket-connect probes
  85. // are cheap. Paired with deferring the CodeGraph load (engine.ts) off the bind
  86. // path, this narrows the "No such tool available" race window.
  87. const DAEMON_CONNECT_MAX_RETRIES = 240;
  88. const DAEMON_CONNECT_RETRY_DELAY_MS = 25;
  89. /** Whether `CODEGRAPH_NO_DAEMON` was set to a truthy value. */
  90. function daemonOptOutSet(): boolean {
  91. const raw = process.env.CODEGRAPH_NO_DAEMON;
  92. if (!raw) return false;
  93. return raw !== '0' && raw.toLowerCase() !== 'false';
  94. }
  95. /** Whether this process was spawned to BE the detached daemon. */
  96. function daemonInternalSet(): boolean {
  97. const raw = process.env[DAEMON_INTERNAL_ENV];
  98. return !!raw && raw !== '0' && raw.toLowerCase() !== 'false';
  99. }
  100. /**
  101. * Prefix every `process.stderr.write` chunk with an ISO-8601 timestamp. Called
  102. * once, only when this process becomes the detached daemon — whose stderr is
  103. * appended to `.codegraph/daemon.log`. Before #1431 no log line carried a
  104. * timestamp, so watchdog kills and restarts could be counted but never placed
  105. * in time. (The watchdog child writes its kill notice through its own
  106. * inherited fd 2, bypassing this wrapper — it stamps that line itself.)
  107. */
  108. export function timestampStderrLines(): void {
  109. const orig = process.stderr.write.bind(process.stderr);
  110. process.stderr.write = ((chunk: string | Uint8Array, ...rest: unknown[]) => {
  111. return (orig as (...args: unknown[]) => boolean)(stampLogChunk(chunk), ...rest);
  112. }) as typeof process.stderr.write;
  113. }
  114. /** Prepend `[<ISO-8601>] ` to a log chunk; unknown chunk types pass through. */
  115. export function stampLogChunk(chunk: string | Uint8Array): string | Uint8Array {
  116. try {
  117. const stamp = `[${new Date().toISOString()}] `;
  118. if (typeof chunk === 'string') return stamp + chunk;
  119. if (Buffer.isBuffer(chunk)) return Buffer.concat([Buffer.from(stamp), chunk]);
  120. } catch { /* stamping is best-effort; never block the write */ }
  121. return chunk;
  122. }
  123. /**
  124. * Watchdog `progressPaths` for a server keyed on `root`'s index: the SQLite DB
  125. * + its WAL. With these, the #850 liveness watchdog only kills on heartbeat
  126. * silence when the DB files are NOT advancing — the same slow-disk deferral
  127. * the CLI `index`/`init` path got in #1231. Without it, one >timeout
  128. * synchronous statement on a big DB (multi-GB index behind Windows Defender)
  129. * SIGKILLs a perfectly healthy daemon — and a daemon SIGKILL'd at the end of
  130. * nearly every session is what ratcheted the WAL leak in #1431. A true wedge
  131. * still dies: a wedged loop writes nothing, so the files stay still.
  132. */
  133. export function watchdogProgressPaths(root: string | null): { progressPaths?: string[] } {
  134. if (!root) return {};
  135. const dbPath = path.join(getCodeGraphDir(root), 'codegraph.db');
  136. return { progressPaths: [dbPath, `${dbPath}-wal`] };
  137. }
  138. /**
  139. * Resolve the project root the daemon machinery should key on. Returns
  140. * `null` when no `.codegraph/` is reachable from the candidate path — in
  141. * that case the caller must run in direct mode, since the daemon lockfile
  142. * and socket both live under `.codegraph/`.
  143. *
  144. * Uses the same resolution as the engine (#1606): up-walk first, then the
  145. * bounded workspace down-scan that adopts a SINGLE indexed sub-project. A
  146. * workspace root above one indexed child therefore gets the shared daemon
  147. * (one watcher, one writer, keyed on the child) instead of a direct-mode
  148. * server per host.
  149. *
  150. * The result is canonicalized with `realpathSync` so every client converges on
  151. * the same socket/lock path regardless of how it expressed the path: a client
  152. * launched with cwd under a symlink (e.g. macOS `/var` → `/private/var`, where
  153. * spawned `process.cwd()` is already realpath'd) and one that passed a
  154. * symlinked `rootUri` would otherwise hash to different sockets and silently
  155. * fail to share the daemon.
  156. */
  157. function resolveDaemonRoot(explicitPath: string | null): string | null {
  158. const candidate = explicitPath ?? process.cwd();
  159. const root = resolveServerRoot(candidate).root;
  160. if (!root) return null;
  161. try { return fs.realpathSync(root); } catch { return root; }
  162. }
  163. /**
  164. * Spawn the shared daemon as a fully detached background process: its own
  165. * session/process group (so a SIGHUP/SIGINT to the launcher's terminal can't
  166. * reach it) with stdio decoupled from the launcher (logs to
  167. * `.codegraph/daemon.log`). Re-invokes the *same* CLI faithfully across dev and
  168. * bundled launches by reusing `process.argv[0]` (the right node), the current
  169. * `process.execArgv` (carries `--liftoff-only`, so the daemon never re-execs)
  170. * and `process.argv[1]` (this script). The spawned process self-arbitrates the
  171. * O_EXCL lock, so racing launchers may each spawn one — losers exit and every
  172. * launcher proxies through the single winner.
  173. */
  174. function spawnDetachedDaemon(root: string): void {
  175. const scriptPath = process.argv[1];
  176. if (!scriptPath) {
  177. // No resolvable CLI entry point to re-invoke — let the caller fall back to
  178. // direct mode rather than spawn something broken.
  179. throw new Error('cannot resolve CLI script path to spawn the daemon');
  180. }
  181. let logFd: number | null = null;
  182. let stdio: StdioOptions = 'ignore';
  183. try {
  184. logFd = fs.openSync(path.join(getCodeGraphDir(root), 'daemon.log'), 'a');
  185. stdio = ['ignore', logFd, logFd];
  186. } catch {
  187. stdio = 'ignore'; // no log file — discard daemon output rather than fail
  188. }
  189. try {
  190. // The daemon has no host: scrub the threaded host pid so it can't leak
  191. // into the daemon's env (and from there into anything the daemon spawns),
  192. // where a long-dead session's host pid would trigger spurious shutdowns.
  193. const env: NodeJS.ProcessEnv = { ...process.env, [DAEMON_INTERNAL_ENV]: '1' };
  194. delete env[HOST_PPID_ENV];
  195. const child = spawn(
  196. process.execPath,
  197. [...process.execArgv, scriptPath, 'serve', '--mcp', '--path', root],
  198. {
  199. detached: true,
  200. stdio,
  201. windowsHide: true,
  202. env,
  203. },
  204. );
  205. child.unref();
  206. } finally {
  207. // The child holds its own dup of the log fd now; the launcher doesn't need it.
  208. if (logFd !== null) {
  209. try { fs.closeSync(logFd); } catch { /* ignore */ }
  210. }
  211. }
  212. }
  213. /**
  214. * MCP Server for CodeGraph
  215. *
  216. * Implements the Model Context Protocol to expose CodeGraph
  217. * functionality as tools that can be called by AI assistants.
  218. *
  219. * Backwards-compatible constructor and `start()` signature with the
  220. * pre-issue-#411 implementation: callers continue to do
  221. * `new MCPServer(path).start()`. Internally we now pick from direct / proxy /
  222. * daemon at start time.
  223. */
  224. export class MCPServer {
  225. private projectPath: string | null;
  226. // Direct-mode-only state. In daemon mode the per-connection sessions live
  227. // inside the Daemon class; in proxy mode there is no session at all.
  228. private session: MCPSession | null = null;
  229. private engine: MCPEngine | null = null;
  230. private daemon: Daemon | null = null;
  231. private ppidWatchdog: ReturnType<typeof setInterval> | null = null;
  232. // Worker-thread liveness watchdog (#850). Long-lived modes only; SIGKILLs the
  233. // process if the main thread wedges in a non-yielding sync loop.
  234. private livenessWatchdog: WatchdogHandle | null = null;
  235. // PPID watchdog baseline — from the CLI entry's earliest-possible capture
  236. // (early-ppid.ts). Capturing here (construction) already lost the race when
  237. // the launcher was killed during module loading (#1185).
  238. private originalPpid: number = EARLY_PPID;
  239. private hostPpid: number | null = parseHostPpid(process.env[HOST_PPID_ENV]);
  240. // Idempotency guard for stop().
  241. private stopped = false;
  242. private mode: 'unstarted' | 'direct' | 'proxy' | 'daemon' = 'unstarted';
  243. /** Project root whose writer.pid we hold in direct mode (#1740); released on stop. */
  244. private writerLockRoot: string | null = null;
  245. constructor(projectPath?: string) {
  246. this.projectPath = projectPath || null;
  247. }
  248. /**
  249. * Start the MCP server.
  250. *
  251. * Decision order:
  252. * 1. `CODEGRAPH_NO_DAEMON=1` → direct mode (unchanged pre-#411 behavior).
  253. * 2. `CODEGRAPH_DAEMON_INTERNAL=1` → we ARE the detached daemon; listen.
  254. * 3. No `.codegraph/` reachable → direct mode (the daemon's lockfile and
  255. * socket both live under `.codegraph/`).
  256. * 4. Otherwise connect to (or spawn) the shared daemon and proxy to it.
  257. *
  258. * On any unexpected failure in step 4 we transparently fall back to direct
  259. * mode — a misbehaving daemon must never block a session from starting.
  260. */
  261. async start(): Promise<void> {
  262. // Long-lived process (direct / proxy / daemon alike): flush buffered
  263. // telemetry opportunistically. Fire-and-forget + unref'd — adds nothing
  264. // to the handshake path and never keeps the process alive.
  265. getTelemetry().startInterval();
  266. // #1243: the MCP config launches the local binary, so a server left
  267. // running drifts behind releases with no signal. Refresh the shared
  268. // update-check cache in the background and log ONE stderr notice when a
  269. // newer version exists (stderr only — stdout is the protocol channel).
  270. // The notice also reaches the agent via the initialize instructions and
  271. // codegraph_status. Fire-and-forget: adds nothing to the handshake path.
  272. checkForUpdateInBackground();
  273. // The detached daemon process itself. Checked before the opt-out so the
  274. // daemon honors the same env it was spawned with (it never sets NO_DAEMON).
  275. if (daemonInternalSet()) {
  276. return this.startDaemonProcess();
  277. }
  278. // Direct mode if the user opted out. Setting the env var is sufficient to
  279. // get the pre-#411 single-process behavior.
  280. if (daemonOptOutSet()) {
  281. return this.startDirect('CODEGRAPH_NO_DAEMON set');
  282. }
  283. const root = resolveDaemonRoot(this.projectPath);
  284. if (!root) {
  285. // No initialized project found — daemon mode has nowhere to put its
  286. // socket. The fresh-checkout / outside-project case; behave as before.
  287. return this.startDirect('no .codegraph/ root found');
  288. }
  289. try {
  290. // Answer the MCP handshake LOCALLY (instant tool registration — no waiting
  291. // ~600ms for the daemon to spawn+bind, which produced the cold-start race)
  292. // and forward tool CALLS to the shared daemon, connected in the background.
  293. // Runs until the host disconnects; the proxy installs its own watchdog and
  294. // falls back to an in-process engine if the daemon never comes up.
  295. this.mode = 'proxy';
  296. await this.runProxyWithLocalHandshake(root);
  297. return;
  298. } catch (err) {
  299. // Belt-and-braces: a throw during proxy SETUP (before the client was served)
  300. // is still safe to recover from with a direct-mode session.
  301. const msg = err instanceof Error ? err.message : String(err);
  302. process.stderr.write(`[CodeGraph MCP] Proxy path failed (${msg}); falling back to direct mode.\n`);
  303. return this.startDirect('proxy path threw');
  304. }
  305. }
  306. /**
  307. * Stop the server. In daemon mode this triggers graceful shutdown of every
  308. * connected session; in direct mode it mirrors the pre-#411 behavior (close
  309. * cg, exit). Proxy mode never routes through here — the proxy exits itself.
  310. */
  311. stop(): void {
  312. if (this.stopped) return;
  313. this.stopped = true;
  314. if (this.writerLockRoot) {
  315. releaseWriterLock(this.writerLockRoot);
  316. this.writerLockRoot = null;
  317. }
  318. if (this.ppidWatchdog) {
  319. clearInterval(this.ppidWatchdog);
  320. this.ppidWatchdog = null;
  321. }
  322. if (this.livenessWatchdog) {
  323. this.livenessWatchdog.stop();
  324. this.livenessWatchdog = null;
  325. }
  326. if (this.daemon) {
  327. void this.daemon.stop('stop()');
  328. // Daemon.stop calls process.exit; nothing else to do.
  329. return;
  330. }
  331. if (this.session) {
  332. this.session.stop();
  333. this.session = null;
  334. }
  335. if (this.engine) {
  336. this.engine.stop();
  337. this.engine = null;
  338. }
  339. process.exit(0);
  340. }
  341. /** Single-process stdio MCP session — the pre-issue-#411 code path. */
  342. private async startDirect(reason: string): Promise<void> {
  343. if (reason && process.env.CODEGRAPH_MCP_DEBUG) {
  344. process.stderr.write(`[CodeGraph MCP] Direct mode: ${reason}.\n`);
  345. }
  346. // #1740: refuse a second direct writer on an initialized project. Daemon
  347. // mode multiplexes clients; direct mode is single-writer-per-project.
  348. const writerRoot = resolveDaemonRoot(this.projectPath);
  349. if (writerRoot) {
  350. const writer = tryAcquireWriterLock(writerRoot, 'direct');
  351. if (writer.kind === 'taken') {
  352. const msg = writerLockHeldMessage(writer.existing, writer.pidPath);
  353. process.stderr.write(`[CodeGraph MCP] ${msg}\n`);
  354. process.exit(1);
  355. }
  356. this.writerLockRoot = writerRoot;
  357. }
  358. this.engine = new MCPEngine();
  359. const transport = new StdioTransport();
  360. this.session = new MCPSession(transport, this.engine, {
  361. explicitProjectPath: this.projectPath,
  362. });
  363. if (this.projectPath) {
  364. // Background init so the initialize response stays fast (#172).
  365. void this.engine.ensureInitialized(this.projectPath);
  366. }
  367. this.session.start();
  368. // Detect parent-process death — same logic as pre-refactor. When stdin
  369. // closes we go through StdioTransport's `process.exit(0)` already, but
  370. // SIGKILL of the parent doesn't reliably close stdin on Linux (#277).
  371. // Also treat a stdin `'error'` (a socket-backed stdin can fail with
  372. // ECONNRESET/hangup instead of a clean close) as shutdown, and destroy the
  373. // stream so a hung fd can't busy-spin the event loop (#799).
  374. treatStdinFailureAsShutdown(() => this.stop());
  375. // Backstop for a launch abandoned during startup (#1185): launcher killed
  376. // before EARLY_PPID could see it + host holding our pipes open. A server
  377. // that never receives a byte of MCP traffic isn't serving anyone. Armed
  378. // after session.start() attached the real stdin consumer.
  379. armStartupHandshakeTimeout(() => {
  380. process.stderr.write(
  381. '[CodeGraph MCP] No MCP traffic since startup; assuming an abandoned launch and shutting down (#1185). ' +
  382. 'Tune with CODEGRAPH_STARTUP_HANDSHAKE_TIMEOUT_MS (0 disables).\n'
  383. );
  384. this.stop();
  385. });
  386. this.mode = 'direct';
  387. this.installSignalHandlers();
  388. this.installPpidWatchdog();
  389. this.livenessWatchdog = installMainThreadWatchdog(watchdogProgressPaths(resolveDaemonRoot(this.projectPath)));
  390. }
  391. /**
  392. * Run as the detached shared daemon (process spawned with
  393. * `CODEGRAPH_DAEMON_INTERNAL=1`). Arbitrate the O_EXCL lock, then either
  394. * become the daemon (bind the socket, serve forever) or — if a live daemon
  395. * already holds the lock — exit so we don't leak a redundant process.
  396. *
  397. * No PPID watchdog and no stdin handlers: the daemon is detached on purpose
  398. * and reaps itself via client-refcount + idle timeout (see {@link Daemon}).
  399. */
  400. private async startDaemonProcess(): Promise<void> {
  401. // In daemon mode stderr IS `.codegraph/daemon.log`; stamp every line so
  402. // kills/restarts can be placed in time (#1431 — the log was undatable).
  403. timestampStderrLines();
  404. const root = resolveDaemonRoot(this.projectPath) ?? this.projectPath ?? process.cwd();
  405. for (let attempt = 0; attempt < TAKEOVER_MAX_RETRIES; attempt++) {
  406. const lock = tryAcquireDaemonLock(root);
  407. if (lock.kind === 'acquired') {
  408. const daemon = new Daemon(root);
  409. await daemon.start();
  410. this.daemon = daemon;
  411. this.mode = 'daemon';
  412. // The detached daemon has no PPID watchdog or stdin lifeline, so a
  413. // wedged main thread would pin a core forever (#850). The liveness
  414. // watchdog is its only recovery path.
  415. this.livenessWatchdog = installMainThreadWatchdog(watchdogProgressPaths(root));
  416. return; // the net.Server keeps the process alive
  417. }
  418. // Taken. If the holder is alive, another daemon already serves (or is
  419. // binding) — we're redundant; exit cleanly so the launcher proxies to it.
  420. const existing = lock.existing;
  421. if (existing && existing.pid > 0 && isProcessAlive(existing.pid)) {
  422. // Give a newly-elected daemon time to bind, then require its socket hello
  423. // to match the lock PID/version. PID existence alone accepts an unrelated
  424. // process after OS PID reuse and permanently wedges startup (#1553).
  425. const age = Date.now() - existing.startedAt;
  426. const stillStarting = existing.startedAt > 0 && age >= 0 && age < 10_000;
  427. if (stillStarting || await probeDaemonIdentity(existing)) {
  428. process.stderr.write(
  429. `[CodeGraph daemon] Another daemon (pid ${existing.pid}) already holds the lock; exiting.\n`
  430. );
  431. process.exit(0);
  432. }
  433. }
  434. // Holder is dead (or the record is unreadable) — clear it (pid-verified,
  435. // so we never delete a live daemon's lock) and retry the acquire.
  436. clearStaleDaemonLock(lock.pidPath, existing?.pid, { allowLivePid: true });
  437. await sleep(TAKEOVER_RETRY_DELAY_MS);
  438. }
  439. process.stderr.write('[CodeGraph daemon] Could not acquire the daemon lock; exiting.\n');
  440. process.exit(0);
  441. }
  442. /**
  443. * Proxy mode (the common case). Serve the MCP handshake LOCALLY for instant
  444. * tool registration, forwarding tool calls to the shared daemon — which is
  445. * connected in the background (probed, then spawned + polled if absent) so the
  446. * handshake never waits ~600ms on it. Runs until the host disconnects; the
  447. * proxy falls back to an in-process engine if the daemon never binds, so this
  448. * never wedges a session.
  449. */
  450. private async runProxyWithLocalHandshake(root: string): Promise<void> {
  451. // The daemon may relocate its socket past an in-project filesystem that can't
  452. // host one (ExFAT/FAT volumes, WSL2 DrvFs; #997) to the deterministic tmpdir
  453. // fallback. We don't read the bound path from the lockfile — both sides walk
  454. // the SAME ordered candidate list, so we converge on whichever the daemon
  455. // bound with zero coordination. The in-project candidate is tried first, so a
  456. // normal repo pays nothing extra (it connects on the very first probe).
  457. const candidates = getDaemonSocketCandidates(root);
  458. const connectAnyCandidate = async (): Promise<Awaited<ReturnType<typeof connectWithHello>>> => {
  459. for (const candidate of candidates) {
  460. const s = await connectWithHello(candidate);
  461. // A wrong-version daemon IS up — definitive; propagate so the caller
  462. // serves in-process instead of spawning + polling for 6s. Don't keep
  463. // probing fallbacks past it.
  464. if (s === 'version-mismatch') return s;
  465. if (s) return s;
  466. }
  467. return null;
  468. };
  469. const getDaemonSocket = async () => {
  470. // Fast path: a daemon may already be listening (on either candidate).
  471. const probe = await connectAnyCandidate();
  472. if (probe === 'version-mismatch') return null; // definitive — serve in-process, don't poll for 6s
  473. if (probe) return probe;
  474. // None reachable — spawn one (detached) and poll for its bind.
  475. spawnDetachedDaemon(root);
  476. for (let attempt = 0; attempt < DAEMON_CONNECT_MAX_RETRIES; attempt++) {
  477. await sleep(DAEMON_CONNECT_RETRY_DELAY_MS);
  478. const s = await connectAnyCandidate();
  479. if (s === 'version-mismatch') return null;
  480. if (s) return s;
  481. }
  482. return null; // never bound — the proxy serves this session in-process
  483. };
  484. await runLocalHandshakeProxy({ getDaemonSocket, makeEngine: () => new MCPEngine(), root });
  485. }
  486. /** Standard SIGINT/SIGTERM handlers that route to our `stop()` (direct mode). */
  487. private installSignalHandlers(): void {
  488. process.on('SIGINT', () => this.stop());
  489. process.on('SIGTERM', () => this.stop());
  490. }
  491. /**
  492. * PPID watchdog (#277) — direct mode only. Daemon mode is detached on purpose
  493. * and reaps via idle timeout; proxy mode installs its own watchdog inside
  494. * {@link runProxy}. So this only ever runs for an in-process direct session.
  495. */
  496. private installPpidWatchdog(): void {
  497. if (this.mode !== 'direct') return;
  498. const pollMs = parsePpidPollMs(process.env.CODEGRAPH_PPID_POLL_MS);
  499. if (pollMs <= 0) return;
  500. this.ppidWatchdog = setInterval(() => {
  501. const reason = supervisionLostReason({
  502. originalPpid: this.originalPpid,
  503. currentPpid: process.ppid,
  504. hostPpid: this.hostPpid,
  505. isAlive: isProcessAlive,
  506. });
  507. if (reason) {
  508. process.stderr.write(
  509. `[CodeGraph MCP] Parent process exited (${reason}); shutting down.\n`
  510. );
  511. this.stop();
  512. }
  513. }, pollMs);
  514. this.ppidWatchdog.unref();
  515. }
  516. }
  517. function sleep(ms: number): Promise<void> {
  518. // Deliberately NOT unref'd. During the daemon connect/takeover retry loop we
  519. // may be between processes — no socket bound yet, no transport, no listener
  520. // pinning the event loop. An unref'd timer would let Node drain the loop and
  521. // exit silently before we get a chance to try again.
  522. return new Promise((resolve) => { setTimeout(resolve, ms); });
  523. }
  524. // Export for use in CLI
  525. export { StdioTransport } from './transport';
  526. export { tools, ToolHandler } from './tools';
  527. // Surface a few daemon-mode bits for tests + diagnostics.
  528. export { Daemon } from './daemon';
  529. export { CodeGraphPackageVersion } from './version';