feat(lsp): add background daemon for language servers
This commit is contained in:
158
src/daemonProtocol.ts
Normal file
158
src/daemonProtocol.ts
Normal 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");
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user