/ services / lsp / LSPClient.ts
LSPClient.ts
  1  import { type ChildProcess, spawn } from 'child_process'
  2  import {
  3    createMessageConnection,
  4    type MessageConnection,
  5    StreamMessageReader,
  6    StreamMessageWriter,
  7    Trace,
  8  } from 'vscode-jsonrpc/node.js'
  9  import type {
 10    InitializeParams,
 11    InitializeResult,
 12    ServerCapabilities,
 13  } from 'vscode-languageserver-protocol'
 14  import { logForDebugging } from '../../utils/debug.js'
 15  import { errorMessage } from '../../utils/errors.js'
 16  import { logError } from '../../utils/log.js'
 17  import { subprocessEnv } from '../../utils/subprocessEnv.js'
 18  /**
 19   * LSP client interface.
 20   */
 21  export type LSPClient = {
 22    readonly capabilities: ServerCapabilities | undefined
 23    readonly isInitialized: boolean
 24    start: (
 25      command: string,
 26      args: string[],
 27      options?: {
 28        env?: Record<string, string>
 29        cwd?: string
 30      },
 31    ) => Promise<void>
 32    initialize: (params: InitializeParams) => Promise<InitializeResult>
 33    sendRequest: <TResult>(method: string, params: unknown) => Promise<TResult>
 34    sendNotification: (method: string, params: unknown) => Promise<void>
 35    onNotification: (method: string, handler: (params: unknown) => void) => void
 36    onRequest: <TParams, TResult>(
 37      method: string,
 38      handler: (params: TParams) => TResult | Promise<TResult>,
 39    ) => void
 40    stop: () => Promise<void>
 41  }
 42  
 43  /**
 44   * Create an LSP client wrapper using vscode-jsonrpc.
 45   * Manages communication with an LSP server process via stdio.
 46   *
 47   * @param onCrash - Called when the server process exits unexpectedly (non-zero
 48   *   exit code during operation, not during intentional stop). Allows the owner
 49   *   to propagate crash state so the server can be restarted on next use.
 50   */
 51  export function createLSPClient(
 52    serverName: string,
 53    onCrash?: (error: Error) => void,
 54  ): LSPClient {
 55    // State variables in closure
 56    let process: ChildProcess | undefined
 57    let connection: MessageConnection | undefined
 58    let capabilities: ServerCapabilities | undefined
 59    let isInitialized = false
 60    let startFailed = false
 61    let startError: Error | undefined
 62    let isStopping = false // Track intentional shutdown to avoid spurious error logging
 63    // Queue handlers registered before connection ready (lazy initialization support)
 64    const pendingHandlers: Array<{
 65      method: string
 66      handler: (params: unknown) => void
 67    }> = []
 68    const pendingRequestHandlers: Array<{
 69      method: string
 70      handler: (params: unknown) => unknown | Promise<unknown>
 71    }> = []
 72  
 73    function checkStartFailed(): void {
 74      if (startFailed) {
 75        throw startError || new Error(`LSP server ${serverName} failed to start`)
 76      }
 77    }
 78  
 79    return {
 80      get capabilities(): ServerCapabilities | undefined {
 81        return capabilities
 82      },
 83  
 84      get isInitialized(): boolean {
 85        return isInitialized
 86      },
 87  
 88      async start(
 89        command: string,
 90        args: string[],
 91        options?: {
 92          env?: Record<string, string>
 93          cwd?: string
 94        },
 95      ): Promise<void> {
 96        try {
 97          // 1. Spawn LSP server process
 98          process = spawn(command, args, {
 99            stdio: ['pipe', 'pipe', 'pipe'],
100            env: { ...subprocessEnv(), ...options?.env },
101            cwd: options?.cwd,
102            // Prevent visible console window on Windows (no-op on other platforms)
103            windowsHide: true,
104          })
105  
106          if (!process.stdout || !process.stdin) {
107            throw new Error('LSP server process stdio not available')
108          }
109  
110          // 1.5. Wait for process to successfully spawn before using streams
111          // This is CRITICAL: spawn() returns immediately, but the 'error' event
112          // (e.g., ENOENT for command not found) fires asynchronously.
113          // If we use the streams before confirming spawn succeeded, we get
114          // unhandled promise rejections when writes fail on invalid streams.
115          const spawnedProcess = process // Capture for closure
116          await new Promise<void>((resolve, reject) => {
117            const onSpawn = (): void => {
118              cleanup()
119              resolve()
120            }
121            const onError = (error: Error): void => {
122              cleanup()
123              reject(error)
124            }
125            const cleanup = (): void => {
126              spawnedProcess.removeListener('spawn', onSpawn)
127              spawnedProcess.removeListener('error', onError)
128            }
129            spawnedProcess.once('spawn', onSpawn)
130            spawnedProcess.once('error', onError)
131          })
132  
133          // Capture stderr for server diagnostics and errors
134          if (process.stderr) {
135            process.stderr.on('data', (data: Buffer) => {
136              const output = data.toString().trim()
137              if (output) {
138                logForDebugging(`[LSP SERVER ${serverName}] ${output}`)
139              }
140            })
141          }
142  
143          // Handle process errors (after successful spawn, e.g., crash during operation)
144          process.on('error', error => {
145            if (!isStopping) {
146              startFailed = true
147              startError = error
148              logError(
149                new Error(
150                  `LSP server ${serverName} failed to start: ${error.message}`,
151                ),
152              )
153            }
154          })
155  
156          process.on('exit', (code, _signal) => {
157            if (code !== 0 && code !== null && !isStopping) {
158              isInitialized = false
159              startFailed = false
160              startError = undefined
161              const crashError = new Error(
162                `LSP server ${serverName} crashed with exit code ${code}`,
163              )
164              logError(crashError)
165              onCrash?.(crashError)
166            }
167          })
168  
169          // Handle stdin stream errors to prevent unhandled promise rejections
170          // when the LSP server process exits before we finish writing
171          process.stdin.on('error', (error: Error) => {
172            if (!isStopping) {
173              logForDebugging(
174                `LSP server ${serverName} stdin error: ${error.message}`,
175              )
176            }
177            // Error is logged but not thrown - the connection error handler will catch this
178          })
179  
180          // 2. Create JSON-RPC connection
181          const reader = new StreamMessageReader(process.stdout)
182          const writer = new StreamMessageWriter(process.stdin)
183          connection = createMessageConnection(reader, writer)
184  
185          // 2.5. Register error/close handlers BEFORE listen() to catch all errors
186          // This prevents unhandled promise rejections when the server crashes or closes unexpectedly
187          connection.onError(([error, _message, _code]) => {
188            // Only log if not intentionally stopping (avoid spurious errors during shutdown)
189            if (!isStopping) {
190              startFailed = true
191              startError = error
192              logError(
193                new Error(
194                  `LSP server ${serverName} connection error: ${error.message}`,
195                ),
196              )
197            }
198          })
199  
200          connection.onClose(() => {
201            // Only treat as error if not intentionally stopping
202            if (!isStopping) {
203              isInitialized = false
204              // Don't set startFailed here - the connection may close after graceful shutdown
205              logForDebugging(`LSP server ${serverName} connection closed`)
206            }
207          })
208  
209          // 3. Start listening for messages
210          connection.listen()
211  
212          // 3.5. Enable protocol tracing for debugging
213          // Note: trace() sends a $/setTrace notification which can fail if the server
214          // process has already exited. We catch and log the error rather than letting
215          // it become an unhandled promise rejection.
216          connection
217            .trace(Trace.Verbose, {
218              log: (message: string) => {
219                logForDebugging(`[LSP PROTOCOL ${serverName}] ${message}`)
220              },
221            })
222            .catch((error: Error) => {
223              logForDebugging(
224                `Failed to enable tracing for ${serverName}: ${error.message}`,
225              )
226            })
227  
228          // 4. Apply any queued notification handlers
229          for (const { method, handler } of pendingHandlers) {
230            connection.onNotification(method, handler)
231            logForDebugging(
232              `Applied queued notification handler for ${serverName}.${method}`,
233            )
234          }
235          pendingHandlers.length = 0 // Clear the queue
236  
237          // 5. Apply any queued request handlers
238          for (const { method, handler } of pendingRequestHandlers) {
239            connection.onRequest(method, handler)
240            logForDebugging(
241              `Applied queued request handler for ${serverName}.${method}`,
242            )
243          }
244          pendingRequestHandlers.length = 0 // Clear the queue
245  
246          logForDebugging(`LSP client started for ${serverName}`)
247        } catch (error) {
248          const err = error as Error
249          logError(
250            new Error(`LSP server ${serverName} failed to start: ${err.message}`),
251          )
252          throw error
253        }
254      },
255  
256      async initialize(params: InitializeParams): Promise<InitializeResult> {
257        if (!connection) {
258          throw new Error('LSP client not started')
259        }
260  
261        checkStartFailed()
262  
263        try {
264          const result: InitializeResult = await connection.sendRequest(
265            'initialize',
266            params,
267          )
268  
269          capabilities = result.capabilities
270  
271          // Send initialized notification
272          await connection.sendNotification('initialized', {})
273  
274          isInitialized = true
275          logForDebugging(`LSP server ${serverName} initialized`)
276  
277          return result
278        } catch (error) {
279          const err = error as Error
280          logError(
281            new Error(
282              `LSP server ${serverName} initialize failed: ${err.message}`,
283            ),
284          )
285          throw error
286        }
287      },
288  
289      async sendRequest<TResult>(
290        method: string,
291        params: unknown,
292      ): Promise<TResult> {
293        if (!connection) {
294          throw new Error('LSP client not started')
295        }
296  
297        checkStartFailed()
298  
299        if (!isInitialized) {
300          throw new Error('LSP server not initialized')
301        }
302  
303        try {
304          return await connection.sendRequest(method, params)
305        } catch (error) {
306          const err = error as Error
307          logError(
308            new Error(
309              `LSP server ${serverName} request ${method} failed: ${err.message}`,
310            ),
311          )
312          throw error
313        }
314      },
315  
316      async sendNotification(method: string, params: unknown): Promise<void> {
317        if (!connection) {
318          throw new Error('LSP client not started')
319        }
320  
321        checkStartFailed()
322  
323        try {
324          await connection.sendNotification(method, params)
325        } catch (error) {
326          const err = error as Error
327          logError(
328            new Error(
329              `LSP server ${serverName} notification ${method} failed: ${err.message}`,
330            ),
331          )
332          // Don't re-throw for notifications - they're fire-and-forget
333          logForDebugging(`Notification ${method} failed but continuing`)
334        }
335      },
336  
337      onNotification(method: string, handler: (params: unknown) => void): void {
338        if (!connection) {
339          // Queue handler for application when connection is ready (lazy initialization)
340          pendingHandlers.push({ method, handler })
341          logForDebugging(
342            `Queued notification handler for ${serverName}.${method} (connection not ready)`,
343          )
344          return
345        }
346  
347        checkStartFailed()
348  
349        connection.onNotification(method, handler)
350      },
351  
352      onRequest<TParams, TResult>(
353        method: string,
354        handler: (params: TParams) => TResult | Promise<TResult>,
355      ): void {
356        if (!connection) {
357          // Queue handler for application when connection is ready (lazy initialization)
358          pendingRequestHandlers.push({
359            method,
360            handler: handler as (params: unknown) => unknown | Promise<unknown>,
361          })
362          logForDebugging(
363            `Queued request handler for ${serverName}.${method} (connection not ready)`,
364          )
365          return
366        }
367  
368        checkStartFailed()
369  
370        connection.onRequest(method, handler)
371      },
372  
373      async stop(): Promise<void> {
374        let shutdownError: Error | undefined
375  
376        // Mark as stopping to prevent error handlers from logging spurious errors
377        isStopping = true
378  
379        try {
380          if (connection) {
381            // Try to send shutdown request and exit notification
382            await connection.sendRequest('shutdown', {})
383            await connection.sendNotification('exit', {})
384          }
385        } catch (error) {
386          const err = error as Error
387          logError(
388            new Error(`LSP server ${serverName} stop failed: ${err.message}`),
389          )
390          shutdownError = err
391          // Continue to cleanup despite shutdown failure
392        } finally {
393          // Always cleanup resources, even if shutdown/exit failed
394          if (connection) {
395            try {
396              connection.dispose()
397            } catch (error) {
398              // Log but don't throw - disposal errors are less critical
399              logForDebugging(
400                `Connection disposal failed for ${serverName}: ${errorMessage(error)}`,
401              )
402            }
403            connection = undefined
404          }
405  
406          if (process) {
407            // Remove event listeners to prevent memory leaks
408            process.removeAllListeners('error')
409            process.removeAllListeners('exit')
410            if (process.stdin) {
411              process.stdin.removeAllListeners('error')
412            }
413            if (process.stderr) {
414              process.stderr.removeAllListeners('data')
415            }
416  
417            try {
418              process.kill()
419            } catch (error) {
420              // Process might already be dead, which is fine
421              logForDebugging(
422                `Process kill failed for ${serverName} (may already be dead): ${errorMessage(error)}`,
423              )
424            }
425            process = undefined
426          }
427  
428          isInitialized = false
429          capabilities = undefined
430          isStopping = false // Reset for potential restart
431          // Don't reset startFailed - preserve error state for diagnostics
432          // startFailed and startError remain as-is
433          if (shutdownError) {
434            startFailed = true
435            startError = shutdownError
436          }
437  
438          logForDebugging(`LSP client stopped for ${serverName}`)
439        }
440  
441        // Re-throw shutdown error after cleanup is complete
442        if (shutdownError) {
443          throw shutdownError
444        }
445      },
446    }
447  }