init claude-code

This commit is contained in:
2026-04-01 17:32:37 +02:00
commit 73b208c009
1902 changed files with 513237 additions and 0 deletions
+447
View File
@@ -0,0 +1,447 @@
import { type ChildProcess, spawn } from 'child_process'
import {
createMessageConnection,
type MessageConnection,
StreamMessageReader,
StreamMessageWriter,
Trace,
} from 'vscode-jsonrpc/node.js'
import type {
InitializeParams,
InitializeResult,
ServerCapabilities,
} from 'vscode-languageserver-protocol'
import { logForDebugging } from '../../utils/debug.js'
import { errorMessage } from '../../utils/errors.js'
import { logError } from '../../utils/log.js'
import { subprocessEnv } from '../../utils/subprocessEnv.js'
/**
* LSP client interface.
*/
export type LSPClient = {
readonly capabilities: ServerCapabilities | undefined
readonly isInitialized: boolean
start: (
command: string,
args: string[],
options?: {
env?: Record<string, string>
cwd?: string
},
) => Promise<void>
initialize: (params: InitializeParams) => Promise<InitializeResult>
sendRequest: <TResult>(method: string, params: unknown) => Promise<TResult>
sendNotification: (method: string, params: unknown) => Promise<void>
onNotification: (method: string, handler: (params: unknown) => void) => void
onRequest: <TParams, TResult>(
method: string,
handler: (params: TParams) => TResult | Promise<TResult>,
) => void
stop: () => Promise<void>
}
/**
* Create an LSP client wrapper using vscode-jsonrpc.
* Manages communication with an LSP server process via stdio.
*
* @param onCrash - Called when the server process exits unexpectedly (non-zero
* exit code during operation, not during intentional stop). Allows the owner
* to propagate crash state so the server can be restarted on next use.
*/
export function createLSPClient(
serverName: string,
onCrash?: (error: Error) => void,
): LSPClient {
// State variables in closure
let process: ChildProcess | undefined
let connection: MessageConnection | undefined
let capabilities: ServerCapabilities | undefined
let isInitialized = false
let startFailed = false
let startError: Error | undefined
let isStopping = false // Track intentional shutdown to avoid spurious error logging
// Queue handlers registered before connection ready (lazy initialization support)
const pendingHandlers: Array<{
method: string
handler: (params: unknown) => void
}> = []
const pendingRequestHandlers: Array<{
method: string
handler: (params: unknown) => unknown | Promise<unknown>
}> = []
function checkStartFailed(): void {
if (startFailed) {
throw startError || new Error(`LSP server ${serverName} failed to start`)
}
}
return {
get capabilities(): ServerCapabilities | undefined {
return capabilities
},
get isInitialized(): boolean {
return isInitialized
},
async start(
command: string,
args: string[],
options?: {
env?: Record<string, string>
cwd?: string
},
): Promise<void> {
try {
// 1. Spawn LSP server process
process = spawn(command, args, {
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...subprocessEnv(), ...options?.env },
cwd: options?.cwd,
// Prevent visible console window on Windows (no-op on other platforms)
windowsHide: true,
})
if (!process.stdout || !process.stdin) {
throw new Error('LSP server process stdio not available')
}
// 1.5. Wait for process to successfully spawn before using streams
// This is CRITICAL: spawn() returns immediately, but the 'error' event
// (e.g., ENOENT for command not found) fires asynchronously.
// If we use the streams before confirming spawn succeeded, we get
// unhandled promise rejections when writes fail on invalid streams.
const spawnedProcess = process // Capture for closure
await new Promise<void>((resolve, reject) => {
const onSpawn = (): void => {
cleanup()
resolve()
}
const onError = (error: Error): void => {
cleanup()
reject(error)
}
const cleanup = (): void => {
spawnedProcess.removeListener('spawn', onSpawn)
spawnedProcess.removeListener('error', onError)
}
spawnedProcess.once('spawn', onSpawn)
spawnedProcess.once('error', onError)
})
// Capture stderr for server diagnostics and errors
if (process.stderr) {
process.stderr.on('data', (data: Buffer) => {
const output = data.toString().trim()
if (output) {
logForDebugging(`[LSP SERVER ${serverName}] ${output}`)
}
})
}
// Handle process errors (after successful spawn, e.g., crash during operation)
process.on('error', error => {
if (!isStopping) {
startFailed = true
startError = error
logError(
new Error(
`LSP server ${serverName} failed to start: ${error.message}`,
),
)
}
})
process.on('exit', (code, _signal) => {
if (code !== 0 && code !== null && !isStopping) {
isInitialized = false
startFailed = false
startError = undefined
const crashError = new Error(
`LSP server ${serverName} crashed with exit code ${code}`,
)
logError(crashError)
onCrash?.(crashError)
}
})
// Handle stdin stream errors to prevent unhandled promise rejections
// when the LSP server process exits before we finish writing
process.stdin.on('error', (error: Error) => {
if (!isStopping) {
logForDebugging(
`LSP server ${serverName} stdin error: ${error.message}`,
)
}
// Error is logged but not thrown - the connection error handler will catch this
})
// 2. Create JSON-RPC connection
const reader = new StreamMessageReader(process.stdout)
const writer = new StreamMessageWriter(process.stdin)
connection = createMessageConnection(reader, writer)
// 2.5. Register error/close handlers BEFORE listen() to catch all errors
// This prevents unhandled promise rejections when the server crashes or closes unexpectedly
connection.onError(([error, _message, _code]) => {
// Only log if not intentionally stopping (avoid spurious errors during shutdown)
if (!isStopping) {
startFailed = true
startError = error
logError(
new Error(
`LSP server ${serverName} connection error: ${error.message}`,
),
)
}
})
connection.onClose(() => {
// Only treat as error if not intentionally stopping
if (!isStopping) {
isInitialized = false
// Don't set startFailed here - the connection may close after graceful shutdown
logForDebugging(`LSP server ${serverName} connection closed`)
}
})
// 3. Start listening for messages
connection.listen()
// 3.5. Enable protocol tracing for debugging
// Note: trace() sends a $/setTrace notification which can fail if the server
// process has already exited. We catch and log the error rather than letting
// it become an unhandled promise rejection.
connection
.trace(Trace.Verbose, {
log: (message: string) => {
logForDebugging(`[LSP PROTOCOL ${serverName}] ${message}`)
},
})
.catch((error: Error) => {
logForDebugging(
`Failed to enable tracing for ${serverName}: ${error.message}`,
)
})
// 4. Apply any queued notification handlers
for (const { method, handler } of pendingHandlers) {
connection.onNotification(method, handler)
logForDebugging(
`Applied queued notification handler for ${serverName}.${method}`,
)
}
pendingHandlers.length = 0 // Clear the queue
// 5. Apply any queued request handlers
for (const { method, handler } of pendingRequestHandlers) {
connection.onRequest(method, handler)
logForDebugging(
`Applied queued request handler for ${serverName}.${method}`,
)
}
pendingRequestHandlers.length = 0 // Clear the queue
logForDebugging(`LSP client started for ${serverName}`)
} catch (error) {
const err = error as Error
logError(
new Error(`LSP server ${serverName} failed to start: ${err.message}`),
)
throw error
}
},
async initialize(params: InitializeParams): Promise<InitializeResult> {
if (!connection) {
throw new Error('LSP client not started')
}
checkStartFailed()
try {
const result: InitializeResult = await connection.sendRequest(
'initialize',
params,
)
capabilities = result.capabilities
// Send initialized notification
await connection.sendNotification('initialized', {})
isInitialized = true
logForDebugging(`LSP server ${serverName} initialized`)
return result
} catch (error) {
const err = error as Error
logError(
new Error(
`LSP server ${serverName} initialize failed: ${err.message}`,
),
)
throw error
}
},
async sendRequest<TResult>(
method: string,
params: unknown,
): Promise<TResult> {
if (!connection) {
throw new Error('LSP client not started')
}
checkStartFailed()
if (!isInitialized) {
throw new Error('LSP server not initialized')
}
try {
return await connection.sendRequest(method, params)
} catch (error) {
const err = error as Error
logError(
new Error(
`LSP server ${serverName} request ${method} failed: ${err.message}`,
),
)
throw error
}
},
async sendNotification(method: string, params: unknown): Promise<void> {
if (!connection) {
throw new Error('LSP client not started')
}
checkStartFailed()
try {
await connection.sendNotification(method, params)
} catch (error) {
const err = error as Error
logError(
new Error(
`LSP server ${serverName} notification ${method} failed: ${err.message}`,
),
)
// Don't re-throw for notifications - they're fire-and-forget
logForDebugging(`Notification ${method} failed but continuing`)
}
},
onNotification(method: string, handler: (params: unknown) => void): void {
if (!connection) {
// Queue handler for application when connection is ready (lazy initialization)
pendingHandlers.push({ method, handler })
logForDebugging(
`Queued notification handler for ${serverName}.${method} (connection not ready)`,
)
return
}
checkStartFailed()
connection.onNotification(method, handler)
},
onRequest<TParams, TResult>(
method: string,
handler: (params: TParams) => TResult | Promise<TResult>,
): void {
if (!connection) {
// Queue handler for application when connection is ready (lazy initialization)
pendingRequestHandlers.push({
method,
handler: handler as (params: unknown) => unknown | Promise<unknown>,
})
logForDebugging(
`Queued request handler for ${serverName}.${method} (connection not ready)`,
)
return
}
checkStartFailed()
connection.onRequest(method, handler)
},
async stop(): Promise<void> {
let shutdownError: Error | undefined
// Mark as stopping to prevent error handlers from logging spurious errors
isStopping = true
try {
if (connection) {
// Try to send shutdown request and exit notification
await connection.sendRequest('shutdown', {})
await connection.sendNotification('exit', {})
}
} catch (error) {
const err = error as Error
logError(
new Error(`LSP server ${serverName} stop failed: ${err.message}`),
)
shutdownError = err
// Continue to cleanup despite shutdown failure
} finally {
// Always cleanup resources, even if shutdown/exit failed
if (connection) {
try {
connection.dispose()
} catch (error) {
// Log but don't throw - disposal errors are less critical
logForDebugging(
`Connection disposal failed for ${serverName}: ${errorMessage(error)}`,
)
}
connection = undefined
}
if (process) {
// Remove event listeners to prevent memory leaks
process.removeAllListeners('error')
process.removeAllListeners('exit')
if (process.stdin) {
process.stdin.removeAllListeners('error')
}
if (process.stderr) {
process.stderr.removeAllListeners('data')
}
try {
process.kill()
} catch (error) {
// Process might already be dead, which is fine
logForDebugging(
`Process kill failed for ${serverName} (may already be dead): ${errorMessage(error)}`,
)
}
process = undefined
}
isInitialized = false
capabilities = undefined
isStopping = false // Reset for potential restart
// Don't reset startFailed - preserve error state for diagnostics
// startFailed and startError remain as-is
if (shutdownError) {
startFailed = true
startError = shutdownError
}
logForDebugging(`LSP client stopped for ${serverName}`)
}
// Re-throw shutdown error after cleanup is complete
if (shutdownError) {
throw shutdownError
}
},
}
}
+386
View File
@@ -0,0 +1,386 @@
import { randomUUID } from 'crypto'
import { LRUCache } from 'lru-cache'
import { logForDebugging } from '../../utils/debug.js'
import { toError } from '../../utils/errors.js'
import { logError } from '../../utils/log.js'
import { jsonStringify } from '../../utils/slowOperations.js'
import type { DiagnosticFile } from '../diagnosticTracking.js'
/**
* Pending LSP diagnostic notification
*/
export type PendingLSPDiagnostic = {
/** Server that sent the diagnostic */
serverName: string
/** Diagnostic files */
files: DiagnosticFile[]
/** When diagnostic was received */
timestamp: number
/** Whether attachment was already sent to conversation */
attachmentSent: boolean
}
/**
* LSP Diagnostic Registry
*
* Stores LSP diagnostics received asynchronously from LSP servers via
* textDocument/publishDiagnostics notifications. Follows the same pattern
* as AsyncHookRegistry for consistent async attachment delivery.
*
* Pattern:
* 1. LSP server sends publishDiagnostics notification
* 2. registerPendingLSPDiagnostic() stores diagnostic
* 3. checkForLSPDiagnostics() retrieves pending diagnostics
* 4. getLSPDiagnosticAttachments() converts to Attachment[]
* 5. getAttachments() delivers to conversation automatically
*
* Similar to AsyncHookRegistry but simpler since diagnostics arrive
* synchronously (no need to accumulate output over time).
*/
// Volume limiting constants
const MAX_DIAGNOSTICS_PER_FILE = 10
const MAX_TOTAL_DIAGNOSTICS = 30
// Max files to track for deduplication - prevents unbounded memory growth
const MAX_DELIVERED_FILES = 500
// Global registry state
const pendingDiagnostics = new Map<string, PendingLSPDiagnostic>()
// Cross-turn deduplication: tracks diagnostics that have been delivered
// Maps file URI to a set of diagnostic keys (hash of message+severity+range)
// Using LRUCache to prevent unbounded growth in long sessions
const deliveredDiagnostics = new LRUCache<string, Set<string>>({
max: MAX_DELIVERED_FILES,
})
/**
* Register LSP diagnostics received from a server.
* These will be delivered as attachments in the next query.
*
* @param serverName - Name of LSP server that sent diagnostics
* @param files - Diagnostic files to deliver
*/
export function registerPendingLSPDiagnostic({
serverName,
files,
}: {
serverName: string
files: DiagnosticFile[]
}): void {
// Use UUID for guaranteed uniqueness (handles rapid registrations)
const diagnosticId = randomUUID()
logForDebugging(
`LSP Diagnostics: Registering ${files.length} diagnostic file(s) from ${serverName} (ID: ${diagnosticId})`,
)
pendingDiagnostics.set(diagnosticId, {
serverName,
files,
timestamp: Date.now(),
attachmentSent: false,
})
}
/**
* Maps severity string to numeric value for sorting.
* Error=1, Warning=2, Info=3, Hint=4
*/
function severityToNumber(severity: string | undefined): number {
switch (severity) {
case 'Error':
return 1
case 'Warning':
return 2
case 'Info':
return 3
case 'Hint':
return 4
default:
return 4
}
}
/**
* Creates a unique key for a diagnostic based on its content.
* Used for both within-batch and cross-turn deduplication.
*/
function createDiagnosticKey(diag: {
message: string
severity?: string
range?: unknown
source?: string
code?: unknown
}): string {
return jsonStringify({
message: diag.message,
severity: diag.severity,
range: diag.range,
source: diag.source || null,
code: diag.code || null,
})
}
/**
* Deduplicates diagnostics by file URI and diagnostic content.
* Also filters out diagnostics that were already delivered in previous turns.
* Two diagnostics are considered duplicates if they have the same:
* - File URI
* - Range (start/end line and character)
* - Message
* - Severity
* - Source and code (if present)
*/
function deduplicateDiagnosticFiles(
allFiles: DiagnosticFile[],
): DiagnosticFile[] {
// Group diagnostics by file URI
const fileMap = new Map<string, Set<string>>()
const dedupedFiles: DiagnosticFile[] = []
for (const file of allFiles) {
if (!fileMap.has(file.uri)) {
fileMap.set(file.uri, new Set())
dedupedFiles.push({ uri: file.uri, diagnostics: [] })
}
const seenDiagnostics = fileMap.get(file.uri)!
const dedupedFile = dedupedFiles.find(f => f.uri === file.uri)!
// Get previously delivered diagnostics for this file (for cross-turn dedup)
const previouslyDelivered = deliveredDiagnostics.get(file.uri) || new Set()
for (const diag of file.diagnostics) {
try {
const key = createDiagnosticKey(diag)
// Skip if already seen in this batch OR already delivered in previous turns
if (seenDiagnostics.has(key) || previouslyDelivered.has(key)) {
continue
}
seenDiagnostics.add(key)
dedupedFile.diagnostics.push(diag)
} catch (error: unknown) {
const err = toError(error)
const truncatedMessage =
diag.message?.substring(0, 100) || '<no message>'
logError(
new Error(
`Failed to deduplicate diagnostic in ${file.uri}: ${err.message}. ` +
`Diagnostic message: ${truncatedMessage}`,
),
)
// Include the diagnostic anyway to avoid losing information
dedupedFile.diagnostics.push(diag)
}
}
}
// Filter out files with no diagnostics after deduplication
return dedupedFiles.filter(f => f.diagnostics.length > 0)
}
/**
* Get all pending LSP diagnostics that haven't been delivered yet.
* Deduplicates diagnostics to prevent sending the same diagnostic multiple times.
* Marks diagnostics as sent to prevent duplicate delivery.
*
* @returns Array of pending diagnostics ready for delivery (deduplicated)
*/
export function checkForLSPDiagnostics(): Array<{
serverName: string
files: DiagnosticFile[]
}> {
logForDebugging(
`LSP Diagnostics: Checking registry - ${pendingDiagnostics.size} pending`,
)
// Collect all diagnostic files from all pending notifications
const allFiles: DiagnosticFile[] = []
const serverNames = new Set<string>()
const diagnosticsToMark: PendingLSPDiagnostic[] = []
for (const diagnostic of pendingDiagnostics.values()) {
if (!diagnostic.attachmentSent) {
allFiles.push(...diagnostic.files)
serverNames.add(diagnostic.serverName)
diagnosticsToMark.push(diagnostic)
}
}
if (allFiles.length === 0) {
return []
}
// Deduplicate diagnostics across all files
let dedupedFiles: DiagnosticFile[]
try {
dedupedFiles = deduplicateDiagnosticFiles(allFiles)
} catch (error: unknown) {
const err = toError(error)
logError(new Error(`Failed to deduplicate LSP diagnostics: ${err.message}`))
// Fall back to undedup'd files to avoid losing diagnostics
dedupedFiles = allFiles
}
// Only mark as sent AFTER successful deduplication, then delete from map.
// Entries are tracked in deliveredDiagnostics LRU for dedup, so we don't
// need to keep them in pendingDiagnostics after delivery.
for (const diagnostic of diagnosticsToMark) {
diagnostic.attachmentSent = true
}
for (const [id, diagnostic] of pendingDiagnostics) {
if (diagnostic.attachmentSent) {
pendingDiagnostics.delete(id)
}
}
const originalCount = allFiles.reduce(
(sum, f) => sum + f.diagnostics.length,
0,
)
const dedupedCount = dedupedFiles.reduce(
(sum, f) => sum + f.diagnostics.length,
0,
)
if (originalCount > dedupedCount) {
logForDebugging(
`LSP Diagnostics: Deduplication removed ${originalCount - dedupedCount} duplicate diagnostic(s)`,
)
}
// Apply volume limiting: cap per file and total
let totalDiagnostics = 0
let truncatedCount = 0
for (const file of dedupedFiles) {
// Sort by severity (Error=1 < Warning=2 < Info=3 < Hint=4) to prioritize errors
file.diagnostics.sort(
(a, b) => severityToNumber(a.severity) - severityToNumber(b.severity),
)
// Cap per file
if (file.diagnostics.length > MAX_DIAGNOSTICS_PER_FILE) {
truncatedCount += file.diagnostics.length - MAX_DIAGNOSTICS_PER_FILE
file.diagnostics = file.diagnostics.slice(0, MAX_DIAGNOSTICS_PER_FILE)
}
// Cap total
const remainingCapacity = MAX_TOTAL_DIAGNOSTICS - totalDiagnostics
if (file.diagnostics.length > remainingCapacity) {
truncatedCount += file.diagnostics.length - remainingCapacity
file.diagnostics = file.diagnostics.slice(0, remainingCapacity)
}
totalDiagnostics += file.diagnostics.length
}
// Filter out files that ended up with no diagnostics after limiting
dedupedFiles = dedupedFiles.filter(f => f.diagnostics.length > 0)
if (truncatedCount > 0) {
logForDebugging(
`LSP Diagnostics: Volume limiting removed ${truncatedCount} diagnostic(s) (max ${MAX_DIAGNOSTICS_PER_FILE}/file, ${MAX_TOTAL_DIAGNOSTICS} total)`,
)
}
// Track delivered diagnostics for cross-turn deduplication
for (const file of dedupedFiles) {
if (!deliveredDiagnostics.has(file.uri)) {
deliveredDiagnostics.set(file.uri, new Set())
}
const delivered = deliveredDiagnostics.get(file.uri)!
for (const diag of file.diagnostics) {
try {
delivered.add(createDiagnosticKey(diag))
} catch (error: unknown) {
// Log but continue - failure to track shouldn't prevent delivery
const err = toError(error)
const truncatedMessage =
diag.message?.substring(0, 100) || '<no message>'
logError(
new Error(
`Failed to track delivered diagnostic in ${file.uri}: ${err.message}. ` +
`Diagnostic message: ${truncatedMessage}`,
),
)
}
}
}
const finalCount = dedupedFiles.reduce(
(sum, f) => sum + f.diagnostics.length,
0,
)
// Return empty if no diagnostics to deliver (all filtered by deduplication)
if (finalCount === 0) {
logForDebugging(
`LSP Diagnostics: No new diagnostics to deliver (all filtered by deduplication)`,
)
return []
}
logForDebugging(
`LSP Diagnostics: Delivering ${dedupedFiles.length} file(s) with ${finalCount} diagnostic(s) from ${serverNames.size} server(s)`,
)
// Return single result with all deduplicated diagnostics
return [
{
serverName: Array.from(serverNames).join(', '),
files: dedupedFiles,
},
]
}
/**
* Clear all pending diagnostics.
* Used during cleanup/shutdown or for testing.
* Note: Does NOT clear deliveredDiagnostics - that's for cross-turn deduplication
* and should only be cleared when files are edited or on session reset.
*/
export function clearAllLSPDiagnostics(): void {
logForDebugging(
`LSP Diagnostics: Clearing ${pendingDiagnostics.size} pending diagnostic(s)`,
)
pendingDiagnostics.clear()
}
/**
* Reset all diagnostic state including cross-turn tracking.
* Used on session reset or for testing.
*/
export function resetAllLSPDiagnosticState(): void {
logForDebugging(
`LSP Diagnostics: Resetting all state (${pendingDiagnostics.size} pending, ${deliveredDiagnostics.size} files tracked)`,
)
pendingDiagnostics.clear()
deliveredDiagnostics.clear()
}
/**
* Clear delivered diagnostics for a specific file.
* Should be called when a file is edited so that new diagnostics for that file
* will be shown even if they match previously delivered ones.
*
* @param fileUri - URI of the file that was edited
*/
export function clearDeliveredDiagnosticsForFile(fileUri: string): void {
if (deliveredDiagnostics.has(fileUri)) {
logForDebugging(
`LSP Diagnostics: Clearing delivered diagnostics for ${fileUri}`,
)
deliveredDiagnostics.delete(fileUri)
}
}
/**
* Get count of pending diagnostics (for monitoring)
*/
export function getPendingLSPDiagnosticCount(): number {
return pendingDiagnostics.size
}
+511
View File
@@ -0,0 +1,511 @@
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!),
)
}
+420
View File
@@ -0,0 +1,420 @@
import * as path from 'path'
import { pathToFileURL } from 'url'
import { logForDebugging } from '../../utils/debug.js'
import { errorMessage } from '../../utils/errors.js'
import { logError } from '../../utils/log.js'
import { getAllLspServers } from './config.js'
import {
createLSPServerInstance,
type LSPServerInstance,
} from './LSPServerInstance.js'
import type { ScopedLspServerConfig } from './types.js'
/**
* LSP Server Manager interface returned by createLSPServerManager.
* Manages multiple LSP server instances and routes requests based on file extensions.
*/
export type LSPServerManager = {
/** Initialize the manager by loading all configured LSP servers */
initialize(): Promise<void>
/** Shutdown all running servers and clear state */
shutdown(): Promise<void>
/** Get the LSP server instance for a given file path */
getServerForFile(filePath: string): LSPServerInstance | undefined
/** Ensure the appropriate LSP server is started for the given file */
ensureServerStarted(filePath: string): Promise<LSPServerInstance | undefined>
/** Send a request to the appropriate LSP server for the given file */
sendRequest<T>(
filePath: string,
method: string,
params: unknown,
): Promise<T | undefined>
/** Get all running server instances */
getAllServers(): Map<string, LSPServerInstance>
/** Synchronize file open to LSP server (sends didOpen notification) */
openFile(filePath: string, content: string): Promise<void>
/** Synchronize file change to LSP server (sends didChange notification) */
changeFile(filePath: string, content: string): Promise<void>
/** Synchronize file save to LSP server (sends didSave notification) */
saveFile(filePath: string): Promise<void>
/** Synchronize file close to LSP server (sends didClose notification) */
closeFile(filePath: string): Promise<void>
/** Check if a file is already open on a compatible LSP server */
isFileOpen(filePath: string): boolean
}
/**
* Creates an LSP server manager instance.
*
* Manages multiple LSP server instances and routes requests based on file extensions.
* Uses factory function pattern with closures for state encapsulation (avoiding classes).
*
* @returns LSP server manager instance
*
* @example
* const manager = createLSPServerManager()
* await manager.initialize()
* const result = await manager.sendRequest('/path/to/file.ts', 'textDocument/definition', params)
* await manager.shutdown()
*/
export function createLSPServerManager(): LSPServerManager {
// Private state managed via closures
const servers: Map<string, LSPServerInstance> = new Map()
const extensionMap: Map<string, string[]> = new Map()
// Track which files have been opened on which servers (URI -> server name)
const openedFiles: Map<string, string> = new Map()
/**
* Initialize the manager by loading all configured LSP servers.
*
* @throws {Error} If configuration loading fails
*/
async function initialize(): Promise<void> {
let serverConfigs: Record<string, ScopedLspServerConfig>
try {
const result = await getAllLspServers()
serverConfigs = result.servers
logForDebugging(
`[LSP SERVER MANAGER] getAllLspServers returned ${Object.keys(serverConfigs).length} server(s)`,
)
} catch (error) {
const err = error as Error
logError(
new Error(`Failed to load LSP server configuration: ${err.message}`),
)
throw error
}
// Build extension → server mapping
for (const [serverName, config] of Object.entries(serverConfigs)) {
try {
// Validate config before using it
if (!config.command) {
throw new Error(
`Server ${serverName} missing required 'command' field`,
)
}
if (
!config.extensionToLanguage ||
Object.keys(config.extensionToLanguage).length === 0
) {
throw new Error(
`Server ${serverName} missing required 'extensionToLanguage' field`,
)
}
// Map file extensions to this server (derive from extensionToLanguage)
const fileExtensions = Object.keys(config.extensionToLanguage)
for (const ext of fileExtensions) {
const normalized = ext.toLowerCase()
if (!extensionMap.has(normalized)) {
extensionMap.set(normalized, [])
}
const serverList = extensionMap.get(normalized)
if (serverList) {
serverList.push(serverName)
}
}
// Create server instance
const instance = createLSPServerInstance(serverName, config)
servers.set(serverName, instance)
// Register handler for workspace/configuration requests from the server
// Some servers (like TypeScript) send these even when we say we don't support them
instance.onRequest(
'workspace/configuration',
(params: { items: Array<{ section?: string }> }) => {
logForDebugging(
`LSP: Received workspace/configuration request from ${serverName}`,
)
// Return empty/null config for each requested item
// This satisfies the protocol without providing actual configuration
return params.items.map(() => null)
},
)
} catch (error) {
const err = error as Error
logError(
new Error(
`Failed to initialize LSP server ${serverName}: ${err.message}`,
),
)
// Continue with other servers - don't fail entire initialization
}
}
logForDebugging(`LSP manager initialized with ${servers.size} servers`)
}
/**
* Shutdown all running servers and clear state.
* Only servers in 'running' state are explicitly stopped;
* servers in other states are cleared without shutdown.
*
* @throws {Error} If one or more servers fail to stop
*/
async function shutdown(): Promise<void> {
const toStop = Array.from(servers.entries()).filter(
([, s]) => s.state === 'running' || s.state === 'error',
)
const results = await Promise.allSettled(
toStop.map(([, server]) => server.stop()),
)
servers.clear()
extensionMap.clear()
openedFiles.clear()
const errors = results
.map((r, i) =>
r.status === 'rejected'
? `${toStop[i]![0]}: ${errorMessage(r.reason)}`
: null,
)
.filter((e): e is string => e !== null)
if (errors.length > 0) {
const err = new Error(
`Failed to stop ${errors.length} LSP server(s): ${errors.join('; ')}`,
)
logError(err)
throw err
}
}
/**
* Get the LSP server instance for a given file path.
* If multiple servers handle the same extension, returns the first registered server.
* Returns undefined if no server handles this file type.
*/
function getServerForFile(filePath: string): LSPServerInstance | undefined {
const ext = path.extname(filePath).toLowerCase()
const serverNames = extensionMap.get(ext)
if (!serverNames || serverNames.length === 0) {
return undefined
}
// Use first server (can add priority later)
const serverName = serverNames[0]
if (!serverName) {
return undefined
}
return servers.get(serverName)
}
/**
* Ensure the appropriate LSP server is started for the given file.
* Returns undefined if no server handles this file type.
*
* @throws {Error} If server fails to start
*/
async function ensureServerStarted(
filePath: string,
): Promise<LSPServerInstance | undefined> {
const server = getServerForFile(filePath)
if (!server) return undefined
if (server.state === 'stopped' || server.state === 'error') {
try {
await server.start()
} catch (error) {
const err = error as Error
logError(
new Error(
`Failed to start LSP server for file ${filePath}: ${err.message}`,
),
)
throw error
}
}
return server
}
/**
* Send a request to the appropriate LSP server for the given file.
* Returns undefined if no server handles this file type.
*
* @throws {Error} If server fails to start or request fails
*/
async function sendRequest<T>(
filePath: string,
method: string,
params: unknown,
): Promise<T | undefined> {
const server = await ensureServerStarted(filePath)
if (!server) return undefined
try {
return await server.sendRequest<T>(method, params)
} catch (error) {
const err = error as Error
logError(
new Error(
`LSP request failed for file ${filePath}, method '${method}': ${err.message}`,
),
)
throw error
}
}
// Return public interface
function getAllServers(): Map<string, LSPServerInstance> {
return servers
}
async function openFile(filePath: string, content: string): Promise<void> {
const server = await ensureServerStarted(filePath)
if (!server) return
const fileUri = pathToFileURL(path.resolve(filePath)).href
// Skip if already opened on this server
if (openedFiles.get(fileUri) === server.name) {
logForDebugging(
`LSP: File already open, skipping didOpen for ${filePath}`,
)
return
}
// Get language ID from server's extensionToLanguage mapping
const ext = path.extname(filePath).toLowerCase()
const languageId = server.config.extensionToLanguage[ext] || 'plaintext'
try {
await server.sendNotification('textDocument/didOpen', {
textDocument: {
uri: fileUri,
languageId,
version: 1,
text: content,
},
})
// Track that this file is now open on this server
openedFiles.set(fileUri, server.name)
logForDebugging(
`LSP: Sent didOpen for ${filePath} (languageId: ${languageId})`,
)
} catch (error) {
const err = new Error(
`Failed to sync file open ${filePath}: ${errorMessage(error)}`,
)
logError(err)
// Re-throw to propagate error to caller
throw err
}
}
async function changeFile(filePath: string, content: string): Promise<void> {
const server = getServerForFile(filePath)
if (!server || server.state !== 'running') {
return openFile(filePath, content)
}
const fileUri = pathToFileURL(path.resolve(filePath)).href
// If file hasn't been opened on this server yet, open it first
// LSP servers require didOpen before didChange
if (openedFiles.get(fileUri) !== server.name) {
return openFile(filePath, content)
}
try {
await server.sendNotification('textDocument/didChange', {
textDocument: {
uri: fileUri,
version: 1,
},
contentChanges: [{ text: content }],
})
logForDebugging(`LSP: Sent didChange for ${filePath}`)
} catch (error) {
const err = new Error(
`Failed to sync file change ${filePath}: ${errorMessage(error)}`,
)
logError(err)
// Re-throw to propagate error to caller
throw err
}
}
/**
* Save a file in LSP servers (sends didSave notification)
* Called after file is written to disk to trigger diagnostics
*/
async function saveFile(filePath: string): Promise<void> {
const server = getServerForFile(filePath)
if (!server || server.state !== 'running') return
try {
await server.sendNotification('textDocument/didSave', {
textDocument: {
uri: pathToFileURL(path.resolve(filePath)).href,
},
})
logForDebugging(`LSP: Sent didSave for ${filePath}`)
} catch (error) {
const err = new Error(
`Failed to sync file save ${filePath}: ${errorMessage(error)}`,
)
logError(err)
// Re-throw to propagate error to caller
throw err
}
}
/**
* Close a file in LSP servers (sends didClose notification)
*
* NOTE: Currently available but not yet integrated with compact flow.
* TODO: Integrate with compact - call closeFile() when compact removes files from context
* This will notify LSP servers that files are no longer in active use.
*/
async function closeFile(filePath: string): Promise<void> {
const server = getServerForFile(filePath)
if (!server || server.state !== 'running') return
const fileUri = pathToFileURL(path.resolve(filePath)).href
try {
await server.sendNotification('textDocument/didClose', {
textDocument: {
uri: fileUri,
},
})
// Remove from tracking so file can be reopened later
openedFiles.delete(fileUri)
logForDebugging(`LSP: Sent didClose for ${filePath}`)
} catch (error) {
const err = new Error(
`Failed to sync file close ${filePath}: ${errorMessage(error)}`,
)
logError(err)
// Re-throw to propagate error to caller
throw err
}
}
function isFileOpen(filePath: string): boolean {
const fileUri = pathToFileURL(path.resolve(filePath)).href
return openedFiles.has(fileUri)
}
return {
initialize,
shutdown,
getServerForFile,
ensureServerStarted,
sendRequest,
getAllServers,
openFile,
changeFile,
saveFile,
closeFile,
isFileOpen,
}
}
+79
View File
@@ -0,0 +1,79 @@
import type { PluginError } from '../../types/plugin.js'
import { logForDebugging } from '../../utils/debug.js'
import { errorMessage, toError } from '../../utils/errors.js'
import { logError } from '../../utils/log.js'
import { getPluginLspServers } from '../../utils/plugins/lspPluginIntegration.js'
import { loadAllPluginsCacheOnly } from '../../utils/plugins/pluginLoader.js'
import type { ScopedLspServerConfig } from './types.js'
/**
* Get all configured LSP servers from plugins.
* LSP servers are only supported via plugins, not user/project settings.
*
* @returns Object containing servers configuration keyed by scoped server name
*/
export async function getAllLspServers(): Promise<{
servers: Record<string, ScopedLspServerConfig>
}> {
const allServers: Record<string, ScopedLspServerConfig> = {}
try {
// Get all enabled plugins
const { enabled: plugins } = await loadAllPluginsCacheOnly()
// Load LSP servers from each plugin in parallel.
// Each plugin is independent — results are merged in original order so
// Object.assign collision precedence (later plugins win) is preserved.
const results = await Promise.all(
plugins.map(async plugin => {
const errors: PluginError[] = []
try {
const scopedServers = await getPluginLspServers(plugin, errors)
return { plugin, scopedServers, errors }
} catch (e) {
// Defensive: if one plugin throws, don't lose results from the
// others. The previous serial loop implicitly tolerated this.
logForDebugging(
`Failed to load LSP servers for plugin ${plugin.name}: ${e}`,
{ level: 'error' },
)
return { plugin, scopedServers: undefined, errors }
}
}),
)
for (const { plugin, scopedServers, errors } of results) {
const serverCount = scopedServers ? Object.keys(scopedServers).length : 0
if (serverCount > 0) {
// Merge into all servers (already scoped by getPluginLspServers)
Object.assign(allServers, scopedServers)
logForDebugging(
`Loaded ${serverCount} LSP server(s) from plugin: ${plugin.name}`,
)
}
// Log any errors encountered
if (errors.length > 0) {
logForDebugging(
`${errors.length} error(s) loading LSP servers from plugin: ${plugin.name}`,
)
}
}
logForDebugging(
`Total LSP servers loaded: ${Object.keys(allServers).length}`,
)
} catch (error) {
// Log error for monitoring production issues.
// LSP is optional, so we don't throw - but we need visibility
// into why plugin loading fails to improve the feature.
logError(toError(error))
logForDebugging(`Error loading LSP servers: ${errorMessage(error)}`)
}
return {
servers: allServers,
}
}
+289
View File
@@ -0,0 +1,289 @@
import { logForDebugging } from '../../utils/debug.js'
import { isBareMode } from '../../utils/envUtils.js'
import { errorMessage } from '../../utils/errors.js'
import { logError } from '../../utils/log.js'
import {
createLSPServerManager,
type LSPServerManager,
} from './LSPServerManager.js'
import { registerLSPNotificationHandlers } from './passiveFeedback.js'
/**
* Initialization state of the LSP server manager
*/
type InitializationState = 'not-started' | 'pending' | 'success' | 'failed'
/**
* Global singleton instance of the LSP server manager.
* Initialized during Claude Code startup.
*/
let lspManagerInstance: LSPServerManager | undefined
/**
* Current initialization state
*/
let initializationState: InitializationState = 'not-started'
/**
* Error from last initialization attempt, if any
*/
let initializationError: Error | undefined
/**
* Generation counter to prevent stale initialization promises from updating state
*/
let initializationGeneration = 0
/**
* Promise that resolves when initialization completes (success or failure)
*/
let initializationPromise: Promise<void> | undefined
/**
* Test-only sync reset. shutdownLspServerManager() is async and tears down
* real connections; this only clears the module-scope singleton state so
* reinitializeLspServerManager() early-returns on 'not-started' in downstream
* tests on the same shard.
*/
export function _resetLspManagerForTesting(): void {
initializationState = 'not-started'
initializationError = undefined
initializationPromise = undefined
initializationGeneration++
}
/**
* Get the singleton LSP server manager instance.
* Returns undefined if not yet initialized, initialization failed, or still pending.
*
* Callers should check for undefined and handle gracefully, as initialization happens
* asynchronously during Claude Code startup. Use getInitializationStatus() to
* distinguish between pending, failed, and not-started states.
*/
export function getLspServerManager(): LSPServerManager | undefined {
// Don't return a broken instance if initialization failed
if (initializationState === 'failed') {
return undefined
}
return lspManagerInstance
}
/**
* Get the current initialization status of the LSP server manager.
*
* @returns Status object with current state and error (if failed)
*/
export function getInitializationStatus():
| { status: 'not-started' }
| { status: 'pending' }
| { status: 'success' }
| { status: 'failed'; error: Error } {
if (initializationState === 'failed') {
return {
status: 'failed',
error: initializationError || new Error('Initialization failed'),
}
}
if (initializationState === 'not-started') {
return { status: 'not-started' }
}
if (initializationState === 'pending') {
return { status: 'pending' }
}
return { status: 'success' }
}
/**
* Check whether at least one language server is connected and healthy.
* Backs LSPTool.isEnabled().
*/
export function isLspConnected(): boolean {
if (initializationState === 'failed') return false
const manager = getLspServerManager()
if (!manager) return false
const servers = manager.getAllServers()
if (servers.size === 0) return false
for (const server of servers.values()) {
if (server.state !== 'error') return true
}
return false
}
/**
* Wait for LSP server manager initialization to complete.
*
* Returns immediately if initialization has already completed (success or failure).
* If initialization is pending, waits for it to complete.
* If initialization hasn't started, returns immediately.
*
* @returns Promise that resolves when initialization is complete
*/
export async function waitForInitialization(): Promise<void> {
// If already initialized or failed, return immediately
if (initializationState === 'success' || initializationState === 'failed') {
return
}
// If pending and we have a promise, wait for it
if (initializationState === 'pending' && initializationPromise) {
await initializationPromise
}
// If not started, return immediately (nothing to wait for)
}
/**
* Initialize the LSP server manager singleton.
*
* This function is called during Claude Code startup. It synchronously creates
* the manager instance, then starts async initialization (loading LSP configs)
* in the background without blocking the startup process.
*
* Safe to call multiple times - will only initialize once (idempotent).
* However, if initialization previously failed, calling again will retry.
*/
export function initializeLspServerManager(): void {
// --bare / SIMPLE: no LSP. LSP is for editor integration (diagnostics,
// hover, go-to-def in the REPL). Scripted -p calls have no use for it.
if (isBareMode()) {
return
}
logForDebugging('[LSP MANAGER] initializeLspServerManager() called')
// Skip if already initialized or currently initializing
if (lspManagerInstance !== undefined && initializationState !== 'failed') {
logForDebugging(
'[LSP MANAGER] Already initialized or initializing, skipping',
)
return
}
// Reset state for retry if previous initialization failed
if (initializationState === 'failed') {
lspManagerInstance = undefined
initializationError = undefined
}
// Create the manager instance and mark as pending
lspManagerInstance = createLSPServerManager()
initializationState = 'pending'
logForDebugging('[LSP MANAGER] Created manager instance, state=pending')
// Increment generation to invalidate any pending initializations
const currentGeneration = ++initializationGeneration
logForDebugging(
`[LSP MANAGER] Starting async initialization (generation ${currentGeneration})`,
)
// Start initialization asynchronously without blocking
// Store the promise so callers can await it via waitForInitialization()
initializationPromise = lspManagerInstance
.initialize()
.then(() => {
// Only update state if this is still the current initialization
if (currentGeneration === initializationGeneration) {
initializationState = 'success'
logForDebugging('LSP server manager initialized successfully')
// Register passive notification handlers for diagnostics
if (lspManagerInstance) {
registerLSPNotificationHandlers(lspManagerInstance)
}
}
})
.catch((error: unknown) => {
// Only update state if this is still the current initialization
if (currentGeneration === initializationGeneration) {
initializationState = 'failed'
initializationError = error as Error
// Clear the instance since it's not usable
lspManagerInstance = undefined
logError(error as Error)
logForDebugging(
`Failed to initialize LSP server manager: ${errorMessage(error)}`,
)
}
})
}
/**
* Force re-initialization of the LSP server manager, even after a prior
* successful init. Called from refreshActivePlugins() after plugin caches
* are cleared, so newly-loaded plugin LSP servers are picked up.
*
* Fixes https://github.com/anthropics/claude-code/issues/15521:
* loadAllPlugins() is memoized and can be called very early in startup
* (via getCommands prefetch in setup.ts) before marketplaces are reconciled,
* caching an empty plugin list. initializeLspServerManager() then reads that
* stale memoized result and initializes with 0 servers. Unlike commands/agents/
* hooks/MCP, LSP was never re-initialized on plugin refresh.
*
* Safe to call when no LSP plugins changed: initialize() is just config
* parsing (servers are lazy-started on first use). Also safe during pending
* init: the generation counter invalidates the in-flight promise.
*/
export function reinitializeLspServerManager(): void {
if (initializationState === 'not-started') {
// initializeLspServerManager() was never called (e.g. headless subcommand
// path). Don't start it now.
return
}
logForDebugging('[LSP MANAGER] reinitializeLspServerManager() called')
// Best-effort shutdown of any running servers on the old instance so
// /reload-plugins doesn't leak child processes. Fire-and-forget: the
// primary use case (issue #15521) has 0 servers so this is usually a no-op.
if (lspManagerInstance) {
void lspManagerInstance.shutdown().catch(err => {
logForDebugging(
`[LSP MANAGER] old instance shutdown during reinit failed: ${errorMessage(err)}`,
)
})
}
// Force the idempotence check in initializeLspServerManager() to fall
// through. Generation counter handles invalidating any in-flight init.
lspManagerInstance = undefined
initializationState = 'not-started'
initializationError = undefined
initializeLspServerManager()
}
/**
* Shutdown the LSP server manager and clean up resources.
*
* This should be called during Claude Code shutdown. Stops all running LSP servers
* and clears internal state. Safe to call when not initialized (no-op).
*
* NOTE: Errors during shutdown are logged for monitoring but NOT propagated to the caller.
* State is always cleared even if shutdown fails, to prevent resource accumulation.
* This is acceptable during application exit when recovery is not possible.
*
* @returns Promise that resolves when shutdown completes (errors are swallowed)
*/
export async function shutdownLspServerManager(): Promise<void> {
if (lspManagerInstance === undefined) {
return
}
try {
await lspManagerInstance.shutdown()
logForDebugging('LSP server manager shut down successfully')
} catch (error: unknown) {
logError(error as Error)
logForDebugging(
`Failed to shutdown LSP server manager: ${errorMessage(error)}`,
)
} finally {
// Always clear state even if shutdown failed
lspManagerInstance = undefined
initializationState = 'not-started'
initializationError = undefined
initializationPromise = undefined
// Increment generation to invalidate any pending initializations
initializationGeneration++
}
}
+328
View File
@@ -0,0 +1,328 @@
import { fileURLToPath } from 'url'
import type { PublishDiagnosticsParams } from 'vscode-languageserver-protocol'
import { logForDebugging } from '../../utils/debug.js'
import { toError } from '../../utils/errors.js'
import { logError } from '../../utils/log.js'
import { jsonStringify } from '../../utils/slowOperations.js'
import type { DiagnosticFile } from '../diagnosticTracking.js'
import { registerPendingLSPDiagnostic } from './LSPDiagnosticRegistry.js'
import type { LSPServerManager } from './LSPServerManager.js'
/**
* Map LSP severity to Claude diagnostic severity
*
* Maps LSP severity numbers to Claude diagnostic severity strings.
* Accepts numeric severity values (1=Error, 2=Warning, 3=Information, 4=Hint)
* or undefined, defaulting to 'Error' for invalid/missing values.
*/
function mapLSPSeverity(
lspSeverity: number | undefined,
): 'Error' | 'Warning' | 'Info' | 'Hint' {
// LSP DiagnosticSeverity enum:
// 1 = Error, 2 = Warning, 3 = Information, 4 = Hint
switch (lspSeverity) {
case 1:
return 'Error'
case 2:
return 'Warning'
case 3:
return 'Info'
case 4:
return 'Hint'
default:
return 'Error'
}
}
/**
* Convert LSP diagnostics to Claude diagnostic format
*
* Converts LSP PublishDiagnosticsParams to DiagnosticFile[] format
* used by Claude's attachment system.
*/
export function formatDiagnosticsForAttachment(
params: PublishDiagnosticsParams,
): DiagnosticFile[] {
// Parse URI (may be file:// or plain path) and normalize to file system path
let uri: string
try {
// Handle both file:// URIs and plain paths
uri = params.uri.startsWith('file://')
? fileURLToPath(params.uri)
: params.uri
} catch (error) {
const err = toError(error)
logError(err)
logForDebugging(
`Failed to convert URI to file path: ${params.uri}. Error: ${err.message}. Using original URI as fallback.`,
)
// Gracefully fallback to original URI - LSP servers may send malformed URIs
uri = params.uri
}
const diagnostics = params.diagnostics.map(
(diag: {
message: string
severity?: number
range: {
start: { line: number; character: number }
end: { line: number; character: number }
}
source?: string
code?: string | number
}) => ({
message: diag.message,
severity: mapLSPSeverity(diag.severity),
range: {
start: {
line: diag.range.start.line,
character: diag.range.start.character,
},
end: {
line: diag.range.end.line,
character: diag.range.end.character,
},
},
source: diag.source,
code:
diag.code !== undefined && diag.code !== null
? String(diag.code)
: undefined,
}),
)
return [
{
uri,
diagnostics,
},
]
}
/**
* Handler registration result with tracking data
*/
export type HandlerRegistrationResult = {
/** Total number of servers */
totalServers: number
/** Number of successful registrations */
successCount: number
/** Registration errors per server */
registrationErrors: Array<{ serverName: string; error: string }>
/** Runtime failure tracking (shared across all handler invocations) */
diagnosticFailures: Map<string, { count: number; lastError: string }>
}
/**
* Register LSP notification handlers on all servers
*
* Sets up handlers to listen for textDocument/publishDiagnostics notifications
* from all LSP servers and routes them to Claude's diagnostic system.
* Uses public getAllServers() API for clean access to server instances.
*
* @returns Tracking data for registration status and runtime failures
*/
export function registerLSPNotificationHandlers(
manager: LSPServerManager,
): HandlerRegistrationResult {
// Register handlers on all configured servers to capture diagnostics from any language
const servers = manager.getAllServers()
// Track partial failures - allow successful server registrations even if some fail
const registrationErrors: Array<{ serverName: string; error: string }> = []
let successCount = 0
// Track consecutive failures per server to warn users after 3+ failures
const diagnosticFailures: Map<string, { count: number; lastError: string }> =
new Map()
for (const [serverName, serverInstance] of servers.entries()) {
try {
// Validate server instance has onNotification method
if (
!serverInstance ||
typeof serverInstance.onNotification !== 'function'
) {
const errorMsg = !serverInstance
? 'Server instance is null/undefined'
: 'Server instance has no onNotification method'
registrationErrors.push({ serverName, error: errorMsg })
const err = new Error(`${errorMsg} for ${serverName}`)
logError(err)
logForDebugging(
`Skipping handler registration for ${serverName}: ${errorMsg}`,
)
continue // Skip this server but track the failure
}
// Errors are isolated to avoid breaking other servers
serverInstance.onNotification(
'textDocument/publishDiagnostics',
(params: unknown) => {
logForDebugging(
`[PASSIVE DIAGNOSTICS] Handler invoked for ${serverName}! Params type: ${typeof params}`,
)
try {
// Validate params structure before casting
if (
!params ||
typeof params !== 'object' ||
!('uri' in params) ||
!('diagnostics' in params)
) {
const err = new Error(
`LSP server ${serverName} sent invalid diagnostic params (missing uri or diagnostics)`,
)
logError(err)
logForDebugging(
`Invalid diagnostic params from ${serverName}: ${jsonStringify(params)}`,
)
return
}
const diagnosticParams = params as PublishDiagnosticsParams
logForDebugging(
`Received diagnostics from ${serverName}: ${diagnosticParams.diagnostics.length} diagnostic(s) for ${diagnosticParams.uri}`,
)
// Convert LSP diagnostics to Claude format (can throw on invalid URIs)
const diagnosticFiles =
formatDiagnosticsForAttachment(diagnosticParams)
// Only send notification if there are diagnostics
const firstFile = diagnosticFiles[0]
if (
!firstFile ||
diagnosticFiles.length === 0 ||
firstFile.diagnostics.length === 0
) {
logForDebugging(
`Skipping empty diagnostics from ${serverName} for ${diagnosticParams.uri}`,
)
return
}
// Register diagnostics for async delivery via attachment system
// Follows same pattern as AsyncHookRegistry for consistent async attachment delivery
try {
registerPendingLSPDiagnostic({
serverName,
files: diagnosticFiles,
})
logForDebugging(
`LSP Diagnostics: Registered ${diagnosticFiles.length} diagnostic file(s) from ${serverName} for async delivery`,
)
// Success - reset failure counter for this server
diagnosticFailures.delete(serverName)
} catch (error) {
const err = toError(error)
logError(err)
logForDebugging(
`Error registering LSP diagnostics from ${serverName}: ` +
`URI: ${diagnosticParams.uri}, ` +
`Diagnostic count: ${firstFile.diagnostics.length}, ` +
`Error: ${err.message}`,
)
// Track consecutive failures and warn after 3+
const failures = diagnosticFailures.get(serverName) || {
count: 0,
lastError: '',
}
failures.count++
failures.lastError = err.message
diagnosticFailures.set(serverName, failures)
if (failures.count >= 3) {
logForDebugging(
`WARNING: LSP diagnostic handler for ${serverName} has failed ${failures.count} times consecutively. ` +
`Last error: ${failures.lastError}. ` +
`This may indicate a problem with the LSP server or diagnostic processing. ` +
`Check logs for details.`,
)
}
}
} catch (error) {
// Catch any unexpected errors from the entire handler to prevent breaking the notification loop
const err = toError(error)
logError(err)
logForDebugging(
`Unexpected error processing diagnostics from ${serverName}: ${err.message}`,
)
// Track consecutive failures and warn after 3+
const failures = diagnosticFailures.get(serverName) || {
count: 0,
lastError: '',
}
failures.count++
failures.lastError = err.message
diagnosticFailures.set(serverName, failures)
if (failures.count >= 3) {
logForDebugging(
`WARNING: LSP diagnostic handler for ${serverName} has failed ${failures.count} times consecutively. ` +
`Last error: ${failures.lastError}. ` +
`This may indicate a problem with the LSP server or diagnostic processing. ` +
`Check logs for details.`,
)
}
// Don't re-throw - isolate errors to this server only
}
},
)
logForDebugging(`Registered diagnostics handler for ${serverName}`)
successCount++
} catch (error) {
const err = toError(error)
registrationErrors.push({
serverName,
error: err.message,
})
logError(err)
logForDebugging(
`Failed to register diagnostics handler for ${serverName}: ` +
`Error: ${err.message}`,
)
}
}
// Report overall registration status
const totalServers = servers.size
if (registrationErrors.length > 0) {
const failedServers = registrationErrors
.map(e => `${e.serverName} (${e.error})`)
.join(', ')
// Log aggregate failures for tracking
logError(
new Error(
`Failed to register diagnostics for ${registrationErrors.length} LSP server(s): ${failedServers}`,
),
)
logForDebugging(
`LSP notification handler registration: ${successCount}/${totalServers} succeeded. ` +
`Failed servers: ${failedServers}. ` +
`Diagnostics from failed servers will not be delivered.`,
)
} else {
logForDebugging(
`LSP notification handlers registered successfully for all ${totalServers} server(s)`,
)
}
// Return tracking data for monitoring and testing
return {
totalServers,
successCount,
registrationErrors,
diagnosticFailures,
}
}