From 3aae65f44d2c7b07862b70f451e6416ea5011fa4 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 13 Apr 2026 12:50:43 -0400 Subject: [PATCH] fix: eagerly prune deleted LSP roots --- packages/opencode/src/lsp/index.ts | 76 +++++++++++++++++-- .../test/fixture/lsp/fake-lsp-server.js | 2 + .../opencode/test/lsp/cleanup-effect.test.ts | 27 +++++-- 3 files changed, 91 insertions(+), 14 deletions(-) diff --git a/packages/opencode/src/lsp/index.ts b/packages/opencode/src/lsp/index.ts index 7b622346ac..14fb78b9ae 100644 --- a/packages/opencode/src/lsp/index.ts +++ b/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 broken: Set + pruning: Promise | undefined spawning: Map> + subs: Map + timer: ReturnType | 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* (fn: (client: LSPClient.Info) => Promise) { diff --git a/packages/opencode/test/fixture/lsp/fake-lsp-server.js b/packages/opencode/test/fixture/lsp/fake-lsp-server.js index 41a088d586..3a0e092e0d 100644 --- a/packages/opencode/test/fixture/lsp/fake-lsp-server.js +++ b/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) { diff --git a/packages/opencode/test/lsp/cleanup-effect.test.ts b/packages/opencode/test/lsp/cleanup-effect.test.ts index 79c01be8ed..819aa1f065 100644 --- a/packages/opencode/test/lsp/cleanup-effect.test.ts +++ b/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() + 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 }) + yield* Deferred.await(done).pipe(Effect.timeout("2 seconds")) + + 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 + }) + + expect(stopped).toBe(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") }), ), )