fix: prune LSP clients for deleted roots
This commit is contained in:
parent
94f71f59a3
commit
88ce43bf58
3 changed files with 99 additions and 0 deletions
|
|
@ -14,6 +14,7 @@ import { spawn as lspspawn } from "./launch"
|
||||||
import { Effect, Layer, Context } from "effect"
|
import { Effect, Layer, Context } from "effect"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { makeRuntime } from "@/effect/run-service"
|
import { makeRuntime } from "@/effect/run-service"
|
||||||
|
import { Filesystem } from "@/util/filesystem"
|
||||||
|
|
||||||
export namespace LSP {
|
export namespace LSP {
|
||||||
const log = Log.create({ service: "lsp" })
|
const log = Log.create({ service: "lsp" })
|
||||||
|
|
@ -226,6 +227,7 @@ export namespace LSP {
|
||||||
|
|
||||||
const getClients = Effect.fnUntraced(function* (file: string) {
|
const getClients = Effect.fnUntraced(function* (file: string) {
|
||||||
if (!Instance.containsPath(file)) return [] as LSPClient.Info[]
|
if (!Instance.containsPath(file)) return [] as LSPClient.Info[]
|
||||||
|
yield* trim()
|
||||||
const s = yield* InstanceState.get(state)
|
const s = yield* InstanceState.get(state)
|
||||||
return yield* Effect.promise(async () => {
|
return yield* Effect.promise(async () => {
|
||||||
const extension = path.parse(file).ext || file
|
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))))
|
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* <T>(fn: (client: LSPClient.Info) => Promise<T>) {
|
const runAll = Effect.fnUntraced(function* <T>(fn: (client: LSPClient.Info) => Promise<T>) {
|
||||||
|
yield* trim()
|
||||||
const s = yield* InstanceState.get(state)
|
const s = yield* InstanceState.get(state)
|
||||||
return yield* Effect.promise(() => Promise.all(s.clients.map((x) => fn(x))))
|
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* () {
|
const status = Effect.fn("LSP.status")(function* () {
|
||||||
|
yield* trim()
|
||||||
const s = yield* InstanceState.get(state)
|
const s = yield* InstanceState.get(state)
|
||||||
const result: Status[] = []
|
const result: Status[] = []
|
||||||
for (const client of s.clients) {
|
for (const client of s.clients) {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,28 @@
|
||||||
// Simple JSON-RPC 2.0 LSP-like fake server over stdio
|
// Simple JSON-RPC 2.0 LSP-like fake server over stdio
|
||||||
// Implements a minimal LSP handshake and triggers a request upon notification
|
// Implements a minimal LSP handshake and triggers a request upon notification
|
||||||
|
|
||||||
|
const fs = require("fs")
|
||||||
const net = require("net")
|
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
|
let nextId = 1
|
||||||
|
|
||||||
function encode(message) {
|
function encode(message) {
|
||||||
|
|
|
||||||
57
packages/opencode/test/lsp/cleanup-effect.test.ts
Normal file
57
packages/opencode/test/lsp/cleanup-effect.test.ts
Normal file
|
|
@ -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")
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue