writer-lock.ts 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188
  1. /**
  2. * Project writer lock (#1740).
  3. *
  4. * At most one long-lived MCP *writer* (shared daemon OR direct-mode /
  5. * in-process engine that owns the FileWatcher) may serve a given project.
  6. * The shared daemon already multiplexes N stdio proxies onto one writer; this
  7. * lock closes the same-OS gap where two direct-mode `serve --mcp` processes
  8. * (via `CODEGRAPH_NO_DAEMON=1` or proxy→in-process fallback) each start a
  9. * watcher, contend on `codegraph.lock`, and degrade auto-sync.
  10. *
  11. * Deliberately separate from `daemon.pid`: proxies probe the daemon socket
  12. * and may clear a live pid that has no socket. A direct-mode holder must not
  13. * look like a daemon. `writer.pid` is only about "who owns live auto-sync".
  14. */
  15. import * as fs from 'fs';
  16. import * as path from 'path';
  17. import { getCodeGraphDir } from '../directory';
  18. /** Signal-0 liveness (EPERM ⇒ alive). Local copy to avoid a daemon↔writer cycle. */
  19. function isProcessAlive(pid: number): boolean {
  20. try {
  21. process.kill(pid, 0);
  22. return true;
  23. } catch (err: unknown) {
  24. const e = err as NodeJS.ErrnoException;
  25. if (e.code === 'EPERM') return true;
  26. return false;
  27. }
  28. }
  29. /** Absolute path to the writer pid lockfile for `projectRoot`. */
  30. export function getWriterPidPath(projectRoot: string): string {
  31. let root = projectRoot;
  32. try { root = fs.realpathSync(projectRoot); } catch { /* keep lexical */ }
  33. return path.join(getCodeGraphDir(root), 'writer.pid');
  34. }
  35. /** Structured contents of the writer pidfile. */
  36. export interface WriterLockInfo {
  37. pid: number;
  38. /** `direct` | `daemon` | `fallback` — for actionable error text only. */
  39. mode: string;
  40. startedAt: number;
  41. }
  42. export type WriterAcquireResult =
  43. | { kind: 'acquired'; pidPath: string; info: WriterLockInfo }
  44. | { kind: 'taken'; existing: WriterLockInfo | null; pidPath: string };
  45. function encode(info: WriterLockInfo): string {
  46. return JSON.stringify(info) + '\n';
  47. }
  48. export function decodeWriterLockInfo(raw: string): WriterLockInfo | null {
  49. try {
  50. const parsed = JSON.parse(raw.trim()) as Partial<WriterLockInfo>;
  51. if (typeof parsed.pid !== 'number' || typeof parsed.mode !== 'string') return null;
  52. return {
  53. pid: parsed.pid,
  54. mode: parsed.mode,
  55. startedAt: typeof parsed.startedAt === 'number' ? parsed.startedAt : 0,
  56. };
  57. } catch {
  58. return null;
  59. }
  60. }
  61. /**
  62. * Atomically create `writer.pid` (link-into-place, O_EXCL fallback). If held
  63. * by a dead PID, clear and retry once. Does not steal from a live holder.
  64. */
  65. export function tryAcquireWriterLock(
  66. projectRoot: string,
  67. mode: string,
  68. ): WriterAcquireResult {
  69. const pidPath = getWriterPidPath(projectRoot);
  70. fs.mkdirSync(path.dirname(pidPath), { recursive: true });
  71. const info: WriterLockInfo = {
  72. pid: process.pid,
  73. mode,
  74. startedAt: Date.now(),
  75. };
  76. const attempt = (): WriterAcquireResult => {
  77. const tmp = `${pidPath}.${process.pid}.tmp`;
  78. let acquired = false;
  79. try {
  80. fs.writeFileSync(tmp, encode(info), { mode: 0o600 });
  81. try {
  82. fs.linkSync(tmp, pidPath);
  83. acquired = true;
  84. } catch (err: unknown) {
  85. if ((err as NodeJS.ErrnoException).code === 'EEXIST') {
  86. // taken
  87. } else {
  88. // No hard links — O_EXCL create.
  89. try {
  90. const fd = fs.openSync(pidPath, 'wx', 0o600);
  91. try {
  92. fs.writeSync(fd, encode(info));
  93. acquired = true;
  94. } finally {
  95. fs.closeSync(fd);
  96. }
  97. } catch (e2: unknown) {
  98. if ((e2 as NodeJS.ErrnoException).code !== 'EEXIST') throw e2;
  99. }
  100. }
  101. }
  102. } finally {
  103. try { fs.unlinkSync(tmp); } catch { /* ignore */ }
  104. }
  105. if (acquired) return { kind: 'acquired', pidPath, info };
  106. let existing: WriterLockInfo | null = null;
  107. try {
  108. existing = decodeWriterLockInfo(fs.readFileSync(pidPath, 'utf8'));
  109. } catch { /* unreadable */ }
  110. return { kind: 'taken', existing, pidPath };
  111. };
  112. let result = attempt();
  113. if (result.kind === 'taken' && result.existing && result.existing.pid === process.pid) {
  114. // Same process already holds it (daemon acquired before engine watch).
  115. return { kind: 'acquired', pidPath: result.pidPath, info: result.existing };
  116. }
  117. if (result.kind === 'taken') {
  118. const existing = result.existing;
  119. if (!existing || existing.pid <= 0 || !isProcessAlive(existing.pid)) {
  120. // Stale — clear (pid-verified) and retry once.
  121. try {
  122. const raw = fs.readFileSync(pidPath, 'utf8');
  123. const cur = decodeWriterLockInfo(raw);
  124. if (!cur || cur.pid === existing?.pid) {
  125. if (!cur || cur.pid <= 0 || !isProcessAlive(cur.pid)) {
  126. fs.unlinkSync(pidPath);
  127. }
  128. }
  129. } catch { /* ENOENT ok */ }
  130. result = attempt();
  131. }
  132. }
  133. return result;
  134. }
  135. /** Release if we still own the lock (pid match). */
  136. export function releaseWriterLock(projectRoot: string): void {
  137. const pidPath = getWriterPidPath(projectRoot);
  138. try {
  139. if (!fs.existsSync(pidPath)) return;
  140. const info = decodeWriterLockInfo(fs.readFileSync(pidPath, 'utf8'));
  141. if (info && info.pid === process.pid) {
  142. fs.unlinkSync(pidPath);
  143. }
  144. } catch { /* best-effort */ }
  145. }
  146. /** Read current lock without acquiring. */
  147. export function readWriterLock(projectRoot: string): WriterLockInfo | null {
  148. const pidPath = getWriterPidPath(projectRoot);
  149. try {
  150. return decodeWriterLockInfo(fs.readFileSync(pidPath, 'utf8'));
  151. } catch {
  152. return null;
  153. }
  154. }
  155. /**
  156. * Actionable message when another live process owns the writer lock (#1740).
  157. */
  158. export function writerLockHeldMessage(
  159. existing: WriterLockInfo | null,
  160. pidPath: string,
  161. ): string {
  162. const who = existing && existing.pid > 0
  163. ? `PID ${existing.pid} (${existing.mode || 'unknown'} mode)`
  164. : 'another process';
  165. return (
  166. 'CodeGraph writer lock held by ' + who + '. ' +
  167. 'Only one live MCP writer may serve a project (auto-sync / index). ' +
  168. 'Stop the other server (codegraph daemon stop if a shared daemon, or end the other MCP session), ' +
  169. 'or unset CODEGRAPH_NO_DAEMON so additional clients proxy to the shared daemon. ' +
  170. 'If this is stale, delete ' + pidPath
  171. );
  172. }