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

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");
});
}