import { type ChildProcess, spawn } from 'child_process' import { createMessageConnection, type MessageConnection, StreamMessageReader, StreamMessageWriter, Trace, } from 'vscode-jsonrpc/node.js' import type { InitializeParams, InitializeResult, ServerCapabilities, } from 'vscode-languageserver-protocol' import { logForDebugging } from '../../utils/debug.js' import { errorMessage } from '../../utils/errors.js' import { logError } from '../../utils/log.js' import { subprocessEnv } from '../../utils/subprocessEnv.js' /** * LSP client interface. */ export type LSPClient = { readonly capabilities: ServerCapabilities | undefined readonly isInitialized: boolean start: ( command: string, args: string[], options?: { env?: Record cwd?: string }, ) => Promise initialize: (params: InitializeParams) => Promise sendRequest: (method: string, params: unknown) => Promise sendNotification: (method: string, params: unknown) => Promise onNotification: (method: string, handler: (params: unknown) => void) => void onRequest: ( method: string, handler: (params: TParams) => TResult | Promise, ) => void stop: () => Promise } /** * Create an LSP client wrapper using vscode-jsonrpc. * Manages communication with an LSP server process via stdio. * * @param onCrash - Called when the server process exits unexpectedly (non-zero * exit code during operation, not during intentional stop). Allows the owner * to propagate crash state so the server can be restarted on next use. */ export function createLSPClient( serverName: string, onCrash?: (error: Error) => void, ): LSPClient { // State variables in closure let process: ChildProcess | undefined let connection: MessageConnection | undefined let capabilities: ServerCapabilities | undefined let isInitialized = false let startFailed = false let startError: Error | undefined let isStopping = false // Track intentional shutdown to avoid spurious error logging // Queue handlers registered before connection ready (lazy initialization support) const pendingHandlers: Array<{ method: string handler: (params: unknown) => void }> = [] const pendingRequestHandlers: Array<{ method: string handler: (params: unknown) => unknown | Promise }> = [] function checkStartFailed(): void { if (startFailed) { throw startError || new Error(`LSP server ${serverName} failed to start`) } } return { get capabilities(): ServerCapabilities | undefined { return capabilities }, get isInitialized(): boolean { return isInitialized }, async start( command: string, args: string[], options?: { env?: Record cwd?: string }, ): Promise { try { // 1. Spawn LSP server process process = spawn(command, args, { stdio: ['pipe', 'pipe', 'pipe'], env: { ...subprocessEnv(), ...options?.env }, cwd: options?.cwd, // Prevent visible console window on Windows (no-op on other platforms) windowsHide: true, }) if (!process.stdout || !process.stdin) { throw new Error('LSP server process stdio not available') } // 1.5. Wait for process to successfully spawn before using streams // This is CRITICAL: spawn() returns immediately, but the 'error' event // (e.g., ENOENT for command not found) fires asynchronously. // If we use the streams before confirming spawn succeeded, we get // unhandled promise rejections when writes fail on invalid streams. const spawnedProcess = process // Capture for closure await new Promise((resolve, reject) => { const onSpawn = (): void => { cleanup() resolve() } const onError = (error: Error): void => { cleanup() reject(error) } const cleanup = (): void => { spawnedProcess.removeListener('spawn', onSpawn) spawnedProcess.removeListener('error', onError) } spawnedProcess.once('spawn', onSpawn) spawnedProcess.once('error', onError) }) // Capture stderr for server diagnostics and errors if (process.stderr) { process.stderr.on('data', (data: Buffer) => { const output = data.toString().trim() if (output) { logForDebugging(`[LSP SERVER ${serverName}] ${output}`) } }) } // Handle process errors (after successful spawn, e.g., crash during operation) process.on('error', error => { if (!isStopping) { startFailed = true startError = error logError( new Error( `LSP server ${serverName} failed to start: ${error.message}`, ), ) } }) process.on('exit', (code, _signal) => { if (code !== 0 && code !== null && !isStopping) { isInitialized = false startFailed = false startError = undefined const crashError = new Error( `LSP server ${serverName} crashed with exit code ${code}`, ) logError(crashError) onCrash?.(crashError) } }) // Handle stdin stream errors to prevent unhandled promise rejections // when the LSP server process exits before we finish writing process.stdin.on('error', (error: Error) => { if (!isStopping) { logForDebugging( `LSP server ${serverName} stdin error: ${error.message}`, ) } // Error is logged but not thrown - the connection error handler will catch this }) // 2. Create JSON-RPC connection const reader = new StreamMessageReader(process.stdout) const writer = new StreamMessageWriter(process.stdin) connection = createMessageConnection(reader, writer) // 2.5. Register error/close handlers BEFORE listen() to catch all errors // This prevents unhandled promise rejections when the server crashes or closes unexpectedly connection.onError(([error, _message, _code]) => { // Only log if not intentionally stopping (avoid spurious errors during shutdown) if (!isStopping) { startFailed = true startError = error logError( new Error( `LSP server ${serverName} connection error: ${error.message}`, ), ) } }) connection.onClose(() => { // Only treat as error if not intentionally stopping if (!isStopping) { isInitialized = false // Don't set startFailed here - the connection may close after graceful shutdown logForDebugging(`LSP server ${serverName} connection closed`) } }) // 3. Start listening for messages connection.listen() // 3.5. Enable protocol tracing for debugging // Note: trace() sends a $/setTrace notification which can fail if the server // process has already exited. We catch and log the error rather than letting // it become an unhandled promise rejection. connection .trace(Trace.Verbose, { log: (message: string) => { logForDebugging(`[LSP PROTOCOL ${serverName}] ${message}`) }, }) .catch((error: Error) => { logForDebugging( `Failed to enable tracing for ${serverName}: ${error.message}`, ) }) // 4. Apply any queued notification handlers for (const { method, handler } of pendingHandlers) { connection.onNotification(method, handler) logForDebugging( `Applied queued notification handler for ${serverName}.${method}`, ) } pendingHandlers.length = 0 // Clear the queue // 5. Apply any queued request handlers for (const { method, handler } of pendingRequestHandlers) { connection.onRequest(method, handler) logForDebugging( `Applied queued request handler for ${serverName}.${method}`, ) } pendingRequestHandlers.length = 0 // Clear the queue logForDebugging(`LSP client started for ${serverName}`) } catch (error) { const err = error as Error logError( new Error(`LSP server ${serverName} failed to start: ${err.message}`), ) throw error } }, async initialize(params: InitializeParams): Promise { if (!connection) { throw new Error('LSP client not started') } checkStartFailed() try { const result: InitializeResult = await connection.sendRequest( 'initialize', params, ) capabilities = result.capabilities // Send initialized notification await connection.sendNotification('initialized', {}) isInitialized = true logForDebugging(`LSP server ${serverName} initialized`) return result } catch (error) { const err = error as Error logError( new Error( `LSP server ${serverName} initialize failed: ${err.message}`, ), ) throw error } }, async sendRequest( method: string, params: unknown, ): Promise { if (!connection) { throw new Error('LSP client not started') } checkStartFailed() if (!isInitialized) { throw new Error('LSP server not initialized') } try { return await connection.sendRequest(method, params) } catch (error) { const err = error as Error logError( new Error( `LSP server ${serverName} request ${method} failed: ${err.message}`, ), ) throw error } }, async sendNotification(method: string, params: unknown): Promise { if (!connection) { throw new Error('LSP client not started') } checkStartFailed() try { await connection.sendNotification(method, params) } catch (error) { const err = error as Error logError( new Error( `LSP server ${serverName} notification ${method} failed: ${err.message}`, ), ) // Don't re-throw for notifications - they're fire-and-forget logForDebugging(`Notification ${method} failed but continuing`) } }, onNotification(method: string, handler: (params: unknown) => void): void { if (!connection) { // Queue handler for application when connection is ready (lazy initialization) pendingHandlers.push({ method, handler }) logForDebugging( `Queued notification handler for ${serverName}.${method} (connection not ready)`, ) return } checkStartFailed() connection.onNotification(method, handler) }, onRequest( method: string, handler: (params: TParams) => TResult | Promise, ): void { if (!connection) { // Queue handler for application when connection is ready (lazy initialization) pendingRequestHandlers.push({ method, handler: handler as (params: unknown) => unknown | Promise, }) logForDebugging( `Queued request handler for ${serverName}.${method} (connection not ready)`, ) return } checkStartFailed() connection.onRequest(method, handler) }, async stop(): Promise { let shutdownError: Error | undefined // Mark as stopping to prevent error handlers from logging spurious errors isStopping = true try { if (connection) { // Try to send shutdown request and exit notification await connection.sendRequest('shutdown', {}) await connection.sendNotification('exit', {}) } } catch (error) { const err = error as Error logError( new Error(`LSP server ${serverName} stop failed: ${err.message}`), ) shutdownError = err // Continue to cleanup despite shutdown failure } finally { // Always cleanup resources, even if shutdown/exit failed if (connection) { try { connection.dispose() } catch (error) { // Log but don't throw - disposal errors are less critical logForDebugging( `Connection disposal failed for ${serverName}: ${errorMessage(error)}`, ) } connection = undefined } if (process) { // Remove event listeners to prevent memory leaks process.removeAllListeners('error') process.removeAllListeners('exit') if (process.stdin) { process.stdin.removeAllListeners('error') } if (process.stderr) { process.stderr.removeAllListeners('data') } try { process.kill() } catch (error) { // Process might already be dead, which is fine logForDebugging( `Process kill failed for ${serverName} (may already be dead): ${errorMessage(error)}`, ) } process = undefined } isInitialized = false capabilities = undefined isStopping = false // Reset for potential restart // Don't reset startFailed - preserve error state for diagnostics // startFailed and startError remain as-is if (shutdownError) { startFailed = true startError = shutdownError } logForDebugging(`LSP client stopped for ${serverName}`) } // Re-throw shutdown error after cleanup is complete if (shutdownError) { throw shutdownError } }, } }