/** * Minimal JSON-RPC-over-stdio LSP client. * * Implements exactly the protocol subset the resolution pass needs * (LSP 3.17): initialize → initialized → didOpen → definition → * shutdown → exit. No npm LSP framework — the protocol subset is small * and a dependency here would bloat the optional package (issue #1555). * * Failure discipline (rule 13): every operation degrades to a tagged * `LspDegradation` — the client NEVER throws to a caller. Server crashes * mid-run, protocol errors, and timeouts all surface as distinct codes. * * Lifecycle: the client owns the child process. `dispose()` sends * shutdown + exit, then hard-kills (SIGKILL) any lingering process — * no zombie children survive (tested). */ import { spawn, type ChildProcess } from "node:child_process"; import path from "node:path"; import process from "node:process"; import { encodeLspFrame, LspFrameDecoder, } from "./framing.js"; import { type JsonRpcRequest, type LspInitializeParams, type LspInitializeResult, type LspLocation, type LspTextDocumentItem, type LspTextDocumentPositionParams, } from "./types.js"; import { lspDegradation, type LspDegradation, type LspDegradationCode, type LspResult, } from "./degradation.js"; import type { LspServerLaunchSpec } from "./config.js"; // ────────────────────────────────────────────────────────────────────────── // Pending-request entry — stores the resolver pair + timeout handle. // ────────────────────────────────────────────────────────────────────────── interface PendingRequest { readonly resolve: (value: unknown) => void; readonly reject: (degradation: LspDegradation) => void; readonly timer: ReturnType; } // ────────────────────────────────────────────────────────────────────────── // Connection options // ────────────────────────────────────────────────────────────────────────── export interface LspClientOptions { readonly launchSpec: LspServerLaunchSpec; readonly rootUri: string | null; readonly timeoutMs: number; /** * Optional spawn override — test seam. When provided, the client calls * this instead of `child_process.spawn`. Must return a ChildProcess- * compatible object with stdin/stdout streams. */ readonly spawnFn?: typeof spawn; } // ────────────────────────────────────────────────────────────────────────── // LspClient — the connection. One instance per server process. // ────────────────────────────────────────────────────────────────────────── export class LspClient { private readonly child: ChildProcess; private readonly decoder = new LspFrameDecoder(); private readonly rootUri: string | null; private readonly timeoutMs: number; private nextId = 1; private readonly pending = new Map(); private disposed = false; private crashed = false; private crashCode: LspDegradationCode | null = null; private serverCapabilities: LspInitializeResult["capabilities"] | null = null; private constructor(child: ChildProcess, rootUri: string | null, timeoutMs: number) { this.child = child; this.rootUri = rootUri; this.timeoutMs = timeoutMs; // Wire stdout → decoder → message dispatcher. this.child.stdout?.setEncoding("utf8"); this.child.stdout?.on("data", (chunk: Buffer | string) => this.onStdoutData(chunk)); // Drain stderr so the OS pipe buffer doesn't fill and block the server. this.child.stderr?.on("data", () => {}); // Unexpected exit → mark crashed, reject all pending. this.child.on("exit", (code, signal) => this.onChildExit(code, signal)); this.child.on("error", (err) => this.onChildError(err)); } /** * Spawn the server and perform the initialize handshake. Returns a * tagged result — `{ ok: true, client }` on success, or a degradation * on failure (server_missing, handshake_timeout, handshake_error). */ static async connect(options: LspClientOptions): Promise< | { ok: true; client: LspClient } | { ok: false; degradation: LspDegradation } > { let child: ChildProcess; const spawnFn = options.spawnFn ?? spawn; try { child = spawnFn(options.launchSpec.command, [...options.launchSpec.args], { stdio: ["pipe", "pipe", "pipe"], shell: false, // The server inherits our cwd so relative rootUri paths resolve. cwd: process.cwd(), }); } catch { return { ok: false, degradation: lspDegradation( "server_missing", `failed to spawn ${options.launchSpec.command}`, ), }; } // If spawn emitted 'error' synchronously (ENOENT), treat as missing. // We detect this by checking if the child has already exited. if (child.exitCode !== null && child.exitCode !== undefined) { return { ok: false, degradation: lspDegradation("server_missing"), }; } const client = new LspClient(child, options.rootUri, options.timeoutMs); // If the spawn errored asynchronously (ENOENT fires as 'error' event), // the client.crashed flag is set by onChildError. Check after wiring. if (client.crashed) { return { ok: false, degradation: lspDegradation(client.crashCode ?? "server_crashed"), }; } // ── initialize handshake ── const initParams: LspInitializeParams = { processId: process.pid, rootUri: options.rootUri, capabilities: {}, }; const initResult = await client.request("initialize", initParams); if (!initResult.ok) { // Remap request_timeout → handshake_timeout (the timeout happened // during the initialize handshake). protocol_error, server_crashed, // and server_missing pass through with their original codes — they // describe the specific failure precisely enough. await client.dispose(); const code = initResult.degradation.code === "request_timeout" ? "handshake_timeout" : initResult.degradation.code; return { ok: false, degradation: lspDegradation(code, initResult.degradation.detail), }; } if (initResult.value === null || typeof initResult.value !== "object") { await client.dispose(); return { ok: false, degradation: lspDegradation("protocol_error", "initialize response missing or invalid"), }; } const initResponse = initResult.value as LspInitializeResult; client.serverCapabilities = initResponse.capabilities; // initialized notification (no response expected). client.notify("initialized", {}); return { ok: true, client }; } /** * Send `textDocument/didOpen` — notifies the server about an open * document with its full content. No response expected. */ didOpen(item: LspTextDocumentItem): void { this.notify("textDocument/didOpen", { textDocument: item }); } /** * Send `textDocument/definition` for a position in a document. Returns * the definition locations (may be empty, a single location, or an * array). Degrades on timeout/error/crash — never throws. */ async definition( params: LspTextDocumentPositionParams, ): Promise> { if (this.disposed || this.crashed) { return { ok: false, degradation: lspDegradation("server_crashed"), }; } const result = await this.request("textDocument/definition", params); if (!result.ok) { return result; } // LSP allows Location | Location[] | null for definition. const value = result.value; let locations: LspLocation[]; if (value === null || value === undefined) { locations = []; } else if (Array.isArray(value)) { locations = value as LspLocation[]; } else { locations = [value as LspLocation]; } return { ok: true, locations }; } /** * Send `shutdown`, then `exit`, then SIGKILL if the process lingers. * Idempotent — safe to call multiple times. After dispose, no child * process remains (tested — zombie cleanup). */ async dispose(): Promise { if (this.disposed) return; this.disposed = true; // Reject all pending requests immediately. for (const [id, entry] of this.pending) { clearTimeout(entry.timer); entry.reject(lspDegradation("server_crashed")); this.pending.delete(id); } // Try graceful shutdown: send shutdown request, then exit notification. if (!this.crashed && this.child.stdin && !this.child.stdin.destroyed) { try { const promise = new Promise((resolve) => { // Send shutdown — don't wait for a response longer than the timeout. this.child.stdin?.write(encodeLspFrame({ jsonrpc: "2.0", id: 0, method: "shutdown" })); this.child.stdin?.write(encodeLspFrame({ jsonrpc: "2.0", method: "exit" })); // Give the server a brief window to exit cleanly. const timer = setTimeout(resolve, 500); this.child.on("exit", () => { clearTimeout(timer); resolve(); }); }); await promise; } catch { // Best-effort — if writing fails, hard-kill below. } } // Hard kill — ensures no zombie survives even if graceful exit failed. this.hardKill(); } /** Returns the pid of the child process (for zombie-cleanup tests). */ get pid(): number | undefined { return this.child.pid; } /** True if the server reported definitionProvider capability. */ get supportsDefinition(): boolean { const caps = this.serverCapabilities; return caps !== null && Boolean(caps.definitionProvider); } // ────────────────────────────────────────────────────────────────────── // Internal — JSON-RPC request/notification machinery // ────────────────────────────────────────────────────────────────────── /** * Send a request and await its response. Returns the `result` field * on success, or a degradation on timeout/error/crash/protocol-error. */ private request(method: string, params: unknown): Promise< | { ok: true; value: unknown } | { ok: false; degradation: LspDegradation } > { if (this.disposed || this.crashed) { return Promise.resolve({ ok: false, degradation: lspDegradation("server_crashed"), }); } const id = this.nextId++; const message: JsonRpcRequest = { jsonrpc: "2.0", id, method, params }; const promise = new Promise((resolve, reject) => { const timer = setTimeout(() => { const entry = this.pending.get(id); if (entry) { this.pending.delete(id); entry.reject(lspDegradation("request_timeout", `method=${method}`)); } }, this.timeoutMs); this.pending.set(id, { resolve, reject, timer }); try { const frame = encodeLspFrame(message); if (!this.child.stdin || this.child.stdin.destroyed) { clearTimeout(timer); this.pending.delete(id); reject(lspDegradation("server_crashed")); return; } this.child.stdin.write(frame); } catch { clearTimeout(timer); this.pending.delete(id); reject(lspDegradation("server_crashed")); } }); return promise.then( (value: unknown) => ({ ok: true as const, value }), (degradation: LspDegradation) => ({ ok: false as const, degradation }), ); } /** * Send a notification (no response expected). Best-effort — if the * write fails, the next request will surface the crash. */ private notify(method: string, params: unknown): void { if (this.disposed || this.crashed) return; try { const message: JsonRpcRequest = { jsonrpc: "2.0", method, params }; this.child.stdin?.write(encodeLspFrame(message)); } catch { // Best-effort — notifications have no response path. } } /** * Dispatch a decoded JSON-RPC message. Correlates responses to pending * requests by id; ignores server-initiated notifications (we don't * need them for the resolution pass). */ /** * Dispatch a decoded JSON-RPC message. Correlates responses to pending * requests by id; ignores server-initiated notifications. */ private dispatchMessage(msg: unknown): void { if (typeof msg !== "object" || msg === null) return; const rpc = msg as Record; if (rpc.id === undefined || (rpc.result === undefined && rpc.error === undefined)) { return; } const id = typeof rpc.id === "number" ? rpc.id : Number(rpc.id); const entry = this.pending.get(id); if (!entry) return; clearTimeout(entry.timer); this.pending.delete(id); if (rpc.error !== undefined) { // Don't echo raw server error text — it may contain absolute paths. entry.reject(lspDegradation("request_error", "server returned an error response")); } else { entry.resolve(rpc.result); } } /** * stdout data handler — feed the decoder, dispatch complete messages, * detect protocol errors. */ private onStdoutData(chunk: Buffer | string): void { const result = this.decoder.feed(chunk); if (!result.ok) { this.handleProtocolError(result.error.detail); return; } for (const msg of result.messages) { this.dispatchMessage(msg); } } /** * Handle an unexpected child exit. All pending requests are rejected * with server_crashed. */ private onChildExit(code: number | null, signal: NodeJS.Signals | null): void { // Normal exit during dispose — don't mark as crashed. if (this.disposed) return; this.crashed = true; this.crashCode = "server_crashed"; const detail = code !== null ? `server exited with code ${code}` : `server killed by ${signal}`; for (const [id, entry] of this.pending) { clearTimeout(entry.timer); entry.reject(lspDegradation("server_crashed", detail)); this.pending.delete(id); } } /** * Handle a spawn error (ENOENT etc). Marks the server as missing. */ private onChildError(err: Error): void { // ENOENT = binary not found; other spawn errors = server present but failing. this.crashed = true; const isENOENT = err.message.includes("ENOENT"); this.crashCode = isENOENT ? "server_missing" : "server_crashed"; const detail = isENOENT ? undefined : "spawn error"; for (const [id, entry] of this.pending) { clearTimeout(entry.timer); entry.reject(lspDegradation(this.crashCode, detail)); this.pending.delete(id); } } /** * Protocol error — the stream produced a malformed frame. Reject all * pending and mark disposed so no further requests can be sent. */ private handleProtocolError(detail: string): void { this.crashed = true; for (const [id, entry] of this.pending) { clearTimeout(entry.timer); entry.reject(lspDegradation("protocol_error", detail)); this.pending.delete(id); } } /** * Hard-kill the child process: SIGKILL. Called by dispose() as a * final cleanup guarantee. Also called if the graceful shutdown path * fails. Uses `kill` which is a no-op if the process already exited. */ private hardKill(): void { try { if (this.child.pid !== undefined && !this.child.killed) { this.child.kill("SIGKILL"); } } catch { // Best-effort — the process may have already exited. } } } // ────────────────────────────────────────────────────────────────────────── // URI helpers — convert between file paths and LSP URIs. // ────────────────────────────────────────────────────────────────────────── /** * Convert a repo-relative or absolute file path to a `file://` URI. * Handles Windows drive letters (C:\ → file:///C:/). */ export function pathToUri(filePath: string): string { const abs = path.isAbsolute(filePath) ? filePath : path.resolve(filePath); const normalized = abs.replace(/\\/g, "/"); // Windows drive paths need a leading slash: C:/foo → /C:/foo → file:///C:/foo const withSlash = /^[A-Za-z]:/.test(normalized) ? `/${normalized}` : normalized; return `file://${withSlash}`; } /** * Convert a `file://` URI back to an absolute file path. */ export function uriToPath(uri: string): string { if (uri.startsWith("file://")) { let rest = uri.slice("file://".length); // Decode percent-encoded characters (e.g. %20 → space) per RFC 8089. try { rest = decodeURIComponent(rest); } catch { // Malformed escape sequence — keep raw path (best-effort). } // Windows: file:///C:/foo → C:/foo if (/^\/[A-Za-z]:/.test(rest)) { return rest.slice(1).replace(/\//g, path.sep); } // Unix: file:///foo → /foo return rest.replace(/\//g, path.sep); } return uri; }