708 lines
26 KiB
TypeScript
708 lines
26 KiB
TypeScript
/**
|
|
* LSP Manager — manages multiple LSP server instances, one per language.
|
|
*
|
|
* Lazily starts servers on first use. Auto-detects language from file extension.
|
|
* Uses a WorkspaceProvider for workspace detection, multi-root folders, and daemon state.
|
|
*/
|
|
|
|
import { resolve, join, dirname } from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
import { existsSync, readFileSync, readdirSync, unlinkSync } from "node:fs";
|
|
import { spawn as spawnChild } from "node:child_process";
|
|
import { LspClient } from "./lsp-client.js";
|
|
import { type WorkspaceProvider, DefaultWorkspaceProvider } from "./workspace-provider.js";
|
|
import { getLanguageIdFromPath } from "./shared/language-map.js";
|
|
import { DAEMON_SOCKET_READY_DELAY_MS, DAEMON_RETRY_INTERVAL_MS, DAEMON_MAX_RETRIES } from "./shared/timing.js";
|
|
|
|
export interface ServerConfig {
|
|
command: string;
|
|
args: string[];
|
|
env?: Record<string, string>;
|
|
/** LSP initializationOptions passed during the initialize handshake */
|
|
initializationOptions?: Record<string, unknown>;
|
|
/** Settings returned by workspace/configuration handler (keyed by section) */
|
|
settings?: Record<string, unknown>;
|
|
}
|
|
|
|
/** Lifecycle callbacks for UI notifications */
|
|
export interface LspManagerCallbacks {
|
|
onWorkspaceSetupStart?: () => void;
|
|
onWorkspaceSetupEnd?: (success: boolean, duration: number) => void;
|
|
onServerStart?: (languageId: string, command: string) => void;
|
|
onServerReady?: (languageId: string) => void;
|
|
onServerError?: (languageId: string, error: string) => void;
|
|
onServerCrash?: (languageId: string, restarting: boolean, attempt: number) => void;
|
|
}
|
|
|
|
/** Default server configurations for common languages */
|
|
const DEFAULT_SERVERS: Record<string, ServerConfig> = {
|
|
typescript: { command: "typescript-language-server", args: ["--stdio"] },
|
|
javascript: { command: "typescript-language-server", args: ["--stdio"] },
|
|
typescriptreact: { command: "typescript-language-server", args: ["--stdio"] },
|
|
javascriptreact: { command: "typescript-language-server", args: ["--stdio"] },
|
|
rust: { command: "rust-analyzer", args: [] },
|
|
python: { command: "pyright-langserver", args: ["--stdio"] },
|
|
go: { command: "gopls", args: ["serve"] },
|
|
java: { command: "jdtls", args: [] },
|
|
};
|
|
|
|
// File extension → language ID mapping is in shared/language-map.ts
|
|
|
|
export interface ServerStatus {
|
|
languageId: string;
|
|
command: string;
|
|
running: boolean;
|
|
diagnosticsCount: number;
|
|
/** True if using a shared daemon (vs direct spawn) */
|
|
shared: boolean;
|
|
}
|
|
|
|
export class LspManager {
|
|
private clients: Map<string, LspClient> = new Map();
|
|
private serverConfigs: Map<string, ServerConfig>;
|
|
private rootDir: string;
|
|
private startingServers: Map<string, Promise<LspClient>> = new Map();
|
|
private _workspace: WorkspaceProvider;
|
|
private _workspaceReady = false;
|
|
private _workspaceReadying: Promise<boolean> | null = null;
|
|
private _callbacks: LspManagerCallbacks;
|
|
private _sessionId: string;
|
|
private _lombokJarPath: string | null = null;
|
|
private _shuttingDown = false;
|
|
private _restartAttempts: Map<string, number> = new Map();
|
|
private _restartBackoff: Map<string, number> = new Map();
|
|
|
|
private static readonly MAX_RESTART_ATTEMPTS = 3;
|
|
private static readonly INITIAL_BACKOFF_MS = 1000;
|
|
private static readonly MAX_BACKOFF_MS = 30000;
|
|
|
|
constructor(rootDir: string, customConfigs?: Record<string, ServerConfig>, callbacks?: LspManagerCallbacks, sessionId?: string, workspace?: WorkspaceProvider) {
|
|
this.rootDir = resolve(rootDir);
|
|
this.serverConfigs = new Map(Object.entries({
|
|
...DEFAULT_SERVERS,
|
|
...customConfigs,
|
|
}));
|
|
this._workspace = workspace ?? new DefaultWorkspaceProvider();
|
|
this._callbacks = callbacks ?? {};
|
|
this._sessionId = sessionId ?? `${process.pid}-${Date.now()}`;
|
|
}
|
|
|
|
/** Get the workspace provider */
|
|
get workspace(): WorkspaceProvider {
|
|
return this._workspace;
|
|
}
|
|
|
|
/** Replace the workspace provider (e.g. when an external extension registers one) */
|
|
setWorkspaceProvider(provider: WorkspaceProvider): void {
|
|
this._workspace = provider;
|
|
this._workspaceReady = false;
|
|
this._workspaceReadying = null;
|
|
}
|
|
|
|
/** Update or add a server configuration */
|
|
setServerConfig(languageId: string, config: ServerConfig): void {
|
|
this.serverConfigs.set(languageId, config);
|
|
}
|
|
|
|
/** Set an explicit path to a Lombok jar for Java/jdtls support */
|
|
setLombokJar(jarPath: string): void {
|
|
this._lombokJarPath = resolve(this.rootDir, jarPath);
|
|
}
|
|
|
|
/** Get the currently configured Lombok jar path (if any) */
|
|
getLombokJar(): string | null {
|
|
return this.findLombokJar();
|
|
}
|
|
|
|
/**
|
|
* Auto-detect Lombok jar.
|
|
* Searches: explicit path, LOMBOK_JAR env var, workspace root env/ directories.
|
|
*/
|
|
private findLombokJar(): string | null {
|
|
// 1. Explicit path set via setLombokJar()
|
|
if (this._lombokJarPath) {
|
|
if (existsSync(this._lombokJarPath)) return this._lombokJarPath;
|
|
}
|
|
|
|
// 2. LOMBOK_JAR environment variable
|
|
const envJar = process.env.LOMBOK_JAR;
|
|
if (envJar) {
|
|
const resolved = resolve(this.rootDir, envJar);
|
|
if (existsSync(resolved)) return resolved;
|
|
}
|
|
|
|
// 3. Auto-detect from workspace root (e.g. Brazil workspace env/ directory)
|
|
// Search both workspace root and cwd in case they differ
|
|
const searchRoots = [this.rootDir];
|
|
const wsRoot = this._workspace.workspaceRoot;
|
|
if (wsRoot && wsRoot !== this.rootDir) {
|
|
searchRoots.unshift(wsRoot);
|
|
}
|
|
|
|
for (const root of searchRoots) {
|
|
const envDir = join(root, "env");
|
|
if (!existsSync(envDir)) continue;
|
|
|
|
// env/Lombok-{version}/runtime/lib/lombok-*.jar
|
|
try {
|
|
const lombokDirs = readdirSync(envDir).filter(d => d.startsWith("Lombok-"));
|
|
for (const dir of lombokDirs) {
|
|
const libDir = join(envDir, dir, "runtime", "lib");
|
|
if (existsSync(libDir)) {
|
|
const jars = readdirSync(libDir).filter(f => f.startsWith("lombok-") && f.endsWith(".jar"));
|
|
if (jars.length > 0) return join(libDir, jars[0]);
|
|
}
|
|
}
|
|
} catch { /* ignore */ }
|
|
|
|
// env/gradle-cache-2/org/projectlombok/lombok/{version}/lombok-{version}.jar
|
|
const gradleLombok = join(envDir, "gradle-cache-2", "org", "projectlombok", "lombok");
|
|
if (existsSync(gradleLombok)) {
|
|
try {
|
|
const versions = readdirSync(gradleLombok);
|
|
for (const ver of versions) {
|
|
const jarPath = join(gradleLombok, ver, `lombok-${ver}.jar`);
|
|
if (existsSync(jarPath)) return jarPath;
|
|
}
|
|
} catch { /* ignore */ }
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/** Build initializationOptions for Java (jdtls) with Lombok support */
|
|
private getJavaInitializationOptions(): Record<string, unknown> | undefined {
|
|
const lombokJar = this.findLombokJar();
|
|
if (!lombokJar) return undefined;
|
|
|
|
// jdtls expects flat dotted keys in settings, not nested objects
|
|
return {
|
|
settings: {
|
|
"java.jdt.ls.vmargs": `-javaagent:${lombokJar}`,
|
|
},
|
|
};
|
|
}
|
|
|
|
/** Get all configured languages */
|
|
getConfiguredLanguages(): string[] {
|
|
return [...this.serverConfigs.keys()];
|
|
}
|
|
|
|
/** Resolve a file path to a language ID */
|
|
getLanguageId(filePath: string): string | undefined {
|
|
return getLanguageIdFromPath(filePath);
|
|
}
|
|
|
|
/** Get a file URI from a path */
|
|
getFileUri(filePath: string): string {
|
|
const abs = resolve(this.rootDir, filePath);
|
|
return pathToFileURL(abs).toString();
|
|
}
|
|
|
|
/** Resolve an absolute path from potentially relative input */
|
|
resolvePath(filePath: string): string {
|
|
return resolve(this.rootDir, filePath);
|
|
}
|
|
|
|
/**
|
|
* Get the LSP client for a language ONLY if it's already running.
|
|
* Does not start a new server. Returns null if no client is active.
|
|
*/
|
|
getRunningClient(languageId: string): LspClient | null {
|
|
const existing = this.clients.get(languageId);
|
|
if (existing && existing.initialized && !existing.disposed) {
|
|
return existing;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Get the LSP client for a file, starting the server if needed.
|
|
* Returns null if no server is configured for this file type.
|
|
*/
|
|
async getClientForFile(filePath: string): Promise<LspClient | null> {
|
|
const languageId = this.getLanguageId(filePath);
|
|
if (!languageId) return null;
|
|
return this.getClientForLanguage(languageId);
|
|
}
|
|
|
|
/**
|
|
* Get a user-friendly message about why no client is available for a file.
|
|
*/
|
|
getUnavailableReason(filePath: string): string {
|
|
const languageId = this.getLanguageId(filePath);
|
|
if (!languageId) return `No LSP server configured for file type: ${filePath}`;
|
|
const waitHint = this.getExpectedStartTime(languageId);
|
|
if (this.startingServers.has(languageId)) {
|
|
return `LSP server for ${languageId} is starting. ${waitHint} Retry shortly.`;
|
|
}
|
|
const existing = this.clients.get(languageId);
|
|
if (existing && !existing.initialized && !existing.disposed) {
|
|
return `LSP server for ${languageId} is initializing. ${waitHint} Retry shortly.`;
|
|
}
|
|
return `No LSP server available for: ${filePath}. If you just opened this project, call any LSP tool on a file to trigger server startup.`;
|
|
}
|
|
|
|
/** Estimated startup time hint for a language server */
|
|
private getExpectedStartTime(languageId: string): string {
|
|
switch (languageId) {
|
|
case "java":
|
|
return "This typically takes 1-5 minutes for Java (project indexing).";
|
|
case "rust":
|
|
return "This typically takes 30s-2min for Rust (cargo metadata + indexing).";
|
|
case "typescript":
|
|
case "javascript":
|
|
case "typescriptreact":
|
|
case "javascriptreact":
|
|
return "This typically takes 10-30s for TypeScript/JavaScript.";
|
|
case "python":
|
|
return "This typically takes 10-30s for Python.";
|
|
case "go":
|
|
return "This typically takes 10-30s for Go.";
|
|
default:
|
|
return "This may take a few seconds to a minute.";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the LSP client for a language, starting the server if needed.
|
|
* In Brazil workspaces, connects to a shared daemon (or spawns one).
|
|
* Returns null if no server is configured for this language.
|
|
*/
|
|
async getClientForLanguage(languageId: string): Promise<LspClient | null> {
|
|
// Already running?
|
|
const existing = this.clients.get(languageId);
|
|
if (existing && existing.initialized && !existing.disposed) {
|
|
return existing;
|
|
}
|
|
|
|
// Already starting? Don't block — return null so tools can show "starting" message
|
|
const starting = this.startingServers.get(languageId);
|
|
if (starting) return null;
|
|
|
|
const config = this.serverConfigs.get(languageId);
|
|
if (!config) return null;
|
|
|
|
// Kick off server start in the background — don't await
|
|
const startPromise = this.startServer(languageId, config);
|
|
this.startingServers.set(languageId, startPromise);
|
|
|
|
// Fire-and-forget: clean up on completion or failure
|
|
startPromise.catch((err) => {
|
|
this.startingServers.delete(languageId);
|
|
const message = `Failed to start LSP server for ${languageId}: ${err.message}`;
|
|
this._callbacks.onServerError?.(languageId, message);
|
|
});
|
|
|
|
return null; // Server not ready yet
|
|
}
|
|
|
|
/**
|
|
* Check if a server is currently starting up for a language.
|
|
*/
|
|
isServerStarting(languageId: string): boolean {
|
|
return this.startingServers.has(languageId);
|
|
}
|
|
|
|
/**
|
|
* Eagerly start LSP servers for the given languages in the background.
|
|
* Unlike getClientForLanguage (which returns null immediately), this method
|
|
* is fire-and-forget — intended for session_start auto-start.
|
|
*/
|
|
startEagerly(languageIds: string[]): void {
|
|
for (const languageId of languageIds) {
|
|
// Skip if already running or starting
|
|
const existing = this.clients.get(languageId);
|
|
if (existing && existing.initialized && !existing.disposed) continue;
|
|
if (this.startingServers.has(languageId)) continue;
|
|
|
|
const config = this.serverConfigs.get(languageId);
|
|
if (!config) continue;
|
|
|
|
const startPromise = this.startServer(languageId, config);
|
|
this.startingServers.set(languageId, startPromise);
|
|
|
|
startPromise.catch((err) => {
|
|
this.startingServers.delete(languageId);
|
|
this._callbacks.onServerError?.(languageId, `Auto-start failed: ${err.message}`);
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Ensure workspace provider is ready (one-time per session).
|
|
* Deduplicates concurrent calls.
|
|
*/
|
|
private async ensureWorkspace(): Promise<void> {
|
|
if (this._workspaceReady) return;
|
|
if (this._workspaceReadying) {
|
|
await this._workspaceReadying;
|
|
return;
|
|
}
|
|
this._callbacks.onWorkspaceSetupStart?.();
|
|
const start = Date.now();
|
|
this._workspaceReadying = this._workspace.ensureReady(this._sessionId);
|
|
try {
|
|
const success = await this._workspaceReadying;
|
|
this._callbacks.onWorkspaceSetupEnd?.(success, Date.now() - start);
|
|
} finally {
|
|
this._workspaceReady = true;
|
|
this._workspaceReadying = null;
|
|
}
|
|
}
|
|
|
|
private async startServer(languageId: string, config: ServerConfig): Promise<LspClient> {
|
|
// Ensure workspace provider is ready (one-time setup)
|
|
await this.ensureWorkspace();
|
|
|
|
const stateDir = this._workspace.stateDir;
|
|
const folders = this._workspace.getWorkspaceFolders();
|
|
const workspaceFolders = folders.length > 0 ? folders : undefined;
|
|
|
|
// Build language-specific initializationOptions (e.g. Lombok for Java)
|
|
// Use config-level initializationOptions if provided, otherwise fall back to language-specific defaults
|
|
const initializationOptions = config.initializationOptions
|
|
?? (languageId === "java" ? this.getJavaInitializationOptions() : undefined);
|
|
|
|
// For Java with Lombok, inject --jvm-arg so jdtls launches with -javaagent
|
|
let effectiveArgs = config.args;
|
|
if (languageId === "java") {
|
|
const lombokJar = this.findLombokJar();
|
|
if (lombokJar) {
|
|
effectiveArgs = [`--jvm-arg=-javaagent:${lombokJar}`, ...config.args];
|
|
}
|
|
}
|
|
|
|
// Callback for auto-restart on unexpected exit
|
|
const onUnexpectedExit = (code: number | null) => this.handleUnexpectedExit(languageId, code);
|
|
// Try connecting to an existing daemon socket first
|
|
const socketPath = this.getSocketPath(languageId);
|
|
if (socketPath && this.isDaemonAlive(languageId)) {
|
|
this._callbacks.onServerStart?.(languageId, `${config.command} (shared)`);
|
|
try {
|
|
const client = new LspClient({
|
|
command: config.command,
|
|
args: effectiveArgs,
|
|
rootDir: this.rootDir,
|
|
languageId,
|
|
socketPath,
|
|
initializationOptions,
|
|
settings: config.settings,
|
|
onUnexpectedExit,
|
|
});
|
|
await client.start();
|
|
this.clients.set(languageId, client);
|
|
this.startingServers.delete(languageId);
|
|
this._callbacks.onServerReady?.(languageId);
|
|
this._restartAttempts.delete(languageId);
|
|
this._restartBackoff.delete(languageId);
|
|
this.triggerPostInit(languageId, client);
|
|
return client;
|
|
} catch {
|
|
// Daemon may be stale — fall through to spawn new one
|
|
}
|
|
}
|
|
|
|
// No existing daemon — spawn one (or start direct if no state directory)
|
|
this._callbacks.onServerStart?.(languageId, config.command);
|
|
|
|
if (stateDir) {
|
|
// Spawn daemon and connect via socket
|
|
try {
|
|
await this.spawnDaemon(languageId, config, effectiveArgs, workspaceFolders, initializationOptions);
|
|
// Small delay to let daemon start listening
|
|
await new Promise((r) => setTimeout(r, DAEMON_SOCKET_READY_DELAY_MS));
|
|
|
|
const daemonSocket = this.getSocketPath(languageId)!;
|
|
|
|
// Retry connection (daemon may still be initializing jdtls which can take minutes)
|
|
let lastErr: Error | null = null;
|
|
for (let attempt = 0; attempt < DAEMON_MAX_RETRIES; attempt++) {
|
|
try {
|
|
const client = new LspClient({
|
|
command: config.command,
|
|
args: effectiveArgs,
|
|
rootDir: this.rootDir,
|
|
languageId,
|
|
socketPath: daemonSocket,
|
|
initializationOptions,
|
|
settings: config.settings,
|
|
onUnexpectedExit,
|
|
});
|
|
await client.start();
|
|
this.clients.set(languageId, client);
|
|
this.startingServers.delete(languageId);
|
|
this._callbacks.onServerReady?.(languageId);
|
|
this._restartAttempts.delete(languageId);
|
|
this._restartBackoff.delete(languageId);
|
|
this.triggerPostInit(languageId, client);
|
|
return client;
|
|
} catch (err: any) {
|
|
lastErr = err;
|
|
// Check if daemon is still alive before retrying
|
|
if (!this.isDaemonAlive(languageId)) {
|
|
throw new Error(`Daemon for ${languageId} died during startup: ${err.message}`);
|
|
}
|
|
await new Promise((r) => setTimeout(r, DAEMON_RETRY_INTERVAL_MS));
|
|
}
|
|
}
|
|
throw lastErr ?? new Error("Failed to connect to daemon");
|
|
} catch (err: any) {
|
|
// Fall back to direct mode — log the daemon failure
|
|
this._callbacks.onServerError?.(languageId, `Daemon mode failed, falling back to direct: ${err.message}`);
|
|
this.startingServers.delete(languageId);
|
|
// Don't throw — try direct mode below
|
|
}
|
|
}
|
|
|
|
// Direct mode (no state directory for daemon, or daemon failed)
|
|
const client = new LspClient({
|
|
command: config.command,
|
|
args: effectiveArgs,
|
|
rootDir: this.rootDir,
|
|
languageId,
|
|
env: config.env,
|
|
workspaceFolders,
|
|
initializationOptions,
|
|
settings: config.settings,
|
|
onUnexpectedExit,
|
|
});
|
|
|
|
try {
|
|
await client.start();
|
|
this.clients.set(languageId, client);
|
|
this.startingServers.delete(languageId);
|
|
this._callbacks.onServerReady?.(languageId);
|
|
this._restartAttempts.delete(languageId);
|
|
this._restartBackoff.delete(languageId);
|
|
this.triggerPostInit(languageId, client);
|
|
return client;
|
|
} catch (err: any) {
|
|
this.startingServers.delete(languageId);
|
|
const message = `Failed to start LSP server for ${languageId} (${config.command}): ${err.message}`;
|
|
this._callbacks.onServerError?.(languageId, message);
|
|
throw new Error(message);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Post-initialization hook for language-specific setup.
|
|
* For Java/jdtls: triggers a workspace build so diagnostics refresh
|
|
* after Lombok annotation processing completes.
|
|
*/
|
|
private triggerPostInit(languageId: string, client: LspClient): void {
|
|
if (languageId === "java") {
|
|
// java/buildWorkspace forces jdtls to rebuild and re-publish diagnostics.
|
|
// The boolean parameter means "full build" (true) vs incremental (false).
|
|
client.sendRequest("java/buildWorkspace", true).catch(() => {
|
|
// Not critical — diagnostics will just be stale until a file changes
|
|
});
|
|
}
|
|
}
|
|
|
|
/** Get the socket path for a language's daemon */
|
|
private getSocketPath(languageId: string): string | null {
|
|
const stateDir = this._workspace.stateDir;
|
|
if (!stateDir) return null;
|
|
return join(stateDir, "sockets", `lsp-${languageId}.sock`);
|
|
}
|
|
|
|
/** Check if a daemon is alive for this language */
|
|
private isDaemonAlive(languageId: string): boolean {
|
|
const stateDir = this._workspace.stateDir;
|
|
if (!stateDir) return false;
|
|
const pidPath = join(stateDir, "sockets", `lsp-${languageId}.pid`);
|
|
try {
|
|
if (!existsSync(pidPath)) return false;
|
|
const pid = parseInt(readFileSync(pidPath, "utf-8").trim(), 10);
|
|
if (isNaN(pid)) return false;
|
|
process.kill(pid, 0); // throws if not alive
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/** Spawn an LSP daemon as a detached background process */
|
|
private async spawnDaemon(
|
|
languageId: string,
|
|
config: ServerConfig,
|
|
effectiveArgs: string[],
|
|
workspaceFolders?: { uri: string; name: string }[],
|
|
initializationOptions?: Record<string, unknown>,
|
|
): Promise<void> {
|
|
const socketPath = this.getSocketPath(languageId)!;
|
|
const daemonScript = new URL("./lsp-daemon.ts", import.meta.url).pathname;
|
|
const launcherScript = new URL("./lsp-daemon-launcher.cjs", import.meta.url).pathname;
|
|
|
|
// Resolve jiti from the running process's module context
|
|
let jitiPath: string;
|
|
try {
|
|
jitiPath = require.resolve("jiti");
|
|
} catch {
|
|
try {
|
|
jitiPath = require.resolve("@mariozechner/jiti");
|
|
} catch {
|
|
throw new Error("Cannot resolve jiti for daemon spawn — jiti not found in module path");
|
|
}
|
|
}
|
|
|
|
const env: Record<string, string> = {
|
|
...process.env as Record<string, string>,
|
|
...(config.env ?? {}),
|
|
LSP_ROOT_DIR: this.rootDir,
|
|
LSP_LANGUAGE_ID: languageId,
|
|
};
|
|
|
|
if (workspaceFolders && workspaceFolders.length > 0) {
|
|
env.LSP_WORKSPACE_FOLDERS = JSON.stringify(workspaceFolders);
|
|
}
|
|
|
|
if (initializationOptions) {
|
|
env.LSP_INITIALIZATION_OPTIONS = JSON.stringify(initializationOptions);
|
|
}
|
|
|
|
if (config.settings) {
|
|
env.LSP_SETTINGS = JSON.stringify(config.settings);
|
|
}
|
|
|
|
const child = spawnChild(
|
|
process.execPath, // node
|
|
[launcherScript, jitiPath, daemonScript, socketPath, config.command, ...effectiveArgs],
|
|
{
|
|
cwd: this.rootDir,
|
|
env,
|
|
detached: true,
|
|
stdio: "ignore",
|
|
},
|
|
);
|
|
|
|
child.unref(); // let daemon outlive this process
|
|
}
|
|
|
|
/** Get status of all configured/running servers */
|
|
getStatus(): ServerStatus[] {
|
|
const statuses: ServerStatus[] = [];
|
|
for (const [languageId, config] of this.serverConfigs) {
|
|
const client = this.clients.get(languageId);
|
|
let diagnosticsCount = 0;
|
|
if (client) {
|
|
for (const diags of client.getAllDiagnostics().values()) {
|
|
diagnosticsCount += diags.length;
|
|
}
|
|
}
|
|
const daemonAlive = this.isDaemonAlive(languageId);
|
|
statuses.push({
|
|
languageId,
|
|
command: config.command,
|
|
running: client?.initialized === true && !client.disposed,
|
|
diagnosticsCount,
|
|
shared: daemonAlive,
|
|
});
|
|
}
|
|
return statuses;
|
|
}
|
|
|
|
/**
|
|
* Handle an unexpected server exit. Attempts auto-restart with exponential backoff.
|
|
* Gives up after MAX_RESTART_ATTEMPTS failures per language per session.
|
|
*/
|
|
private handleUnexpectedExit(languageId: string, code: number | null): void {
|
|
if (this._shuttingDown) return;
|
|
|
|
// Clean up the dead client
|
|
this.clients.delete(languageId);
|
|
this.startingServers.delete(languageId);
|
|
|
|
const attempts = this._restartAttempts.get(languageId) ?? 0;
|
|
if (attempts >= LspManager.MAX_RESTART_ATTEMPTS) {
|
|
this._callbacks.onServerError?.(languageId, `LSP server for ${languageId} crashed ${attempts} times — giving up auto-restart`);
|
|
this._callbacks.onServerCrash?.(languageId, false, attempts);
|
|
return;
|
|
}
|
|
|
|
const backoff = this._restartBackoff.get(languageId) ?? LspManager.INITIAL_BACKOFF_MS;
|
|
this._restartAttempts.set(languageId, attempts + 1);
|
|
this._restartBackoff.set(languageId, Math.min(backoff * 2, LspManager.MAX_BACKOFF_MS));
|
|
|
|
this._callbacks.onServerCrash?.(languageId, true, attempts + 1);
|
|
|
|
setTimeout(() => {
|
|
if (this._shuttingDown) return;
|
|
const config = this.serverConfigs.get(languageId);
|
|
if (!config) return;
|
|
|
|
const startPromise = this.startServer(languageId, config);
|
|
this.startingServers.set(languageId, startPromise);
|
|
startPromise.catch((err) => {
|
|
this.startingServers.delete(languageId);
|
|
this._callbacks.onServerError?.(languageId, `Auto-restart failed for ${languageId}: ${err.message}`);
|
|
// Trigger another restart attempt (recursive backoff)
|
|
this.handleUnexpectedExit(languageId, null);
|
|
});
|
|
}, backoff);
|
|
}
|
|
|
|
/** Shut down all clients (disconnect from daemons, kill direct servers) */
|
|
async shutdownAll(): Promise<void> {
|
|
this._workspace.shutdown();
|
|
this._shuttingDown = true;
|
|
const shutdowns = [...this.clients.values()].map((client) =>
|
|
client.shutdown().catch(() => {})
|
|
);
|
|
await Promise.all(shutdowns);
|
|
this.clients.clear();
|
|
this.startingServers.clear();
|
|
}
|
|
|
|
/**
|
|
* Restart a specific language server. Shuts down the existing client
|
|
* (and kills the daemon if shared), then starts a fresh server.
|
|
* Returns once the new server is initialized, or throws on failure.
|
|
*/
|
|
async restartServer(languageId: string): Promise<void> {
|
|
// Shut down existing client
|
|
const existing = this.clients.get(languageId);
|
|
if (existing) {
|
|
await existing.shutdown().catch(() => {});
|
|
this.clients.delete(languageId);
|
|
}
|
|
|
|
// Kill the daemon if one is running (so we get a fresh server with new config)
|
|
this.killDaemon(languageId);
|
|
|
|
// Wait for pending starts to clear
|
|
const pending = this.startingServers.get(languageId);
|
|
if (pending) {
|
|
await pending.catch(() => {});
|
|
this.startingServers.delete(languageId);
|
|
}
|
|
|
|
// Start fresh
|
|
const config = this.serverConfigs.get(languageId);
|
|
if (!config) throw new Error(`No server configured for ${languageId}`);
|
|
|
|
await this.startServer(languageId, config);
|
|
}
|
|
|
|
/** Kill a running daemon for a language (if any) */
|
|
private killDaemon(languageId: string): void {
|
|
const stateDir = this._workspace.stateDir;
|
|
if (!stateDir) return;
|
|
const pidPath = join(stateDir, "sockets", `lsp-${languageId}.pid`);
|
|
try {
|
|
if (!existsSync(pidPath)) return;
|
|
const pid = parseInt(readFileSync(pidPath, "utf-8").trim(), 10);
|
|
if (isNaN(pid)) return;
|
|
process.kill(pid, "SIGTERM");
|
|
// Clean up socket and pid files
|
|
const socketPath = join(stateDir, "sockets", `lsp-${languageId}.sock`);
|
|
try { unlinkSync(socketPath); } catch {}
|
|
try { unlinkSync(pidPath); } catch {}
|
|
} catch {
|
|
// Process may already be dead
|
|
}
|
|
}
|
|
}
|