Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 79 additions & 7 deletions packages/opencode/src/lsp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" })

Expand Down Expand Up @@ -85,7 +90,7 @@ export namespace LSP {
if (cfg.lsp === false) {
log.info("all LSPs are disabled")
return {
broken: new Set<string>(),
broken: new Map<string, { failTime: number; attemptCount: number }>(),
servers,
clients,
spawning: new Map<string, Promise<LSPClient.Info | undefined>>(),
Expand Down Expand Up @@ -132,7 +137,7 @@ export namespace LSP {
})

return {
broken: new Set<string>(),
broken: new Map<string, { failTime: number; attemptCount: number }>(),
servers,
clients,
spawning: new Map<string, Promise<LSPClient.Info | undefined>>(),
Expand Down Expand Up @@ -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<string, { failTime: number; attemptCount: number }> },
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
Expand All @@ -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
})
Expand All @@ -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
Expand All @@ -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
}

Expand All @@ -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) {
Expand Down Expand Up @@ -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
Expand Down