| 165 | * daemons. |
| 166 | */ |
| 167 | export class Daemon { |
| 168 | private server: net.Server | null = null; |
| 169 | private clients = new Set<MCPSession>(); |
| 170 | /** Per-client peer pids from the optional client-hello, for the liveness sweep. */ |
| 171 | private clientPeers = new Map<MCPSession, { pid: number | null; hostPid: number | null }>(); |
| 172 | private idleTimer: NodeJS.Timeout | null = null; |
| 173 | private idleTimeoutMs: number; |
| 174 | private maxIdleMs: number; |
| 175 | private lastActivityAt = Date.now(); |
| 176 | private maxIdleTimer: NodeJS.Timeout | null = null; |
| 177 | private clientSweepTimer: NodeJS.Timeout | null = null; |
| 178 | private engine: MCPEngine; |
| 179 | private stopping = false; |
| 180 | private socketPath: string; |
| 181 | private pidPath: string; |
| 182 | |
| 183 | constructor( |
| 184 | private projectRoot: string, |
| 185 | opts: { idleTimeoutMs?: number; maxIdleMs?: number } = {}, |
| 186 | ) { |
| 187 | this.socketPath = getDaemonSocketPath(projectRoot); |
| 188 | this.pidPath = getDaemonPidPath(projectRoot); |
| 189 | this.idleTimeoutMs = opts.idleTimeoutMs ?? resolveIdleTimeoutMs(); |
| 190 | this.maxIdleMs = opts.maxIdleMs ?? resolveMaxIdleMs(); |
| 191 | // Daemon mode serves many concurrent clients on one event loop, so off-load |
| 192 | // read-tool dispatch to a worker pool — otherwise concurrent explores |
| 193 | // serialize and starve the MCP transport (clients time out). Direct mode |
| 194 | // (one stdio client) leaves the pool off; `CODEGRAPH_QUERY_POOL_SIZE=0` |
| 195 | // disables it here too. |
| 196 | this.engine = new MCPEngine({ queryPool: true }); |
| 197 | this.engine.setProjectPathHint(projectRoot); |
| 198 | } |
| 199 | |
| 200 | /** |
| 201 | * Bind the socket, kick off engine init, and register signal handlers. The |
| 202 | * lockfile body was already written atomically by `tryAcquireDaemonLock`, so |
| 203 | * there is nothing to write here. The promise resolves once the server is |
| 204 | * listening — the daemon then sticks around until idle/shutdown. |
| 205 | */ |
| 206 | async start(): Promise<DaemonStartResult> { |
| 207 | // Engine init is deliberately backgrounded — see #172. The first session |
| 208 | // to land waits on `ensureInitialized` either way, and unloaded sessions |
| 209 | // (cross-project tool calls only) shouldn't pay any open cost. |
| 210 | void this.engine.ensureInitialized(this.projectRoot); |
| 211 | |
| 212 | // Walk the ordered socket candidates and bind the first that works. The |
| 213 | // in-project path comes first; the deterministic tmpdir path is the fallback |
| 214 | // for a filesystem that can't host an AF_UNIX node at all (ExFAT/FAT external |
| 215 | // volumes, some network mounts, WSL2 DrvFs → ENOTSUP/EACCES; #997, #974). The |
| 216 | // `listen` closure clears a stale socket (left by a SIGKILL'd previous daemon) |
| 217 | // before each attempt — safe because we hold the lockfile, so no live daemon |
| 218 | // owns it; without it `listen` would wedge on EADDRINUSE. |
| 219 | const candidates = getDaemonSocketCandidates(this.projectRoot); |
| 220 | const listen = (socketPath: string): Promise<net.Server> => |
| 221 | new Promise<net.Server>((resolve, reject) => { |
| 222 | if (process.platform !== 'win32') { |
| 223 | try { fs.unlinkSync(socketPath); } catch { /* not-exists is fine */ } |
| 224 | } |
nothing calls this directly
no outgoing calls
no test coverage detected