Download src/services/lsp/LSPClient.ts from chenbhao/codev: direct link, hf CLI and curl.
- Browser
- Download file 14.4 kB
-
https://huggingface.co/chenbhao/codev/resolve/afbc7edaae4e6bb0102ba024b49ce8be89d2264c/src/services/lsp/LSPClient.ts
- Command line
-
hf download hf://chenbhao/codev@afbc7edaae4e6bb0102ba024b49ce8be89d2264c/src/services/lsp/LSPClient.ts
-
curl -L -o LSPClient.ts https://huggingface.co/chenbhao/codev/resolve/afbc7edaae4e6bb0102ba024b49ce8be89d2264c/src/services/lsp/LSPClient.ts
14.4 kB
| 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 | |
| } | |
| }, | |
| } | |
| } | |