daemon-paths.ts 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200
  1. /**
  2. * Daemon socket + lockfile path helpers — issue #411.
  3. *
  4. * One shared `codegraph serve --mcp` daemon per project root means we need a
  5. * stable, project-keyed rendezvous between cooperating processes. The IPC
  6. * surface area is just two file paths:
  7. *
  8. * - `daemon.sock` — Unix domain socket / named pipe the daemon listens on.
  9. * - `daemon.pid` — atomic-create lockfile holding the daemon's pid + version.
  10. *
  11. * Both live under `.codegraph/` so the project-scoped uninstall (`codegraph
  12. * uninit`) sweeps them up for free.
  13. *
  14. * Special-case: Unix domain socket paths have a hard length limit (~104 on
  15. * macOS, ~108 on Linux); when the in-project path exceeds it we fall back to
  16. * an absolute-path hash under `os.tmpdir()`. The pidfile always stays in the
  17. * project (it doesn't have a length limit) — and acts as the authoritative
  18. * pointer to the socket path the daemon chose.
  19. *
  20. * Second special-case (#997, #974): some filesystems can't host an AF_UNIX node
  21. * AT ALL — ExFAT/FAT external volumes, certain network mounts, WSL2 DrvFs — so
  22. * `listen()` throws ENOTSUP/EACCES regardless of path length. We can't cheaply
  23. * tell those apart from a normal volume up front, so instead of guessing we
  24. * expose an ORDERED candidate list (`getDaemonSocketCandidates`): the in-project
  25. * path first, the deterministic tmpdir path as the fallback of last resort. The
  26. * daemon binds the first that works (relocating past a capability error); the
  27. * proxy connects the first that answers. Both walk the SAME list, so they still
  28. * converge on whichever the daemon bound with zero coordination.
  29. */
  30. import * as crypto from 'crypto';
  31. import * as net from 'net';
  32. import * as os from 'os';
  33. import * as path from 'path';
  34. import { getCodeGraphDir } from '../directory';
  35. /** Soft upper bound for in-project socket paths. */
  36. const POSIX_SOCKET_PATH_LIMIT = 100;
  37. /** Short stable identifier for a project root — used in tmpdir/pipe names. */
  38. function projectHash(projectRoot: string): string {
  39. return crypto.createHash('sha256').update(path.resolve(projectRoot)).digest('hex').slice(0, 16);
  40. }
  41. /**
  42. * The deterministic tmpdir socket path for `projectRoot` — the fallback used
  43. * when the in-project location can't host a socket (too long, or an FS that
  44. * doesn't support AF_UNIX). Hash keeps it project-scoped, and being purely a
  45. * function of the root means the daemon and the proxy compute the identical
  46. * path without talking to each other.
  47. */
  48. function tmpdirSocketPath(projectRoot: string): string {
  49. return path.join(os.tmpdir(), `codegraph-${projectHash(projectRoot)}.sock`);
  50. }
  51. /**
  52. * Ordered socket / named-pipe path candidates the daemon should try to bind (and
  53. * the proxy should try to connect) for `projectRoot`, most-preferred first.
  54. * Deterministic given a project root, so independent processes converge without
  55. * coordination — even when the preferred candidate is unusable and both fall
  56. * through to the same fallback.
  57. *
  58. * - Windows: a single named pipe (lives in the kernel pipe namespace, not on
  59. * the project FS, so neither the length nor the ExFAT hazard applies).
  60. * - Short in-project path: `[ .codegraph/daemon.sock , <tmpdir> ]` — try the
  61. * project first, fall back to tmpdir if its FS can't host a socket (#997).
  62. * - Long in-project path (deep monorepos, Bazel out dirs): `[ <tmpdir> ]` only
  63. * — bind would throw ENAMETOOLONG, so we skip straight to tmpdir.
  64. */
  65. export function getDaemonSocketCandidates(projectRoot: string): string[] {
  66. if (process.platform === 'win32') {
  67. return [`\\\\.\\pipe\\codegraph-${projectHash(projectRoot)}`];
  68. }
  69. const inProject = path.join(getCodeGraphDir(projectRoot), 'daemon.sock');
  70. const tmp = tmpdirSocketPath(projectRoot);
  71. if (inProject.length > POSIX_SOCKET_PATH_LIMIT) return [tmp];
  72. return [inProject, tmp];
  73. }
  74. /**
  75. * The PREFERRED (primary) socket path — candidate 0. Use this only where a
  76. * single representative path is wanted (the lockfile's informational
  77. * `socketPath` field, status display). For binding/connecting, walk the full
  78. * {@link getDaemonSocketCandidates} list — the daemon may bind a fallback when
  79. * candidate 0 is unusable.
  80. */
  81. export function getDaemonSocketPath(projectRoot: string): string {
  82. // The candidate list is never empty (≥1 on every platform), so [0] is safe.
  83. return getDaemonSocketCandidates(projectRoot)[0]!;
  84. }
  85. /** Absolute path to the daemon pid lockfile for `projectRoot`. */
  86. export function getDaemonPidPath(projectRoot: string): string {
  87. return path.join(getCodeGraphDir(projectRoot), 'daemon.pid');
  88. }
  89. /** Structured contents of the pid lockfile. */
  90. export interface DaemonLockInfo {
  91. pid: number;
  92. version: string;
  93. socketPath: string;
  94. startedAt: number;
  95. }
  96. /** Whether a lock record contains enough identity data for a socket hello. */
  97. export function canProbeDaemonIdentity(info: DaemonLockInfo): boolean {
  98. return (
  99. Number.isInteger(info.pid) &&
  100. info.pid > 0 &&
  101. typeof info.socketPath === 'string' &&
  102. info.socketPath.length > 0
  103. );
  104. }
  105. /**
  106. * Verify that the process named by a lockfile is the CodeGraph daemon serving
  107. * its socket. A bare PID liveness probe is insufficient because OSes reuse PIDs
  108. * after an OOM/SIGKILL (#1553).
  109. */
  110. export function probeDaemonIdentity(info: DaemonLockInfo, timeoutMs = 1_000): Promise<boolean> {
  111. if (!canProbeDaemonIdentity(info)) return Promise.resolve(false);
  112. return new Promise<boolean>((resolve) => {
  113. let socket: net.Socket;
  114. let buffer = '';
  115. let done = false;
  116. const finish = (ok: boolean) => {
  117. if (done) return;
  118. done = true;
  119. clearTimeout(timer);
  120. socket.destroy();
  121. resolve(ok);
  122. };
  123. const timer = setTimeout(() => finish(false), timeoutMs);
  124. timer.unref?.();
  125. try {
  126. socket = net.createConnection(info.socketPath);
  127. } catch {
  128. clearTimeout(timer);
  129. resolve(false);
  130. return;
  131. }
  132. socket.setEncoding('utf8');
  133. socket.on('data', (chunk) => {
  134. buffer += String(chunk);
  135. if (buffer.length > 4096) return finish(false);
  136. const newline = buffer.indexOf('\n');
  137. if (newline < 0) return;
  138. try {
  139. const hello = JSON.parse(buffer.slice(0, newline)) as Record<string, unknown>;
  140. finish(
  141. hello.protocol === 1 &&
  142. hello.pid === info.pid &&
  143. (info.version === 'unknown' || hello.codegraph === info.version)
  144. );
  145. } catch {
  146. finish(false);
  147. }
  148. });
  149. socket.on('error', () => finish(false));
  150. socket.on('close', () => finish(false));
  151. });
  152. }
  153. /**
  154. * Serialize a {@link DaemonLockInfo} for writing to the pidfile. JSON for
  155. * human readability — operators occasionally `cat` this when debugging.
  156. */
  157. export function encodeLockInfo(info: DaemonLockInfo): string {
  158. return JSON.stringify(info, null, 2) + '\n';
  159. }
  160. /**
  161. * Parse a pidfile body. Tolerant of old-format pidfiles (plain decimal pid) so
  162. * a 0.10.x daemon doesn't trip over a 0.9.x lockfile if that ever happens —
  163. * we treat such a lockfile as "process is unknown version, refuse to share."
  164. */
  165. export function decodeLockInfo(raw: string): DaemonLockInfo | null {
  166. const trimmed = raw.trim();
  167. if (!trimmed) return null;
  168. try {
  169. const parsed = JSON.parse(trimmed);
  170. if (
  171. parsed &&
  172. typeof parsed.pid === 'number' &&
  173. typeof parsed.version === 'string' &&
  174. typeof parsed.socketPath === 'string' &&
  175. typeof parsed.startedAt === 'number'
  176. ) {
  177. return parsed as DaemonLockInfo;
  178. }
  179. } catch {
  180. // Fall through to legacy plain-pid handling.
  181. }
  182. if (!/^[1-9]\d*$/.test(trimmed)) return null;
  183. const pid = Number(trimmed);
  184. if (Number.isSafeInteger(pid)) {
  185. return { pid, version: 'unknown', socketPath: '', startedAt: 0 };
  186. }
  187. return null;
  188. }