source dump of claude code
at main 511 lines 17 kB view raw
1import * as path from 'path' 2import { pathToFileURL } from 'url' 3import type { InitializeParams } from 'vscode-languageserver-protocol' 4import { getCwd } from '../../utils/cwd.js' 5import { logForDebugging } from '../../utils/debug.js' 6import { errorMessage } from '../../utils/errors.js' 7import { logError } from '../../utils/log.js' 8import { sleep } from '../../utils/sleep.js' 9import type { createLSPClient as createLSPClientType } from './LSPClient.js' 10import type { LspServerState, ScopedLspServerConfig } from './types.js' 11 12/** 13 * LSP error code for "content modified" - indicates the server's state changed 14 * during request processing (e.g., rust-analyzer still indexing the project). 15 * This is a transient error that can be retried. 16 */ 17const LSP_ERROR_CONTENT_MODIFIED = -32801 18 19/** 20 * Maximum number of retries for transient LSP errors like "content modified". 21 */ 22const MAX_RETRIES_FOR_TRANSIENT_ERRORS = 3 23 24/** 25 * Base delay in milliseconds for exponential backoff on transient errors. 26 * Actual delays: 500ms, 1000ms, 2000ms 27 */ 28const RETRY_BASE_DELAY_MS = 500 29/** 30 * LSP server instance interface returned by createLSPServerInstance. 31 * Manages the lifecycle of a single LSP server with state tracking and health monitoring. 32 */ 33export type LSPServerInstance = { 34 /** Unique server identifier */ 35 readonly name: string 36 /** Server configuration */ 37 readonly config: ScopedLspServerConfig 38 /** Current server state */ 39 readonly state: LspServerState 40 /** When the server was last started */ 41 readonly startTime: Date | undefined 42 /** Last error encountered */ 43 readonly lastError: Error | undefined 44 /** Number of times restart() has been called */ 45 readonly restartCount: number 46 /** Start the server and initialize it */ 47 start(): Promise<void> 48 /** Stop the server gracefully */ 49 stop(): Promise<void> 50 /** Manually restart the server (stop then start) */ 51 restart(): Promise<void> 52 /** Check if server is healthy and ready for requests */ 53 isHealthy(): boolean 54 /** Send an LSP request to the server */ 55 sendRequest<T>(method: string, params: unknown): Promise<T> 56 /** Send an LSP notification to the server (fire-and-forget) */ 57 sendNotification(method: string, params: unknown): Promise<void> 58 /** Register a handler for LSP notifications */ 59 onNotification(method: string, handler: (params: unknown) => void): void 60 /** Register a handler for LSP requests from the server */ 61 onRequest<TParams, TResult>( 62 method: string, 63 handler: (params: TParams) => TResult | Promise<TResult>, 64 ): void 65} 66 67/** 68 * Creates and manages a single LSP server instance. 69 * 70 * Uses factory function pattern with closures for state encapsulation (avoiding classes). 71 * Provides state tracking, health monitoring, and request forwarding for an LSP server. 72 * Supports manual restart with configurable retry limits. 73 * 74 * State machine transitions: 75 * - stopped → starting → running 76 * - running → stopping → stopped 77 * - any → error (on failure) 78 * - error → starting (on retry) 79 * 80 * @param name - Unique identifier for this server instance 81 * @param config - Server configuration including command, args, and limits 82 * @returns LSP server instance with lifecycle management methods 83 * 84 * @example 85 * const instance = createLSPServerInstance('my-server', config) 86 * await instance.start() 87 * const result = await instance.sendRequest('textDocument/definition', params) 88 * await instance.stop() 89 */ 90export function createLSPServerInstance( 91 name: string, 92 config: ScopedLspServerConfig, 93): LSPServerInstance { 94 // Validate that unimplemented fields are not set 95 if (config.restartOnCrash !== undefined) { 96 throw new Error( 97 `LSP server '${name}': restartOnCrash is not yet implemented. Remove this field from the configuration.`, 98 ) 99 } 100 if (config.shutdownTimeout !== undefined) { 101 throw new Error( 102 `LSP server '${name}': shutdownTimeout is not yet implemented. Remove this field from the configuration.`, 103 ) 104 } 105 106 // Private state encapsulated via closures. Lazy-require LSPClient so 107 // vscode-jsonrpc (~129KB) only loads when an LSP server is actually 108 // instantiated, not when the static import chain reaches this module. 109 // eslint-disable-next-line @typescript-eslint/no-require-imports 110 const { createLSPClient } = require('./LSPClient.js') as { 111 createLSPClient: typeof createLSPClientType 112 } 113 let state: LspServerState = 'stopped' 114 let startTime: Date | undefined 115 let lastError: Error | undefined 116 let restartCount = 0 117 let crashRecoveryCount = 0 118 // Propagate crash state so ensureServerStarted can restart on next use. 119 // Without this, state stays 'running' after crash and the server is never 120 // restarted (zombie state). 121 const client = createLSPClient(name, error => { 122 state = 'error' 123 lastError = error 124 crashRecoveryCount++ 125 }) 126 127 /** 128 * Starts the LSP server and initializes it with workspace information. 129 * 130 * If the server is already running or starting, this method returns immediately. 131 * On failure, sets state to 'error', logs for monitoring, and throws. 132 * 133 * @throws {Error} If server fails to start or initialize 134 */ 135 async function start(): Promise<void> { 136 if (state === 'running' || state === 'starting') { 137 return 138 } 139 140 // Cap crash-recovery attempts so a persistently crashing server doesn't 141 // spawn unbounded child processes on every incoming request. 142 const maxRestarts = config.maxRestarts ?? 3 143 if (state === 'error' && crashRecoveryCount > maxRestarts) { 144 const error = new Error( 145 `LSP server '${name}' exceeded max crash recovery attempts (${maxRestarts})`, 146 ) 147 lastError = error 148 logError(error) 149 throw error 150 } 151 152 let initPromise: Promise<unknown> | undefined 153 try { 154 state = 'starting' 155 logForDebugging(`Starting LSP server instance: ${name}`) 156 157 // Start the client 158 await client.start(config.command, config.args || [], { 159 env: config.env, 160 cwd: config.workspaceFolder, 161 }) 162 163 // Initialize with workspace info 164 const workspaceFolder = config.workspaceFolder || getCwd() 165 const workspaceUri = pathToFileURL(workspaceFolder).href 166 167 const initParams: InitializeParams = { 168 processId: process.pid, 169 170 // Pass server-specific initialization options from plugin config 171 // Required by vue-language-server, optional for others 172 // Provide empty object as default to avoid undefined errors in servers 173 // that expect this field to exist 174 initializationOptions: config.initializationOptions ?? {}, 175 176 // Modern approach (LSP 3.16+) - required for Pyright, gopls 177 workspaceFolders: [ 178 { 179 uri: workspaceUri, 180 name: path.basename(workspaceFolder), 181 }, 182 ], 183 184 // Deprecated fields - some servers still need these for proper URI resolution 185 rootPath: workspaceFolder, // Deprecated in LSP 3.8 but needed by some servers 186 rootUri: workspaceUri, // Deprecated in LSP 3.16 but needed by typescript-language-server for goToDefinition 187 188 // Client capabilities - declare what features we support 189 capabilities: { 190 workspace: { 191 // Don't claim to support workspace/configuration since we don't implement it 192 // This prevents servers from requesting config we can't provide 193 configuration: false, 194 // Don't claim to support workspace folders changes since we don't handle 195 // workspace/didChangeWorkspaceFolders notifications 196 workspaceFolders: false, 197 }, 198 textDocument: { 199 synchronization: { 200 dynamicRegistration: false, 201 willSave: false, 202 willSaveWaitUntil: false, 203 didSave: true, 204 }, 205 publishDiagnostics: { 206 relatedInformation: true, 207 tagSupport: { 208 valueSet: [1, 2], // Unnecessary (1), Deprecated (2) 209 }, 210 versionSupport: false, 211 codeDescriptionSupport: true, 212 dataSupport: false, 213 }, 214 hover: { 215 dynamicRegistration: false, 216 contentFormat: ['markdown', 'plaintext'], 217 }, 218 definition: { 219 dynamicRegistration: false, 220 linkSupport: true, 221 }, 222 references: { 223 dynamicRegistration: false, 224 }, 225 documentSymbol: { 226 dynamicRegistration: false, 227 hierarchicalDocumentSymbolSupport: true, 228 }, 229 callHierarchy: { 230 dynamicRegistration: false, 231 }, 232 }, 233 general: { 234 positionEncodings: ['utf-16'], 235 }, 236 }, 237 } 238 239 initPromise = client.initialize(initParams) 240 if (config.startupTimeout !== undefined) { 241 await withTimeout( 242 initPromise, 243 config.startupTimeout, 244 `LSP server '${name}' timed out after ${config.startupTimeout}ms during initialization`, 245 ) 246 } else { 247 await initPromise 248 } 249 250 state = 'running' 251 startTime = new Date() 252 crashRecoveryCount = 0 253 logForDebugging(`LSP server instance started: ${name}`) 254 } catch (error) { 255 // Clean up the spawned child process on timeout/error 256 client.stop().catch(() => {}) 257 // Prevent unhandled rejection from abandoned initialize promise 258 initPromise?.catch(() => {}) 259 state = 'error' 260 lastError = error as Error 261 logError(error) 262 throw error 263 } 264 } 265 266 /** 267 * Stops the LSP server gracefully. 268 * 269 * If already stopped or stopping, returns immediately. 270 * On failure, sets state to 'error', logs for monitoring, and throws. 271 * 272 * @throws {Error} If server fails to stop 273 */ 274 async function stop(): Promise<void> { 275 if (state === 'stopped' || state === 'stopping') { 276 return 277 } 278 279 try { 280 state = 'stopping' 281 await client.stop() 282 state = 'stopped' 283 logForDebugging(`LSP server instance stopped: ${name}`) 284 } catch (error) { 285 state = 'error' 286 lastError = error as Error 287 logError(error) 288 throw error 289 } 290 } 291 292 /** 293 * Manually restarts the server by stopping and starting it. 294 * 295 * Increments restartCount and enforces maxRestarts limit. 296 * Note: This is NOT automatic - must be called explicitly. 297 * 298 * @throws {Error} If stop or start fails, or if restartCount exceeds config.maxRestarts (default: 3) 299 */ 300 async function restart(): Promise<void> { 301 try { 302 await stop() 303 } catch (error) { 304 const stopError = new Error( 305 `Failed to stop LSP server '${name}' during restart: ${errorMessage(error)}`, 306 ) 307 logError(stopError) 308 throw stopError 309 } 310 311 restartCount++ 312 313 const maxRestarts = config.maxRestarts ?? 3 314 if (restartCount > maxRestarts) { 315 const error = new Error( 316 `Max restart attempts (${maxRestarts}) exceeded for server '${name}'`, 317 ) 318 logError(error) 319 throw error 320 } 321 322 try { 323 await start() 324 } catch (error) { 325 const startError = new Error( 326 `Failed to start LSP server '${name}' during restart (attempt ${restartCount}/${maxRestarts}): ${errorMessage(error)}`, 327 ) 328 logError(startError) 329 throw startError 330 } 331 } 332 333 /** 334 * Checks if the server is healthy and ready to handle requests. 335 * 336 * @returns true if state is 'running' AND the client has completed initialization 337 */ 338 function isHealthy(): boolean { 339 return state === 'running' && client.isInitialized 340 } 341 342 /** 343 * Sends an LSP request to the server with retry logic for transient errors. 344 * 345 * Checks server health before sending and wraps errors with context. 346 * Automatically retries on "content modified" errors (code -32801) which occur 347 * when servers like rust-analyzer are still indexing. This is expected LSP behavior 348 * and clients should retry silently per the LSP specification. 349 * 350 * @param method - LSP method name (e.g., 'textDocument/definition') 351 * @param params - Method-specific parameters 352 * @returns The server's response 353 * @throws {Error} If server is not healthy or request fails after all retries 354 */ 355 async function sendRequest<T>(method: string, params: unknown): Promise<T> { 356 if (!isHealthy()) { 357 const error = new Error( 358 `Cannot send request to LSP server '${name}': server is ${state}` + 359 `${lastError ? `, last error: ${lastError.message}` : ''}`, 360 ) 361 logError(error) 362 throw error 363 } 364 365 let lastAttemptError: Error | undefined 366 367 for ( 368 let attempt = 0; 369 attempt <= MAX_RETRIES_FOR_TRANSIENT_ERRORS; 370 attempt++ 371 ) { 372 try { 373 return await client.sendRequest(method, params) 374 } catch (error) { 375 lastAttemptError = error as Error 376 377 // Check if this is a transient "content modified" error that we should retry 378 // This commonly happens with rust-analyzer during initial project indexing. 379 // We use duck typing instead of instanceof because there may be multiple 380 // versions of vscode-jsonrpc in the dependency tree (8.2.0 vs 8.2.1). 381 const errorCode = (error as { code?: number }).code 382 const isContentModifiedError = 383 typeof errorCode === 'number' && 384 errorCode === LSP_ERROR_CONTENT_MODIFIED 385 386 if ( 387 isContentModifiedError && 388 attempt < MAX_RETRIES_FOR_TRANSIENT_ERRORS 389 ) { 390 const delay = RETRY_BASE_DELAY_MS * Math.pow(2, attempt) 391 logForDebugging( 392 `LSP request '${method}' to '${name}' got ContentModified error, ` + 393 `retrying in ${delay}ms (attempt ${attempt + 1}/${MAX_RETRIES_FOR_TRANSIENT_ERRORS})…`, 394 ) 395 await sleep(delay) 396 continue 397 } 398 399 // Non-retryable error or max retries exceeded 400 break 401 } 402 } 403 404 // All retries failed or non-retryable error 405 const requestError = new Error( 406 `LSP request '${method}' failed for server '${name}': ${lastAttemptError?.message ?? 'unknown error'}`, 407 ) 408 logError(requestError) 409 throw requestError 410 } 411 412 /** 413 * Send a notification to the LSP server (fire-and-forget). 414 * Used for file synchronization (didOpen, didChange, didClose). 415 */ 416 async function sendNotification( 417 method: string, 418 params: unknown, 419 ): Promise<void> { 420 if (!isHealthy()) { 421 const error = new Error( 422 `Cannot send notification to LSP server '${name}': server is ${state}`, 423 ) 424 logError(error) 425 throw error 426 } 427 428 try { 429 await client.sendNotification(method, params) 430 } catch (error) { 431 const notificationError = new Error( 432 `LSP notification '${method}' failed for server '${name}': ${errorMessage(error)}`, 433 ) 434 logError(notificationError) 435 throw notificationError 436 } 437 } 438 439 /** 440 * Registers a handler for LSP notifications from the server. 441 * 442 * @param method - LSP notification method (e.g., 'window/logMessage') 443 * @param handler - Callback function to handle the notification 444 */ 445 function onNotification( 446 method: string, 447 handler: (params: unknown) => void, 448 ): void { 449 client.onNotification(method, handler) 450 } 451 452 /** 453 * Registers a handler for LSP requests from the server. 454 * 455 * Some LSP servers send requests TO the client (reverse direction). 456 * This allows registering handlers for such requests. 457 * 458 * @param method - LSP request method (e.g., 'workspace/configuration') 459 * @param handler - Callback function to handle the request and return a response 460 */ 461 function onRequest<TParams, TResult>( 462 method: string, 463 handler: (params: TParams) => TResult | Promise<TResult>, 464 ): void { 465 client.onRequest(method, handler) 466 } 467 468 // Return public API 469 return { 470 name, 471 config, 472 get state() { 473 return state 474 }, 475 get startTime() { 476 return startTime 477 }, 478 get lastError() { 479 return lastError 480 }, 481 get restartCount() { 482 return restartCount 483 }, 484 start, 485 stop, 486 restart, 487 isHealthy, 488 sendRequest, 489 sendNotification, 490 onNotification, 491 onRequest, 492 } 493} 494 495/** 496 * Race a promise against a timeout. Cleans up the timer regardless of outcome 497 * to avoid unhandled rejections from orphaned setTimeout callbacks. 498 */ 499function withTimeout<T>( 500 promise: Promise<T>, 501 ms: number, 502 message: string, 503): Promise<T> { 504 let timer: ReturnType<typeof setTimeout> 505 const timeoutPromise = new Promise<never>((_, reject) => { 506 timer = setTimeout((rej, msg) => rej(new Error(msg)), ms, reject, message) 507 }) 508 return Promise.race([promise, timeoutPromise]).finally(() => 509 clearTimeout(timer!), 510 ) 511}