Przeglądaj źródła

fix: eagerly prune deleted LSP roots

Kit Langton 4 dni temu
rodzic
commit
3aae65f44d

+ 70 - 6
packages/opencode/src/lsp/index.ts

@@ -2,6 +2,7 @@ import { BusEvent } from "@/bus/bus-event"
 import { Bus } from "@/bus"
 import { Log } from "../util/log"
 import { LSPClient } from "./client"
+import { watch as fswatch, type FSWatcher } from "fs"
 import path from "path"
 import { pathToFileURL, fileURLToPath } from "url"
 import { LSPServer } from "./server"
@@ -137,7 +138,10 @@ export namespace LSP {
     clients: LSPClient.Info[]
     servers: Record<string, LSPServer.Info>
     broken: Set<string>
+    pruning: Promise<void> | undefined
     spawning: Map<string, Promise<LSPClient.Info | undefined>>
+    subs: Map<string, FSWatcher>
+    timer: ReturnType<typeof setTimeout> | undefined
   }
 
   export interface Interface {
@@ -212,11 +216,18 @@ export namespace LSP {
             clients: [],
             servers,
             broken: new Set(),
+            pruning: undefined,
             spawning: new Map(),
+            subs: new Map(),
+            timer: undefined,
           }
 
           yield* Effect.addFinalizer(() =>
             Effect.promise(async () => {
+              if (s.timer) clearTimeout(s.timer)
+              for (const sub of s.subs.values()) {
+                sub.close()
+              }
               await Promise.all(s.clients.map((client) => client.shutdown()))
             }),
           )
@@ -269,6 +280,7 @@ export namespace LSP {
             }
 
             s.clients.push(client)
+            sync(s)
             return client
           }
 
@@ -318,22 +330,74 @@ 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 () => {
+      function sync(s: State) {
+        const next = new Set(s.clients.map((client) => path.dirname(client.root)))
+
+        for (const [dir, sub] of s.subs) {
+          if (next.has(dir)) continue
+          s.subs.delete(dir)
+          sub.close()
+        }
+
+        for (const dir of next) {
+          if (s.subs.has(dir)) continue
+          try {
+            const sub = fswatch(
+              dir,
+              { persistent: false },
+              Instance.bind(() => {
+                kick(s)
+              }),
+            )
+            sub.on(
+              "error",
+              Instance.bind(() => {
+                if (s.subs.get(dir) !== sub) return
+                s.subs.delete(dir)
+                sub.close()
+                kick(s)
+              }),
+            )
+            s.subs.set(dir, sub)
+          } catch {}
+        }
+      }
+
+      function kick(s: State) {
+        if (s.timer) clearTimeout(s.timer)
+        s.timer = setTimeout(() => {
+          s.timer = undefined
+          void scan(s)
+        }, 50)
+      }
+
+      async function scan(s: State) {
+        if (s.pruning) return s.pruning
+
+        const task = (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[]
+          if (!dead.length) return
 
           const ids = new Set(dead.map((client) => `${client.serverID}:${client.root}`))
           s.clients = s.clients.filter((client) => !ids.has(`${client.serverID}:${client.root}`))
+          sync(s)
           await Promise.all(dead.map((client) => client.shutdown().catch(() => undefined)))
-          return dead
+          await Bus.publish(Event.Updated, {})
+        })().finally(() => {
+          if (s.pruning === task) s.pruning = undefined
         })
-        if (dead.length) Bus.publish(Event.Updated, {})
+
+        s.pruning = task
+        return task
+      }
+
+      const trim = Effect.fnUntraced(function* () {
+        const s = yield* InstanceState.get(state)
+        yield* Effect.promise(() => scan(s))
       })
 
       const runAll = Effect.fnUntraced(function* <T>(fn: (client: LSPClient.Info) => Promise<T>) {

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

@@ -23,6 +23,8 @@ process.on("SIGINT", () => {
   process.exit(0)
 })
 
+setInterval(() => {}, 1000)
+
 let nextId = 1
 
 function encode(message) {

+ 18 - 7
packages/opencode/test/lsp/cleanup-effect.test.ts

@@ -1,5 +1,6 @@
 import { afterEach, describe, expect } from "bun:test"
-import { Effect, Layer } from "effect"
+import { Deferred, Effect, Layer } from "effect"
+import { Bus } from "../../src/bus"
 import path from "path"
 import { setTimeout as sleep } from "node:timers/promises"
 import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
@@ -42,15 +43,25 @@ describe("LSP cleanup", () => {
         yield* LSP.Service.use((svc) => svc.touchFile(file))
         expect(yield* LSP.Service.use((svc) => svc.status())).toHaveLength(1)
 
+        const done = yield* Deferred.make<void>()
+        const off = Bus.subscribe(LSP.Event.Updated, () => {
+          Deferred.doneUnsafe(done, Effect.void)
+        })
+        yield* Effect.addFinalizer(() => Effect.sync(off))
+
         yield* fs.remove(dir, { recursive: true, force: true })
-        expect(yield* LSP.Service.use((svc) => svc.status())).toHaveLength(0)
+        yield* Deferred.await(done).pipe(Effect.timeout("2 seconds"))
 
-        for (const _ of Array.from({ length: 20 })) {
-          if (yield* fs.exists(mark)) return
-          yield* Effect.promise(() => sleep(50))
-        }
+        const stopped = yield* Effect.promise(async () => {
+          for (const _ of Array.from({ length: 20 })) {
+            if (await fs.exists(mark)) return true
+            await sleep(50)
+          }
+          return false
+        })
 
-        throw new Error("fake lsp server did not exit")
+        expect(stopped).toBe(true)
+        expect(yield* LSP.Service.use((svc) => svc.status())).toHaveLength(0)
       }),
     ),
   )