summaryrefslogtreecommitdiffhomepage
path: root/packages/lsp/src/manager.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/lsp/src/manager.ts')
-rw-r--r--packages/lsp/src/manager.ts478
1 files changed, 239 insertions, 239 deletions
diff --git a/packages/lsp/src/manager.ts b/packages/lsp/src/manager.ts
index bc84479..273011f 100644
--- a/packages/lsp/src/manager.ts
+++ b/packages/lsp/src/manager.ts
@@ -5,38 +5,38 @@
import { join } from "node:path";
import {
- type ClientDeps,
- type FileWatcher,
- type FsAccess,
- LanguageServerClient,
- type SpawnProcess,
+ type ClientDeps,
+ type FileWatcher,
+ type FsAccess,
+ LanguageServerClient,
+ type SpawnProcess,
} from "./client.js";
import { configFingerprint, type ResolvedServer, resolveServers } from "./config.js";
import { findRoot } from "./root.js";
import type { LspServerState, LspServerStatus } from "./types.js";
export type Logger = {
- readonly info: (msg: string, attrs?: Record<string, string | number | boolean | null>) => void;
- readonly warn: (msg: string, attrs?: Record<string, string | number | boolean | null>) => void;
- readonly error: (msg: string, attrs?: Record<string, unknown>) => void;
+ readonly info: (msg: string, attrs?: Record<string, string | number | boolean | null>) => void;
+ readonly warn: (msg: string, attrs?: Record<string, string | number | boolean | null>) => void;
+ readonly error: (msg: string, attrs?: Record<string, unknown>) => void;
};
export interface ManagerDeps {
- readonly spawn: SpawnProcess;
- readonly fileWatcher: FileWatcher;
- readonly fs: FsAccess;
- readonly logger?: Logger | undefined;
- /**
- * Injected clock (epoch-ms) for bounded-backoff bookkeeping. Defaults to
- * `Date.now`; injected in tests so backoff is deterministic.
- */
- readonly now?: (() => number) | undefined;
+ readonly spawn: SpawnProcess;
+ readonly fileWatcher: FileWatcher;
+ readonly fs: FsAccess;
+ readonly logger?: Logger | undefined;
+ /**
+ * Injected clock (epoch-ms) for bounded-backoff bookkeeping. Defaults to
+ * `Date.now`; injected in tests so backoff is deterministic.
+ */
+ readonly now?: (() => number) | undefined;
}
type ClientEntry = {
- readonly client: LanguageServerClient;
- readonly server: ResolvedServer;
- readonly promise: Promise<void>;
+ readonly client: LanguageServerClient;
+ readonly server: ResolvedServer;
+ readonly promise: Promise<void>;
};
/**
@@ -49,251 +49,251 @@ type ClientEntry = {
* broken.
*/
type BrokenEntry = {
- readonly configFingerprint: string;
- readonly brokenAt: number;
- readonly error: string;
+ readonly configFingerprint: string;
+ readonly brokenAt: number;
+ readonly error: string;
};
/** Bounded backoff before a transient (config-unchanged) failure is retried. */
const BACKOFF_MS = 30_000;
export class LspManager {
- private clients = new Map<string, ClientEntry>();
- private broken = new Map<string, BrokenEntry>();
- private spawning = new Map<string, Promise<void>>();
- private shadowWarned = new Set<string>();
- private readonly deps: ManagerDeps;
- private readonly now: () => number;
+ private clients = new Map<string, ClientEntry>();
+ private broken = new Map<string, BrokenEntry>();
+ private spawning = new Map<string, Promise<void>>();
+ private shadowWarned = new Set<string>();
+ private readonly deps: ManagerDeps;
+ private readonly now: () => number;
- constructor(deps: ManagerDeps) {
- this.deps = deps;
- this.now = deps.now ?? Date.now;
- }
+ constructor(deps: ManagerDeps) {
+ this.deps = deps;
+ this.now = deps.now ?? Date.now;
+ }
- async status(cwd: string): Promise<readonly LspServerStatus[]> {
- // Config is resolved PER cwd: a different conversation cwd (e.g. a Roblox
- // project) gets its own .dispatch/lsp.json or opencode.json, not a global one.
- const dispatchLspJson = await this.readOrNull(join(cwd, ".dispatch", "lsp.json"));
- const opencodeJson = await this.readOrNull(join(cwd, "opencode.json"));
- const { servers, shadowed } = await resolveServers({
- cwd,
- dispatchLspJson,
- opencodeJson,
- exists: this.deps.fs.exists,
- });
+ async status(cwd: string): Promise<readonly LspServerStatus[]> {
+ // Config is resolved PER cwd: a different conversation cwd (e.g. a Roblox
+ // project) gets its own .dispatch/lsp.json or opencode.json, not a global one.
+ const dispatchLspJson = await this.readOrNull(join(cwd, ".dispatch", "lsp.json"));
+ const opencodeJson = await this.readOrNull(join(cwd, "opencode.json"));
+ const { servers, shadowed } = await resolveServers({
+ cwd,
+ dispatchLspJson,
+ opencodeJson,
+ exists: this.deps.fs.exists,
+ });
- // A present `.dispatch/lsp.json` silently shadows `opencode.json`'s lsp
- // key — warn once per cwd so a broken shadow names itself (an agent
- // running inside dispatch can only see `status()`, not the journal).
- if (shadowed && !this.shadowWarned.has(cwd)) {
- this.shadowWarned.add(cwd);
- this.deps.logger?.warn(
- `.dispatch/lsp.json is shadowing the opencode.json "lsp" config — its servers take precedence and the opencode.json lsp entry is ignored`,
- { cwd },
- );
- }
+ // A present `.dispatch/lsp.json` silently shadows `opencode.json`'s lsp
+ // key — warn once per cwd so a broken shadow names itself (an agent
+ // running inside dispatch can only see `status()`, not the journal).
+ if (shadowed && !this.shadowWarned.has(cwd)) {
+ this.shadowWarned.add(cwd);
+ this.deps.logger?.warn(
+ `.dispatch/lsp.json is shadowing the opencode.json "lsp" config — its servers take precedence and the opencode.json lsp entry is ignored`,
+ { cwd },
+ );
+ }
- const results: LspServerStatus[] = [];
+ const results: LspServerStatus[] = [];
- for (const server of servers) {
- const root = await findRoot(cwd, cwd, server.rootMarkers, this.deps.fs.exists);
- const key = `${server.id}:${root}`;
+ for (const server of servers) {
+ const root = await findRoot(cwd, cwd, server.rootMarkers, this.deps.fs.exists);
+ const key = `${server.id}:${root}`;
- const brokenEntry = this.broken.get(key);
- if (brokenEntry) {
- // Recovery, storm-safe: a config change is a discrete event, so it
- // cannot loop. Transient failures (config unchanged) are retried
- // only after a bounded backoff — never in a tight loop.
- const configChanged = configFingerprint(server) !== brokenEntry.configFingerprint;
- const backoffElapsed = this.now() - brokenEntry.brokenAt >= BACKOFF_MS;
- if (configChanged || backoffElapsed) {
- this.broken.delete(key);
- // Discard the stale client entry (and any leaked process) from
- // the failed spawn so status() re-spawns fresh.
- const stale = this.clients.get(key);
- if (stale) {
- this.clients.delete(key);
- stale.client.shutdown();
- }
- // fall through to (re)spawn
- } else {
- results.push({
- id: server.id,
- name: server.name,
- root,
- extensions: server.extensions,
- state: "error",
- error: brokenEntry.error,
- configSource: server.configSource,
- });
- continue;
- }
- }
+ const brokenEntry = this.broken.get(key);
+ if (brokenEntry) {
+ // Recovery, storm-safe: a config change is a discrete event, so it
+ // cannot loop. Transient failures (config unchanged) are retried
+ // only after a bounded backoff — never in a tight loop.
+ const configChanged = configFingerprint(server) !== brokenEntry.configFingerprint;
+ const backoffElapsed = this.now() - brokenEntry.brokenAt >= BACKOFF_MS;
+ if (configChanged || backoffElapsed) {
+ this.broken.delete(key);
+ // Discard the stale client entry (and any leaked process) from
+ // the failed spawn so status() re-spawns fresh.
+ const stale = this.clients.get(key);
+ if (stale) {
+ this.clients.delete(key);
+ stale.client.shutdown();
+ }
+ // fall through to (re)spawn
+ } else {
+ results.push({
+ id: server.id,
+ name: server.name,
+ root,
+ extensions: server.extensions,
+ state: "error",
+ error: brokenEntry.error,
+ configSource: server.configSource,
+ });
+ continue;
+ }
+ }
- const existing = this.clients.get(key);
- if (existing) {
- const state = existing.client.getState();
- const stateError = existing.client.getStateError();
- // A client that died or corrupted AFTER connecting flipped its
- // own state to "error" (client.ts handleExit/markBroken). Spawn
- // succeeded so there's no broken entry yet — seed one so the
- // bounded-backoff path above re-spawns it, instead of reporting
- // error forever (and so getDiagnostics' "connected" filter skips
- // it, avoiding a per-edit hang on the corpse).
- if (state === "error" && !this.broken.has(key)) {
- this.broken.set(key, {
- configFingerprint: configFingerprint(server),
- brokenAt: this.now(),
- error: enrichError(server, stateError ?? "server unavailable"),
- });
- }
- const status: LspServerStatus = {
- id: server.id,
- name: server.name,
- root,
- extensions: server.extensions,
- state: mapState(state),
- configSource: server.configSource,
- };
- if (stateError !== undefined) {
- (status as { error?: string }).error = enrichError(server, stateError);
- }
- results.push(status);
- continue;
- }
+ const existing = this.clients.get(key);
+ if (existing) {
+ const state = existing.client.getState();
+ const stateError = existing.client.getStateError();
+ // A client that died or corrupted AFTER connecting flipped its
+ // own state to "error" (client.ts handleExit/markBroken). Spawn
+ // succeeded so there's no broken entry yet — seed one so the
+ // bounded-backoff path above re-spawns it, instead of reporting
+ // error forever (and so getDiagnostics' "connected" filter skips
+ // it, avoiding a per-edit hang on the corpse).
+ if (state === "error" && !this.broken.has(key)) {
+ this.broken.set(key, {
+ configFingerprint: configFingerprint(server),
+ brokenAt: this.now(),
+ error: enrichError(server, stateError ?? "server unavailable"),
+ });
+ }
+ const status: LspServerStatus = {
+ id: server.id,
+ name: server.name,
+ root,
+ extensions: server.extensions,
+ state: mapState(state),
+ configSource: server.configSource,
+ };
+ if (stateError !== undefined) {
+ (status as { error?: string }).error = enrichError(server, stateError);
+ }
+ results.push(status);
+ continue;
+ }
- try {
- await this.spawnClient(server, root, key);
- const entry = this.clients.get(key);
- if (entry) {
- const state = entry.client.getState();
- const stateError = entry.client.getStateError();
- const status: LspServerStatus = {
- id: server.id,
- name: server.name,
- root,
- extensions: server.extensions,
- state: mapState(state),
- configSource: server.configSource,
- };
- if (stateError !== undefined) {
- (status as { error?: string }).error = enrichError(server, stateError);
- }
- results.push(status);
- }
- } catch (err: unknown) {
- const message = err instanceof Error ? err.message : String(err);
- this.broken.set(key, {
- configFingerprint: configFingerprint(server),
- brokenAt: this.now(),
- error: enrichError(server, message),
- });
- results.push({
- id: server.id,
- name: server.name,
- root,
- extensions: server.extensions,
- state: "error",
- error: enrichError(server, message),
- configSource: server.configSource,
- });
- }
- }
+ try {
+ await this.spawnClient(server, root, key);
+ const entry = this.clients.get(key);
+ if (entry) {
+ const state = entry.client.getState();
+ const stateError = entry.client.getStateError();
+ const status: LspServerStatus = {
+ id: server.id,
+ name: server.name,
+ root,
+ extensions: server.extensions,
+ state: mapState(state),
+ configSource: server.configSource,
+ };
+ if (stateError !== undefined) {
+ (status as { error?: string }).error = enrichError(server, stateError);
+ }
+ results.push(status);
+ }
+ } catch (err: unknown) {
+ const message = err instanceof Error ? err.message : String(err);
+ this.broken.set(key, {
+ configFingerprint: configFingerprint(server),
+ brokenAt: this.now(),
+ error: enrichError(server, message),
+ });
+ results.push({
+ id: server.id,
+ name: server.name,
+ root,
+ extensions: server.extensions,
+ state: "error",
+ error: enrichError(server, message),
+ configSource: server.configSource,
+ });
+ }
+ }
- return results;
- }
+ return results;
+ }
- getClient(serverId: string, root: string): LanguageServerClient | undefined {
- const key = `${serverId}:${root}`;
- return this.clients.get(key)?.client;
- }
+ getClient(serverId: string, root: string): LanguageServerClient | undefined {
+ const key = `${serverId}:${root}`;
+ return this.clients.get(key)?.client;
+ }
- /** Read a config file's contents, or null if it is absent/unreadable. */
- private async readOrNull(path: string): Promise<string | null> {
- if (!(await this.deps.fs.exists(path))) return null;
- try {
- return await this.deps.fs.readText(path);
- } catch {
- return null;
- }
- }
+ /** Read a config file's contents, or null if it is absent/unreadable. */
+ private async readOrNull(path: string): Promise<string | null> {
+ if (!(await this.deps.fs.exists(path))) return null;
+ try {
+ return await this.deps.fs.readText(path);
+ } catch {
+ return null;
+ }
+ }
- private async spawnClient(server: ResolvedServer, root: string, key: string): Promise<void> {
- const existingSpawn = this.spawning.get(key);
- if (existingSpawn) return existingSpawn;
+ private async spawnClient(server: ResolvedServer, root: string, key: string): Promise<void> {
+ const existingSpawn = this.spawning.get(key);
+ if (existingSpawn) return existingSpawn;
- const spawnPromise = this.doSpawn(server, root, key);
- this.spawning.set(key, spawnPromise);
+ const spawnPromise = this.doSpawn(server, root, key);
+ this.spawning.set(key, spawnPromise);
- try {
- await spawnPromise;
- } finally {
- this.spawning.delete(key);
- }
- }
+ try {
+ await spawnPromise;
+ } finally {
+ this.spawning.delete(key);
+ }
+ }
- private async doSpawn(server: ResolvedServer, root: string, key: string): Promise<void> {
- const clientDeps: ClientDeps = {
- spawn: this.deps.spawn,
- fileWatcher: this.deps.fileWatcher,
- fs: this.deps.fs,
- command: server.command,
- root,
- serverId: server.id,
- };
- if (server.env) {
- (clientDeps as { env?: Readonly<Record<string, string>> }).env = server.env;
- }
- if (server.initialization) {
- (clientDeps as { initialization?: Readonly<Record<string, unknown>> }).initialization =
- server.initialization;
- }
+ private async doSpawn(server: ResolvedServer, root: string, key: string): Promise<void> {
+ const clientDeps: ClientDeps = {
+ spawn: this.deps.spawn,
+ fileWatcher: this.deps.fileWatcher,
+ fs: this.deps.fs,
+ command: server.command,
+ root,
+ serverId: server.id,
+ };
+ if (server.env) {
+ (clientDeps as { env?: Readonly<Record<string, string>> }).env = server.env;
+ }
+ if (server.initialization) {
+ (clientDeps as { initialization?: Readonly<Record<string, unknown>> }).initialization =
+ server.initialization;
+ }
- const client = new LanguageServerClient(clientDeps);
- const entry: ClientEntry = {
- client,
- server,
- promise: client.start(),
- };
+ const client = new LanguageServerClient(clientDeps);
+ const entry: ClientEntry = {
+ client,
+ server,
+ promise: client.start(),
+ };
- this.clients.set(key, entry);
- await entry.promise;
+ this.clients.set(key, entry);
+ await entry.promise;
- if (client.getState() === "error") {
- const message = client.getStateError() ?? "unknown";
- this.broken.set(key, {
- configFingerprint: configFingerprint(server),
- brokenAt: this.now(),
- error: enrichError(server, message),
- });
- this.deps.logger?.warn("LSP server failed to start", {
- serverId: server.id,
- root,
- configSource: server.configSource ?? "unknown",
- error: message,
- });
- } else {
- this.deps.logger?.info("LSP server connected", {
- serverId: server.id,
- root,
- });
- }
- }
+ if (client.getState() === "error") {
+ const message = client.getStateError() ?? "unknown";
+ this.broken.set(key, {
+ configFingerprint: configFingerprint(server),
+ brokenAt: this.now(),
+ error: enrichError(server, message),
+ });
+ this.deps.logger?.warn("LSP server failed to start", {
+ serverId: server.id,
+ root,
+ configSource: server.configSource ?? "unknown",
+ error: message,
+ });
+ } else {
+ this.deps.logger?.info("LSP server connected", {
+ serverId: server.id,
+ root,
+ });
+ }
+ }
- shutdownAll(): void {
- for (const [key, entry] of this.clients) {
- entry.client.shutdown();
- this.deps.logger?.info("LSP server shutdown", { key });
- }
- this.clients.clear();
- this.broken.clear();
- this.spawning.clear();
- this.shadowWarned.clear();
- }
+ shutdownAll(): void {
+ for (const [key, entry] of this.clients) {
+ entry.client.shutdown();
+ this.deps.logger?.info("LSP server shutdown", { key });
+ }
+ this.clients.clear();
+ this.broken.clear();
+ this.spawning.clear();
+ this.shadowWarned.clear();
+ }
}
function mapState(state: LspServerState): LspServerState {
- return state;
+ return state;
}
/**
@@ -302,6 +302,6 @@ function mapState(state: LspServerState): LspServerState {
* `ruby-lsp [from .dispatch/lsp.json]: spawn failed`).
*/
function enrichError(server: ResolvedServer, message: string): string {
- const source = server.configSource ?? "unknown";
- return `${server.id} [from ${source}]: ${message}`;
+ const source = server.configSource ?? "unknown";
+ return `${server.id} [from ${source}]: ${message}`;
}