feat(mcp): share one serve --mcp per project across MCP clients (#411)
One shared, detached daemon per project root: every `codegraph serve --mcp` is a thin stdio<->socket proxy (Unix socket / Windows named pipe) to it, so N agents in one repo share a single file watcher, SQLite connection, and tree-sitter warm-up instead of N copies. The daemon outlives any single session and reaps via client-refcount + idle timeout; `CODEGRAPH_NO_DAEMON=1` opts out. Hardened during review: detached-process lifecycle (preserves the #277 watchdog via the proxy; the daemon no longer orphans on host SIGKILL), atomic lockfile + pid-verified stale-clear (no double-daemon on concurrent startup), realpath root canonicalization. Validated on macOS, Linux (Docker - 3x fewer inotify watches for 3 agents), and Windows (named pipes); A/B confirms byte-identical tool output vs direct mode. Closes #411. Co-Authored-By: Colby McHenry <me@colbymchenry.com> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Colby McHenry
Claude Opus 4.7
parent
2721165604
commit
995da54430
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Daemon socket + lockfile path helpers — issue #411.
|
||||
*
|
||||
* One shared `codegraph serve --mcp` daemon per project root means we need a
|
||||
* stable, project-keyed rendezvous between cooperating processes. The IPC
|
||||
* surface area is just two file paths:
|
||||
*
|
||||
* - `daemon.sock` — Unix domain socket / named pipe the daemon listens on.
|
||||
* - `daemon.pid` — atomic-create lockfile holding the daemon's pid + version.
|
||||
*
|
||||
* Both live under `.codegraph/` so the project-scoped uninstall (`codegraph
|
||||
* uninit`) sweeps them up for free.
|
||||
*
|
||||
* Special-case: Unix domain socket paths have a hard length limit (~104 on
|
||||
* macOS, ~108 on Linux); when the in-project path exceeds it we fall back to
|
||||
* an absolute-path hash under `os.tmpdir()`. The pidfile always stays in the
|
||||
* project (it doesn't have a length limit) — and acts as the authoritative
|
||||
* pointer to the socket path the daemon chose.
|
||||
*/
|
||||
|
||||
import * as crypto from 'crypto';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { getCodeGraphDir } from '../directory';
|
||||
|
||||
/** Soft upper bound for in-project socket paths. */
|
||||
const POSIX_SOCKET_PATH_LIMIT = 100;
|
||||
|
||||
/** Short stable identifier for a project root — used in tmpdir/pipe names. */
|
||||
function projectHash(projectRoot: string): string {
|
||||
return crypto.createHash('sha256').update(path.resolve(projectRoot)).digest('hex').slice(0, 16);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the socket / named-pipe path the daemon should listen on (and the
|
||||
* proxy should connect to) for `projectRoot`. Deterministic given a project
|
||||
* root, so independent processes converge without coordination.
|
||||
*/
|
||||
export function getDaemonSocketPath(projectRoot: string): string {
|
||||
if (process.platform === 'win32') {
|
||||
return `\\\\.\\pipe\\codegraph-${projectHash(projectRoot)}`;
|
||||
}
|
||||
const inProject = path.join(getCodeGraphDir(projectRoot), 'daemon.sock');
|
||||
if (inProject.length <= POSIX_SOCKET_PATH_LIMIT) return inProject;
|
||||
// Long project paths (deep monorepos, Bazel out dirs) need tmpdir fallback
|
||||
// or `bind` returns EADDRINUSE / ENAMETOOLONG. Hash keeps it project-scoped.
|
||||
return path.join(os.tmpdir(), `codegraph-${projectHash(projectRoot)}.sock`);
|
||||
}
|
||||
|
||||
/** Absolute path to the daemon pid lockfile for `projectRoot`. */
|
||||
export function getDaemonPidPath(projectRoot: string): string {
|
||||
return path.join(getCodeGraphDir(projectRoot), 'daemon.pid');
|
||||
}
|
||||
|
||||
/** Structured contents of the pid lockfile. */
|
||||
export interface DaemonLockInfo {
|
||||
pid: number;
|
||||
version: string;
|
||||
socketPath: string;
|
||||
startedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a {@link DaemonLockInfo} for writing to the pidfile. JSON for
|
||||
* human readability — operators occasionally `cat` this when debugging.
|
||||
*/
|
||||
export function encodeLockInfo(info: DaemonLockInfo): string {
|
||||
return JSON.stringify(info, null, 2) + '\n';
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a pidfile body. Tolerant of old-format pidfiles (plain decimal pid) so
|
||||
* a 0.10.x daemon doesn't trip over a 0.9.x lockfile if that ever happens —
|
||||
* we treat such a lockfile as "process is unknown version, refuse to share."
|
||||
*/
|
||||
export function decodeLockInfo(raw: string): DaemonLockInfo | null {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (
|
||||
parsed &&
|
||||
typeof parsed.pid === 'number' &&
|
||||
typeof parsed.version === 'string' &&
|
||||
typeof parsed.socketPath === 'string' &&
|
||||
typeof parsed.startedAt === 'number'
|
||||
) {
|
||||
return parsed as DaemonLockInfo;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
// Fall through to legacy plain-pid handling.
|
||||
}
|
||||
const pid = Number(trimmed);
|
||||
if (Number.isFinite(pid) && pid > 0) {
|
||||
return { pid, version: 'unknown', socketPath: '', startedAt: 0 };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user