From 88ce43bf58d667a3d063fb0dc83913a2064de8c2 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 13 Apr 2026 12:28:43 -0400 Subject: [PATCH 1/4] fix: prune LSP clients for deleted roots --- packages/opencode/src/lsp/index.ts | 22 +++++++ .../test/fixture/lsp/fake-lsp-server.js | 20 +++++++ .../opencode/test/lsp/cleanup-effect.test.ts | 57 +++++++++++++++++++ 3 files changed, 99 insertions(+) create mode 100644 packages/opencode/test/lsp/cleanup-effect.test.ts diff --git a/packages/opencode/src/lsp/index.ts b/packages/opencode/src/lsp/index.ts index 8e34a88546..7b622346ac 100644 --- a/packages/opencode/src/lsp/index.ts +++ b/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* (fn: (client: LSPClient.Info) => Promise) { + 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) { diff --git a/packages/opencode/test/fixture/lsp/fake-lsp-server.js b/packages/opencode/test/fixture/lsp/fake-lsp-server.js index 39e5788012..41a088d586 100644 --- a/packages/opencode/test/fixture/lsp/fake-lsp-server.js +++ b/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) { diff --git a/packages/opencode/test/lsp/cleanup-effect.test.ts b/packages/opencode/test/lsp/cleanup-effect.test.ts new file mode 100644 index 0000000000..79c01be8ed --- /dev/null +++ b/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") + }), + ), + ) +}) From 3aae65f44d2c7b07862b70f451e6416ea5011fa4 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 13 Apr 2026 12:50:43 -0400 Subject: [PATCH 2/4] 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") }), ), ) From 43fb93bb95b42d1554d20464da627233d7793b14 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 13 Apr 2026 13:19:20 -0400 Subject: [PATCH 3/4] fix: avoid Windows LSP cleanup test flake --- packages/opencode/src/lsp/index.ts | 62 ++++++++++++------- .../test/fixture/lsp/fake-lsp-server.js | 7 +++ 2 files changed, 47 insertions(+), 22 deletions(-) diff --git a/packages/opencode/src/lsp/index.ts b/packages/opencode/src/lsp/index.ts index bd07956265..589aa7326b 100644 --- a/packages/opencode/src/lsp/index.ts +++ b/packages/opencode/src/lsp/index.ts @@ -118,6 +118,8 @@ export namespace LSP { SymbolKind.Enum, ] + const key = (id: string, root: string) => `${id}\0${root}` + const filterExperimentalServers = (servers: Record) => { if (Flag.OPENCODE_EXPERIMENTAL_LSP_TY) { if (servers["pyright"]) { @@ -139,7 +141,7 @@ export namespace LSP { broken: Set pruning: Promise | undefined spawning: Map> - subs: Map + subs: Map }> timer: ReturnType | undefined } @@ -224,8 +226,8 @@ export namespace LSP { yield* Effect.addFinalizer(() => Effect.promise(async () => { if (s.timer) clearTimeout(s.timer) - for (const sub of s.subs.values()) { - sub.close() + for (const item of s.subs.values()) { + item.sub.close() } await Promise.all(s.clients.map((client) => client.shutdown())) }), @@ -288,7 +290,8 @@ export namespace LSP { const root = await server.root(file) if (!root) continue - if (s.broken.has(root + server.id)) continue + const id = key(server.id, root) + if (s.broken.has(id)) continue const match = s.clients.find((x) => x.root === root && x.serverID === server.id) if (match) { @@ -296,7 +299,7 @@ export namespace LSP { continue } - const inflight = s.spawning.get(root + server.id) + const inflight = s.spawning.get(id) if (inflight) { const client = await inflight if (!client) continue @@ -304,12 +307,12 @@ export namespace LSP { continue } - const task = schedule(server, root, root + server.id) - s.spawning.set(root + server.id, task) + const task = schedule(server, root, id) + s.spawning.set(id, task) task.finally(() => { - if (s.spawning.get(root + server.id) === task) { - s.spawning.delete(root + server.id) + if (s.spawning.get(id) === task) { + s.spawning.delete(id) } }) @@ -330,34 +333,49 @@ export namespace LSP { }) function sync(s: State) { - const next = new Set(s.clients.map((client) => path.dirname(client.root))) + const next = new Map>() - for (const [dir, sub] of s.subs) { - if (next.has(dir)) continue - s.subs.delete(dir) - sub.close() + for (const client of s.clients) { + const dir = path.dirname(client.root) + const names = next.get(dir) ?? new Set() + names.add(path.basename(client.root)) + next.set(dir, names) } - for (const dir of next) { - if (s.subs.has(dir)) continue + for (const [dir, item] of s.subs) { + if (next.has(dir)) continue + s.subs.delete(dir) + item.sub.close() + } + + for (const [dir, names] of next) { + const existing = s.subs.get(dir) + if (existing) { + existing.names = names + continue + } try { const sub = fswatch( dir, { persistent: false }, - Instance.bind(() => { + Instance.bind((_, file) => { + if (file) { + const name = String(file) + if (!s.subs.get(dir)?.names.has(name)) return + } kick(s) }), ) sub.on( "error", Instance.bind(() => { - if (s.subs.get(dir) !== sub) return + if (s.subs.get(dir)?.sub !== sub) return s.subs.delete(dir) sub.close() kick(s) }), ) - s.subs.set(dir, sub) + s.subs.set(dir, { sub, names }) } catch {} } } @@ -381,8 +399,8 @@ export namespace LSP { ).filter((client): client is LSPClient.Info => Boolean(client)) 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}`)) + const ids = new Set(dead.map((client) => key(client.serverID, client.root))) + s.clients = s.clients.filter((client) => !ids.has(key(client.serverID, client.root))) sync(s) await Promise.all(dead.map((client) => client.shutdown().catch(() => undefined))) await Bus.publish(Event.Updated, {}) @@ -432,7 +450,7 @@ export namespace LSP { if (server.extensions.length && !server.extensions.includes(extension)) continue const root = await server.root(file) if (!root) continue - if (s.broken.has(root + server.id)) continue + if (s.broken.has(key(server.id, root))) continue return true } return false diff --git a/packages/opencode/test/fixture/lsp/fake-lsp-server.js b/packages/opencode/test/fixture/lsp/fake-lsp-server.js index 3a0e092e0d..c26e560a66 100644 --- a/packages/opencode/test/fixture/lsp/fake-lsp-server.js +++ b/packages/opencode/test/fixture/lsp/fake-lsp-server.js @@ -3,9 +3,16 @@ const fs = require("fs") const net = require("net") +const path = require("path") const mark = process.argv[2] +if (mark) { + try { + process.chdir(path.dirname(mark)) + } catch {} +} + function writeMark() { if (!mark) return try { From 06c6babb1b4936a0dee0d80d9bb0d6da0ab6edc4 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 13 Apr 2026 13:57:17 -0400 Subject: [PATCH 4/4] refactor: use Effect debounce for LSP cleanup --- packages/opencode/src/lsp/index.ts | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/packages/opencode/src/lsp/index.ts b/packages/opencode/src/lsp/index.ts index 589aa7326b..8fe4de65be 100644 --- a/packages/opencode/src/lsp/index.ts +++ b/packages/opencode/src/lsp/index.ts @@ -12,7 +12,7 @@ import { Instance } from "../project/instance" import { Flag } from "@/flag/flag" import { Process } from "../util/process" import { spawn as lspspawn } from "./launch" -import { Effect, Layer, Context } from "effect" +import { Effect, Layer, Context, PubSub, Stream } from "effect" import { InstanceState } from "@/effect/instance-state" import { Filesystem } from "@/util/filesystem" @@ -139,10 +139,10 @@ export namespace LSP { clients: LSPClient.Info[] servers: Record broken: Set + pulse: PubSub.PubSub pruning: Promise | undefined spawning: Map> subs: Map }> - timer: ReturnType | undefined } export interface Interface { @@ -217,19 +217,25 @@ export namespace LSP { clients: [], servers, broken: new Set(), + pulse: yield* PubSub.unbounded(), pruning: undefined, spawning: new Map(), subs: new Map(), - timer: undefined, } + yield* Stream.fromPubSub(s.pulse).pipe( + Stream.debounce("50 millis"), + Stream.runForEach(() => Effect.promise(() => scan(s))), + Effect.forkScoped, + ) + yield* Effect.addFinalizer(() => - Effect.promise(async () => { - if (s.timer) clearTimeout(s.timer) + Effect.gen(function* () { + yield* PubSub.shutdown(s.pulse).pipe(Effect.ignore) for (const item of s.subs.values()) { item.sub.close() } - await Promise.all(s.clients.map((client) => client.shutdown())) + yield* Effect.promise(() => Promise.all(s.clients.map((client) => client.shutdown()))) }), ) @@ -363,7 +369,7 @@ export namespace LSP { const name = String(file) if (!s.subs.get(dir)?.names.has(name)) return } - kick(s) + fire(s) }), ) sub.on( @@ -372,7 +378,7 @@ export namespace LSP { if (s.subs.get(dir)?.sub !== sub) return s.subs.delete(dir) sub.close() - kick(s) + fire(s) }), ) s.subs.set(dir, { sub, names }) @@ -380,12 +386,8 @@ export namespace LSP { } } - function kick(s: State) { - if (s.timer) clearTimeout(s.timer) - s.timer = setTimeout(() => { - s.timer = undefined - void scan(s) - }, 50) + function fire(s: State) { + Effect.runFork(PubSub.publish(s.pulse, undefined).pipe(Effect.ignore)) } async function scan(s: State) {