proxy.ts 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601
  1. /**
  2. * MCP proxy mode — issue #411.
  3. *
  4. * The proxy is a near-transparent stdio↔socket pipe. Once it has verified
  5. * the daemon's hello line (same major.minor.patch as ours), it does no
  6. * protocol parsing of its own: every byte the MCP host writes to the proxy's
  7. * stdin goes straight to the daemon socket, and every byte the daemon emits
  8. * goes straight to the host's stdout. Server-initiated JSON-RPC requests
  9. * (e.g. `roots/list`) flow through the same pipe transparently.
  10. *
  11. * Lifecycle expectations:
  12. * - The proxy exits when *either* stream closes (host stdin closed →
  13. * daemon socket end, or daemon-side socket close → host stdout end).
  14. * - Closing the socket on the proxy side is what tells the daemon to
  15. * decrement its connected-clients refcount.
  16. * - On a parent-process death we can't detect via stdin close (e.g. SIGKILL
  17. * of the MCP host), the proxy's PPID watchdog catches it — same logic
  18. * the direct-mode server uses; see issue #277.
  19. */
  20. import * as fs from 'fs';
  21. import * as net from 'net';
  22. import { HOST_PPID_ENV } from '../extraction/wasm-runtime-flags';
  23. import { DaemonClientHello, DaemonHello, MAX_HELLO_LINE_BYTES } from './daemon';
  24. import { EARLY_PPID } from './early-ppid';
  25. import { supervisionLostReason } from './ppid-watchdog';
  26. import { armStartupHandshakeTimeout } from './startup-handshake';
  27. import { treatStdinFailureAsShutdown } from './stdin-teardown';
  28. import { CodeGraphPackageVersion } from './version';
  29. import { SERVER_INFO, PROTOCOL_VERSION, initializeInstructions } from './session';
  30. import { SERVER_INSTRUCTIONS } from './server-instructions';
  31. import { getStaticTools } from './tools';
  32. import { ExploreSessionState } from './explore-session-state';
  33. import { getTelemetry, ClientInfo } from '../telemetry';
  34. import type { MCPEngine } from './engine';
  35. /** Default poll cadence for the PPID watchdog (same as the direct server). */
  36. const DEFAULT_PPID_POLL_MS = 5000;
  37. /**
  38. * Env var that opts INTO the "attached to shared daemon" log line. Off by
  39. * default: the line is benign INFO, but MCP hosts render any server stderr at
  40. * error level (and append an `undefined` data field), so on every session start
  41. * a healthy attach showed up as `[error] … undefined`. Set to `1` to surface it
  42. * when debugging daemon attach. (#618; approach from #640 by @mturac)
  43. */
  44. const LOG_ATTACH_ENV = 'CODEGRAPH_MCP_LOG_ATTACH';
  45. /**
  46. * Log a successful daemon attach — gated behind {@link LOG_ATTACH_ENV} so it is
  47. * silent by default (see #618). Exported for tests.
  48. */
  49. export function logAttachedDaemon(socketPath: string, hello: DaemonHello): void {
  50. if (process.env[LOG_ATTACH_ENV] !== '1') return;
  51. process.stderr.write(
  52. `[CodeGraph MCP] Attached to shared daemon on ${socketPath} (pid ${hello.pid}, v${hello.codegraph}).\n`
  53. );
  54. }
  55. export interface ProxyResult {
  56. /**
  57. * `proxied` — successfully attached to a same-version daemon and piped
  58. * stdio. The proxy stays alive until either end closes.
  59. * `fallback-needed` — the daemon rejected us (version mismatch / unreachable
  60. * socket) and the caller should run the server in direct mode.
  61. */
  62. outcome: 'proxied' | 'fallback-needed';
  63. reason?: string;
  64. }
  65. /**
  66. * Attempt to connect to the daemon at `socketPath` and pipe stdio through it.
  67. *
  68. * Returns a promise that resolves when either:
  69. * - the connection succeeded and one of stdin/socket has now closed
  70. * (after which the process should exit), or
  71. * - the connection failed early enough that the caller can still fall
  72. * back to direct mode.
  73. *
  74. * The `expectedVersion` param defaults to the package's own version — daemon
  75. * and proxy MUST match exactly. Mismatch resolves with
  76. * `outcome: 'fallback-needed'` so the caller can transparently start its own
  77. * server. (We accept the cost of two concurrent servers in this case as the
  78. * price of never silently running a stale daemon against newer client code.)
  79. */
  80. export async function runProxy(
  81. socketPath: string,
  82. expectedVersion: string = CodeGraphPackageVersion,
  83. ): Promise<ProxyResult> {
  84. // POSIX: refuse to connect to a stale socket file that points at no
  85. // listening process. `fs.existsSync` is a cheap pre-check; a real
  86. // ECONNREFUSED below catches the rare "exists but unbound" race.
  87. if (process.platform !== 'win32' && !fs.existsSync(socketPath)) {
  88. return { outcome: 'fallback-needed', reason: 'socket file missing' };
  89. }
  90. const socket = net.createConnection(socketPath);
  91. socket.setEncoding('utf8');
  92. const hello = await readHelloLine(socket).catch((err) => {
  93. socket.destroy();
  94. return new Error(String(err));
  95. });
  96. if (hello instanceof Error) {
  97. return { outcome: 'fallback-needed', reason: hello.message };
  98. }
  99. if (hello.codegraph !== expectedVersion) {
  100. process.stderr.write(
  101. `[CodeGraph MCP] Found a daemon on ${socketPath} but version (${hello.codegraph}) ` +
  102. `differs from ours (${expectedVersion}); falling back to direct mode.\n`
  103. );
  104. socket.destroy();
  105. return { outcome: 'fallback-needed', reason: 'version mismatch' };
  106. }
  107. logAttachedDaemon(socketPath, hello);
  108. sendClientHello(socket);
  109. startPpidWatchdog(socket);
  110. await pipeUntilClose(socket);
  111. // Host disconnected (or the daemon went away). The proxy's only job is the
  112. // pipe; exit now so we don't linger — process.stdin's 'data' listener would
  113. // otherwise keep the event loop alive and leave a zombie launcher behind.
  114. process.exit(0);
  115. }
  116. /**
  117. * Connect to a daemon at `socketPath` and verify its hello (exact version match).
  118. * Returns the live socket (hello already consumed) or null if unreachable / stale
  119. * / version-mismatched. Unlike {@link runProxy} it does NOT pipe — the caller
  120. * owns the socket. Used by the local-handshake proxy's background connect.
  121. */
  122. export async function connectWithHello(
  123. socketPath: string,
  124. expectedVersion: string = CodeGraphPackageVersion,
  125. ): Promise<net.Socket | 'version-mismatch' | null> {
  126. if (process.platform !== 'win32' && !fs.existsSync(socketPath)) return null;
  127. const socket = net.createConnection(socketPath);
  128. socket.setEncoding('utf8');
  129. // Keep an 'error' listener attached for the socket's ENTIRE life. readHelloLine
  130. // attaches its own and then REMOVES it on success (its cleanup()), which left a
  131. // window — from here until the caller attaches its onDaemonLost handler — where
  132. // a socket 'error' had NO listener. In Node an unhandled socket 'error' is
  133. // re-thrown as an uncaughtException, which the global fatal handler turns into
  134. // process.exit(1); to an MCP client that surfaces as a bare "Transport closed"
  135. // (#974). The window is rarely hit on a healthy FS but is common on flaky
  136. // AF_UNIX-over-DrvFs (WSL2 /mnt drives). A no-op guard makes the error
  137. // recoverable: the follow-up 'close' drives the caller's normal fallback.
  138. socket.on('error', () => { /* absorbed — see #974; 'close' drives the fallback */ });
  139. const hello = await readHelloLine(socket).catch(() => null);
  140. if (!hello) {
  141. socket.destroy();
  142. return null; // no daemon yet — caller should keep polling
  143. }
  144. if (hello.codegraph !== expectedVersion) {
  145. // A daemon IS up but it's the wrong version — definitive, not a "not yet".
  146. // Don't poll; the caller serves in-process so we never run stale-vs-new.
  147. process.stderr.write(
  148. `[CodeGraph MCP] Found a daemon on ${socketPath} but version (${hello.codegraph}) ` +
  149. `differs from ours (${expectedVersion}); serving this session in-process.\n`
  150. );
  151. socket.destroy();
  152. return 'version-mismatch';
  153. }
  154. logAttachedDaemon(socketPath, hello);
  155. sendClientHello(socket);
  156. return socket;
  157. }
  158. /**
  159. * Tell the daemon our pids right after we verify its hello, so its liveness
  160. * sweep can reap this client if our process dies without the socket ever
  161. * signalling close (the Windows named-pipe hazard behind #692). Best-effort:
  162. * sent before any piped bytes so it's always the daemon's first line from us,
  163. * and a write failure here is harmless (the daemon just falls back to the
  164. * socket-close lifecycle). `hostPid` mirrors the PPID watchdog: the threaded
  165. * host pid if set, else our own parent (the host, on a no-relaunch bundle).
  166. */
  167. function sendClientHello(socket: net.Socket): void {
  168. const clientHello: DaemonClientHello = {
  169. codegraph_client: 1,
  170. pid: process.pid,
  171. hostPid: parseHostPpid(process.env[HOST_PPID_ENV]) ?? EARLY_PPID,
  172. };
  173. try { socket.write(JSON.stringify(clientHello) + '\n'); } catch { /* best-effort */ }
  174. }
  175. type JsonRpc = Record<string, unknown>;
  176. /** Dependencies the local-handshake proxy needs, injected by MCPServer (which
  177. * owns the daemon-spawn machinery and the engine factory). */
  178. export interface LocalHandshakeDeps {
  179. /** Probe → spawn → retry → hello-verify; resolves a connected daemon socket,
  180. * or null when the daemon path is genuinely unavailable (→ in-process fallback). */
  181. getDaemonSocket(): Promise<net.Socket | null>;
  182. /** Lazily create an in-process engine — used ONLY if the daemon never comes up,
  183. * preserving the "a broken daemon never wedges a session" guarantee. */
  184. makeEngine(): MCPEngine;
  185. /** Project root for the fallback engine's lazy init. */
  186. root: string;
  187. }
  188. /**
  189. * Local-handshake proxy (the cold-start fix).
  190. *
  191. * Answers `initialize` + `tools/list` from STATIC constants the instant the
  192. * client asks — tools register in ~process-startup time instead of waiting
  193. * ~600ms for the daemon to spawn+bind, which is what produced the "No such tool
  194. * available" race that made headless agents flail into grep/Read. Tool CALLS are
  195. * forwarded to the shared daemon (connected in the background); the daemon's
  196. * response to the forwarded `initialize` is suppressed (the client already got
  197. * the local one). If the daemon never comes up (version mismatch / spawn fail),
  198. * a lazily-created in-process engine serves the calls — so the handshake speedup
  199. * never costs the old fall-back-to-direct robustness.
  200. */
  201. export async function runLocalHandshakeProxy(deps: LocalHandshakeDeps): Promise<void> {
  202. let daemonStatus: 'connecting' | 'ready' | 'failed' = 'connecting';
  203. let daemonSocket: net.Socket | null = null;
  204. let clientInitId: unknown = undefined; // suppress the daemon's reply to the forwarded initialize
  205. // Telemetry attribution for the in-process fallback only — calls routed to
  206. // the daemon are counted by the daemon's own session (which receives the
  207. // forwarded initialize, clientInfo included), never double-counted here.
  208. let telemetryClient: ClientInfo | undefined;
  209. const pending: string[] = []; // client lines buffered until the daemon resolves
  210. let engine: MCPEngine | null = null;
  211. let engineReady: Promise<void> | null = null;
  212. let shuttingDown = false;
  213. // Requests forwarded to the daemon and not yet answered, keyed by JSON-RPC id.
  214. // If the daemon dies mid-session (#662 — e.g. an MCP host SIGTERM's it when a
  215. // new session starts), these would otherwise hang forever; we re-serve them
  216. // in-process so the host always gets a reply.
  217. const inflight = new Map<unknown, string>();
  218. // Explore call history for the ONE host connection this proxy serves (CG-17).
  219. // Only the daemon-unavailable fallback below uses it; when the daemon is up,
  220. // the tracking happens on the daemon's own MCPSession.
  221. const exploreSession = new ExploreSessionState();
  222. const trackInflight = (line: string): void => {
  223. try {
  224. const m = JSON.parse(line) as JsonRpc;
  225. if (m && m.id !== undefined && typeof m.method === 'string' && m.method !== 'initialize') {
  226. inflight.set(m.id, line);
  227. }
  228. } catch { /* unparseable — nothing we could re-serve anyway */ }
  229. };
  230. const writeClient = (obj: JsonRpc | string): void => {
  231. try { process.stdout.write((typeof obj === 'string' ? obj : JSON.stringify(obj)) + '\n'); } catch { /* host gone */ }
  232. };
  233. const shutdown = (): void => {
  234. if (shuttingDown) return; shuttingDown = true;
  235. try { daemonSocket?.destroy(); } catch { /* ignore */ }
  236. try { engine?.stop(); } catch { /* ignore */ }
  237. process.exit(0);
  238. };
  239. const ensureEngine = (): Promise<void> => {
  240. if (!engine) engine = deps.makeEngine();
  241. if (!engineReady) engineReady = engine.ensureInitialized(deps.root).catch(() => { /* degraded */ });
  242. return engineReady;
  243. };
  244. // Daemon-unavailable fallback: serve a client message in-process.
  245. const handleLocally = async (line: string): Promise<void> => {
  246. let msg: JsonRpc; try { msg = JSON.parse(line) as JsonRpc; } catch { return; }
  247. const id = msg.id;
  248. if (msg.method === 'tools/call' && id !== undefined) {
  249. try {
  250. await ensureEngine();
  251. const params = (msg.params || {}) as { name: string; arguments?: Record<string, unknown> };
  252. const result = await engine!.getToolHandler().execute(params.name, params.arguments || {}, exploreSession);
  253. writeClient({ jsonrpc: '2.0', id, result });
  254. getTelemetry().recordUsage('mcp_tool', params.name, !result.isError, telemetryClient);
  255. } catch (err) {
  256. writeClient({ jsonrpc: '2.0', id, error: { code: -32603, message: err instanceof Error ? err.message : String(err) } });
  257. }
  258. } else if (msg.method === 'ping' && id !== undefined) {
  259. writeClient({ jsonrpc: '2.0', id, result: {} });
  260. } else if (id !== undefined && msg.method !== 'initialize') {
  261. // A request we can't serve in-process (and the daemon is gone) — answer
  262. // with an error rather than let the host hang on a reply that won't come.
  263. writeClient({ jsonrpc: '2.0', id, error: { code: -32603, message: 'CodeGraph daemon unavailable' } });
  264. }
  265. // initialize already answered locally; notifications (initialized) need no reply.
  266. };
  267. const routeToDaemon = (line: string): void => {
  268. if (daemonStatus === 'ready' && daemonSocket) {
  269. trackInflight(line);
  270. if (process.env.CODEGRAPH_MCP_DEBUG) process.stderr.write(`[mcp-debug] proxy->daemon ${line.slice(0, 80)}\n`);
  271. try { daemonSocket.write(line.endsWith('\n') ? line : line + '\n'); } catch { /* close path */ }
  272. } else if (daemonStatus === 'failed') {
  273. void handleLocally(line);
  274. } else {
  275. if (process.env.CODEGRAPH_MCP_DEBUG) process.stderr.write(`[mcp-debug] proxy-buffer(${daemonStatus}) ${line.slice(0, 80)}\n`);
  276. pending.push(line);
  277. }
  278. };
  279. // ---- client (stdin) ----
  280. let stdinBuf = '';
  281. process.stdin.setEncoding('utf8');
  282. process.stdin.on('data', (chunk: string) => {
  283. stdinBuf += chunk;
  284. let idx: number;
  285. while ((idx = stdinBuf.indexOf('\n')) !== -1) {
  286. const line = stdinBuf.slice(0, idx).trim();
  287. stdinBuf = stdinBuf.slice(idx + 1);
  288. if (!line) continue;
  289. let msg: JsonRpc; try { msg = JSON.parse(line) as JsonRpc; } catch { routeToDaemon(line); continue; }
  290. if (msg.method === 'initialize') {
  291. clientInitId = msg.id;
  292. const initParams = (msg.params ?? {}) as { clientInfo?: { name?: unknown; version?: unknown } };
  293. if (initParams.clientInfo) {
  294. telemetryClient = {
  295. name: typeof initParams.clientInfo.name === 'string' ? initParams.clientInfo.name : undefined,
  296. version: typeof initParams.clientInfo.version === 'string' ? initParams.clientInfo.version : undefined,
  297. };
  298. }
  299. writeClient({ jsonrpc: '2.0', id: msg.id, result: { protocolVersion: PROTOCOL_VERSION, capabilities: { tools: {} }, serverInfo: SERVER_INFO, instructions: initializeInstructions(SERVER_INSTRUCTIONS) } });
  300. routeToDaemon(line); // prime the daemon so it resolves the project (its reply is suppressed below)
  301. } else if (msg.method === 'tools/list') {
  302. writeClient({ jsonrpc: '2.0', id: msg.id, result: { tools: getStaticTools() } });
  303. } else if (msg.method === 'resources/list') {
  304. // No resources exposed — answer the probe locally so it never reaches
  305. // the daemon as an unhandled method and logs `-32601`. (#621)
  306. writeClient({ jsonrpc: '2.0', id: msg.id, result: { resources: [] } });
  307. } else if (msg.method === 'resources/templates/list') {
  308. writeClient({ jsonrpc: '2.0', id: msg.id, result: { resourceTemplates: [] } });
  309. } else if (msg.method === 'prompts/list') {
  310. writeClient({ jsonrpc: '2.0', id: msg.id, result: { prompts: [] } });
  311. } else {
  312. routeToDaemon(line);
  313. }
  314. }
  315. });
  316. // Shut down when stdin ends/closes — and also on a stdin `'error'`, which a
  317. // socket-backed stdin (the VS Code stdio shape) can emit on client death
  318. // instead of a clean close; destroying the stream stops a hung fd from
  319. // busy-spinning the event loop (#799).
  320. treatStdinFailureAsShutdown(shutdown);
  321. startPpidWatchdogNoSocket(shutdown);
  322. // Backstop for a launch abandoned before any of the above can see it: killed
  323. // launcher + held-open pipes + reparent that beat the EARLY_PPID capture
  324. // (#1185). A server that never receives a single byte isn't serving anyone.
  325. // Armed after the stdin 'data' consumer above so no bytes are emitted while
  326. // only the backstop's listener exists.
  327. armStartupHandshakeTimeout(() => {
  328. process.stderr.write(
  329. '[CodeGraph MCP] No MCP traffic since startup; assuming an abandoned launch and shutting down (#1185). ' +
  330. 'Tune with CODEGRAPH_STARTUP_HANDSHAKE_TIMEOUT_MS (0 disables).\n'
  331. );
  332. shutdown();
  333. });
  334. // ---- daemon connection (background) ----
  335. let socket: net.Socket | null = null;
  336. try { socket = await deps.getDaemonSocket(); } catch { socket = null; }
  337. // `!socket.destroyed`: the connect-window error guard above can absorb an
  338. // 'error' that already destroyed the socket before we got here (#974) — treat
  339. // a dead socket as "no daemon" so we cleanly fall back to the in-process engine.
  340. if (socket && !socket.destroyed && !shuttingDown) {
  341. daemonSocket = socket;
  342. daemonStatus = 'ready';
  343. let sockBuf = '';
  344. socket.setEncoding('utf8');
  345. socket.on('data', (chunk: string) => {
  346. sockBuf += chunk;
  347. let idx: number;
  348. while ((idx = sockBuf.indexOf('\n')) !== -1) {
  349. const line = sockBuf.slice(0, idx);
  350. sockBuf = sockBuf.slice(idx + 1);
  351. if (!line.trim()) continue;
  352. let resp: JsonRpc | null = null;
  353. try { resp = JSON.parse(line) as JsonRpc; } catch { /* not JSON — relay verbatim */ }
  354. if (process.env.CODEGRAPH_MCP_DEBUG) process.stderr.write(`[mcp-debug] daemon->proxy ${line.slice(0, 80)}\n`);
  355. if (resp && resp.id !== undefined && ('result' in resp || 'error' in resp)) {
  356. inflight.delete(resp.id); // answered — no longer in flight
  357. // Suppress the daemon's reply to the initialize we forwarded to prime it
  358. // (the client already got the local handshake response).
  359. if (clientInitId !== undefined && resp.id === clientInitId) continue;
  360. }
  361. writeClient(line);
  362. }
  363. });
  364. // The daemon going away does NOT end the session (#662). An MCP host can
  365. // SIGTERM the shared daemon when another session starts; if we exited here,
  366. // this host would silently lose CodeGraph and any in-flight request would
  367. // hang. Instead, fall back to the in-process engine for the rest of the
  368. // session and re-serve whatever the dead daemon never answered.
  369. const onDaemonLost = (): void => {
  370. if (shuttingDown || daemonStatus !== 'ready') return; // host teardown, or already handled
  371. daemonStatus = 'failed';
  372. try { daemonSocket?.destroy(); } catch { /* ignore */ }
  373. daemonSocket = null;
  374. process.stderr.write(
  375. `[CodeGraph MCP] Shared daemon connection lost; serving this session in-process (degraded), re-serving ${inflight.size} in-flight request(s).\n`
  376. );
  377. const orphaned = [...inflight.values()];
  378. inflight.clear();
  379. for (const line of orphaned) void handleLocally(line);
  380. };
  381. socket.on('close', onDaemonLost);
  382. socket.on('error', onDaemonLost);
  383. for (const line of pending) {
  384. trackInflight(line);
  385. if (process.env.CODEGRAPH_MCP_DEBUG) process.stderr.write(`[mcp-debug] proxy-flush ${line.slice(0, 80)}\n`);
  386. try { socket.write(line + '\n'); } catch { /* ignore */ }
  387. }
  388. pending.length = 0;
  389. } else if (!shuttingDown) {
  390. daemonStatus = 'failed';
  391. process.stderr.write('[CodeGraph MCP] Shared daemon unavailable; serving this session in-process (degraded).\n');
  392. const buffered = pending.splice(0);
  393. for (const line of buffered) await handleLocally(line);
  394. }
  395. await new Promise<void>(() => { /* stdin keeps the loop alive; exit via shutdown() */ });
  396. }
  397. /** PPID watchdog for the local-handshake proxy — same #277 logic as
  398. * {@link startPpidWatchdog} but with no socket to close (the caller's shutdown
  399. * handles teardown). */
  400. function startPpidWatchdogNoSocket(onDeath: () => void): void {
  401. const pollMs = parsePollMs(process.env.CODEGRAPH_PPID_POLL_MS);
  402. if (pollMs <= 0) return;
  403. // Baseline from the CLI entry's earliest capture, not process.ppid here —
  404. // a launcher killed during our first ~100ms would otherwise leave the
  405. // baseline at 1 and blind the divergence check forever (#1185).
  406. const originalPpid = EARLY_PPID;
  407. const hostPpid = parseHostPpid(process.env[HOST_PPID_ENV]);
  408. const timer = setInterval(() => {
  409. const reason = supervisionLostReason({
  410. originalPpid,
  411. currentPpid: process.ppid,
  412. hostPpid,
  413. isAlive: isProcessAliveLocal,
  414. });
  415. if (reason) {
  416. process.stderr.write(`[CodeGraph MCP] Parent process exited (${reason}); shutting down.\n`);
  417. onDeath();
  418. }
  419. }, pollMs);
  420. timer.unref?.();
  421. }
  422. /**
  423. * Read one CRLF/LF-terminated JSON line from the socket, parse it as the
  424. * daemon hello, and return it. Bounded to {@link MAX_HELLO_LINE_BYTES} so a
  425. * malicious or broken peer can't OOM us. Times out at 3s — a healthy daemon
  426. * sends hello immediately on accept.
  427. */
  428. function readHelloLine(socket: net.Socket): Promise<DaemonHello> {
  429. return new Promise((resolve, reject) => {
  430. let buffer = '';
  431. const cleanup = () => {
  432. socket.removeListener('data', onData);
  433. socket.removeListener('error', onError);
  434. socket.removeListener('close', onClose);
  435. clearTimeout(timer);
  436. };
  437. const onData = (chunk: string | Buffer) => {
  438. buffer += typeof chunk === 'string' ? chunk : chunk.toString('utf8');
  439. const idx = buffer.indexOf('\n');
  440. if (idx === -1) {
  441. if (buffer.length > MAX_HELLO_LINE_BYTES) {
  442. cleanup();
  443. reject(new Error('daemon hello line exceeded size limit'));
  444. }
  445. return;
  446. }
  447. const line = buffer.slice(0, idx);
  448. // Re-emit anything past the newline so the pipe-stage sees it.
  449. const tail = buffer.slice(idx + 1);
  450. cleanup();
  451. if (tail.length > 0) {
  452. // Push back via unshift — Node's net.Socket supports it on readable streams.
  453. socket.unshift(tail);
  454. }
  455. try {
  456. const parsed = JSON.parse(line) as DaemonHello;
  457. if (typeof parsed.codegraph !== 'string' || typeof parsed.pid !== 'number') {
  458. reject(new Error('daemon hello missing required fields'));
  459. return;
  460. }
  461. resolve(parsed);
  462. } catch (err) {
  463. reject(new Error(`daemon hello not JSON: ${err instanceof Error ? err.message : String(err)}`));
  464. }
  465. };
  466. const onError = (err: Error) => { cleanup(); reject(err); };
  467. const onClose = () => { cleanup(); reject(new Error('daemon closed connection before hello')); };
  468. const timer = setTimeout(() => {
  469. cleanup();
  470. reject(new Error('timed out waiting for daemon hello'));
  471. }, 3000);
  472. timer.unref?.();
  473. socket.on('data', onData);
  474. socket.on('error', onError);
  475. socket.on('close', onClose);
  476. });
  477. }
  478. /**
  479. * Pipe stdin → socket and socket → stdout. Resolves once either end closes
  480. * so the process can exit. Note: we deliberately do NOT use
  481. * `process.stdin.pipe(socket)` because pipe propagates 'end' onto the
  482. * downstream, which would close the socket prematurely if stdin happens to
  483. * end early — the MCP spec allows it to stay open across reconnects.
  484. */
  485. function pipeUntilClose(socket: net.Socket): Promise<void> {
  486. return new Promise((resolve) => {
  487. let resolved = false;
  488. const done = () => { if (!resolved) { resolved = true; resolve(); } };
  489. process.stdin.on('data', (chunk) => {
  490. try { socket.write(chunk); } catch { /* socket may have errored — close path catches it */ }
  491. });
  492. process.stdin.on('end', () => {
  493. try { socket.end(); } catch { /* ignore */ }
  494. done();
  495. });
  496. // 'close' and 'error' both tear down: a socket-backed stdin can fail with
  497. // an 'error' (ECONNRESET/hangup) rather than a clean close; destroying it
  498. // stops a hung fd from busy-spinning the event loop (#799).
  499. const teardown = () => {
  500. try { process.stdin.destroy(); } catch { /* ignore */ }
  501. try { socket.destroy(); } catch { /* ignore */ }
  502. done();
  503. };
  504. process.stdin.on('close', teardown);
  505. process.stdin.on('error', teardown);
  506. socket.on('data', (chunk) => {
  507. try { process.stdout.write(chunk); } catch { /* ignore */ }
  508. });
  509. socket.on('end', () => done());
  510. socket.on('close', () => done());
  511. socket.on('error', (err) => {
  512. process.stderr.write(`[CodeGraph MCP] daemon socket error: ${err.message}\n`);
  513. done();
  514. });
  515. });
  516. }
  517. /**
  518. * PPID watchdog mirroring the one in `MCPServer.start` — kills the proxy if
  519. * the MCP host (or its proxy of a host, see HOST_PPID_ENV) goes away without
  520. * closing stdin. Issue #277 documents why we can't rely on stdin EOF on
  521. * Linux: the parent may be SIGKILL'd and reparenting doesn't close pipes.
  522. *
  523. * The proxy's "kill" is just a socket close + process.exit — no SQLite or
  524. * watchers to clean up, so this is cheap.
  525. */
  526. function startPpidWatchdog(socket: net.Socket): void {
  527. const pollMs = parsePollMs(process.env.CODEGRAPH_PPID_POLL_MS);
  528. if (pollMs <= 0) return;
  529. // Baseline from the CLI entry's earliest capture, not process.ppid here —
  530. // a launcher killed during our first ~100ms would otherwise leave the
  531. // baseline at 1 and blind the divergence check forever (#1185).
  532. const originalPpid = EARLY_PPID;
  533. const hostPpid = parseHostPpid(process.env[HOST_PPID_ENV]);
  534. const timer = setInterval(() => {
  535. const reason = supervisionLostReason({
  536. originalPpid,
  537. currentPpid: process.ppid,
  538. hostPpid,
  539. isAlive: isProcessAliveLocal,
  540. });
  541. if (reason) {
  542. process.stderr.write(`[CodeGraph MCP] Parent process exited (${reason}); shutting down.\n`);
  543. try { socket.destroy(); } catch { /* ignore */ }
  544. process.exit(0);
  545. }
  546. }, pollMs);
  547. timer.unref?.();
  548. }
  549. function parsePollMs(raw: string | undefined): number {
  550. if (raw === undefined || raw === '') return DEFAULT_PPID_POLL_MS;
  551. const parsed = Number(raw);
  552. if (!Number.isFinite(parsed)) return DEFAULT_PPID_POLL_MS;
  553. if (parsed < 0) return DEFAULT_PPID_POLL_MS;
  554. return Math.floor(parsed);
  555. }
  556. function parseHostPpid(raw: string | undefined): number | null {
  557. if (raw === undefined || raw === '') return null;
  558. const parsed = Number(raw);
  559. if (!Number.isInteger(parsed) || parsed <= 1) return null;
  560. return parsed;
  561. }
  562. function isProcessAliveLocal(pid: number): boolean {
  563. try {
  564. process.kill(pid, 0);
  565. return true;
  566. } catch (err: unknown) {
  567. const e = err as NodeJS.ErrnoException;
  568. if (e.code === 'EPERM') return true;
  569. return false;
  570. }
  571. }