| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188 |
- /**
- * Project writer lock (#1740).
- *
- * At most one long-lived MCP *writer* (shared daemon OR direct-mode /
- * in-process engine that owns the FileWatcher) may serve a given project.
- * The shared daemon already multiplexes N stdio proxies onto one writer; this
- * lock closes the same-OS gap where two direct-mode `serve --mcp` processes
- * (via `CODEGRAPH_NO_DAEMON=1` or proxy→in-process fallback) each start a
- * watcher, contend on `codegraph.lock`, and degrade auto-sync.
- *
- * Deliberately separate from `daemon.pid`: proxies probe the daemon socket
- * and may clear a live pid that has no socket. A direct-mode holder must not
- * look like a daemon. `writer.pid` is only about "who owns live auto-sync".
- */
- import * as fs from 'fs';
- import * as path from 'path';
- import { getCodeGraphDir } from '../directory';
- /** Signal-0 liveness (EPERM ⇒ alive). Local copy to avoid a daemon↔writer cycle. */
- function isProcessAlive(pid: number): boolean {
- try {
- process.kill(pid, 0);
- return true;
- } catch (err: unknown) {
- const e = err as NodeJS.ErrnoException;
- if (e.code === 'EPERM') return true;
- return false;
- }
- }
- /** Absolute path to the writer pid lockfile for `projectRoot`. */
- export function getWriterPidPath(projectRoot: string): string {
- let root = projectRoot;
- try { root = fs.realpathSync(projectRoot); } catch { /* keep lexical */ }
- return path.join(getCodeGraphDir(root), 'writer.pid');
- }
- /** Structured contents of the writer pidfile. */
- export interface WriterLockInfo {
- pid: number;
- /** `direct` | `daemon` | `fallback` — for actionable error text only. */
- mode: string;
- startedAt: number;
- }
- export type WriterAcquireResult =
- | { kind: 'acquired'; pidPath: string; info: WriterLockInfo }
- | { kind: 'taken'; existing: WriterLockInfo | null; pidPath: string };
- function encode(info: WriterLockInfo): string {
- return JSON.stringify(info) + '\n';
- }
- export function decodeWriterLockInfo(raw: string): WriterLockInfo | null {
- try {
- const parsed = JSON.parse(raw.trim()) as Partial<WriterLockInfo>;
- if (typeof parsed.pid !== 'number' || typeof parsed.mode !== 'string') return null;
- return {
- pid: parsed.pid,
- mode: parsed.mode,
- startedAt: typeof parsed.startedAt === 'number' ? parsed.startedAt : 0,
- };
- } catch {
- return null;
- }
- }
- /**
- * Atomically create `writer.pid` (link-into-place, O_EXCL fallback). If held
- * by a dead PID, clear and retry once. Does not steal from a live holder.
- */
- export function tryAcquireWriterLock(
- projectRoot: string,
- mode: string,
- ): WriterAcquireResult {
- const pidPath = getWriterPidPath(projectRoot);
- fs.mkdirSync(path.dirname(pidPath), { recursive: true });
- const info: WriterLockInfo = {
- pid: process.pid,
- mode,
- startedAt: Date.now(),
- };
- const attempt = (): WriterAcquireResult => {
- const tmp = `${pidPath}.${process.pid}.tmp`;
- let acquired = false;
- try {
- fs.writeFileSync(tmp, encode(info), { mode: 0o600 });
- try {
- fs.linkSync(tmp, pidPath);
- acquired = true;
- } catch (err: unknown) {
- if ((err as NodeJS.ErrnoException).code === 'EEXIST') {
- // taken
- } else {
- // No hard links — O_EXCL create.
- try {
- const fd = fs.openSync(pidPath, 'wx', 0o600);
- try {
- fs.writeSync(fd, encode(info));
- acquired = true;
- } finally {
- fs.closeSync(fd);
- }
- } catch (e2: unknown) {
- if ((e2 as NodeJS.ErrnoException).code !== 'EEXIST') throw e2;
- }
- }
- }
- } finally {
- try { fs.unlinkSync(tmp); } catch { /* ignore */ }
- }
- if (acquired) return { kind: 'acquired', pidPath, info };
- let existing: WriterLockInfo | null = null;
- try {
- existing = decodeWriterLockInfo(fs.readFileSync(pidPath, 'utf8'));
- } catch { /* unreadable */ }
- return { kind: 'taken', existing, pidPath };
- };
- let result = attempt();
- if (result.kind === 'taken' && result.existing && result.existing.pid === process.pid) {
- // Same process already holds it (daemon acquired before engine watch).
- return { kind: 'acquired', pidPath: result.pidPath, info: result.existing };
- }
- if (result.kind === 'taken') {
- const existing = result.existing;
- if (!existing || existing.pid <= 0 || !isProcessAlive(existing.pid)) {
- // Stale — clear (pid-verified) and retry once.
- try {
- const raw = fs.readFileSync(pidPath, 'utf8');
- const cur = decodeWriterLockInfo(raw);
- if (!cur || cur.pid === existing?.pid) {
- if (!cur || cur.pid <= 0 || !isProcessAlive(cur.pid)) {
- fs.unlinkSync(pidPath);
- }
- }
- } catch { /* ENOENT ok */ }
- result = attempt();
- }
- }
- return result;
- }
- /** Release if we still own the lock (pid match). */
- export function releaseWriterLock(projectRoot: string): void {
- const pidPath = getWriterPidPath(projectRoot);
- try {
- if (!fs.existsSync(pidPath)) return;
- const info = decodeWriterLockInfo(fs.readFileSync(pidPath, 'utf8'));
- if (info && info.pid === process.pid) {
- fs.unlinkSync(pidPath);
- }
- } catch { /* best-effort */ }
- }
- /** Read current lock without acquiring. */
- export function readWriterLock(projectRoot: string): WriterLockInfo | null {
- const pidPath = getWriterPidPath(projectRoot);
- try {
- return decodeWriterLockInfo(fs.readFileSync(pidPath, 'utf8'));
- } catch {
- return null;
- }
- }
- /**
- * Actionable message when another live process owns the writer lock (#1740).
- */
- export function writerLockHeldMessage(
- existing: WriterLockInfo | null,
- pidPath: string,
- ): string {
- const who = existing && existing.pid > 0
- ? `PID ${existing.pid} (${existing.mode || 'unknown'} mode)`
- : 'another process';
- return (
- 'CodeGraph writer lock held by ' + who + '. ' +
- 'Only one live MCP writer may serve a project (auto-sync / index). ' +
- 'Stop the other server (codegraph daemon stop if a shared daemon, or end the other MCP session), ' +
- 'or unset CODEGRAPH_NO_DAEMON so additional clients proxy to the shared daemon. ' +
- 'If this is stale, delete ' + pidPath
- );
- }
|