source dump of claude code
at main 447 lines 14 kB view raw
1import { type ChildProcess, spawn } from 'child_process' 2import { 3 createMessageConnection, 4 type MessageConnection, 5 StreamMessageReader, 6 StreamMessageWriter, 7 Trace, 8} from 'vscode-jsonrpc/node.js' 9import type { 10 InitializeParams, 11 InitializeResult, 12 ServerCapabilities, 13} from 'vscode-languageserver-protocol' 14import { logForDebugging } from '../../utils/debug.js' 15import { errorMessage } from '../../utils/errors.js' 16import { logError } from '../../utils/log.js' 17import { subprocessEnv } from '../../utils/subprocessEnv.js' 18/** 19 * LSP client interface. 20 */ 21export 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 */ 51export 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}