diff --git a/packages/opencode/src/lsp/index.ts b/packages/opencode/src/lsp/index.ts index 0fd3b69dfcd..bd0de13cbdf 100644 --- a/packages/opencode/src/lsp/index.ts +++ b/packages/opencode/src/lsp/index.ts @@ -11,6 +11,11 @@ import { spawn } from "child_process" import { Instance } from "../project/instance" import { Flag } from "@/flag/flag" +// Backoff constants (inlined to avoid circular dependency with SessionRetry) +const RETRY_INITIAL_DELAY = 2000 +const RETRY_BACKOFF_FACTOR = 2 +const RETRY_MAX_DELAY = 30_000 + export namespace LSP { const log = Log.create({ service: "lsp" }) @@ -85,7 +90,7 @@ export namespace LSP { if (cfg.lsp === false) { log.info("all LSPs are disabled") return { - broken: new Set(), + broken: new Map(), servers, clients, spawning: new Map>(), @@ -132,7 +137,7 @@ export namespace LSP { }) return { - broken: new Set(), + broken: new Map(), servers, clients, spawning: new Map>(), @@ -174,6 +179,40 @@ export namespace LSP { }) } + /** + * Check if a broken server should be retried based on exponential backoff. + * @returns true if the server is still broken (should be skipped), false if it should be retried + */ + function isBrokenWithBackoff( + s: { broken: Map }, + key: string, + ): boolean { + const brokenEntry = s.broken.get(key) + if (!brokenEntry) return false + + const now = Date.now() + const elapsed = now - brokenEntry.failTime + + // Calculate exponential backoff delay (inlined to avoid circular dependency) + const retryDelay = Math.min( + RETRY_INITIAL_DELAY * Math.pow(RETRY_BACKOFF_FACTOR, brokenEntry.attemptCount - 1), + RETRY_MAX_DELAY, + ) + + if (elapsed >= retryDelay) { + // Time to retry - remove from broken set + log.info(`Retrying broken LSP server ${key}`, { + attemptCount: brokenEntry.attemptCount, + elapsed, + retryDelay, + }) + s.broken.delete(key) + return false + } + + return true + } + async function getClients(file: string) { const s = await state() const extension = path.parse(file).ext || file @@ -183,11 +222,21 @@ export namespace LSP { const handle = await server .spawn(root) .then((value) => { - if (!value) s.broken.add(key) + if (!value) { + const existing = s.broken.get(key) + s.broken.set(key, { + failTime: Date.now(), + attemptCount: (existing?.attemptCount ?? 0) + 1, + }) + } return value }) .catch((err) => { - s.broken.add(key) + const existing = s.broken.get(key) + s.broken.set(key, { + failTime: Date.now(), + attemptCount: (existing?.attemptCount ?? 0) + 1, + }) log.error(`Failed to spawn LSP server ${server.id}`, { error: err }) return undefined }) @@ -200,7 +249,11 @@ export namespace LSP { server: handle, root, }).catch((err) => { - s.broken.add(key) + const existing = s.broken.get(key) + s.broken.set(key, { + failTime: Date.now(), + attemptCount: (existing?.attemptCount ?? 0) + 1, + }) handle.process.kill() log.error(`Failed to initialize LSP client ${server.id}`, { error: err }) return undefined @@ -214,10 +267,29 @@ export namespace LSP { const existing = s.clients.find((x) => x.root === root && x.serverID === server.id) if (existing) { handle.process.kill() + // Server was already connected - clear broken state + s.broken.delete(key) return existing } + // Add process exit handler for crash detection + handle.process.on("exit", (code, signal) => { + const idx = s.clients.findIndex((x) => x.root === root && x.serverID === server.id) + if (idx !== -1) { + s.clients.splice(idx, 1) + const brokenEntry = s.broken.get(key) + s.broken.set(key, { + failTime: Date.now(), + attemptCount: (brokenEntry?.attemptCount ?? 0) + 1, + }) + log.error(`LSP process ${server.id} exited unexpectedly`, { code, signal, root }) + Bus.publish(Event.Updated, {}) + } + }) + s.clients.push(client) + // Successfully connected - clear broken state + s.broken.delete(key) return client } @@ -226,7 +298,7 @@ export namespace LSP { const root = await server.root(file) if (!root) continue - if (s.broken.has(root + server.id)) continue + if (isBrokenWithBackoff(s, root + server.id)) continue const match = s.clients.find((x) => x.root === root && x.serverID === server.id) if (match) { @@ -268,7 +340,7 @@ export namespace LSP { if (server.extensions.length && !server.extensions.includes(extension)) continue const root = await server.root(file) if (!root) continue - if (s.broken.has(root + server.id)) continue + if (isBrokenWithBackoff(s, root + server.id)) continue return true } return false