Files
pi-lsp/src/daemonProtocol.ts
Evan Reichard b9808a8b1f refactor(daemon): require explicit serverId on all daemon ops
Move all server matching logic to the extension/CLI side. The daemon no
longer calls pickServer() — it receives an explicit serverId (or
serverIds[] for diagnostics) and uses it directly for cache lookup and
server spawning.

Key changes:
- request op requires serverId: string
- diagnostics op requires serverIds: string[] — daemon fans out in
  parallel via Promise.allSettled and returns grouped map
- formatDiagnostics() handles grouped results with per-server headers
  when multiple servers contribute (single-server omits header)
- CLI picks servers locally before calling daemon helpers
- New pickDiagnosticServers() in extension returns all available,
  non-disabled servers matching the file extension

This makes multi-server diagnostics (e.g., typescript-language-server +
oxlint) work naturally — the extension decides which servers to query,
the daemon just executes.
2026-05-04 07:39:03 -04:00

203 lines
6.5 KiB
TypeScript

// 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<string, string>;
}
// 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<string, unknown>;
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<string, unknown>;
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<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");
});
}