fix: eagerly prune deleted LSP roots

This commit is contained in:
Kit Langton
2026-04-13 12:50:43 -04:00
parent 88ce43bf58
commit 3aae65f44d
3 changed files with 91 additions and 14 deletions

View File

@@ -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>) {

View File

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

View File

@@ -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 })
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")
}),
),
)