feat(lsp): add background daemon for language servers
This commit is contained in:
299
src/daemon.ts
Normal file
299
src/daemon.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
// 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<void>;
|
||||
// opened: URI -> last-synced mtimeMs. Used to decide didOpen vs didChange vs nothing.
|
||||
opened: Map<string, number>;
|
||||
// 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<unknown>;
|
||||
idleTimer: NodeJS.Timeout | null;
|
||||
ttlMs: number;
|
||||
lastUsed: number;
|
||||
}
|
||||
|
||||
const entries = new Map<string, ClientEntry>();
|
||||
|
||||
// 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<ClientEntry> {
|
||||
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();
|
||||
}
|
||||
|
||||
// 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<string, unknown>): Record<string, unknown> {
|
||||
const existing = (params.textDocument as Record<string, unknown>) ?? {};
|
||||
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<DaemonResponse> {
|
||||
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 "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<void> {
|
||||
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<void>((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"));
|
||||
}
|
||||
Reference in New Issue
Block a user