// Daemon Server - Owns long-lived LspClient instances keyed by // (server.id, rootDir). Accepts NDJSON requests over a Unix socket and // dispatches them to the appropriate client, lazily spawning servers and // reaping idle ones via ServerConfig.idleTtlMs. import * as fs from "node:fs"; import * as net from "node:net"; import * as path from "node:path"; import { LspClient } from "./client.ts"; import { findRoot, pickServer, pathToUri } from "./root.ts"; import type { ServerConfig } from "./types.ts"; import { logPath, socketPath, tryConnect, type DaemonRequest, type DaemonResponse, } from "./daemonProtocol.ts"; // Default Idle TTL - 5 minutes. Per-server overrides via ServerConfig.idleTtlMs. const DEFAULT_IDLE_TTL_MS = 5 * 60 * 1000; // Client Entry - One LspClient per (server.id, rootDir), plus the bookkeeping // needed to keep files in sync and evict on idleness. interface ClientEntry { key: string; server: ServerConfig; rootDir: string; client: LspClient; // ready: gates concurrent requests during startup so we only initialize once. ready: Promise; // opened: URI -> last-synced mtimeMs. Used to decide didOpen vs didChange vs nothing. opened: Map; // serializer: per-entry mutex so file-sync (didOpen/didChange) can't race // with itself when two requests for the same file land concurrently. serializer: Promise; idleTimer: NodeJS.Timeout | null; ttlMs: number; lastUsed: number; } const entries = new Map(); // Log - Single helper so we can prefix and easily silence in tests. function log(...args: unknown[]) { process.stdout.write( `[${new Date().toISOString()}] ` + args.map((a) => (typeof a === "string" ? a : JSON.stringify(a))).join(" ") + "\n", ); } // Get Or Create Entry - Looks up the cached client for a file, spawning a // fresh LspClient if needed. The returned entry is guaranteed to have its // `ready` promise resolved before the caller uses it. async function getOrCreateEntry(filePath: string): Promise { const server = pickServer(filePath); const rootDir = findRoot(filePath, server.rootMarkers); const key = `${server.id}::${rootDir}`; const existing = entries.get(key); if (existing) { await existing.ready; return existing; } // Cold Start - Build the entry synchronously so concurrent callers all // await the same `ready` promise instead of racing to spawn duplicates. const client = new LspClient(server); const ttlMs = server.idleTtlMs ?? DEFAULT_IDLE_TTL_MS; const entry: ClientEntry = { key, server, rootDir, client, ready: (async () => { log(`spawn`, server.id, rootDir); await client.start(rootDir); await client.waitForReady(); log(`ready`, server.id); })(), opened: new Map(), serializer: Promise.resolve(), idleTimer: null, ttlMs, lastUsed: Date.now(), }; entries.set(key, entry); try { await entry.ready; } catch (err) { entries.delete(key); throw err; } bumpIdle(entry); return entry; } // Bump Idle - Resets the idle eviction timer. Called on every request that // touches the entry. We log evictions so the daemon's behavior is visible. function bumpIdle(entry: ClientEntry) { entry.lastUsed = Date.now(); if (entry.idleTimer) clearTimeout(entry.idleTimer); entry.idleTimer = setTimeout(() => evict(entry, "idle"), entry.ttlMs); } function evict(entry: ClientEntry, reason: string) { if (!entries.has(entry.key)) return; log(`evict`, entry.key, reason); entries.delete(entry.key); if (entry.idleTimer) clearTimeout(entry.idleTimer); void entry.client.dispose(); // Auto Shutdown - If this was the last entry, there's nothing left to // manage. Tear down the daemon so it doesn't sit idle forever. if (entries.size === 0) { shutdownDaemon("all entries evicted"); } } // Sync File - Ensures the language server has the current contents of the // file. Sends didOpen on first access, didChange on subsequent calls when // the on-disk mtime has advanced. Serialized per-entry to avoid races. async function syncFile( entry: ClientEntry, filePath: string, ): Promise<{ uri: string; changed: boolean }> { const uri = pathToUri(filePath); const run = async () => { const stat = fs.statSync(filePath); const prev = entry.opened.get(uri); if (prev === undefined) { entry.client.openDocument(filePath); entry.opened.set(uri, stat.mtimeMs); return { uri, changed: true }; } else if (prev !== stat.mtimeMs) { entry.client.notifyChange(filePath); entry.opened.set(uri, stat.mtimeMs); return { uri, changed: true }; } return { uri, changed: false }; }; // Chain onto the per-entry serializer so concurrent syncs queue up. const next = entry.serializer.then(run, run); entry.serializer = next.catch(() => undefined); return next; } // Inject textDocument.uri - Mirrors the helper in commands.ts; we don't // reuse it because the daemon path operates on raw method strings rather // than the LspCommand union. function withDoc(uri: string, params: Record): Record { const existing = (params.textDocument as Record) ?? {}; return { ...params, textDocument: { uri, ...existing } }; } // Handle Request - Dispatches a single parsed DaemonRequest. Returns a // DaemonResponse; never throws (errors are returned as { ok: false }). async function handle(req: DaemonRequest): Promise { try { switch (req.op) { case "request": { const filePath = path.resolve(req.file); const entry = await getOrCreateEntry(filePath); const { uri } = await syncFile(entry, filePath); bumpIdle(entry); const result = await entry.client.sendRequest( req.method, withDoc(uri, req.params), ); return { id: req.id, ok: true, result }; } case "diagnostics": { const filePath = path.resolve(req.file); const entry = await getOrCreateEntry(filePath); const { uri, changed } = await syncFile(entry, filePath); bumpIdle(entry); if (changed) entry.client.clearDiagnostics(uri); const result = await entry.client.waitForDiagnostics( uri, req.timeoutMs ?? 1500, ); return { id: req.id, ok: true, result }; } case "status": { const result = { socket: socketPath(), servers: Array.from(entries.values()).map((e) => ({ id: e.server.id, rootDir: e.rootDir, openedFiles: Array.from(e.opened.keys()), idleMs: Date.now() - e.lastUsed, ttlMs: e.ttlMs, })), }; return { id: req.id, ok: true, result }; } case "destroy_server": { // Manual Kill - Evict entries matching the server ID (or all if // unspecified). This is explicitly destructive; the caller knows // what it's doing. const toDestroy = req.serverId ? Array.from(entries.values()).filter( (e) => e.server.id === req.serverId, ) : Array.from(entries.values()); for (const entry of toDestroy) { evict(entry, "manual destroy"); } return { id: req.id, ok: true, result: { destroyed: toDestroy.map((e) => e.key) }, }; } case "shutdown": { // Acknowledge first, then tear down on next tick so the response // has a chance to flush before we close listeners. setImmediate(() => shutdownDaemon("shutdown request")); return { id: req.id, ok: true, result: { stopping: true } }; } } } catch (err) { return { id: req.id, ok: false, error: (err as Error)?.message ?? String(err), }; } // Exhaustiveness - Should be unreachable given the union above. throw new Error("unreachable"); } // Handle Connection - Reads NDJSON from a client socket; each line is one // independent request. Multiple requests may share a connection. function handleConnection(sock: net.Socket) { let buf = ""; sock.on("data", async (chunk) => { buf += chunk.toString("utf8"); let nl: number; // Process All Complete Lines - Leftover stays in buf for the next chunk. while ((nl = buf.indexOf("\n")) !== -1) { const line = buf.slice(0, nl); buf = buf.slice(nl + 1); if (!line.trim()) continue; let req: DaemonRequest; try { req = JSON.parse(line); } catch (err) { sock.write( JSON.stringify({ id: 0, ok: false, error: `bad json: ${(err as Error).message}`, }) + "\n", ); continue; } const resp = await handle(req); sock.write(JSON.stringify(resp) + "\n"); } }); sock.on("error", (err) => log("conn error", err.message)); } let server: net.Server | null = null; // Shutdown Daemon - Stops accepting connections, disposes all LspClients, // removes the socket file, and exits. Called on SIGTERM/SIGINT and via // the explicit `shutdown` op. function shutdownDaemon(reason: string) { log(`shutdown`, reason); if (server) server.close(); for (const entry of entries.values()) { if (entry.idleTimer) clearTimeout(entry.idleTimer); void entry.client.dispose(); } entries.clear(); try { fs.unlinkSync(socketPath()); } catch { // Ignore - already gone. } // Give pending writes a moment, then exit. setTimeout(() => process.exit(0), 100); } // Start Daemon - Binds the Unix socket, handling stale-socket cleanup. // If another daemon is already listening, we exit cleanly so racing // `ensureDaemon` callers converge on a single instance. export async function startDaemon(): Promise { const sock = socketPath(); // Stale Socket Detection - If something exists at the path, try to // connect. A successful connect means another daemon owns it (we exit); // a failed connect means the socket file is stale (we unlink it). if (fs.existsSync(sock)) { try { const probe = await tryConnect(sock, 200); probe.destroy(); log(`another daemon already listening on ${sock}, exiting`); process.exit(0); } catch { try { fs.unlinkSync(sock); } catch { // Ignore - listen() will surface a clearer error if this matters. } } } server = net.createServer(handleConnection); await new Promise((resolve, reject) => { server!.once("error", reject); server!.listen(sock, () => { // Restrict Permissions - Socket is per-user; nobody else should poke at it. try { fs.chmodSync(sock, 0o600); } catch { // Ignore - best effort. } resolve(); }); }); log(`listening on ${sock} (logs: ${logPath()})`); process.on("SIGTERM", () => shutdownDaemon("SIGTERM")); process.on("SIGINT", () => shutdownDaemon("SIGINT")); }