feat(lsp): add background daemon for language servers

This commit is contained in:
2026-04-29 00:04:06 -04:00
parent 60b8900a09
commit 076eee4e96
12 changed files with 707 additions and 76 deletions

View File

@@ -60,6 +60,10 @@ export class LspClient {
// can await readiness.
private progressTokens = new Set<string | number>();
private progressListeners = new Set<() => void>();
// Per-URI Version Counter - LSP requires monotonically increasing
// version numbers in didOpen/didChange. We track them so the daemon
// can resync files via notifyChange after on-disk edits.
private versions = new Map<string, number>();
constructor(private readonly server: ServerConfig) {}
@@ -182,9 +186,12 @@ export class LspClient {
// Open Document - Reads the file from disk and sends didOpen. Most
// servers require this before they'll answer hover/definition/etc.
// Idempotent-ish: callers should track whether they've already opened
// a URI and prefer notifyChange for subsequent syncs.
openDocument(filePath: string): string {
const uri = pathToUri(filePath);
const text = fs.readFileSync(filePath, "utf8");
this.versions.set(uri, 1);
this.conn.sendNotification("textDocument/didOpen", {
textDocument: {
uri,
@@ -196,11 +203,32 @@ export class LspClient {
return uri;
}
// Notify Change - Re-reads the file from disk and sends a full-text
// didChange. Used by the daemon to keep the server in sync after the
// agent's edit/write tools modify a file.
notifyChange(filePath: string): string {
const uri = pathToUri(filePath);
const text = fs.readFileSync(filePath, "utf8");
const version = (this.versions.get(uri) ?? 1) + 1;
this.versions.set(uri, version);
this.conn.sendNotification("textDocument/didChange", {
textDocument: { uri, version },
contentChanges: [{ text }],
});
return uri;
}
// Send Raw LSP Request - Passthrough used by the command dispatcher.
sendRequest<R = unknown>(method: string, params: unknown): Promise<R> {
return this.conn.sendRequest(method, params) as Promise<R>;
}
// Clear Diagnostics - Drops the cached diagnostics for a URI so callers
// can force waitForDiagnostics to await a fresh publish after didChange.
clearDiagnostics(uri: string): void {
this.diagnostics.delete(uri);
}
// Wait For Diagnostics - Resolves on the first publish for `uri` or
// after `timeoutMs`. Returns whatever we have for that URI.
async waitForDiagnostics(

299
src/daemon.ts Normal file
View 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"));
}

45
src/daemonClient.ts Normal file
View File

@@ -0,0 +1,45 @@
// Daemon Client - High-level helpers used by cli.ts and index.ts to send
// LSP work to the long-lived daemon. The first call autospawns the
// daemon; subsequent calls reuse it.
//
// Why Not One Persistent Socket - For now we open a fresh connection per
// request. The cost is negligible (Unix socket, same machine) compared to
// the LSP request itself, and it keeps client code stateless.
import { sendOnce, type DaemonResponse } from "./daemonProtocol.ts";
// Unwrap - Throws on { ok: false }, returns result on { ok: true }. All
// callers want the result-or-throw shape, so we centralize it.
function unwrap(resp: DaemonResponse): unknown {
if (resp.ok) return resp.result;
throw new Error(resp.error);
}
// Send LSP Request - Forwards an arbitrary LSP method to the daemon. The
// daemon injects textDocument.uri from `file`, so callers omit it.
export async function daemonRequest(
file: string,
method: string,
params: Record<string, unknown>,
): Promise<unknown> {
return unwrap(await sendOnce({ op: "request", file, method, params }));
}
// Wait For Diagnostics - Diagnostics arrive as a notification, not a
// response, so the daemon has a dedicated op that awaits the next publish.
export async function daemonDiagnostics(
file: string,
timeoutMs = 1500,
): Promise<unknown> {
return unwrap(await sendOnce({ op: "diagnostics", file, timeoutMs }));
}
// Status - Lists currently-cached LSP servers (id, root, opened files,
// idle time). Useful for `pi-lsp daemon status`.
export async function daemonStatus(): Promise<unknown> {
return unwrap(await sendOnce({ op: "status" }));
}
// Shutdown - Asks the daemon to dispose all LspClients and exit.
export async function daemonShutdown(): Promise<unknown> {
return unwrap(await sendOnce({ op: "shutdown" }));
}

158
src/daemonProtocol.ts Normal file
View File

@@ -0,0 +1,158 @@
// Daemon Protocol - Shared types, socket-path resolution, and the
// auto-spawn helper used by both the daemon (server) and client lib.
//
// Wire Format - Newline-delimited JSON. Each line is one message. We use
// NDJSON instead of LSP-style framing because the messages are small and
// synchronous, and it keeps the implementation trivial.
import * as fs from "node:fs";
import * as net from "node:net";
import * as os from "node:os";
import * as path from "node:path";
import { spawn } from "node:child_process";
// Request Shapes - Sent client -> daemon.
export type DaemonRequest =
| {
id: number;
op: "request";
file: string;
method: string;
params: Record<string, unknown>;
}
| { id: number; op: "diagnostics"; file: string; timeoutMs?: number }
| { id: number; op: "status" }
| { id: number; op: "shutdown" };
export type DaemonRequestWithoutId =
| {
op: "request";
file: string;
method: string;
params: Record<string, unknown>;
}
| { op: "diagnostics"; file: string; timeoutMs?: number }
| { op: "status" }
| { op: "shutdown" };
// Response Shapes - Sent daemon -> client. `ok: true` carries result, else
// `error` is a human-readable message string.
export type DaemonResponse =
| { id: number; ok: true; result: unknown }
| { id: number; ok: false; error: string };
// Socket Path - Per-user socket; XDG_RUNTIME_DIR when available (tmpfs,
// auto-cleaned on logout), tmpdir() otherwise. We include the uid so two
// users on the same box don't collide on a shared tmpdir.
export function socketPath(): string {
const uid =
typeof process.getuid === "function" ? String(process.getuid()) : "0";
const dir = process.env.XDG_RUNTIME_DIR ?? os.tmpdir();
return path.join(dir, `pi-lsp-${uid}.sock`);
}
// Log Path - Where the spawned daemon writes stdout/stderr.
export function logPath(): string {
return path.join(os.tmpdir(), "pi-lsp-daemon.log");
}
// Try Connect - Resolves with a connected socket or rejects on error.
// Used both by clients and by the daemon's stale-socket check on startup.
export function tryConnect(sockPath: string, timeoutMs = 500): Promise<net.Socket> {
return new Promise((resolve, reject) => {
const sock = net.createConnection(sockPath);
const timer = setTimeout(() => {
sock.destroy();
reject(new Error("connect timeout"));
}, timeoutMs);
sock.once("connect", () => {
clearTimeout(timer);
resolve(sock);
});
sock.once("error", (err) => {
clearTimeout(timer);
reject(err);
});
});
}
// Sleep - Tiny helper for the autospawn retry loop.
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
// Spawn Daemon - Detached background process. We resolve the daemon
// entrypoint relative to this file so it works whether run via tsx (dev)
// or after a future build step.
export function spawnDaemon(): void {
// Locate Entrypoint - daemon.ts sits at the package root, two levels
// up from this file (src/daemonProtocol.ts).
const entry = path.resolve(import.meta.dirname, "..", "daemon.ts");
const out = fs.openSync(logPath(), "a");
const child = spawn(entry, [], {
detached: true,
stdio: ["ignore", out, out],
env: process.env,
});
child.unref();
}
// Ensure Daemon - Connects to the daemon, spawning it first if the socket
// doesn't exist or is stale. Returns a connected socket on success.
export async function ensureDaemon(sockPath = socketPath()): Promise<net.Socket> {
// Fast Path - Already running.
try {
return await tryConnect(sockPath);
} catch {
// Fallthrough to spawn.
}
// Cleanup Stale Socket - If the file exists but no one's listening,
// remove it so the daemon can rebind. The daemon does this defensively
// too, but doing it here avoids a race on first spawn.
try {
fs.unlinkSync(sockPath);
} catch {
// Ignore - file may not exist.
}
spawnDaemon();
// Retry Loop - Wait up to ~5s for the daemon to bind.
const deadline = Date.now() + 5000;
let lastErr: unknown;
while (Date.now() < deadline) {
await sleep(100);
try {
return await tryConnect(sockPath);
} catch (err) {
lastErr = err;
}
}
throw new Error(
`Failed to connect to pi-lsp daemon at ${sockPath}: ${
(lastErr as Error)?.message ?? "unknown"
}. See ${logPath()} for daemon logs.`,
);
}
// Send One Request - Opens (or reuses) a connection, sends one NDJSON
// request, awaits the matching response, and closes the socket. Caller
// owns the connection lifetime when batching is desired.
export async function sendOnce(req: DaemonRequestWithoutId): Promise<DaemonResponse> {
const sock = await ensureDaemon();
return new Promise((resolve, reject) => {
const id = 1;
let buf = "";
sock.on("data", (chunk) => {
buf += chunk.toString("utf8");
const nl = buf.indexOf("\n");
if (nl === -1) return;
const line = buf.slice(0, nl);
try {
const resp = JSON.parse(line) as DaemonResponse;
sock.end();
resolve(resp);
} catch (err) {
sock.destroy();
reject(err);
}
});
sock.on("error", reject);
sock.write(JSON.stringify({ ...req, id }) + "\n");
});
}

View File

@@ -12,8 +12,8 @@ export interface ServerConfig {
rootMarkers: string[];
// LSP languageId sent in didOpen. Defaults to match[0] if omitted.
languageId?: string;
// TTL Planning - When we eventually add a daemon, servers will be kept
// alive per (id, rootUri) for this many ms of idleness. Not used yet.
// Idle TTL - Daemon keeps one server alive per (id, rootDir) and evicts
// it after this many ms of inactivity. Defaults to 5 minutes.
idleTtlMs?: number;
}