π File detail
services/lsp/LSPServerInstance.ts
π― Use case
This file lives under βservices/β, which covers long-lived services (LSP, MCP, OAuth, tool execution, memory, compaction, voice, settings sync, β¦). On the API surface it exposes LSPServerInstance and createLSPServerInstance β mainly functions, hooks, or classes. Dependencies touch Node path helpers, url, and vscode-languageserver-protocol. It composes internal code from utils, LSPClient, and types (relative imports).
Generated from folder role, exports, dependency roots, and inline comments β not hand-reviewed for every path.
π§ Inline summary
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'
π€ Exports (heuristic)
LSPServerInstancecreateLSPServerInstance
π External import roots
Package roots from from "β¦" (relative paths omitted).
pathurlvscode-languageserver-protocol
π₯οΈ Source preview
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<void>
/** Stop the server gracefully */
stop(): Promise<void>
/** Manually restart the server (stop then start) */
restart(): Promise<void>
/** Check if server is healthy and ready for requests */
isHealthy(): boolean
/** Send an LSP request to the server */
sendRequest<T>(method: string, params: unknown): Promise<T>
/** Send an LSP notification to the server (fire-and-forget) */
sendNotification(method: string, params: unknown): Promise<void>
/** Register a handler for LSP notifications */
onNotification(method: string, handler: (params: unknown) => void): void
/** Register a handler for LSP requests from the server */
onRequest<TParams, TResult>(
method: string,
handler: (params: TParams) => TResult | Promise<TResult>,
): 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<void> {
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<unknown> | 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<void> {
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<void> {
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<T>(method: string, params: unknown): Promise<T> {
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<void> {
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<TParams, TResult>(
method: string,
handler: (params: TParams) => TResult | Promise<TResult>,
): 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<T>(
promise: Promise<T>,
ms: number,
message: string,
): Promise<T> {
let timer: ReturnType<typeof setTimeout>
const timeoutPromise = new Promise<never>((_, reject) => {
timer = setTimeout((rej, msg) => rej(new Error(msg)), ms, reject, message)
})
return Promise.race([promise, timeoutPromise]).finally(() =>
clearTimeout(timer!),
)
}