// 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"; // Launch Context - Captures the caller/session environment used when the // daemon spawns a new language server. Running servers keep their original // process env; later requests for the same root reuse the existing server. export interface LaunchContext { env: Record; } // Build Launch Context - Convert Node's optional-valued process.env into the // concrete string map accepted by child_process.spawn(). Env contents are // sensitive: keep them internal to requests and never log or expose them. export function buildLaunchContext( env: NodeJS.ProcessEnv = process.env, ): LaunchContext { return { env: Object.fromEntries( Object.entries(env).filter((entry): entry is [string, string] => { return typeof entry[1] === "string"; }), ), }; } // Request Shapes - Sent client -> daemon. export type DaemonRequest = | { id: number; op: "request"; file: string; serverId: string; method: string; params: Record; launch: LaunchContext; } | { id: number; op: "diagnostics"; file: string; serverIds: string[]; timeoutMs?: number; launch: LaunchContext; } | { id: number; op: "status" } | { id: number; op: "shutdown" } | { id: number; op: "destroy_server"; serverId?: string }; export type DaemonRequestWithoutId = | { op: "request"; file: string; serverId: string; method: string; params: Record; launch: LaunchContext; } | { op: "diagnostics"; file: string; serverIds: string[]; timeoutMs?: number; launch: LaunchContext; } | { op: "status" } | { op: "shutdown" } | { op: "destroy_server"; serverId?: string }; // 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. // `PI_LSP_SOCKET_PATH` env var overrides everything — used by tests to // isolate test daemons from session daemons. export function socketPath(): string { if (process.env.PI_LSP_SOCKET_PATH) return process.env.PI_LSP_SOCKET_PATH; 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 { 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 { // 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 { 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"); }); }