diff --git a/packages/opencode/src/lsp/client.ts b/packages/opencode/src/lsp/client.ts index 084ccf831ee..af1fa2ac671 100644 --- a/packages/opencode/src/lsp/client.ts +++ b/packages/opencode/src/lsp/client.ts @@ -76,6 +76,15 @@ export namespace LSPClient { uri: pathToFileURL(input.root).href, }, ]) + let alive = true + connection.onClose(() => { + l.info("connection closed") + alive = false + }) + connection.onError((err) => { + l.error("connection error", { error: err }) + }) + connection.listen() l.info("sending initialize") @@ -141,6 +150,9 @@ export namespace LSPClient { get serverID() { return input.serverID }, + get alive() { + return alive + }, get connection() { return connection }, diff --git a/packages/opencode/src/lsp/index.ts b/packages/opencode/src/lsp/index.ts index 6ea7554c096..2d8d3f923a5 100644 --- a/packages/opencode/src/lsp/index.ts +++ b/packages/opencode/src/lsp/index.ts @@ -275,18 +275,42 @@ export namespace LSP { return false } + async function removeClient(s: Awaited>, client: LSPClient.Info) { + const idx = s.clients.indexOf(client) + if (idx !== -1) s.clients.splice(idx, 1) + s.broken.add(client.root + client.serverID) + client.shutdown().catch(() => {}) + log.info("removed dead LSP client", { + serverID: client.serverID, + root: client.root, + }) + } + export async function touchFile(input: string, waitForDiagnostics?: boolean) { log.info("touching file", { file: input }) + const s = await state() const clients = await getClients(input) await Promise.all( clients.map(async (client) => { - const wait = waitForDiagnostics ? client.waitForDiagnostics({ path: input }) : Promise.resolve() - await client.notify.open({ path: input }) - return wait + if (!client.alive) { + await removeClient(s, client) + return + } + try { + const wait = waitForDiagnostics ? client.waitForDiagnostics({ path: input }) : Promise.resolve() + await client.notify.open({ path: input }) + return wait + } catch (err: any) { + log.error("failed to touch file", { err, file: input }) + if ( + err?.message?.includes("Connection is closed") || + err?.message?.includes("connection is disposed") + ) { + await removeClient(s, client) + } + } }), - ).catch((err) => { - log.error("failed to touch file", { err, file: input }) - }) + ) } export async function diagnostics() { diff --git a/packages/opencode/test/lsp/client.test.ts b/packages/opencode/test/lsp/client.test.ts index c2ba3ac5b09..43e5bf0f436 100644 --- a/packages/opencode/test/lsp/client.test.ts +++ b/packages/opencode/test/lsp/client.test.ts @@ -92,4 +92,30 @@ describe("LSPClient interop", () => { await client.shutdown() }) + + test("alive becomes false when server process is killed", async () => { + const handle = spawnFakeServer() as any + + const client = await Instance.provide({ + directory: process.cwd(), + fn: () => + LSPClient.create({ + serverID: "fake", + server: handle as unknown as LSPServer.Handle, + root: process.cwd(), + }), + }) + + expect(client.alive).toBe(true) + + // Kill the server process (simulates unexpected LSP death) + handle.process.kill() + + // Wait for onClose to propagate + await new Promise((r) => setTimeout(r, 200)) + expect(client.alive).toBe(false) + + // Cleanup + client.shutdown().catch(() => {}) + }) })