Skip to content

Commit 4c4c6e1

Browse files
committed
fix: remove dead LSP clients to prevent unbounded memory growth
When an LSP connection dies, the dead client stays in the active list forever. Every subsequent file write/edit retries it, waits 3s for diagnostics, and leaks memory. Over days this compounds to GB-scale growth. - Register connection.onClose() to detect dead connections proactively - Expose alive getter on LSP client - Remove dead clients from s.clients and add to s.broken on first failure - Per-client error handling in touchFile so one dead client doesn't affect others - Add test: killing LSP server process sets alive to false Relates to #13796, #15675, #16697
1 parent 9c00669 commit 4c4c6e1

3 files changed

Lines changed: 68 additions & 6 deletions

File tree

packages/opencode/src/lsp/client.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,15 @@ export namespace LSPClient {
7676
uri: pathToFileURL(input.root).href,
7777
},
7878
])
79+
let alive = true
80+
connection.onClose(() => {
81+
l.info("connection closed")
82+
alive = false
83+
})
84+
connection.onError((err) => {
85+
l.error("connection error", { error: err })
86+
})
87+
7988
connection.listen()
8089

8190
l.info("sending initialize")
@@ -141,6 +150,9 @@ export namespace LSPClient {
141150
get serverID() {
142151
return input.serverID
143152
},
153+
get alive() {
154+
return alive
155+
},
144156
get connection() {
145157
return connection
146158
},

packages/opencode/src/lsp/index.ts

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -275,18 +275,42 @@ export namespace LSP {
275275
return false
276276
}
277277

278+
async function removeClient(s: Awaited<ReturnType<typeof state>>, client: LSPClient.Info) {
279+
const idx = s.clients.indexOf(client)
280+
if (idx !== -1) s.clients.splice(idx, 1)
281+
s.broken.add(client.root + client.serverID)
282+
client.shutdown().catch(() => {})
283+
log.info("removed dead LSP client", {
284+
serverID: client.serverID,
285+
root: client.root,
286+
})
287+
}
288+
278289
export async function touchFile(input: string, waitForDiagnostics?: boolean) {
279290
log.info("touching file", { file: input })
291+
const s = await state()
280292
const clients = await getClients(input)
281293
await Promise.all(
282294
clients.map(async (client) => {
283-
const wait = waitForDiagnostics ? client.waitForDiagnostics({ path: input }) : Promise.resolve()
284-
await client.notify.open({ path: input })
285-
return wait
295+
if (!client.alive) {
296+
await removeClient(s, client)
297+
return
298+
}
299+
try {
300+
const wait = waitForDiagnostics ? client.waitForDiagnostics({ path: input }) : Promise.resolve()
301+
await client.notify.open({ path: input })
302+
return wait
303+
} catch (err: any) {
304+
log.error("failed to touch file", { err, file: input })
305+
if (
306+
err?.message?.includes("Connection is closed") ||
307+
err?.message?.includes("connection is disposed")
308+
) {
309+
await removeClient(s, client)
310+
}
311+
}
286312
}),
287-
).catch((err) => {
288-
log.error("failed to touch file", { err, file: input })
289-
})
313+
)
290314
}
291315

292316
export async function diagnostics() {

packages/opencode/test/lsp/client.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,4 +92,30 @@ describe("LSPClient interop", () => {
9292

9393
await client.shutdown()
9494
})
95+
96+
test("alive becomes false when server process is killed", async () => {
97+
const handle = spawnFakeServer() as any
98+
99+
const client = await Instance.provide({
100+
directory: process.cwd(),
101+
fn: () =>
102+
LSPClient.create({
103+
serverID: "fake",
104+
server: handle as unknown as LSPServer.Handle,
105+
root: process.cwd(),
106+
}),
107+
})
108+
109+
expect(client.alive).toBe(true)
110+
111+
// Kill the server process (simulates unexpected LSP death)
112+
handle.process.kill()
113+
114+
// Wait for onClose to propagate
115+
await new Promise((r) => setTimeout(r, 200))
116+
expect(client.alive).toBe(false)
117+
118+
// Cleanup
119+
client.shutdown().catch(() => {})
120+
})
95121
})

0 commit comments

Comments
 (0)