/** * LSP Client for pi-lens * * Handles JSON-RPC communication with language servers: * - Initialize/shutdown lifecycle * - Document synchronization (didOpen, didChange) * - Diagnostics with debouncing * - Request/response handling */ import { spawn as nodeSpawn } from "node:child_process"; import { EventEmitter } from "node:events"; import { existsSync } from "node:fs"; import { pathToFileURL } from "node:url"; import type { MessageConnection } from "vscode-jsonrpc"; import { logLatency } from "../latency-logger.js"; import { createMessageConnection, StreamMessageReader, StreamMessageWriter, } from "vscode-jsonrpc/node.js"; import type { LSPProcess } from "./launch.js"; import { normalizeMapKey, uriToPath } from "./path-utils.js"; import { getStrategy } from "./server-strategies.js"; // --- Types --- export interface LSPDiagnostic { severity: 1 | 2 | 3 | 4; // Error, Warning, Info, Hint message: string; range: { start: { line: number; character: number }; end: { line: number; character: number }; }; code?: string | number; source?: string; } export interface LSPLocation { uri: string; range: { start: { line: number; character: number }; end: { line: number; character: number }; }; } export interface LSPHover { contents: | string | { kind: string; value: string } | Array; range?: LSPLocation["range"]; } export interface LSPSignatureHelp { signatures: Array<{ label: string; documentation?: string | { kind: string; value: string }; parameters?: Array<{ label: string | [number, number]; documentation?: string | { kind: string; value: string }; }>; }>; activeSignature?: number; activeParameter?: number; } export interface LSPCodeAction { title: string; kind?: string; diagnostics?: LSPDiagnostic[]; edit?: unknown; command?: unknown; data?: unknown; isPreferred?: boolean; disabled?: { reason?: string }; } export interface LSPWorkspaceEdit { changes?: Record; documentChanges?: unknown[]; changeAnnotations?: Record; } export interface LSPWorkspaceDiagnosticsSupport { advertised: boolean; mode: "pull" | "push-only"; diagnosticProviderKind: string; } export interface LSPShutdownOptions { /** * Fast shutdown is for process/session teardown paths where extension cleanup * must not keep the TUI or Node process alive. It sends exit/kill signals and * unreferences child handles/timers instead of waiting for graceful escalation. */ fast?: boolean; } export interface LSPOperationSupport { definition: boolean; references: boolean; hover: boolean; signatureHelp: boolean; documentSymbol: boolean; workspaceSymbol: boolean; codeAction: boolean; rename: boolean; implementation: boolean; callHierarchy: boolean; } export interface LSPSymbol { name: string; kind: number; location?: LSPLocation; range?: LSPLocation["range"]; selectionRange?: LSPLocation["range"]; detail?: string; children?: LSPSymbol[]; } // --- Call Hierarchy Types --- export interface LSPCallHierarchyItem { name: string; kind: number; uri: string; range: LSPLocation["range"]; selectionRange: LSPLocation["range"]; } export interface LSPCallHierarchyIncomingCall { from: LSPCallHierarchyItem; fromRanges: LSPLocation["range"][]; } export interface LSPCallHierarchyOutgoingCall { to: LSPCallHierarchyItem; fromRanges: LSPLocation["range"][]; } export interface LSPClientInfo { serverId: string; root: string; connection: MessageConnection; /** Check if the connection is still alive */ isAlive: () => boolean; /** True if the server process has exited or been killed */ processExited: () => boolean; /** Last N lines of server stderr for diagnostics */ recentStderr: (lines?: number) => string; /** Pre-request health check — returns error string if process is dead */ checkAlive: () => string | undefined; notify: { open( filePath: string, content: string, languageId: string, preserveDiagnostics?: boolean, silent?: boolean, ): Promise; change(filePath: string, content: string): Promise; }; getDiagnostics(filePath: string): LSPDiagnostic[]; waitForDiagnostics(filePath: string, timeoutMs?: number): Promise; /** Get all tracked diagnostics with timestamps (for cascade checking) */ getAllDiagnostics(): Map; pruneDiagnostics( predicate: ( filePath: string, ts: number, diags: LSPDiagnostic[], ) => boolean, ): number; /** Capability snapshot for workspace diagnostics support */ getWorkspaceDiagnosticsSupport(): LSPWorkspaceDiagnosticsSupport; /** Capability snapshot for navigation/edit operations */ getOperationSupport(): LSPOperationSupport; /** Go to definition — returns Location[] */ definition( filePath: string, line: number, character: number, ): Promise; /** Find all references */ references( filePath: string, line: number, character: number, includeDeclaration?: boolean, ): Promise; /** Hover info at position */ hover( filePath: string, line: number, character: number, ): Promise; /** Signature help at position */ signatureHelp( filePath: string, line: number, character: number, ): Promise; /** Symbols in a document */ documentSymbol(filePath: string): Promise; /** Workspace-wide symbol search */ workspaceSymbol(query: string): Promise; /** Available code actions at a range */ codeAction( filePath: string, line: number, character: number, endLine: number, endCharacter: number, ): Promise; /** Rename symbol at position */ rename( filePath: string, line: number, character: number, newName: string, ): Promise; /** Go to implementation */ implementation( filePath: string, line: number, character: number, ): Promise; /** Prepare call hierarchy at position */ prepareCallHierarchy( filePath: string, line: number, character: number, ): Promise; /** Find incoming calls (callers) */ incomingCalls( item: LSPCallHierarchyItem, ): Promise; /** Find outgoing calls (callees) */ outgoingCalls( item: LSPCallHierarchyItem, ): Promise; shutdown(options?: LSPShutdownOptions): Promise; } // --- Constants --- const INITIALIZE_TIMEOUT_MS = positiveIntFromEnv( "PI_LENS_LSP_INIT_TIMEOUT_MS", 15_000, ); // 15s — npx downloads are handled by ensureTool, not here const NAV_REQUEST_TIMEOUT_MS = positiveIntFromEnv( "PI_LENS_LSP_NAV_REQUEST_TIMEOUT_MS", 10_000, ); // 10s — per-request ceiling; prevents heavy servers (vue, svelte) from hanging const DIAGNOSTICS_WAIT_TIMEOUT_MS = positiveIntFromEnv( "PI_LENS_LSP_DIAGNOSTICS_WAIT_MS", 10_000, ); const PULL_DIAGNOSTICS_RETRY_INTERVAL_MS = positiveIntFromEnv( "PI_LENS_LSP_PULL_RETRY_INTERVAL_MS", 250, ); const SHUTDOWN_REQUEST_TIMEOUT_MS = positiveIntFromEnv( "PI_LENS_LSP_SHUTDOWN_TIMEOUT_MS", 1000, ); const LSP_CRASH_CODES = new Set([ "ERR_STREAM_DESTROYED", "ERR_STREAM_WRITE_AFTER_END", "EPIPE", "ECONNRESET", ]); let crashGuardInstalled = false; function isIgnorableLspRuntimeCrash(err: unknown): boolean { if (!(err instanceof Error)) return false; const code = (err as { code?: string }).code; if (code && LSP_CRASH_CODES.has(code)) return true; const msg = err.message.toLowerCase(); const stack = (err.stack ?? "").toLowerCase(); return ( msg.includes("stream") || msg.includes("write after end") || stack.includes("vscode-jsonrpc/lib/node/ril.js") ); } function installCrashGuard(): void { if (crashGuardInstalled) return; crashGuardInstalled = true; process.on("uncaughtException", (err) => { if (isIgnorableLspRuntimeCrash(err)) { return; } throw err; }); process.on("unhandledRejection", (reason) => { if (isIgnorableLspRuntimeCrash(reason)) { return; } throw reason instanceof Error ? reason : new Error(String(reason)); }); } // --- Client State + Module-level helpers --- export interface LSPClientState { isConnected: boolean; isDestroyed: boolean; connectionDisposed: boolean; lastError: Error | undefined; readonly connection: MessageConnection; readonly pushDiagnostics: Map; readonly pushDiagnosticTimestamps: Map; readonly documentPullDiagnostics: Map; readonly documentPullDiagnosticTimestamps: Map; readonly pendingDiagnostics: Map>; readonly diagnosticEmitter: EventEmitter; readonly documentVersions: Map; readonly openDocuments: Set; readonly pendingOpens: Set; /** Mutable: updated by applyDynamicCapabilities after registerCapability events */ workspaceDiagnosticsSupport: LSPWorkspaceDiagnosticsSupport; /** Mutable: upgraded by applyDynamicCapabilities after registerCapability events */ operationSupport: LSPOperationSupport; /** Baseline mode from static initResult — used to revert on unregister */ staticDiagnosticsMode: "pull" | "push-only"; /** Live dynamic registrations from client/registerCapability: id → method */ readonly dynamicRegistrations: Map; readonly serverId: string; readonly root: string; readonly lspProcess: LSPProcess; } function isClientAlive(state: LSPClientState): boolean { return ( state.isConnected && !state.isDestroyed && !state.lspProcess.process.killed ); } function disposeClientConnection(state: LSPClientState): void { if (state.connectionDisposed) return; state.connectionDisposed = true; try { state.connection.dispose(); } catch { // ignore } } async function killProcessTree( proc: { kill(signal?: NodeJS.Signals | number): boolean; unref?: () => void; }, pid: number, options: LSPShutdownOptions = {}, ): Promise { if (process.platform === "win32" && pid > 0) { try { // Absolute path avoids PATH-resolution: SystemRoot is set by Windows itself. const taskkill = `${process.env.SystemRoot ?? "C:\\Windows"}\\System32\\taskkill.exe`; const killer = nodeSpawn(taskkill, ["/F", "/T", "/PID", String(pid)], { shell: false, windowsHide: true, stdio: "ignore", detached: !!options.fast, }); if (options.fast) { killer.unref(); proc.unref?.(); return; } await new Promise((resolve) => { killer.once("close", () => resolve()); killer.once("error", () => resolve()); }); } catch { // ignore } return; } try { proc.kill("SIGTERM"); if (options.fast) { const timer = setTimeout(() => { try { if (!(proc as { killed?: boolean }).killed) { proc.kill("SIGKILL"); } } catch { // best-effort } }, 1500); timer.unref?.(); proc.unref?.(); return; } // SIGTERM → 1.5s → SIGKILL escalation. // SIGTERM alone can leave zombie processes if the server hangs. await new Promise((resolve) => setTimeout(resolve, 1500)); try { if (!(proc as { killed?: boolean }).killed) { proc.kill("SIGKILL"); } } catch { // best-effort } } catch { // ignore } } function mergeDiagnosticLists( push: LSPDiagnostic[] | undefined, pull: LSPDiagnostic[] | undefined, ): LSPDiagnostic[] { const merged: LSPDiagnostic[] = []; const seen = new Set(); for (const diagnostic of [...(push ?? []), ...(pull ?? [])]) { const key = [ diagnostic.range.start.line, diagnostic.range.start.character, diagnostic.range.end.line, diagnostic.range.end.character, diagnostic.code ?? "", diagnostic.source ?? "", diagnostic.message, ].join(":"); if (seen.has(key)) continue; seen.add(key); merged.push(diagnostic); } return merged; } function getMergedDiagnosticsForPath( state: LSPClientState, normalizedPath: string, ): LSPDiagnostic[] { const legacy = state as unknown as { diagnostics?: Map; }; return mergeDiagnosticLists( state.pushDiagnostics?.get(normalizedPath) ?? legacy.diagnostics?.get(normalizedPath), state.documentPullDiagnostics?.get(normalizedPath), ); } function clearDiagnosticsForPath( state: LSPClientState, normalizedPath: string, ): void { const legacy = state as unknown as { diagnostics?: Map; diagnosticTimestamps?: Map; }; state.pushDiagnostics?.delete(normalizedPath); state.pushDiagnosticTimestamps?.delete(normalizedPath); state.documentPullDiagnostics?.delete(normalizedPath); state.documentPullDiagnosticTimestamps?.delete(normalizedPath); legacy.diagnostics?.delete(normalizedPath); legacy.diagnosticTimestamps?.delete(normalizedPath); } // Methods that can be registered dynamically and map to operationSupport keys const DYNAMIC_OPERATION_METHOD_MAP: Record = { "textDocument/definition": "definition", "textDocument/references": "references", "textDocument/hover": "hover", "textDocument/signatureHelp": "signatureHelp", "textDocument/documentSymbol": "documentSymbol", "workspace/symbol": "workspaceSymbol", "textDocument/codeAction": "codeAction", "textDocument/rename": "rename", "textDocument/implementation": "implementation", "textDocument/prepareCallHierarchy": "callHierarchy", }; export function applyDynamicCapabilities(state: LSPClientState): void { const registeredMethods = new Set(state.dynamicRegistrations.values()); const hasDynamicPull = registeredMethods.has("textDocument/diagnostic") || registeredMethods.has("workspace/diagnostic"); if (hasDynamicPull) { state.workspaceDiagnosticsSupport = { advertised: true, mode: "pull", diagnosticProviderKind: "dynamic", }; } else if ( state.staticDiagnosticsMode === "push-only" && state.workspaceDiagnosticsSupport.diagnosticProviderKind === "dynamic" ) { // Was only dynamically registered, now unregistered — revert to push-only state.workspaceDiagnosticsSupport = { advertised: false, mode: "push-only", diagnosticProviderKind: "none", }; } for (const [method, key] of Object.entries(DYNAMIC_OPERATION_METHOD_MAP)) { if (registeredMethods.has(method)) { state.operationSupport[key] = true; } } } function setupIncomingHandlers( state: LSPClientState, initialization: Record | undefined, ): void { state.connection.onNotification( "textDocument/publishDiagnostics", (params: { uri: string; diagnostics?: LSPDiagnostic[] }) => { const filePath = uriToPath(params.uri); const normalizedPath = normalizeMapKey(filePath); const newDiags: LSPDiagnostic[] = params.diagnostics || []; const strategy = getStrategy(state.serverId); // Seed on first push for servers whose first push is known complete. // Bypasses the debounce timer entirely — resolves waiting promises immediately. if ( strategy.seedFirstPush && !state.pushDiagnostics.has(normalizedPath) ) { state.pushDiagnostics.set(normalizedPath, newDiags); state.pushDiagnosticTimestamps.set(normalizedPath, Date.now()); state.diagnosticEmitter.emit("diagnostics", normalizedPath); return; } const existingTimer = state.pendingDiagnostics.get(normalizedPath); if (existingTimer) clearTimeout(existingTimer); const timer = setTimeout(() => { state.pushDiagnostics.set(normalizedPath, newDiags); state.pushDiagnosticTimestamps.set(normalizedPath, Date.now()); state.pendingDiagnostics.delete(normalizedPath); state.diagnosticEmitter.emit("diagnostics", normalizedPath); }, strategy.debounceMs); state.pendingDiagnostics.set(normalizedPath, timer); }, ); state.connection.onRequest("workspace/workspaceFolders", () => [ { name: "workspace", uri: pathToFileURL(state.root).href }, ]); state.connection.onRequest( "client/registerCapability", async (params: { registrations?: Array<{ id: string; method: string }>; }) => { for (const reg of params?.registrations ?? []) { if (reg.id && reg.method) { state.dynamicRegistrations.set(reg.id, reg.method); } } applyDynamicCapabilities(state); }, ); state.connection.onRequest( "client/unregisterCapability", async (params: { unregisterations?: Array<{ id: string }> }) => { for (const unreg of params?.unregisterations ?? []) { if (unreg.id) { state.dynamicRegistrations.delete(unreg.id); } } applyDynamicCapabilities(state); }, ); state.connection.onRequest("workspace/configuration", async () => [ initialization ?? {}, ]); state.connection.onRequest("window/workDoneProgress/create", async () => {}); } function setupConnectionLifecycle(state: LSPClientState): void { state.connection.onError(([error]: [Error, ...unknown[]]) => { state.lastError = error instanceof Error ? error : new Error(String(error)); state.isConnected = false; state.isDestroyed = true; disposeClientConnection(state); }); state.connection.onClose(() => { state.isConnected = false; state.isDestroyed = true; disposeClientConnection(state); }); state.lspProcess.process.on("exit", (code) => { const wasConnected = state.isConnected; state.isConnected = false; state.isDestroyed = true; disposeClientConnection(state); if (wasConnected) { logLatency({ type: "phase", phase: "lsp_server_unexpected_exit", filePath: state.root, durationMs: 0, metadata: { serverId: state.serverId, pid: state.lspProcess.pid, exitCode: code ?? null, }, }); } }); } async function clientRequestPullDiagnostics( state: LSPClientState, filePath: string, ): Promise { if (!isClientAlive(state)) return 0; const uri = pathToFileURL(filePath).href; try { const report = await safeSendRequest<{ kind?: string; items?: LSPDiagnostic[]; relatedDocuments?: Record; }>(state.connection, "textDocument/diagnostic", { textDocument: { uri } }); if (!report) return 0; const normalizedPath = normalizeMapKey(filePath); const primaryItems = report.items ?? []; const now = Date.now(); state.documentPullDiagnostics.set(normalizedPath, primaryItems); state.documentPullDiagnosticTimestamps.set(normalizedPath, now); let totalCount = primaryItems.length; if (report.relatedDocuments) { for (const [relatedUri, related] of Object.entries( report.relatedDocuments, )) { const relatedPath = uriToPath(relatedUri); const relatedItems = related?.items ?? []; state.documentPullDiagnostics.set( normalizeMapKey(relatedPath), relatedItems, ); state.documentPullDiagnosticTimestamps.set( normalizeMapKey(relatedPath), now, ); totalCount += relatedItems.length; } } state.diagnosticEmitter.emit("diagnostics", normalizedPath); return totalCount; } catch { return 0; } } export async function clientWaitForDiagnostics( state: LSPClientState, filePath: string, timeoutMs: number, ): Promise { const normalizedPath = normalizeMapKey(filePath); if (state.workspaceDiagnosticsSupport.mode === "pull") { const firstPullCount = await clientRequestPullDiagnostics(state, filePath); if (firstPullCount > 0) return; const strategy = getStrategy(state.serverId); const retryBudgetMs = strategy.pullRetryBudgetMs > 0 ? Math.min(timeoutMs, strategy.pullRetryBudgetMs) : 0; const startedAt = Date.now(); let latestCount = firstPullCount; while (latestCount === 0 && Date.now() - startedAt < retryBudgetMs) { await new Promise((resolve) => setTimeout(resolve, PULL_DIAGNOSTICS_RETRY_INTERVAL_MS), ); latestCount = await clientRequestPullDiagnostics(state, filePath); } if (latestCount > 0) return; } if (getMergedDiagnosticsForPath(state, normalizedPath).length > 0) return; return new Promise((resolve) => { let debounceTimer: ReturnType | undefined; const onDiagnostics = (fp: string) => { if (normalizeMapKey(fp) !== normalizedPath) return; if (debounceTimer) clearTimeout(debounceTimer); // Adaptive debounce: use time since last push to compute remaining // wait instead of always waiting the full debounce window. const strategy = getStrategy(state.serverId); const hit = state.pushDiagnosticTimestamps.get(normalizedPath); const timeSincePush = hit ? Date.now() - hit : Infinity; const remaining = Math.max(0, strategy.debounceMs - timeSincePush); debounceTimer = setTimeout(() => { state.diagnosticEmitter.off("diagnostics", onDiagnostics); clearTimeout(timeout); resolve(); }, remaining); }; state.diagnosticEmitter.on("diagnostics", onDiagnostics); const timeout = setTimeout(() => { if (debounceTimer) clearTimeout(debounceTimer); state.diagnosticEmitter.off("diagnostics", onDiagnostics); resolve(); }, timeoutMs); }); } export async function handleNotifyOpen( state: LSPClientState, filePath: string, content: string, languageId: string, preserveDiagnostics = false, silent = false, ): Promise { if (!isClientAlive(state)) return; const uri = pathToFileURL(filePath).href; const normalizedPath = normalizeMapKey(filePath); if ( state.openDocuments.has(normalizedPath) || state.pendingOpens.has(normalizedPath) ) { const version = (state.documentVersions.get(normalizedPath) ?? 0) + 1; state.documentVersions.set(normalizedPath, version); // preserveDiagnostics: skip cache clear for format-only resyncs so // waitForDiagnostics fast-paths instead of waiting up to 5s for TypeScript // to re-publish what it already knows (formatting doesn't change semantics). if (!preserveDiagnostics) { clearDiagnosticsForPath(state, normalizedPath); } await safeSendNotification(state.connection, "textDocument/didChange", { textDocument: { uri, version }, contentChanges: [{ text: content }], }); return; } state.pendingOpens.add(normalizedPath); state.documentVersions.set(normalizedPath, 0); clearDiagnosticsForPath(state, normalizedPath); // always clear for initial open // Send workspace notification first (like opencode does). // Skipped in silent mode — cascade reads a file for diagnostics, // not reporting a real filesystem change. Avoids N project-wide // rechecks on push-diagnostics LSPs (TypeScript, Python) per CR-1. if (!silent) { await safeSendNotification( state.connection, "workspace/didChangeWatchedFiles", { changes: [{ uri, type: existsSync(filePath) ? 2 : 1 }] }, ); } if (!isClientAlive(state)) return; await safeSendNotification(state.connection, "textDocument/didOpen", { textDocument: { uri, languageId, version: 0, text: content }, }); state.pendingOpens.delete(normalizedPath); state.openDocuments.add(normalizedPath); } export async function handleNotifyChange( state: LSPClientState, filePath: string, content: string, ): Promise { if (!isClientAlive(state)) return; const uri = pathToFileURL(filePath).href; const normalizedPath = normalizeMapKey(filePath); if (!state.openDocuments.has(normalizedPath)) { // Safety fallback: keep protocol ordering valid even if caller sends // didChange before first didOpen for this document. await safeSendNotification(state.connection, "textDocument/didOpen", { textDocument: { uri, languageId: "plaintext", version: 0, text: content }, }); state.documentVersions.set(normalizedPath, 0); state.openDocuments.add(normalizedPath); return; } const version = (state.documentVersions.get(normalizedPath) ?? 0) + 1; state.documentVersions.set(normalizedPath, version); // Clear stale diagnostics before sending new content so waitForDiagnostics // doesn't return immediately with the previous edit's results. clearDiagnosticsForPath(state, normalizedPath); await safeSendNotification(state.connection, "textDocument/didChange", { textDocument: { uri, version }, contentChanges: [{ text: content }], }); } export async function clientShutdown( state: LSPClientState, options: LSPShutdownOptions = {}, ): Promise { state.isConnected = false; state.isDestroyed = true; for (const timer of state.pendingDiagnostics.values()) { clearTimeout(timer); } state.pendingDiagnostics.clear(); state.pendingOpens.clear(); state.openDocuments.clear(); state.diagnosticEmitter.removeAllListeners(); if (!options.fast) { try { await withTimeout( safeSendRequest(state.connection, "shutdown", {}), SHUTDOWN_REQUEST_TIMEOUT_MS, ); } catch { /* ignore — proceed to exit/kill so shutdown cannot hang the session */ } try { await safeSendNotification(state.connection, "exit", {}); } catch { /* ignore */ } } disposeClientConnection(state); const pid = state.lspProcess.pid; // On Windows, killing the direct child first can orphan grandchildren before // taskkill can traverse the tree. Kill the full tree first and wait briefly. await killProcessTree(state.lspProcess.process, pid, options); } async function navRequest( state: LSPClientState, method: string, params: Record, ): Promise { if (!isClientAlive(state)) return null; return withTimeout( safeSendRequest(state.connection, method, params), NAV_REQUEST_TIMEOUT_MS, ).catch((err: unknown) => { if (err instanceof Error && err.message.startsWith("Timeout after")) { return undefined; } throw err; }) as Promise; } // --- Client Factory --- export async function createLSPClient(options: { serverId: string; process: LSPProcess; root: string; initialization?: Record; initializeTimeoutMs?: number; }): Promise { installCrashGuard(); const { serverId, process: lspProcess, root, initialization, initializeTimeoutMs = INITIALIZE_TIMEOUT_MS, } = options; const startupState: { exitCode: number | null; exitSignal: NodeJS.Signals | null; closeCode: number | null; closeSignal: NodeJS.Signals | null; stderr: string; } = { exitCode: null, exitSignal: null, closeCode: null, closeSignal: null, stderr: "", }; // Persistent stderr ring buffer — captures last ~100 lines for diagnostics. // Used in error messages to show what the server said before dying. const stderrRing: string[] = []; const MAX_STDERR_LINES = 100; const onStderr = (chunk: Buffer | string): void => { stderrRing.push(chunk.toString()); if (stderrRing.length > MAX_STDERR_LINES) stderrRing.shift(); // Also capture startup stderr for the initialized-failed error path if (startupState.stderr.length < 4096) { startupState.stderr += chunk.toString(); } }; const recentStderr = (lines = 10): string => stderrRing.slice(-lines).join("").trim(); // Pre-request health check — returns error string if process is dead. const checkProcessAlive = (): string | undefined => { const exited = lspProcess.process.exitCode; if (exited !== null) { const tail = recentStderr(20); return `LSP server ${serverId} exited with code ${exited}${tail ? `. stderr: ${tail}` : ""}`; } if ((lspProcess.process as { killed?: boolean }).killed) { return `LSP server ${serverId} was killed`; } return undefined; }; const onProcessExit = ( code: number | null, signal: NodeJS.Signals | null, ): void => { startupState.exitCode = code; startupState.exitSignal = signal; }; const onProcessClose = ( code: number | null, signal: NodeJS.Signals | null, ): void => { startupState.closeCode = code; startupState.closeSignal = signal; }; (lspProcess.stderr as NodeJS.ReadableStream).on("data", onStderr); lspProcess.process.on("exit", onProcessExit); lspProcess.process.on("close", onProcessClose); // Attach persistent 'error' listeners to all three stdio streams. // // Why: when the LSP process exits, Node.js destroys its stdio streams and // may emit 'error' (ERR_STREAM_DESTROYED / EPIPE / ECONNRESET) on them. // Without a listener that becomes an uncaught exception. // // vscode-jsonrpc covers stdin/stdout during the connection lifetime but // removes its listeners on dispose(). Our permanent listeners cover the gap. const streamErrorHandler = (_label: string) => (err: Error & { code?: string }) => { if ( err.code === "ERR_STREAM_DESTROYED" || err.code === "ERR_STREAM_WRITE_AFTER_END" || err.code === "EPIPE" || err.code === "ECONNRESET" ) return; }; (lspProcess.stdin as NodeJS.WritableStream).on( "error", streamErrorHandler("stdin"), ); (lspProcess.stdout as NodeJS.ReadableStream).on( "error", streamErrorHandler("stdout"), ); (lspProcess.stderr as NodeJS.ReadableStream).on( "error", streamErrorHandler("stderr"), ); const connection = createMessageConnection( new StreamMessageReader(lspProcess.stdout), new StreamMessageWriter(lspProcess.stdin), ); // Local event emitter — signals waitForDiagnostics when new diagnostics arrive. // Scoped to this client instance. setMaxListeners guards against Node.js warning // for concurrent waitForDiagnostics calls. const diagnosticEmitter = new EventEmitter(); diagnosticEmitter.setMaxListeners(50); const state: LSPClientState = { isConnected: true, isDestroyed: false, connectionDisposed: false, lastError: undefined, connection, pushDiagnostics: new Map(), pushDiagnosticTimestamps: new Map(), documentPullDiagnostics: new Map(), documentPullDiagnosticTimestamps: new Map(), pendingDiagnostics: new Map(), diagnosticEmitter, documentVersions: new Map(), openDocuments: new Set(), pendingOpens: new Set(), // these are filled in after initialize — cast to avoid two-phase init workspaceDiagnosticsSupport: undefined as unknown as LSPWorkspaceDiagnosticsSupport, operationSupport: undefined as unknown as LSPOperationSupport, staticDiagnosticsMode: "push-only", dynamicRegistrations: new Map(), serverId, root, lspProcess, }; setupIncomingHandlers(state, initialization); connection.listen(); setupConnectionLifecycle(state); let initResult: Awaited>; try { initResult = await withTimeout( safeSendRequest(connection, "initialize", { processId: process.pid, rootUri: pathToFileURL(root).href, workspaceFolders: [ { name: "workspace", uri: pathToFileURL(root).href }, ], capabilities: { window: { workDoneProgress: true }, workspace: { workspaceFolders: true, configuration: true, didChangeWatchedFiles: { dynamicRegistration: true }, }, textDocument: { synchronization: { didOpen: true, didChange: true }, publishDiagnostics: { versionSupport: true }, }, }, initializationOptions: initialization, }), initializeTimeoutMs, ); } catch (err) { // Hard-kill the hung process so it doesn't become a zombie. // SIGTERM alone is unreliable on Windows for cmd.exe/PowerShell trees. const pid = lspProcess.pid; void killProcessTree(lspProcess.process, pid); setTimeout(() => { if (!lspProcess.process.killed && process.platform !== "win32") { lspProcess.process.kill("SIGKILL"); } }, 2000); throw err; } finally { (lspProcess.stderr as NodeJS.ReadableStream).off("data", onStderr); } if (initResult === undefined) { const compactStderr = startupState.stderr .replace(/\s+/g, " ") .trim() .slice(0, 320); const reinstallHint = serverId === "cpp" ? "Install clangd (LLVM/clang-tools) and ensure clangd.exe is on PATH." : `Try reinstalling: npm install -g ${serverId}-language-server.`; const telemetry = [ `pid=${lspProcess.pid}`, `exitCode=${startupState.exitCode ?? "none"}`, `exitSignal=${startupState.exitSignal ?? "none"}`, `closeCode=${startupState.closeCode ?? "none"}`, `closeSignal=${startupState.closeSignal ?? "none"}`, `root=${root}`, compactStderr ? `stderr=${compactStderr}` : "stderr=", ].join(" "); throw new Error( `[lsp] ${serverId} failed to initialize - stream may have been destroyed. ` + `The server binary may be missing or crashed immediately. ${reinstallHint} ` + `telemetry: ${telemetry}`, ); } state.workspaceDiagnosticsSupport = detectWorkspaceDiagnosticsSupport(initResult); state.operationSupport = detectOperationSupport(initResult); state.staticDiagnosticsMode = state.workspaceDiagnosticsSupport.mode; await safeSendNotification(connection, "initialized", {}); if (initialization) { await safeSendNotification(connection, "workspace/didChangeConfiguration", { settings: initialization, }); } return { serverId, root, connection, isAlive: () => isClientAlive(state), /** True if the server process has exited or been killed. */ processExited: () => lspProcess.process.exitCode !== null || (lspProcess.process as { killed?: boolean }).killed === true, /** Last N lines of server stderr for diagnostics. */ recentStderr: (lines?: number) => recentStderr(lines), /** Pre-request health check — returns error string if dead. */ checkAlive: () => checkProcessAlive(), notify: { async open(filePath, content, languageId, preserveDiagnostics, silent) { return handleNotifyOpen( state, filePath, content, languageId, preserveDiagnostics, silent, ); }, async change(filePath, content) { return handleNotifyChange(state, filePath, content); }, }, getDiagnostics(filePath) { return getMergedDiagnosticsForPath(state, normalizeMapKey(filePath)); }, getAllDiagnostics() { const result = new Map(); const keys = new Set([ ...state.pushDiagnostics.keys(), ...state.documentPullDiagnostics.keys(), ]); for (const key of keys) { result.set(key, { diags: getMergedDiagnosticsForPath(state, key), ts: Math.max( state.pushDiagnosticTimestamps.get(key) ?? 0, state.documentPullDiagnosticTimestamps.get(key) ?? 0, ), }); } return result; }, pruneDiagnostics(predicate) { let removed = 0; const keys = new Set([ ...state.pushDiagnostics.keys(), ...state.documentPullDiagnostics.keys(), ]); for (const key of keys) { const diags = getMergedDiagnosticsForPath(state, key); const ts = Math.max( state.pushDiagnosticTimestamps.get(key) ?? 0, state.documentPullDiagnosticTimestamps.get(key) ?? 0, ); if (!predicate(key, ts, diags)) continue; clearDiagnosticsForPath(state, key); removed++; } return removed; }, getWorkspaceDiagnosticsSupport() { return state.workspaceDiagnosticsSupport; }, getOperationSupport() { return state.operationSupport; }, async waitForDiagnostics( filePath, timeoutMs = DIAGNOSTICS_WAIT_TIMEOUT_MS, ) { return clientWaitForDiagnostics(state, filePath, timeoutMs); }, async definition(filePath, line, character) { const result = await navRequest( state, "textDocument/definition", { textDocument: { uri: pathToFileURL(filePath).href }, position: { line, character }, }, ); if (!result) return []; return Array.isArray(result) ? result : [result]; }, async references(filePath, line, character, includeDeclaration = true) { const result = await navRequest( state, "textDocument/references", { textDocument: { uri: pathToFileURL(filePath).href }, position: { line, character }, context: { includeDeclaration }, }, ); return result ?? []; }, async hover(filePath, line, character) { const result = await navRequest(state, "textDocument/hover", { textDocument: { uri: pathToFileURL(filePath).href }, position: { line, character }, }); return result ?? null; }, async signatureHelp(filePath, line, character) { const result = await navRequest( state, "textDocument/signatureHelp", { textDocument: { uri: pathToFileURL(filePath).href }, position: { line, character }, }, ); return result ?? null; }, async documentSymbol(filePath) { const result = await navRequest( state, "textDocument/documentSymbol", { textDocument: { uri: pathToFileURL(filePath).href } }, ); return result ?? []; }, async workspaceSymbol(query) { if (!isClientAlive(state)) return []; const result = await safeSendRequest( connection, "workspace/symbol", { query }, ); return result ?? []; }, async codeAction(filePath, line, character, endLine, endCharacter) { if (!isClientAlive(state)) return []; const uri = pathToFileURL(filePath).href; const result = await safeSendRequest( connection, "textDocument/codeAction", { textDocument: { uri }, range: { start: { line, character }, end: { line: endLine, character: endCharacter }, }, context: { diagnostics: getMergedDiagnosticsForPath( state, normalizeMapKey(filePath), ), }, }, ); if (!result || !Array.isArray(result)) return []; return result.filter( (item): item is LSPCodeAction => typeof item === "object" && item !== null && "title" in item, ); }, async rename(filePath, line, character, newName) { const result = await navRequest( state, "textDocument/rename", { textDocument: { uri: pathToFileURL(filePath).href }, position: { line, character }, newName, }, ); return result ?? null; }, async implementation(filePath, line, character) { const result = await navRequest( state, "textDocument/implementation", { textDocument: { uri: pathToFileURL(filePath).href }, position: { line, character }, }, ); if (!result) return []; return Array.isArray(result) ? result : [result]; }, async prepareCallHierarchy(filePath, line, character) { const result = await navRequest< LSPCallHierarchyItem | LSPCallHierarchyItem[] >(state, "textDocument/prepareCallHierarchy", { textDocument: { uri: pathToFileURL(filePath).href }, position: { line, character }, }); if (!result) return []; return Array.isArray(result) ? result : [result]; }, async incomingCalls(item) { const result = await navRequest( state, "callHierarchy/incomingCalls", { item }, ); return result ?? []; }, async outgoingCalls(item) { const result = await navRequest( state, "callHierarchy/outgoingCalls", { item }, ); return result ?? []; }, async shutdown(options?: LSPShutdownOptions) { return clientShutdown(state, options); }, }; } // Helper to safely send notifications - catches stream destruction async function safeSendNotification( connection: MessageConnection, method: string, params: unknown, ): Promise { try { await connection.sendNotification(method as never, params as never); } catch (err) { if (isStreamError(err)) { // Silently ignore - stream was destroyed, connection error handlers will update state return; } throw err; } } // Helper to safely send requests - catches stream destruction async function safeSendRequest( connection: MessageConnection, method: string, params: unknown, ): Promise { try { return (await connection.sendRequest( method as never, params as never, )) as T; } catch (err) { if (isStreamError(err)) { // Silently ignore - stream was destroyed return undefined; } throw err; } } // Helper to detect stream destruction / connection disposal errors. // vscode-jsonrpc throws these when the LSP server process exits while // requests are still in flight: // "Connection is disposed." // "Pending response rejected since connection got disposed" // Neither phrase contains "stream", "destroyed", or "closed", which is // why we must also match "disposed" and "cancelled" here. function isStreamError(err: unknown): boolean { if (!(err instanceof Error)) return false; const msg = err.message.toLowerCase(); return ( msg.includes("stream") || msg.includes("destroyed") || msg.includes("closed") || msg.includes("disposed") || msg.includes("cancelled") || (err as { code?: string }).code === "ERR_STREAM_DESTROYED" || (err as { code?: string }).code === "ERR_STREAM_WRITE_AFTER_END" || (err as { code?: string }).code === "EPIPE" ); } // Using shared path utilities from path-utils.ts async function withTimeout( promise: Promise, timeoutMs: number, ): Promise { let timeout: ReturnType | undefined; // Suppress unhandled rejection if `promise` rejects AFTER the timeout // wins the race — Promise.race settles on the first result but the // losing promises still run, and any later rejection would be uncaught. promise.catch(() => {}); try { return await Promise.race([ promise, new Promise((_, reject) => { timeout = setTimeout( () => reject(new Error(`Timeout after ${timeoutMs}ms`)), timeoutMs, ); }), ]); } finally { if (timeout) clearTimeout(timeout); } } function positiveIntFromEnv(name: string, fallback: number): number { const raw = process.env[name]; if (!raw) return fallback; const parsed = Number.parseInt(raw, 10); if (!Number.isFinite(parsed) || parsed <= 0) return fallback; return parsed; } function detectWorkspaceDiagnosticsSupport( initResult: unknown, ): LSPWorkspaceDiagnosticsSupport { const capabilities = typeof initResult === "object" && initResult !== null ? (initResult as { capabilities?: Record }).capabilities : undefined; const diagnosticProvider = capabilities?.diagnosticProvider; if (!diagnosticProvider) { return { advertised: false, mode: "push-only", diagnosticProviderKind: "none", }; } if (typeof diagnosticProvider === "boolean") { return { advertised: diagnosticProvider, mode: diagnosticProvider ? "pull" : "push-only", diagnosticProviderKind: "boolean", }; } if (typeof diagnosticProvider === "object") { return { advertised: true, mode: "pull", diagnosticProviderKind: "object", }; } return { advertised: false, mode: "push-only", diagnosticProviderKind: typeof diagnosticProvider, }; } function detectOperationSupport(initResult: unknown): LSPOperationSupport { const capabilities = typeof initResult === "object" && initResult !== null ? (initResult as { capabilities?: Record }).capabilities : undefined; const hasProvider = (key: string): boolean => { const value = capabilities?.[key]; if (value === undefined || value === null) return false; if (typeof value === "boolean") return value; return true; }; return { definition: hasProvider("definitionProvider"), references: hasProvider("referencesProvider"), hover: hasProvider("hoverProvider"), signatureHelp: hasProvider("signatureHelpProvider"), documentSymbol: hasProvider("documentSymbolProvider"), workspaceSymbol: hasProvider("workspaceSymbolProvider"), codeAction: hasProvider("codeActionProvider"), rename: hasProvider("renameProvider"), implementation: hasProvider("implementationProvider"), callHierarchy: hasProvider("callHierarchyProvider"), }; }