* Shared MCP daemon — issue #411.
*
* One detached `codegraph serve --mcp` daemon process per project root,
* accepting N concurrent MCP clients over a Unix-domain socket (or named pipe
* on Windows). Each incoming connection gets its own {@link MCPSession}; all
* sessions share a single {@link MCPEngine}, which means a single file watcher
* (one inotify set), a single SQLite connection (one WAL writer), and a single
* tree-sitter warm-up — paid once, amortized across every agent talking to the
* project.
*
* Lifecycle (see also `./index.ts` and `./proxy.ts`):
* - The daemon is spawned **detached** (its own session/process group, stdio
* decoupled) by the first launcher that finds no daemon running. It is NOT
* a child of any MCP host, so closing one terminal / Ctrl-C'ing one session
* can't take it down and sever the others. That's why this process has no
* PPID watchdog: it deliberately outlives every individual client.
* - Every MCP host talks to the daemon through a thin `proxy` process (the
* thing the host actually spawned). The proxy keeps the #277 PPID watchdog,
* so a SIGKILL'd host still reaps its proxy promptly; the proxy's socket
* close then decrements the daemon's refcount.
* - When the last client disconnects the daemon lingers for
* `CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS` (default 300s) so back-to-back agent
* runs in the same project don't repay startup, then exits cleanly. This is
* what keeps a single-agent session from leaking a daemon forever (#277).
*
* What this file owns:
* - Listening on the daemon socket and spawning per-connection sessions.
* - The handshake "hello" line that lets a proxy verify it found a
* same-version daemon before piping any JSON-RPC through it.
* - The lockfile (`.codegraph/daemon.pid`) competing daemons arbitrate
* against — atomic `O_EXCL` create with the full record written in the same
* breath (no empty-file window) + cleanup on exit.
* - Reference counting + idle timeout.
* - Graceful shutdown on SIGTERM/SIGINT and idle exit.
*
* What this file does NOT own:
* - The proxy side (`./proxy.ts`).
* - The decision of *whether* to run as daemon at all — that's `MCPServer`.
* - The MCP protocol state machine — that's `./session.ts`.
*/
import * as fs from 'fs';
import * as net from 'net';
import * as path from 'path';
import { MCPEngine } from './engine';
import { MCPSession } from './session';
import { SocketTransport } from './transport';
import {
DaemonLockInfo,
decodeLockInfo,
encodeLockInfo,
getDaemonPidPath,
getDaemonSocketCandidates,
getDaemonSocketPath,
} from './daemon-paths';
import { CodeGraphPackageJsonPath, CodeGraphPackageVersion } from './version';
import {
releaseWriterLock,
swapWriterLock,
tryAcquireWriterLock,
assertNoRebuild,
writerLockHeldMessage,
type WriterLockInfo,
} from './writer-lock';
import { registerDaemon, deregisterDaemon } from './daemon-registry';
const DEFAULT_IDLE_TIMEOUT_MS = 300_000;
* Hard ceiling on how long the daemon stays up with clients connected but no
* inbound traffic. A backstop (#692): if a client's socket-close is never
* delivered (a Windows named-pipe hazard) it stays counted forever and the
* normal idle timer — which only arms at zero clients — never fires. A phantom
* client sends no traffic, so bounding on inactivity reaps the daemon anyway.
* Set generously so a real but momentarily-idle session isn't reaped mid-use.
*/
const DEFAULT_MAX_IDLE_MS = 1_800_000;
* Windows-only shutdown backstop. On Windows, calling `process.exit()` while a
* recursive `fs.watch` handle is still tearing down aborts the process with a
* libuv `UV_HANDLE_CLOSING` assertion (`0xC0000409`) — reproducible whenever the
* watched tree contains a nested repo (submodule / embedded clone), since that's
* what keeps a watch active at shutdown. The fix is to let the event loop drain
* so libuv finishes closing those handles, then exit naturally; this timer only
* force-exits if some unexpected handle keeps the loop alive past the grace
* window. Kept short so shutdown stays snappy in that fallback. See
* `finalizeDaemonExit`.
*/
const DAEMON_SHUTDOWN_BACKSTOP_MS = 2_000;
* Finalize daemon shutdown. On POSIX, exit immediately — it's clean and fast.
* On Windows, do NOT force an exit while watchers may still be closing (that
* trips the libuv assertion above); instead mark success and let the loop drain
* to a natural exit, with an UNREF'd backstop that force-exits only if a stray
* handle would otherwise hang shutdown. Pure and platform-injected so both
* branches are unit-testable off-Windows. Returns the backstop timer (Windows)
* so callers/tests can clear it.
*/
export function finalizeDaemonExit(
platform: NodeJS.Platform,
exit: (code: number) => void,
): NodeJS.Timeout | null {
if (platform === 'win32') {
process.exitCode = 0;
const backstop = setTimeout(() => exit(0), DAEMON_SHUTDOWN_BACKSTOP_MS);
backstop.unref?.();
return backstop;
}
exit(0);
return null;
}
const DEFAULT_CLIENT_SWEEP_MS = 30_000;
const DEFAULT_INSTALL_CHECK_MS = 30_000;
* Why the install this daemon was started from no longer matches it, or null
* while it still does. An upgrade replaces or deletes the package directory
* under a running daemon (npm reinstalling the package, the launcher pruning
* an old bundle), and everything the daemon loads lazily from there afterwards
* fails — grammars among them, so every file its watcher re-indexed came out
* empty (#2335). Such a daemon should make way for one started from the
* current install. Any other read failure, or a package.json caught mid-write,
* answers null: the next check looks again. Exported for testing.
*/
export function installChangedReason(packageJsonPath: string, runningVersion: string): string | null {
let raw: string;
try {
raw = fs.readFileSync(packageJsonPath, 'utf8');
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
return code === 'ENOENT' || code === 'ENOTDIR' ? `Install removed (${packageJsonPath} is gone)` : null;
}
let version: unknown;
try {
version = (JSON.parse(raw) as { version?: unknown } | null)?.version;
} catch {
return null;
}
return typeof version === 'string' && version !== runningVersion
? `Install replaced by v${version} (this daemon is v${runningVersion})`
: null;
}
const CLIENT_HELLO_TIMEOUT_MS = 3_000;
const MAX_HELLO_LINE_BYTES = 4096;
* Wire format for the one-shot hello line the daemon emits on every new
* connection. Versioned with the package's own semver so a 0.9.x proxy never
* pipes through a 0.10.x daemon (or vice-versa) — the proxy falls back to
* direct mode on mismatch rather than risk subtle wire incompatibilities.
*/
export interface DaemonHello {
codegraph: string;
pid: number;
socketPath: string;
protocol: 1;
}
* Optional reverse-handshake line a proxy sends right after it verifies the
* daemon hello, carrying its own pids so the daemon can reap the client if its
* process dies WITHOUT the socket ever signalling close (the Windows named-pipe
* hazard behind #692). Entirely optional and fail-safe: a connection that never
* sends it (a legacy/direct client) just falls back to the socket-close
* lifecycle. The `codegraph_client` marker is what tells it apart from the
* client's first JSON-RPC message.
*/
export interface DaemonClientHello {
codegraph_client: 1;
pid: number;
hostPid: number | null;
}
export interface DaemonStartResult {
socketPath: string;
lock: DaemonLockInfo;
}
* Run as the shared daemon for `projectRoot`. Resolves once the socket is
* listening. The Daemon owns the socket, the engine, and the lockfile until
* `stop()` is called or it exits on idle/signal.
*
* Race-safe: callers must first call `tryAcquireDaemonLock(projectRoot)` and
* only construct a Daemon if they got the lock (`kind: 'acquired'`). The atomic
* create/link inside the acquire helper elects one candidate. The project
* writer lock then fences bind/ownership refresh against stale-artifact cleanup.
*/
export class Daemon {
private server: net.Server | null = null;
private acceptedSockets = new Set<net.Socket>();
private clients = new Set<MCPSession>();
private clientPeers = new Map<MCPSession, { pid: number | null; hostPid: number | null }>();
private idleTimer: NodeJS.Timeout | null = null;
private idleTimeoutMs: number;
private maxIdleMs: number;
private lastActivityAt = Date.now();
private maxIdleTimer: NodeJS.Timeout | null = null;
private clientSweepTimer: NodeJS.Timeout | null = null;
private installCheckTimer: NodeJS.Timeout | null = null;
private engine: MCPEngine;
private stopping = false;
private socketPath: string;
private pidPath: string;
private handoverFrom: number | null;
constructor(
private projectRoot: string,
opts: { idleTimeoutMs?: number; maxIdleMs?: number; handoverFrom?: number | null } = {},
) {
this.socketPath = getDaemonSocketPath(projectRoot);
this.pidPath = getDaemonPidPath(projectRoot);
this.idleTimeoutMs = opts.idleTimeoutMs ?? resolveIdleTimeoutMs();
this.maxIdleMs = opts.maxIdleMs ?? resolveMaxIdleMs();
this.handoverFrom = opts.handoverFrom ?? null;
this.engine = new MCPEngine({ queryPool: true });
this.engine.setProjectPathHint(projectRoot);
}
* Bind the socket, refresh the ownership record, kick off engine init, and
* register signal handlers. The promise resolves once the server is listening
* — the daemon then sticks around until idle/shutdown.
*/
async start(): Promise<DaemonStartResult> {
assertNoRebuild(this.projectRoot);
let writer = tryAcquireWriterLock(this.projectRoot, 'daemon');
if (writer.kind === 'taken' && this.handoverFrom !== null &&
writer.existing?.pid === this.handoverFrom && writer.existing.mode === 'handover') {
const info: WriterLockInfo = { pid: process.pid, mode: 'daemon', startedAt: Date.now(), ready: false };
if (swapWriterLock(this.projectRoot, this.handoverFrom, info)) {
writer = { kind: 'acquired', pidPath: writer.pidPath, info };
process.stderr.write(`[CodeGraph daemon] Took over the writer lock from launcher pid ${this.handoverFrom}, which stopped an older daemon.\n`);
}
}
if (writer.kind === 'taken') {
const msg = writerLockHeldMessage(writer.existing, writer.pidPath);
process.stderr.write(`[CodeGraph daemon] ${msg}\n`);
this.cleanupLockfile();
throw new Error(msg);
}
let initialLockContents: string;
try {
initialLockContents = fs.readFileSync(this.pidPath, 'utf8');
if (decodeLockInfo(initialLockContents)?.pid !== process.pid) {
throw new Error('daemon lock belongs to another process');
}
} catch {
releaseWriterLock(this.projectRoot);
throw new Error('Lost daemon lock ownership before startup.');
}
const candidates = getDaemonSocketCandidates(this.projectRoot);
const listen = (socketPath: string): Promise<net.Server> =>
new Promise<net.Server>((resolve, reject) => {
if (process.platform !== 'win32') {
try { fs.unlinkSync(socketPath); } catch { }
}
const server = net.createServer((socket) => this.handleConnection(socket));
server.once('error', reject);
server.listen(socketPath, () => {
if (process.platform !== 'win32') {
try { fs.chmodSync(socketPath, 0o600); } catch { }
}
resolve(server);
});
});
let bound: { server: net.Server; socketPath: string };
try {
bound = await bindFirstUsableSocket(candidates, listen, {
onRelocate: (from, to, code) =>
process.stderr.write(
`[CodeGraph daemon] Socket ${from} unusable (${code}); relocating to ${to}.\n`
),
});
} catch (err) {
this.cleanupLockfile();
if (process.platform !== 'win32') {
for (const candidate of candidates) {
try { fs.unlinkSync(candidate); } catch { }
}
}
throw err;
}
this.server = bound.server;
this.socketPath = bound.socketPath;
const lock: DaemonLockInfo = {
pid: process.pid,
version: CodeGraphPackageVersion,
socketPath: this.socketPath,
startedAt: Date.now(),
};
try {
refreshDaemonLock(this.pidPath, initialLockContents, lock);
} catch (err) {
try { bound.server.close(); } catch { }
this.cleanupLockfile();
throw err;
}
void this.engine.ensureInitialized(this.projectRoot);
registerDaemon({ root: this.projectRoot, ...lock });
process.stderr.write(
`[CodeGraph daemon] Listening on ${this.socketPath} (pid ${process.pid}, v${CodeGraphPackageVersion}). Idle timeout ${this.idleTimeoutMs}ms.\n`
);
this.armIdleTimer();
this.startLivenessTimers();
process.on('SIGINT', () => this.stop('SIGINT'));
process.on('SIGTERM', () => this.stop('SIGTERM'));
return { socketPath: this.socketPath, lock };
}
getClientCount(): number {
return this.clients.size;
}
getSocketPath(): string {
return this.socketPath;
}
async stop(reason: string = 'stop'): Promise<void> {
if (this.stopping) return;
this.stopping = true;
if (this.idleTimer) {
clearTimeout(this.idleTimer);
this.idleTimer = null;
}
if (this.maxIdleTimer) {
clearInterval(this.maxIdleTimer);
this.maxIdleTimer = null;
}
if (this.clientSweepTimer) {
clearInterval(this.clientSweepTimer);
this.clientSweepTimer = null;
}
if (this.installCheckTimer) {
clearInterval(this.installCheckTimer);
this.installCheckTimer = null;
}
process.stderr.write(`[CodeGraph daemon] Shutting down (${reason}; clients=${this.clients.size}).\n`);
for (const session of [...this.clients]) {
try { session.stop(); } catch { }
}
this.clients.clear();
for (const socket of this.acceptedSockets) socket.destroy();
this.acceptedSockets.clear();
if (this.server) {
await new Promise<void>((resolve) => this.server!.close(() => resolve()));
this.server = null;
}
await this.engine.stop();
this.cleanupLockfile();
deregisterDaemon(this.projectRoot);
if (process.platform !== 'win32') {
try { fs.unlinkSync(this.socketPath); } catch { }
}
finalizeDaemonExit(process.platform, (code) => process.exit(code));
}
private handleConnection(socket: net.Socket): void {
if (this.stopping) { socket.destroy(); return; }
this.acceptedSockets.add(socket);
socket.once('close', () => this.acceptedSockets.delete(socket));
const hello: DaemonHello = {
codegraph: CodeGraphPackageVersion,
pid: process.pid,
socketPath: this.socketPath,
protocol: 1,
};
socket.write(JSON.stringify(hello) + '\n');
void readClientHello(socket).then((peers) => {
if (this.stopping || socket.destroyed) { socket.destroy(); return; }
const transport = new SocketTransport(socket);
const session = new MCPSession(transport, this.engine, {
explicitProjectPath: this.projectRoot,
});
transport.onClose(() => this.dropClient(session));
this.clients.add(session);
this.clientPeers.set(session, peers);
this.disarmIdleTimer();
session.start();
socket.on('data', () => { this.lastActivityAt = Date.now(); });
});
}
private dropClient(session: MCPSession): void {
if (!this.clients.delete(session)) return;
this.clientPeers.delete(session);
if (this.clients.size === 0) this.armIdleTimer();
}
private armIdleTimer(): void {
if (this.idleTimer || this.stopping) return;
if (this.idleTimeoutMs <= 0) return;
this.idleTimer = setTimeout(() => {
this.idleTimer = null;
if (this.clients.size > 0) {
this.armIdleTimer();
return;
}
void this.stop('idle timeout');
}, this.idleTimeoutMs);
this.idleTimer.unref?.();
}
private disarmIdleTimer(): void {
if (!this.idleTimer) return;
clearTimeout(this.idleTimer);
this.idleTimer = null;
}
* Defense-in-depth against a daemon that outlives its clients (#692), for the
* cases the refcount + idle timer miss because a socket close never arrives:
* - **Inactivity backstop:** after `maxIdleMs` with no inbound traffic, reap
* the daemon — but ONLY if no connected client can be proven alive (see
* {@link backstopShouldExit}). This is the sole phantom class the sweep
* below can't catch: a client whose client-hello never arrived, so we have
* no pid to check.
* - **Liveness sweep:** drop any client whose peer process has died (per the
* client-hello pids), which re-arms the idle timer once the last real
* client is gone. Catches a dead peer within one sweep instead of waiting
* out the whole backstop.
* Both timers are unref'd — the listening server keeps the loop alive, and
* neither should hold it open on its own.
*/
private startLivenessTimers(): void {
if (this.maxIdleMs > 0) {
const tick = Math.min(this.maxIdleMs, 60_000);
this.maxIdleTimer = setInterval(() => {
if (this.backstopShouldExit(isProcessAlive)) void this.stop('inactivity backstop');
}, tick);
this.maxIdleTimer.unref?.();
}
const sweepMs = resolveClientSweepMs();
if (sweepMs > 0) {
this.clientSweepTimer = setInterval(() => this.reapDeadClients(isProcessAlive), sweepMs);
this.clientSweepTimer.unref?.();
}
const installMs = resolveInstallCheckMs();
if (installMs > 0 && CodeGraphPackageVersion !== '0.0.0-unknown') {
this.installCheckTimer = setInterval(() => { this.checkInstall(); }, installMs);
this.installCheckTimer.unref?.();
}
}
* Exit once the install this daemon runs from has been upgraded or removed
* (#2335; see {@link installChangedReason}). Its clients' proxies fall back
* and reconnect, and the next launch starts a daemon from the current
* install. Returns whether the daemon is exiting. The check is injected for
* tests; the timer passes the real one.
*/
checkInstall(
changed: () => string | null = () => installChangedReason(CodeGraphPackageJsonPath, CodeGraphPackageVersion),
): boolean {
if (this.stopping) return false;
const reason = changed();
if (!reason) return false;
process.stderr.write(`[CodeGraph daemon] ${reason}; exiting so the next session starts a daemon from the current install.\n`);
void this.stop('install changed');
return true;
}
* Decide whether the inactivity backstop should reap the daemon right now.
* Public + `isAlive`-injected for deterministic tests; the timer calls it each
* tick with the real liveness probe.
*
* The backstop exists ONLY to catch a **phantom** client (#692) — one counted
* but actually gone, whose socket-close was never delivered. It must never
* reap a **live-but-quiet** session (connected, alive peer, just not querying):
* doing so silently severed the shared daemon and degraded that session — and
* any others sharing it — to an in-process engine. `lastActivityAt` only tracks
* inbound query bytes, and MCP has no keepalive, so a genuinely-live session
* trips the raw inactivity window after ~30 min of not being queried.
*
* So: once the inactivity window elapses, drop provably-dead peers (the same
* check the periodic sweep runs), then reap the daemon only when NOT ONE
* remaining client can be proven alive — i.e. every client left is an
* unknown-pid connection the sweep can't verify. A single provably-alive
* client keeps the daemon up. Has the sweep's side effect (drops dead peers).
*/
backstopShouldExit(isAlive: (pid: number) => boolean): boolean {
if (this.stopping || this.clients.size === 0) return false;
if (Date.now() - this.lastActivityAt < this.maxIdleMs) return false;
this.reapDeadClients(isAlive);
if (this.clients.size === 0) return false;
const anyProvablyAlive = [...this.clients].some((session) => {
const peers = this.clientPeers.get(session);
return peers != null && peers.pid !== null && !peerIsDead(peers, isAlive);
});
return !anyProvablyAlive;
}
* Drop every connected client whose peer process is gone. Returns the count
* reaped. `isAlive` is injected for testing. Clients with unknown pids (no
* client-hello) are skipped — they rely on the socket-close path.
*/
reapDeadClients(isAlive: (pid: number) => boolean): number {
if (this.clients.size === 0) return 0;
let reaped = 0;
for (const session of [...this.clients]) {
const peers = this.clientPeers.get(session);
if (!peers || !peerIsDead(peers, isAlive)) continue;
process.stderr.write(
`[CodeGraph daemon] Reaping client with dead peer (pid ${peers.pid}); clients=${this.clients.size - 1}.\n`
);
try { session.stop(); } catch { }
this.dropClient(session);
reaped++;
}
return reaped;
}
private cleanupLockfile(): void {
releaseWriterLock(this.projectRoot);
try {
if (fs.existsSync(this.pidPath)) {
const raw = fs.readFileSync(this.pidPath, 'utf8');
const info = decodeLockInfo(raw);
if (info && info.pid === process.pid) {
fs.unlinkSync(this.pidPath);
}
}
} catch { }
}
}
* Waits between retries of the pid-file replace on Windows, doubling to a cap:
* eight attempts wait about 1.6s in all. Windows refuses to replace a file
* while any handle to it is open, so an antivirus scan, an indexer, or another
* session reading the lock can each hold it for a moment, and for longer on a
* loaded machine (#1773). The total stays well inside a launcher's ~6s connect
* window.
*/
const LOCK_REFRESH_RETRY_DELAYS_MS = [25, 50, 100, 200, 400, 400, 400];
* Publish the bound socket without abandoning a live daemon on a transient
* Windows sharing violation. Keep this startup step synchronous: accepting a
* client before ownership is refreshed could initialize an engine too early.
* Retries wait per {@link LOCK_REFRESH_RETRY_DELAYS_MS}; permanent failures
* still abort startup.
*/
export function refreshDaemonLock(
pidPath: string,
initialContents: string,
lock: DaemonLockInfo,
platform: NodeJS.Platform = process.platform,
): void {
const tmpPid = `${pidPath}.${process.pid}.bound`;
try {
fs.writeFileSync(tmpPid, encodeLockInfo(lock), { mode: 0o600 });
for (let attempt = 0; ; attempt++) {
if (fs.readFileSync(pidPath, 'utf8') !== initialContents) {
throw new Error('Lost daemon lock ownership after binding.');
}
try {
fs.renameSync(tmpPid, pidPath);
return;
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
const delay = LOCK_REFRESH_RETRY_DELAYS_MS[attempt];
if (platform !== 'win32' || !['EPERM', 'EACCES', 'EBUSY'].includes(code ?? '') || delay === undefined) {
throw error;
}
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delay);
}
}
} finally {
try { fs.unlinkSync(tmpPid); } catch { }
}
}
* Result of `tryAcquireDaemonLock`. Either we got the lockfile (caller becomes
* the daemon), or it already existed (caller should connect to the existing
* daemon as a proxy, or — if the holder is dead — clear it and retry).
*/
export type AcquireResult =
| { kind: 'acquired'; pidPath: string; info: DaemonLockInfo }
| {
kind: 'taken';
existing: DaemonLockInfo | null;
lockContents: string | null;
pidPath: string;
};
* Atomically create the daemon pidfile with its full record already in place.
* Returns either an `acquired` result (the caller is the daemon-elect and may
* construct a {@link Daemon}) or a `taken` result.
*
* must-fix 1 (issue #411 review): the lockfile must appear in ONE atomic step,
* already complete — never empty, even momentarily. The first attempt at this
* (`O_EXCL` create then a separate `writeSync`) left a microsecond window where
* the file existed but was empty; under concurrent daemon startup a third
* candidate could read that empty file, decode it as `null`, and `unlink` the
* winner's lock → two daemons (two watchers, two writers). The window was
* normally too small to hit, but the file watcher's extra startup time made
* concurrent daemons overlap enough to reproduce it reliably.
*
* The fix writes the complete record to a private temp file, then hard-links it
* into place: `link()` is atomic AND exclusive (EEXIST if the target exists), so
* the pidfile becomes visible in one step already containing a full record.
* Whoever links first wins; everyone else gets EEXIST and reads a complete file.
* There is no empty-file window at all.
*
* Filesystems without hard links (#997): ExFAT/FAT external volumes and some
* network mounts can't `link()` at all — it throws ENOTSUP/EPERM, which would
* otherwise kill the daemon before it ever reaches the socket bind. There we
* fall back to an O_EXCL create (`acquireLockViaExclusiveOpen`): still exclusive
* ("first writer wins"), but the full record is written through the fd in a
* second step, so the empty-file window the link approach removed is reopened —
* only on these filesystems, only for the microseconds between create and write
* (far narrower than the original bug, which the file watcher's startup latency
* widened). The race's worst case is two daemons briefly; on a single external
* drive that's strictly better than the daemon never starting at all.
*/
export function tryAcquireDaemonLock(projectRoot: string): AcquireResult {
const pidPath = getDaemonPidPath(projectRoot);
fs.mkdirSync(path.dirname(pidPath), { recursive: true });
const info: DaemonLockInfo = {
pid: process.pid,
version: CodeGraphPackageVersion,
socketPath: getDaemonSocketPath(projectRoot),
startedAt: Date.now(),
};
const tmp = `${pidPath}.${process.pid}.tmp`;
let acquired = false;
try {
fs.writeFileSync(tmp, encodeLockInfo(info), { mode: 0o600 });
try {
fs.linkSync(tmp, pidPath);
acquired = true;
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code === 'EEXIST') {
} else {
acquired = acquireLockViaExclusiveOpen(pidPath, info);
}
}
} finally {
try { fs.unlinkSync(tmp); } catch { }
}
if (acquired) return { kind: 'acquired', pidPath, info };
let existing: DaemonLockInfo | null = null;
let lockContents: string | null = null;
try {
lockContents = fs.readFileSync(pidPath, 'utf8');
existing = decodeLockInfo(lockContents);
} catch { }
return { kind: 'taken', existing, lockContents, pidPath };
}
* Exclusive-create the pidfile (O_CREAT|O_EXCL via the `wx` flag) and write the
* full record through the same fd — the hard-link-free fallback used by
* {@link tryAcquireDaemonLock} on filesystems without `link()`. Returns true if
* we created it (acquired the lock), false on EEXIST (another candidate holds
* it). Any other error propagates. Still exclusive, so "first writer wins" holds
* exactly as the link path does; the only difference is the brief empty-file
* window between create and write. Exported for testing.
*/
export function acquireLockViaExclusiveOpen(pidPath: string, info: DaemonLockInfo): boolean {
let fd: number;
try {
fd = fs.openSync(pidPath, 'wx', 0o600);
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code === 'EEXIST') return false;
throw err;
}
try {
fs.writeSync(fd, encodeLockInfo(info));
} finally {
fs.closeSync(fd);
}
return true;
}
* Remove a stale pidfile. Re-reads the file immediately before unlinking so a
* different daemon that acquired the lock in the meantime is never disturbed.
*
* must-fix 1 (issue #411 review): the original unconditionally `unlink`'d,
* which let a racing candidate delete a healthy daemon's lock. Passing
* `expectedDeadPid` (the pid the caller believed was dead) makes the clear a
* compare-and-delete: bail if the file now holds a different pid. By default a
* live pid is also preserved; `allowLivePid` is reserved for callers that have
* already disproved daemon identity with the socket hello (#1553). Returns true
* when the stale lock is gone (or was already gone).
*/
export function clearStaleDaemonLock(
pidPath: string,
expectedDeadPid?: number,
opts: { allowLivePid?: boolean; expectedLockContents?: string } = {}
): boolean {
try {
const raw = fs.readFileSync(pidPath, 'utf8');
if (opts.expectedLockContents !== undefined && raw !== opts.expectedLockContents) return false;
const info = decodeLockInfo(raw);
if (info) {
if (expectedDeadPid !== undefined && info.pid !== expectedDeadPid) return false;
if (!opts.allowLivePid && info.pid > 0 && isProcessAlive(info.pid)) return false;
}
fs.unlinkSync(pidPath);
return true;
} catch (err: unknown) {
const e = err as NodeJS.ErrnoException;
if (e.code === 'ENOENT') return true;
return false;
}
}
* Probe whether `pid` is currently alive (signal-0). Treats EPERM as alive on
* every platform (the process exists, it's just not ours to signal) so we never
* mistake a live daemon for a dead one and clear its lock.
*/
export 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;
}
}
* The one `listen()` error we must NOT relocate past. EADDRINUSE means the path
* is genuinely occupied — a racing daemon that legitimately owns it, or a
* leftover node we couldn't clear (the #974 planted-dir case) — so relocating
* would abandon a path another daemon owns; the caller instead releases its lock
* and falls back to direct mode. EVERY OTHER bind error just means "this path
* didn't work," almost always a filesystem that can't host an AF_UNIX node at all
* (ExFAT/FAT, network mounts, WSL2 DrvFs), which reports a DIFFERENT errno per OS
* (ENOTSUP macOS, EPERM Linux; #997). Enumerating the "unsupported" codes is
* whack-a-mole, so we relocate on anything-but-conflict instead — robust and
* self-correcting: if the deterministic tmpdir fallback ALSO fails, that error
* propagates from the last candidate. (ENAMETOOLONG never reaches here — the
* candidate list already routes over-long paths straight to tmpdir.)
*/
const SOCKET_BIND_CONFLICT_CODE = 'EADDRINUSE';
* Bind the first usable socket from an ordered candidate list, relocating past
* any path that fails to bind for a non-conflict reason (see {@link
* SOCKET_BIND_CONFLICT_CODE}). The injected `listen` does the real
* `net.Server.listen` (and stale-socket clear); abstracted so the relocation
* policy is unit-testable without a real unsupported filesystem. Returns the
* server plus the path actually bound. An EADDRINUSE, or any error on the LAST
* candidate, propagates — the caller releases the lockfile and falls back to
* direct mode (#974). Exported for testing.
*/
export async function bindFirstUsableSocket(
candidates: string[],
listen: (socketPath: string) => Promise<net.Server>,
opts: { onRelocate?: (from: string, to: string, code: string) => void } = {},
): Promise<{ server: net.Server; socketPath: string }> {
let lastErr: unknown;
for (let i = 0; i < candidates.length; i++) {
const socketPath = candidates[i]!;
const isLast = i === candidates.length - 1;
try {
const server = await listen(socketPath);
return { server, socketPath };
} catch (err) {
lastErr = err;
const code = (err as NodeJS.ErrnoException).code;
if (!isLast && code !== SOCKET_BIND_CONFLICT_CODE) {
opts.onRelocate?.(socketPath, candidates[i + 1]!, code ?? '');
continue;
}
throw err;
}
}
throw lastErr ?? new Error('no socket candidates to bind');
}
function resolveIdleTimeoutMs(): number {
const raw = process.env.CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS;
if (raw === undefined || raw === '') return DEFAULT_IDLE_TIMEOUT_MS;
const parsed = Number(raw);
if (!Number.isFinite(parsed) || parsed < 0) return DEFAULT_IDLE_TIMEOUT_MS;
return Math.floor(parsed);
}
function resolveMaxIdleMs(): number {
const raw = process.env.CODEGRAPH_DAEMON_MAX_IDLE_MS;
if (raw === undefined || raw === '') return DEFAULT_MAX_IDLE_MS;
const parsed = Number(raw);
if (!Number.isFinite(parsed) || parsed < 0) return DEFAULT_MAX_IDLE_MS;
return Math.floor(parsed);
}
function resolveClientSweepMs(): number {
const raw = process.env.CODEGRAPH_DAEMON_CLIENT_SWEEP_MS;
if (raw === undefined || raw === '') return DEFAULT_CLIENT_SWEEP_MS;
const parsed = Number(raw);
if (!Number.isFinite(parsed) || parsed < 0) return DEFAULT_CLIENT_SWEEP_MS;
return Math.floor(parsed);
}
function resolveInstallCheckMs(): number {
const raw = process.env.CODEGRAPH_DAEMON_INSTALL_CHECK_MS;
if (raw === undefined || raw === '') return DEFAULT_INSTALL_CHECK_MS;
const parsed = Number(raw);
if (!Number.isFinite(parsed) || parsed < 0) return DEFAULT_INSTALL_CHECK_MS;
return Math.floor(parsed);
}
* Parse one client-hello line. Returns the peer pids if `line` is a well-formed
* client-hello (carries the `codegraph_client` marker), or null otherwise — in
* which case the caller treats the bytes as ordinary JSON-RPC.
*/
export function parseClientHelloLine(
line: string,
): { pid: number; hostPid: number | null } | null {
let parsed: unknown;
try { parsed = JSON.parse(line); } catch { return null; }
if (!parsed || typeof parsed !== 'object') return null;
const o = parsed as Record<string, unknown>;
if (o.codegraph_client !== 1 || typeof o.pid !== 'number') return null;
return { pid: o.pid, hostPid: typeof o.hostPid === 'number' ? o.hostPid : null };
}
* A client's peer is dead when its proxy process is gone, or when its known
* host process is gone. Unknown pid (no client-hello) is never "dead" on this
* basis — those clients rely on the socket-close path. Exported for testing.
*/
export function peerIsDead(
peers: { pid: number | null; hostPid: number | null },
isAlive: (pid: number) => boolean,
): boolean {
if (peers.pid === null) return false;
if (!isAlive(peers.pid)) return true;
if (peers.hostPid !== null && !isAlive(peers.hostPid)) return true;
return false;
}
* Read the optional client-hello line a proxy sends after the daemon hello.
* Always resolves (never rejects) — fail-safe by design, since every connection
* funnels through here. Resolves with the peer pids when the first line is a
* client-hello; otherwise resolves with null pids and unshifts the already-read
* bytes so the transport parses them as the client's first JSON-RPC message(s).
* Accumulates as Buffers and splits on the newline byte so a UTF-8 sequence
* straddling a chunk boundary in the unshifted tail is never corrupted.
*/
function readClientHello(
socket: net.Socket,
): Promise<{ pid: number | null; hostPid: number | null }> {
return new Promise((resolve) => {
let chunks: Buffer[] = [];
let total = 0;
let settled = false;
const finish = (
peers: { pid: number | null; hostPid: number | null },
putBack?: Buffer,
) => {
if (settled) return;
settled = true;
try { socket.pause(); } catch { }
socket.removeListener('data', onData);
socket.removeListener('error', onEnd);
socket.removeListener('close', onEnd);
clearTimeout(timer);
if (process.env.CODEGRAPH_MCP_DEBUG) {
process.stderr.write(`[mcp-debug] clientHello finish pid=${String(peers.pid)} putBack=${putBack ? putBack.length : 0} flowing=${String(socket.readableFlowing)}\n`);
}
if (putBack && putBack.length > 0 && !socket.destroyed) {
try { socket.unshift(putBack); } catch { }
}
resolve(peers);
};
const onData = (chunk: Buffer | string) => {
const buf = typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : chunk;
chunks.push(buf);
total += buf.length;
const all = chunks.length === 1 ? buf : Buffer.concat(chunks, total);
const nl = all.indexOf(0x0a);
if (nl === -1) {
if (total > MAX_HELLO_LINE_BYTES) finish({ pid: null, hostPid: null }, all);
else chunks = [all];
return;
}
const peers = parseClientHelloLine(all.subarray(0, nl).toString('utf8'));
if (peers) {
const tail = all.subarray(nl + 1);
finish(peers, tail.length > 0 ? tail : undefined);
} else {
finish({ pid: null, hostPid: null }, all);
}
};
const onEnd = () => finish({ pid: null, hostPid: null });
const timer = setTimeout(() => {
const partial = chunks.length === 0 ? undefined : (chunks.length === 1 ? chunks[0] : Buffer.concat(chunks, total));
finish({ pid: null, hostPid: null }, partial);
}, CLIENT_HELLO_TIMEOUT_MS);
timer.unref?.();
socket.on('data', onData);
socket.on('error', onEnd);
socket.on('close', onEnd);
});
}
export { MAX_HELLO_LINE_BYTES };