daemon-registry.ts 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247
  1. /**
  2. * Global daemon registry + stop/list control — the discovery layer behind
  3. * `codegraph list` and `codegraph stop [--all]`.
  4. *
  5. * Every per-project daemon already writes an authoritative lockfile at
  6. * `<root>/.codegraph/daemon.pid`. That's enough to stop ONE daemon you can name,
  7. * but there's no central place to find them ALL — which `list` and `stop --all`
  8. * need. So each daemon also drops a tiny record under `~/.codegraph/daemons/` on
  9. * start and removes it on graceful shutdown.
  10. *
  11. * The registry is a DISCOVERY index, never a source of truth: the live pid is.
  12. * A SIGKILL'd daemon can't remove its own record, so readers prune any record
  13. * whose pid is dead (`isProcessAlive`). Every write/read is best-effort — a
  14. * registry hiccup must never break the daemon or a command; worst case `list`
  15. * momentarily misses or over-lists one, which the next liveness prune corrects.
  16. *
  17. * Cross-platform by construction: only files + `process.kill(pid, signal)`,
  18. * which behave consistently on macOS/Linux (real signals) and Windows (mapped to
  19. * TerminateProcess). Validated live on all three.
  20. */
  21. import * as fs from 'fs';
  22. import * as os from 'os';
  23. import * as path from 'path';
  24. import * as crypto from 'crypto';
  25. import {
  26. getDaemonPidPath,
  27. getDaemonSocketCandidates,
  28. decodeLockInfo,
  29. probeDaemonIdentity,
  30. type DaemonLockInfo,
  31. } from './daemon-paths';
  32. export interface DaemonRecord {
  33. /** Realpath'd project root the daemon serves. */
  34. root: string;
  35. pid: number;
  36. version: string;
  37. socketPath: string;
  38. /** Epoch ms when the daemon bound its socket. */
  39. startedAt: number;
  40. }
  41. /**
  42. * `~/.codegraph/daemons` — GLOBAL, keyed off the home install dir. (The
  43. * `CODEGRAPH_DIR` env var only renames the per-project index dir, not this.)
  44. */
  45. export function getRegistryDir(): string {
  46. return path.join(os.homedir(), '.codegraph', 'daemons');
  47. }
  48. function recordPath(root: string): string {
  49. const hash = crypto.createHash('sha256').update(path.resolve(root)).digest('hex').slice(0, 16);
  50. return path.join(getRegistryDir(), `${hash}.json`);
  51. }
  52. /**
  53. * Is `pid` a live process? `kill(pid, 0)` sends no signal — it just probes:
  54. * ESRCH ⇒ dead, EPERM ⇒ alive but not ours (still alive). Same liveness check
  55. * the PPID watchdog (#277) and daemon lock arbitration use.
  56. */
  57. export function isProcessAlive(pid: number): boolean {
  58. if (!Number.isInteger(pid) || pid <= 0) return false;
  59. try {
  60. process.kill(pid, 0);
  61. return true;
  62. } catch (err) {
  63. return (err as NodeJS.ErrnoException).code === 'EPERM';
  64. }
  65. }
  66. /** Best-effort: record this daemon so `list`/`stop --all` can find it. */
  67. export function registerDaemon(rec: DaemonRecord): void {
  68. try {
  69. fs.mkdirSync(getRegistryDir(), { recursive: true });
  70. fs.writeFileSync(recordPath(rec.root), JSON.stringify(rec, null, 2) + '\n', { mode: 0o600 });
  71. } catch {
  72. /* best-effort — list's liveness prune tolerates a missing record */
  73. }
  74. }
  75. /** Best-effort: drop this daemon's record on graceful shutdown. */
  76. export function deregisterDaemon(root: string): void {
  77. try {
  78. fs.unlinkSync(recordPath(root));
  79. } catch {
  80. /* already gone */
  81. }
  82. }
  83. /**
  84. * All registered daemons whose process is still alive, newest first. Dead/garbage
  85. * records are deleted as a side effect (self-healing) unless `prune` is false.
  86. */
  87. export function listDaemons(opts: { prune?: boolean } = {}): DaemonRecord[] {
  88. const prune = opts.prune ?? true;
  89. const dir = getRegistryDir();
  90. let files: string[];
  91. try {
  92. files = fs.readdirSync(dir).filter((f) => f.endsWith('.json'));
  93. } catch {
  94. return []; // no registry dir yet
  95. }
  96. const live: DaemonRecord[] = [];
  97. for (const file of files) {
  98. const full = path.join(dir, file);
  99. let rec: DaemonRecord | null = null;
  100. try {
  101. rec = JSON.parse(fs.readFileSync(full, 'utf8')) as DaemonRecord;
  102. } catch {
  103. rec = null;
  104. }
  105. const valid = rec && typeof rec.pid === 'number' && typeof rec.root === 'string';
  106. if (valid && isProcessAlive(rec!.pid)) {
  107. live.push(rec!);
  108. } else if (prune) {
  109. try { fs.unlinkSync(full); } catch { /* ignore */ }
  110. }
  111. }
  112. return live.sort((a, b) => b.startedAt - a.startedAt);
  113. }
  114. /**
  115. * Registry entries whose socket hello proves the recorded process is the
  116. * daemon. Used by every user-facing list/stop-all path so a reused PID cannot
  117. * appear as a phantom running daemon (#1553).
  118. */
  119. export async function listVerifiedDaemons(opts: { prune?: boolean } = {}): Promise<DaemonRecord[]> {
  120. const prune = opts.prune ?? true;
  121. const candidates = listDaemons({ prune });
  122. const checks = await Promise.all(candidates.map(async (rec) => ({
  123. rec,
  124. verified: await probeDaemonIdentity(rec),
  125. })));
  126. const verified: DaemonRecord[] = [];
  127. for (const check of checks) {
  128. if (check.verified) verified.push(check.rec);
  129. else if (prune) deregisterDaemon(check.rec.root);
  130. }
  131. return verified;
  132. }
  133. /** Remove a stopped daemon's leftover lockfile + socket + registry record. */
  134. function cleanupDaemonArtifacts(root: string): void {
  135. try { fs.unlinkSync(getDaemonPidPath(root)); } catch { /* gone */ }
  136. // POSIX sockets are real files; Windows named pipes vanish with the process.
  137. // Sweep every candidate — a daemon that relocated past an unusable in-project
  138. // FS (ExFAT/FAT; #997) left its socket at the tmpdir fallback, not candidate 0.
  139. if (process.platform !== 'win32') {
  140. for (const candidate of getDaemonSocketCandidates(root)) {
  141. try { fs.unlinkSync(candidate); } catch { /* gone */ }
  142. }
  143. }
  144. deregisterDaemon(root);
  145. }
  146. /** Remove daemon artifacts only when no matching daemon answers the socket hello. */
  147. export async function clearStaleDaemonArtifacts(root: string): Promise<boolean> {
  148. const pidPath = getDaemonPidPath(root);
  149. const hadArtifacts = fs.existsSync(pidPath) || (
  150. process.platform !== 'win32' && getDaemonSocketCandidates(root).some((p) => fs.existsSync(p))
  151. );
  152. if (!hadArtifacts) return false;
  153. let info: DaemonLockInfo | null = null;
  154. try { info = decodeLockInfo(fs.readFileSync(pidPath, 'utf8')); } catch { /* missing/corrupt */ }
  155. if (info && isProcessAlive(info.pid) && await probeDaemonIdentity(info)) return false;
  156. cleanupDaemonArtifacts(root);
  157. return true;
  158. }
  159. const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
  160. async function waitForDeath(pid: number, timeoutMs: number): Promise<boolean> {
  161. const deadline = Date.now() + timeoutMs;
  162. while (Date.now() < deadline) {
  163. if (!isProcessAlive(pid)) return true;
  164. await sleep(100);
  165. }
  166. return !isProcessAlive(pid);
  167. }
  168. export interface StopResult {
  169. root: string;
  170. pid: number | null;
  171. /** 'term' graceful, 'kill' force, 'not-running' stale lock, 'no-daemon' none found. */
  172. outcome: 'term' | 'kill' | 'not-running' | 'no-daemon';
  173. }
  174. /**
  175. * Stop the daemon serving `root`: SIGTERM, wait, then SIGKILL if it won't go,
  176. * then sweep its artifacts. `root` must be realpath'd (match how the daemon
  177. * keys its socket/lockfile). Resolves the pid from the authoritative lockfile,
  178. * falling back to the registry.
  179. */
  180. export async function stopDaemonAt(root: string): Promise<StopResult> {
  181. let pid: number | null = null;
  182. let identity: DaemonLockInfo | null = null;
  183. try {
  184. identity = decodeLockInfo(fs.readFileSync(getDaemonPidPath(root), 'utf8'));
  185. pid = identity?.pid ?? null;
  186. } catch {
  187. /* no lockfile */
  188. }
  189. if (pid == null) {
  190. const rec = listDaemons({ prune: false }).find(
  191. (r) => path.resolve(r.root) === path.resolve(root)
  192. );
  193. pid = rec?.pid ?? null;
  194. if (rec) identity = rec;
  195. }
  196. if (pid == null) {
  197. cleanupDaemonArtifacts(root);
  198. return { root, pid: null, outcome: 'no-daemon' };
  199. }
  200. if (!isProcessAlive(pid)) {
  201. cleanupDaemonArtifacts(root);
  202. return { root, pid, outcome: 'not-running' };
  203. }
  204. // Never signal a process merely because it reused a stale daemon PID. The
  205. // daemon's immediate hello is the process-identity proof (#1553).
  206. if (!identity || !await probeDaemonIdentity(identity)) {
  207. cleanupDaemonArtifacts(root);
  208. return { root, pid, outcome: 'not-running' };
  209. }
  210. // POSIX: SIGTERM runs the daemon's graceful shutdown. Windows: TerminateProcess
  211. // (no graceful path), so we always sweep artifacts ourselves below.
  212. try { process.kill(pid, 'SIGTERM'); } catch { /* raced to exit */ }
  213. let outcome: StopResult['outcome'] = 'term';
  214. if (!(await waitForDeath(pid, 3000))) {
  215. try { process.kill(pid, 'SIGKILL'); } catch { /* raced to exit */ }
  216. await waitForDeath(pid, 2000);
  217. outcome = 'kill';
  218. }
  219. cleanupDaemonArtifacts(root);
  220. return { root, pid, outcome };
  221. }
  222. /** Stop every registered, live daemon. */
  223. export async function stopAllDaemons(): Promise<StopResult[]> {
  224. const results: StopResult[] = [];
  225. for (const rec of await listVerifiedDaemons()) {
  226. results.push(await stopDaemonAt(rec.root));
  227. }
  228. return results;
  229. }