Quellcode durchsuchen

fix: prune LSP clients for deleted roots

Kit Langton vor 4 Tagen
Ursprung
Commit
88ce43bf58

+ 22 - 0
packages/opencode/src/lsp/index.ts

@@ -14,6 +14,7 @@ import { spawn as lspspawn } from "./launch"
 import { Effect, Layer, Context } from "effect"
 import { InstanceState } from "@/effect/instance-state"
 import { makeRuntime } from "@/effect/run-service"
+import { Filesystem } from "@/util/filesystem"
 
 export namespace LSP {
   const log = Log.create({ service: "lsp" })
@@ -226,6 +227,7 @@ export namespace LSP {
 
       const getClients = Effect.fnUntraced(function* (file: string) {
         if (!Instance.containsPath(file)) return [] as LSPClient.Info[]
+        yield* trim()
         const s = yield* InstanceState.get(state)
         return yield* Effect.promise(async () => {
           const extension = path.parse(file).ext || file
@@ -316,7 +318,26 @@ export namespace LSP {
         return yield* Effect.promise(() => Promise.all(clients.map((x) => fn(x))))
       })
 
+      const trim = Effect.fnUntraced(function* () {
+        const s = yield* InstanceState.get(state)
+        const dead = yield* Effect.promise(async () => {
+          const dead = (
+            await Promise.all(
+              s.clients.map(async (client) => ((await Filesystem.exists(client.root)) ? undefined : client)),
+            )
+          ).filter((client): client is LSPClient.Info => Boolean(client))
+          if (!dead.length) return [] as LSPClient.Info[]
+
+          const ids = new Set(dead.map((client) => `${client.serverID}:${client.root}`))
+          s.clients = s.clients.filter((client) => !ids.has(`${client.serverID}:${client.root}`))
+          await Promise.all(dead.map((client) => client.shutdown().catch(() => undefined)))
+          return dead
+        })
+        if (dead.length) Bus.publish(Event.Updated, {})
+      })
+
       const runAll = Effect.fnUntraced(function* <T>(fn: (client: LSPClient.Info) => Promise<T>) {
+        yield* trim()
         const s = yield* InstanceState.get(state)
         return yield* Effect.promise(() => Promise.all(s.clients.map((x) => fn(x))))
       })
@@ -326,6 +347,7 @@ export namespace LSP {
       })
 
       const status = Effect.fn("LSP.status")(function* () {
+        yield* trim()
         const s = yield* InstanceState.get(state)
         const result: Status[] = []
         for (const client of s.clients) {

+ 20 - 0
packages/opencode/test/fixture/lsp/fake-lsp-server.js

@@ -1,8 +1,28 @@
 // Simple JSON-RPC 2.0 LSP-like fake server over stdio
 // Implements a minimal LSP handshake and triggers a request upon notification
 
+const fs = require("fs")
 const net = require("net")
 
+const mark = process.argv[2]
+
+function writeMark() {
+  if (!mark) return
+  try {
+    fs.writeFileSync(mark, "exit")
+  } catch {}
+}
+
+process.on("exit", writeMark)
+process.on("SIGTERM", () => {
+  writeMark()
+  process.exit(0)
+})
+process.on("SIGINT", () => {
+  writeMark()
+  process.exit(0)
+})
+
 let nextId = 1
 
 function encode(message) {

+ 57 - 0
packages/opencode/test/lsp/cleanup-effect.test.ts

@@ -0,0 +1,57 @@
+import { afterEach, describe, expect } from "bun:test"
+import { Effect, Layer } from "effect"
+import path from "path"
+import { setTimeout as sleep } from "node:timers/promises"
+import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
+import { AppFileSystem } from "../../src/filesystem"
+import { LSP } from "../../src/lsp"
+import { Instance } from "../../src/project/instance"
+import { provideTmpdirInstance } from "../fixture/fixture"
+import { testEffect } from "../lib/effect"
+
+afterEach(async () => {
+  await Instance.disposeAll()
+})
+
+const it = testEffect(Layer.mergeAll(LSP.defaultLayer, CrossSpawnSpawner.defaultLayer, AppFileSystem.defaultLayer))
+const server = path.join(import.meta.dir, "../fixture/lsp/fake-lsp-server.js")
+
+describe("LSP cleanup", () => {
+  it.live("shuts down clients when their root is deleted", () =>
+    provideTmpdirInstance((dir) =>
+      Effect.gen(function* () {
+        const fs = yield* AppFileSystem.Service
+        const mark = path.join(path.dirname(dir), `${path.basename(dir)}.exit`)
+        const file = path.join(dir, "test.ts")
+
+        yield* Effect.addFinalizer(() => fs.remove(mark, { force: true }).pipe(Effect.ignore))
+        yield* fs.writeWithDirs(
+          path.join(dir, "opencode.json"),
+          JSON.stringify({
+            $schema: "https://opencode.ai/config.json",
+            lsp: {
+              typescript: { disabled: true },
+              fake: {
+                command: [process.execPath, server, mark],
+                extensions: [".ts"],
+              },
+            },
+          }),
+        )
+        yield* fs.writeWithDirs(file, "export {}\n")
+        yield* LSP.Service.use((svc) => svc.touchFile(file))
+        expect(yield* LSP.Service.use((svc) => svc.status())).toHaveLength(1)
+
+        yield* fs.remove(dir, { recursive: true, force: true })
+        expect(yield* LSP.Service.use((svc) => svc.status())).toHaveLength(0)
+
+        for (const _ of Array.from({ length: 20 })) {
+          if (yield* fs.exists(mark)) return
+          yield* Effect.promise(() => sleep(50))
+        }
+
+        throw new Error("fake lsp server did not exit")
+      }),
+    ),
+  )
+})