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.
203 lines
6.5 KiB
TypeScript
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");
|
|
});
|
|
}
|