import * as path from 'path' import { pathToFileURL } from 'url' import type { InitializeParams } from 'vscode-languageserver-protocol' import { getCwd } from '../../utils/cwd.js' import { logForDebugging } from '../../utils/debug.js' import { errorMessage } from '../../utils/errors.js' import { logError } from '../../utils/log.js' import { sleep } from '../../utils/sleep.js' import type { createLSPClient as createLSPClientType } from './LSPClient.js' import type { LspServerState, ScopedLspServerConfig } from './types.js' /** * LSP error code for "content modified" - indicates the server's state changed * during request processing (e.g., rust-analyzer still indexing the project). * This is a transient error that can be retried. */ const LSP_ERROR_CONTENT_MODIFIED = -32801 /** * Maximum number of retries for transient LSP errors like "content modified". */ const MAX_RETRIES_FOR_TRANSIENT_ERRORS = 3 /** * Base delay in milliseconds for exponential backoff on transient errors. * Actual delays: 500ms, 1000ms, 2000ms */ const RETRY_BASE_DELAY_MS = 500 /** * LSP server instance interface returned by createLSPServerInstance. * Manages the lifecycle of a single LSP server with state tracking and health monitoring. */ export type LSPServerInstance = { /** Unique server identifier */ readonly name: string /** Server configuration */ readonly config: ScopedLspServerConfig /** Current server state */ readonly state: LspServerState /** When the server was last started */ readonly startTime: Date | undefined /** Last error encountered */ readonly lastError: Error | undefined /** Number of times restart() has been called */ readonly restartCount: number /** Start the server and initialize it */ start(): Promise /** Stop the server gracefully */ stop(): Promise /** Manually restart the server (stop then start) */ restart(): Promise /** Check if server is healthy and ready for requests */ isHealthy(): boolean /** Send an LSP request to the server */ sendRequest(method: string, params: unknown): Promise /** Send an LSP notification to the server (fire-and-forget) */ sendNotification(method: string, params: unknown): Promise /** Register a handler for LSP notifications */ onNotification(method: string, handler: (params: unknown) => void): void /** Register a handler for LSP requests from the server */ onRequest( method: string, handler: (params: TParams) => TResult | Promise, ): void } /** * Creates and manages a single LSP server instance. * * Uses factory function pattern with closures for state encapsulation (avoiding classes). * Provides state tracking, health monitoring, and request forwarding for an LSP server. * Supports manual restart with configurable retry limits. * * State machine transitions: * - stopped → starting → running * - running → stopping → stopped * - any → error (on failure) * - error → starting (on retry) * * @param name - Unique identifier for this server instance * @param config - Server configuration including command, args, and limits * @returns LSP server instance with lifecycle management methods * * @example * const instance = createLSPServerInstance('my-server', config) * await instance.start() * const result = await instance.sendRequest('textDocument/definition', params) * await instance.stop() */ export function createLSPServerInstance( name: string, config: ScopedLspServerConfig, ): LSPServerInstance { // Validate that unimplemented fields are not set if (config.restartOnCrash !== undefined) { throw new Error( `LSP server '${name}': restartOnCrash is not yet implemented. Remove this field from the configuration.`, ) } if (config.shutdownTimeout !== undefined) { throw new Error( `LSP server '${name}': shutdownTimeout is not yet implemented. Remove this field from the configuration.`, ) } // Private state encapsulated via closures. Lazy-require LSPClient so // vscode-jsonrpc (~129KB) only loads when an LSP server is actually // instantiated, not when the static import chain reaches this module. // eslint-disable-next-line @typescript-eslint/no-require-imports const { createLSPClient } = require('./LSPClient.js') as { createLSPClient: typeof createLSPClientType } let state: LspServerState = 'stopped' let startTime: Date | undefined let lastError: Error | undefined let restartCount = 0 let crashRecoveryCount = 0 // Propagate crash state so ensureServerStarted can restart on next use. // Without this, state stays 'running' after crash and the server is never // restarted (zombie state). const client = createLSPClient(name, error => { state = 'error' lastError = error crashRecoveryCount++ }) /** * Starts the LSP server and initializes it with workspace information. * * If the server is already running or starting, this method returns immediately. * On failure, sets state to 'error', logs for monitoring, and throws. * * @throws {Error} If server fails to start or initialize */ async function start(): Promise { if (state === 'running' || state === 'starting') { return } // Cap crash-recovery attempts so a persistently crashing server doesn't // spawn unbounded child processes on every incoming request. const maxRestarts = config.maxRestarts ?? 3 if (state === 'error' && crashRecoveryCount > maxRestarts) { const error = new Error( `LSP server '${name}' exceeded max crash recovery attempts (${maxRestarts})`, ) lastError = error logError(error) throw error } let initPromise: Promise | undefined try { state = 'starting' logForDebugging(`Starting LSP server instance: ${name}`) // Start the client await client.start(config.command, config.args || [], { env: config.env, cwd: config.workspaceFolder, }) // Initialize with workspace info const workspaceFolder = config.workspaceFolder || getCwd() const workspaceUri = pathToFileURL(workspaceFolder).href const initParams: InitializeParams = { processId: process.pid, // Pass server-specific initialization options from plugin config // Required by vue-language-server, optional for others // Provide empty object as default to avoid undefined errors in servers // that expect this field to exist initializationOptions: config.initializationOptions ?? {}, // Modern approach (LSP 3.16+) - required for Pyright, gopls workspaceFolders: [ { uri: workspaceUri, name: path.basename(workspaceFolder), }, ], // Deprecated fields - some servers still need these for proper URI resolution rootPath: workspaceFolder, // Deprecated in LSP 3.8 but needed by some servers rootUri: workspaceUri, // Deprecated in LSP 3.16 but needed by typescript-language-server for goToDefinition // Client capabilities - declare what features we support capabilities: { workspace: { // Don't claim to support workspace/configuration since we don't implement it // This prevents servers from requesting config we can't provide configuration: false, // Don't claim to support workspace folders changes since we don't handle // workspace/didChangeWorkspaceFolders notifications workspaceFolders: false, }, textDocument: { synchronization: { dynamicRegistration: false, willSave: false, willSaveWaitUntil: false, didSave: true, }, publishDiagnostics: { relatedInformation: true, tagSupport: { valueSet: [1, 2], // Unnecessary (1), Deprecated (2) }, versionSupport: false, codeDescriptionSupport: true, dataSupport: false, }, hover: { dynamicRegistration: false, contentFormat: ['markdown', 'plaintext'], }, definition: { dynamicRegistration: false, linkSupport: true, }, references: { dynamicRegistration: false, }, documentSymbol: { dynamicRegistration: false, hierarchicalDocumentSymbolSupport: true, }, callHierarchy: { dynamicRegistration: false, }, }, general: { positionEncodings: ['utf-16'], }, }, } initPromise = client.initialize(initParams) if (config.startupTimeout !== undefined) { await withTimeout( initPromise, config.startupTimeout, `LSP server '${name}' timed out after ${config.startupTimeout}ms during initialization`, ) } else { await initPromise } state = 'running' startTime = new Date() crashRecoveryCount = 0 logForDebugging(`LSP server instance started: ${name}`) } catch (error) { // Clean up the spawned child process on timeout/error client.stop().catch(() => {}) // Prevent unhandled rejection from abandoned initialize promise initPromise?.catch(() => {}) state = 'error' lastError = error as Error logError(error) throw error } } /** * Stops the LSP server gracefully. * * If already stopped or stopping, returns immediately. * On failure, sets state to 'error', logs for monitoring, and throws. * * @throws {Error} If server fails to stop */ async function stop(): Promise { if (state === 'stopped' || state === 'stopping') { return } try { state = 'stopping' await client.stop() state = 'stopped' logForDebugging(`LSP server instance stopped: ${name}`) } catch (error) { state = 'error' lastError = error as Error logError(error) throw error } } /** * Manually restarts the server by stopping and starting it. * * Increments restartCount and enforces maxRestarts limit. * Note: This is NOT automatic - must be called explicitly. * * @throws {Error} If stop or start fails, or if restartCount exceeds config.maxRestarts (default: 3) */ async function restart(): Promise { try { await stop() } catch (error) { const stopError = new Error( `Failed to stop LSP server '${name}' during restart: ${errorMessage(error)}`, ) logError(stopError) throw stopError } restartCount++ const maxRestarts = config.maxRestarts ?? 3 if (restartCount > maxRestarts) { const error = new Error( `Max restart attempts (${maxRestarts}) exceeded for server '${name}'`, ) logError(error) throw error } try { await start() } catch (error) { const startError = new Error( `Failed to start LSP server '${name}' during restart (attempt ${restartCount}/${maxRestarts}): ${errorMessage(error)}`, ) logError(startError) throw startError } } /** * Checks if the server is healthy and ready to handle requests. * * @returns true if state is 'running' AND the client has completed initialization */ function isHealthy(): boolean { return state === 'running' && client.isInitialized } /** * Sends an LSP request to the server with retry logic for transient errors. * * Checks server health before sending and wraps errors with context. * Automatically retries on "content modified" errors (code -32801) which occur * when servers like rust-analyzer are still indexing. This is expected LSP behavior * and clients should retry silently per the LSP specification. * * @param method - LSP method name (e.g., 'textDocument/definition') * @param params - Method-specific parameters * @returns The server's response * @throws {Error} If server is not healthy or request fails after all retries */ async function sendRequest(method: string, params: unknown): Promise { if (!isHealthy()) { const error = new Error( `Cannot send request to LSP server '${name}': server is ${state}` + `${lastError ? `, last error: ${lastError.message}` : ''}`, ) logError(error) throw error } let lastAttemptError: Error | undefined for ( let attempt = 0; attempt <= MAX_RETRIES_FOR_TRANSIENT_ERRORS; attempt++ ) { try { return await client.sendRequest(method, params) } catch (error) { lastAttemptError = error as Error // Check if this is a transient "content modified" error that we should retry // This commonly happens with rust-analyzer during initial project indexing. // We use duck typing instead of instanceof because there may be multiple // versions of vscode-jsonrpc in the dependency tree (8.2.0 vs 8.2.1). const errorCode = (error as { code?: number }).code const isContentModifiedError = typeof errorCode === 'number' && errorCode === LSP_ERROR_CONTENT_MODIFIED if ( isContentModifiedError && attempt < MAX_RETRIES_FOR_TRANSIENT_ERRORS ) { const delay = RETRY_BASE_DELAY_MS * Math.pow(2, attempt) logForDebugging( `LSP request '${method}' to '${name}' got ContentModified error, ` + `retrying in ${delay}ms (attempt ${attempt + 1}/${MAX_RETRIES_FOR_TRANSIENT_ERRORS})…`, ) await sleep(delay) continue } // Non-retryable error or max retries exceeded break } } // All retries failed or non-retryable error const requestError = new Error( `LSP request '${method}' failed for server '${name}': ${lastAttemptError?.message ?? 'unknown error'}`, ) logError(requestError) throw requestError } /** * Send a notification to the LSP server (fire-and-forget). * Used for file synchronization (didOpen, didChange, didClose). */ async function sendNotification( method: string, params: unknown, ): Promise { if (!isHealthy()) { const error = new Error( `Cannot send notification to LSP server '${name}': server is ${state}`, ) logError(error) throw error } try { await client.sendNotification(method, params) } catch (error) { const notificationError = new Error( `LSP notification '${method}' failed for server '${name}': ${errorMessage(error)}`, ) logError(notificationError) throw notificationError } } /** * Registers a handler for LSP notifications from the server. * * @param method - LSP notification method (e.g., 'window/logMessage') * @param handler - Callback function to handle the notification */ function onNotification( method: string, handler: (params: unknown) => void, ): void { client.onNotification(method, handler) } /** * Registers a handler for LSP requests from the server. * * Some LSP servers send requests TO the client (reverse direction). * This allows registering handlers for such requests. * * @param method - LSP request method (e.g., 'workspace/configuration') * @param handler - Callback function to handle the request and return a response */ function onRequest( method: string, handler: (params: TParams) => TResult | Promise, ): void { client.onRequest(method, handler) } // Return public API return { name, config, get state() { return state }, get startTime() { return startTime }, get lastError() { return lastError }, get restartCount() { return restartCount }, start, stop, restart, isHealthy, sendRequest, sendNotification, onNotification, onRequest, } } /** * Race a promise against a timeout. Cleans up the timer regardless of outcome * to avoid unhandled rejections from orphaned setTimeout callbacks. */ function withTimeout( promise: Promise, ms: number, message: string, ): Promise { let timer: ReturnType const timeoutPromise = new Promise((_, reject) => { timer = setTimeout((rej, msg) => rej(new Error(msg)), ms, reject, message) }) return Promise.race([promise, timeoutPromise]).finally(() => clearTimeout(timer!), ) }