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