From cf7762957f1e71eee0e683dd0555da3e0222d5f2 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 19 Mar 2026 21:22:58 -0400 Subject: [PATCH 1/2] effectify LSP service: migrate from Instance.state to Effect service pattern Replace the legacy Instance.state() pattern in LSP with Interface, Service, layer, and async facades using runPromiseInstance. Wire LSP.Service into InstanceServices and add runSyncInstance helper. --- packages/opencode/specs/effect-migration.md | 2 +- packages/opencode/src/effect/instances.ts | 3 + packages/opencode/src/effect/runtime.ts | 4 + packages/opencode/src/lsp/index.ts | 773 ++++++++++++-------- 4 files changed, 467 insertions(+), 315 deletions(-) diff --git a/packages/opencode/specs/effect-migration.md b/packages/opencode/specs/effect-migration.md index 4f195917fde..8e29e0d2d28 100644 --- a/packages/opencode/specs/effect-migration.md +++ b/packages/opencode/specs/effect-migration.md @@ -140,5 +140,5 @@ Still open and likely worth migrating: - [ ] `SessionCompaction` - [ ] `Provider` - [ ] `Project` -- [ ] `LSP` +- [x] `LSP` - [ ] `MCP` diff --git a/packages/opencode/src/effect/instances.ts b/packages/opencode/src/effect/instances.ts index c05458d5df9..bcb37a61180 100644 --- a/packages/opencode/src/effect/instances.ts +++ b/packages/opencode/src/effect/instances.ts @@ -3,6 +3,7 @@ import { File } from "@/file" import { FileTime } from "@/file/time" import { FileWatcher } from "@/file/watcher" import { Format } from "@/format" +import { LSP } from "@/lsp" import { PermissionNext } from "@/permission" import { Instance } from "@/project/instance" import { Vcs } from "@/project/vcs" @@ -26,6 +27,7 @@ export type InstanceServices = | File.Service | Skill.Service | Snapshot.Service + | LSP.Service // NOTE: LayerMap only passes the key (directory string) to lookup, but we need // the full instance context (directory, worktree, project). We read from the @@ -46,6 +48,7 @@ function lookup(_key: string) { Layer.fresh(File.layer), Layer.fresh(Skill.defaultLayer), Layer.fresh(Snapshot.defaultLayer), + Layer.fresh(LSP.layer), ).pipe(Layer.provide(ctx)) } diff --git a/packages/opencode/src/effect/runtime.ts b/packages/opencode/src/effect/runtime.ts index f52203b2220..039f570f432 100644 --- a/packages/opencode/src/effect/runtime.ts +++ b/packages/opencode/src/effect/runtime.ts @@ -18,6 +18,10 @@ export function runPromiseInstance(effect: Effect.Effect(effect: Effect.Effect) { + return runtime.runSync(effect.pipe(Effect.provide(Instances.get(Instance.directory)))) +} + export function disposeRuntime() { return runtime.dispose() } diff --git a/packages/opencode/src/lsp/index.ts b/packages/opencode/src/lsp/index.ts index 2eb1ad93e98..4ab2f2214db 100644 --- a/packages/opencode/src/lsp/index.ts +++ b/packages/opencode/src/lsp/index.ts @@ -1,5 +1,7 @@ import { BusEvent } from "@/bus/bus-event" import { Bus } from "@/bus" +import { InstanceContext } from "@/effect/instance-context" +import { runPromiseInstance } from "@/effect/runtime" import { Log } from "../util/log" import { LSPClient } from "./client" import path from "path" @@ -7,10 +9,10 @@ import { pathToFileURL, fileURLToPath } from "url" import { LSPServer } from "./server" import z from "zod" import { Config } from "../config/config" -import { Instance } from "../project/instance" import { Flag } from "@/flag/flag" import { Process } from "../util/process" import { spawn as lspspawn } from "./launch" +import { Effect, Layer, ServiceMap } from "effect" export namespace LSP { const log = Log.create({ service: "lsp" }) @@ -77,77 +79,6 @@ export namespace LSP { } } - const state = Instance.state( - async () => { - const clients: LSPClient.Info[] = [] - const servers: Record = {} - const cfg = await Config.get() - - if (cfg.lsp === false) { - log.info("all LSPs are disabled") - return { - broken: new Set(), - servers, - clients, - spawning: new Map>(), - } - } - - for (const server of Object.values(LSPServer)) { - servers[server.id] = server - } - - filterExperimentalServers(servers) - - for (const [name, item] of Object.entries(cfg.lsp ?? {})) { - const existing = servers[name] - if (item.disabled) { - log.info(`LSP server ${name} is disabled`) - delete servers[name] - continue - } - servers[name] = { - ...existing, - id: name, - root: existing?.root ?? (async () => Instance.directory), - extensions: item.extensions ?? existing?.extensions ?? [], - spawn: async (root) => { - return { - process: lspspawn(item.command[0], item.command.slice(1), { - cwd: root, - env: { - ...process.env, - ...item.env, - }, - }), - initialization: item.initialization, - } - }, - } - } - - log.info("enabled LSP servers", { - serverIds: Object.values(servers) - .map((server) => server.id) - .join(", "), - }) - - return { - broken: new Set(), - servers, - clients, - spawning: new Map>(), - } - }, - async (state) => { - await Promise.all(state.clients.map((client) => client.shutdown())) - }, - ) - - export async function init() { - return state() - } - export const Status = z .object({ id: z.string(), @@ -160,162 +91,6 @@ export namespace LSP { }) export type Status = z.infer - export async function status() { - return state().then((x) => { - const result: Status[] = [] - for (const client of x.clients) { - result.push({ - id: client.serverID, - name: x.servers[client.serverID].id, - root: path.relative(Instance.directory, client.root), - status: "connected", - }) - } - return result - }) - } - - async function getClients(file: string) { - const s = await state() - const extension = path.parse(file).ext || file - const result: LSPClient.Info[] = [] - - async function schedule(server: LSPServer.Info, root: string, key: string) { - const handle = await server - .spawn(root) - .then((value) => { - if (!value) s.broken.add(key) - return value - }) - .catch((err) => { - s.broken.add(key) - log.error(`Failed to spawn LSP server ${server.id}`, { error: err }) - return undefined - }) - - if (!handle) return undefined - log.info("spawned lsp server", { serverID: server.id }) - - const client = await LSPClient.create({ - serverID: server.id, - server: handle, - root, - }).catch(async (err) => { - s.broken.add(key) - await Process.stop(handle.process) - log.error(`Failed to initialize LSP client ${server.id}`, { error: err }) - return undefined - }) - - if (!client) { - return undefined - } - - const existing = s.clients.find((x) => x.root === root && x.serverID === server.id) - if (existing) { - await Process.stop(handle.process) - return existing - } - - s.clients.push(client) - return client - } - - for (const server of Object.values(s.servers)) { - 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 - - const match = s.clients.find((x) => x.root === root && x.serverID === server.id) - if (match) { - result.push(match) - continue - } - - const inflight = s.spawning.get(root + server.id) - if (inflight) { - const client = await inflight - if (!client) continue - result.push(client) - continue - } - - const task = schedule(server, root, root + server.id) - s.spawning.set(root + server.id, task) - - task.finally(() => { - if (s.spawning.get(root + server.id) === task) { - s.spawning.delete(root + server.id) - } - }) - - const client = await task - if (!client) continue - - result.push(client) - Bus.publish(Event.Updated, {}) - } - - return result - } - - export async function hasClients(file: string) { - const s = await state() - const extension = path.parse(file).ext || file - for (const server of Object.values(s.servers)) { - 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 - return true - } - return false - } - - export async function touchFile(input: string, waitForDiagnostics?: boolean) { - log.info("touching file", { file: input }) - const clients = await getClients(input) - await Promise.all( - clients.map(async (client) => { - const wait = waitForDiagnostics ? client.waitForDiagnostics({ path: input }) : Promise.resolve() - await client.notify.open({ path: input }) - return wait - }), - ).catch((err) => { - log.error("failed to touch file", { err, file: input }) - }) - } - - export async function diagnostics() { - const results: Record = {} - for (const result of await runAll(async (client) => client.diagnostics)) { - for (const [path, diagnostics] of result.entries()) { - const arr = results[path] || [] - arr.push(...diagnostics) - results[path] = arr - } - } - return results - } - - export async function hover(input: { file: string; line: number; character: number }) { - return run(input.file, (client) => { - return client.connection - .sendRequest("textDocument/hover", { - textDocument: { - uri: pathToFileURL(input.file).href, - }, - position: { - line: input.line, - character: input.character, - }, - }) - .catch(() => null) - }) - } - enum SymbolKind { File = 1, Module = 2, @@ -356,114 +131,484 @@ export namespace LSP { SymbolKind.Enum, ] - export async function workspaceSymbol(query: string) { - return runAll((client) => - client.connection - .sendRequest("workspace/symbol", { - query, + export interface Interface { + readonly init: () => Effect.Effect + readonly status: () => Effect.Effect + readonly hasClients: (file: string) => Effect.Effect + readonly touchFile: (input: string, waitForDiagnostics?: boolean) => Effect.Effect + readonly diagnostics: () => Effect.Effect> + readonly hover: (input: { file: string; line: number; character: number }) => Effect.Effect + readonly workspaceSymbol: (query: string) => Effect.Effect + readonly documentSymbol: (uri: string) => Effect.Effect<(LSP.DocumentSymbol | LSP.Symbol)[]> + readonly definition: (input: { file: string; line: number; character: number }) => Effect.Effect + readonly references: (input: { file: string; line: number; character: number }) => Effect.Effect + readonly implementation: (input: { file: string; line: number; character: number }) => Effect.Effect + readonly prepareCallHierarchy: (input: { file: string; line: number; character: number }) => Effect.Effect + readonly incomingCalls: (input: { file: string; line: number; character: number }) => Effect.Effect + readonly outgoingCalls: (input: { file: string; line: number; character: number }) => Effect.Effect + } + + export class Service extends ServiceMap.Service()("@opencode/LSP") {} + + export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const instance = yield* InstanceContext + const directory = instance.directory + + const clients: LSPClient.Info[] = [] + const servers: Record = {} + const broken = new Set() + const spawning = new Map>() + + // Load server configs + yield* Effect.promise(async () => { + const cfg = await Config.get() + + if (cfg.lsp === false) { + log.info("all LSPs are disabled") + return + } + + for (const server of Object.values(LSPServer)) { + servers[server.id] = server + } + + filterExperimentalServers(servers) + + for (const [name, item] of Object.entries(cfg.lsp ?? {})) { + const existing = servers[name] + if (item.disabled) { + log.info(`LSP server ${name} is disabled`) + delete servers[name] + continue + } + servers[name] = { + ...existing, + id: name, + root: existing?.root ?? (async () => directory), + extensions: item.extensions ?? existing?.extensions ?? [], + spawn: async (root) => { + return { + process: lspspawn(item.command[0], item.command.slice(1), { + cwd: root, + env: { + ...process.env, + ...item.env, + }, + }), + initialization: item.initialization, + } + }, + } + } + + log.info("enabled LSP servers", { + serverIds: Object.values(servers) + .map((server) => server.id) + .join(", "), }) - .then((result: any) => result.filter((x: LSP.Symbol) => kinds.includes(x.kind))) - .then((result: any) => result.slice(0, 10)) - .catch(() => []), - ).then((result) => result.flat() as LSP.Symbol[]) + }) + + // Cleanup: shut down all LSP clients when the scope is closed + yield* Effect.addFinalizer(() => + Effect.promise(async () => { + await Promise.all(clients.map((client) => client.shutdown())) + }), + ) + + async function getClientsForFile(file: string) { + const extension = path.parse(file).ext || file + const result: LSPClient.Info[] = [] + + async function schedule(server: LSPServer.Info, root: string, key: string) { + const handle = await server + .spawn(root) + .then((value) => { + if (!value) broken.add(key) + return value + }) + .catch((err) => { + broken.add(key) + log.error(`Failed to spawn LSP server ${server.id}`, { error: err }) + return undefined + }) + + if (!handle) return undefined + log.info("spawned lsp server", { serverID: server.id }) + + const client = await LSPClient.create({ + serverID: server.id, + server: handle, + root, + }).catch(async (err) => { + broken.add(key) + await Process.stop(handle.process) + log.error(`Failed to initialize LSP client ${server.id}`, { error: err }) + return undefined + }) + + if (!client) { + return undefined + } + + const existing = clients.find((x) => x.root === root && x.serverID === server.id) + if (existing) { + await Process.stop(handle.process) + return existing + } + + clients.push(client) + return client + } + + for (const server of Object.values(servers)) { + if (server.extensions.length && !server.extensions.includes(extension)) continue + + const root = await server.root(file) + if (!root) continue + if (broken.has(root + server.id)) continue + + const match = clients.find((x) => x.root === root && x.serverID === server.id) + if (match) { + result.push(match) + continue + } + + const inflight = spawning.get(root + server.id) + if (inflight) { + const client = await inflight + if (!client) continue + result.push(client) + continue + } + + const task = schedule(server, root, root + server.id) + spawning.set(root + server.id, task) + + task.finally(() => { + if (spawning.get(root + server.id) === task) { + spawning.delete(root + server.id) + } + }) + + const client = await task + if (!client) continue + + result.push(client) + Bus.publish(Event.Updated, {}) + } + + return result + } + + async function runAllClients(input: (client: LSPClient.Info) => Promise): Promise { + const tasks = clients.map((x) => input(x)) + return Promise.all(tasks) + } + + async function runForFile(file: string, input: (client: LSPClient.Info) => Promise): Promise { + const matched = await getClientsForFile(file) + const tasks = matched.map((x) => input(x)) + return Promise.all(tasks) + } + + const init = Effect.fn("LSP.init")(function* () { + // State is initialized in the layer body above; this is a no-op now + }) + + const status = Effect.fn("LSP.status")(function* () { + const result: Status[] = [] + for (const client of clients) { + result.push({ + id: client.serverID, + name: servers[client.serverID].id, + root: path.relative(directory, client.root), + status: "connected", + }) + } + return result + }) + + const hasClients = Effect.fn("LSP.hasClients")(function* (file: string) { + return yield* Effect.promise(async () => { + const extension = path.parse(file).ext || file + for (const server of Object.values(servers)) { + if (server.extensions.length && !server.extensions.includes(extension)) continue + const root = await server.root(file) + if (!root) continue + if (broken.has(root + server.id)) continue + return true + } + return false + }) + }) + + const touchFile = Effect.fn("LSP.touchFile")(function* (input: string, waitForDiagnostics?: boolean) { + yield* Effect.promise(async () => { + log.info("touching file", { file: input }) + const matched = await getClientsForFile(input) + await Promise.all( + matched.map(async (client) => { + const wait = waitForDiagnostics ? client.waitForDiagnostics({ path: input }) : Promise.resolve() + await client.notify.open({ path: input }) + return wait + }), + ).catch((err) => { + log.error("failed to touch file", { err, file: input }) + }) + }) + }) + + const diagnostics = Effect.fn("LSP.diagnostics")(function* () { + return yield* Effect.promise(async () => { + const results: Record = {} + for (const result of await runAllClients(async (client) => client.diagnostics)) { + for (const [path, diagnostics] of result.entries()) { + const arr = results[path] || [] + arr.push(...diagnostics) + results[path] = arr + } + } + return results + }) + }) + + const hover = Effect.fn("LSP.hover")(function* (input: { file: string; line: number; character: number }) { + return yield* Effect.promise(async () => { + return runForFile(input.file, (client) => { + return client.connection + .sendRequest("textDocument/hover", { + textDocument: { + uri: pathToFileURL(input.file).href, + }, + position: { + line: input.line, + character: input.character, + }, + }) + .catch(() => null) + }) + }) + }) + + const workspaceSymbol = Effect.fn("LSP.workspaceSymbol")(function* (query: string) { + return yield* Effect.promise(async () => { + return runAllClients((client) => + client.connection + .sendRequest("workspace/symbol", { + query, + }) + .then((result: any) => result.filter((x: LSP.Symbol) => kinds.includes(x.kind))) + .then((result: any) => result.slice(0, 10)) + .catch(() => []), + ).then((result) => result.flat() as LSP.Symbol[]) + }) + }) + + const documentSymbol = Effect.fn("LSP.documentSymbol")(function* (uri: string) { + return yield* Effect.promise(async () => { + const file = fileURLToPath(uri) + return runForFile(file, (client) => + client.connection + .sendRequest("textDocument/documentSymbol", { + textDocument: { + uri, + }, + }) + .catch(() => []), + ) + .then((result) => result.flat() as (LSP.DocumentSymbol | LSP.Symbol)[]) + .then((result) => result.filter(Boolean)) + }) + }) + + const definition = Effect.fn("LSP.definition")(function* (input: { + file: string + line: number + character: number + }) { + return yield* Effect.promise(async () => { + return runForFile(input.file, (client) => + client.connection + .sendRequest("textDocument/definition", { + textDocument: { uri: pathToFileURL(input.file).href }, + position: { line: input.line, character: input.character }, + }) + .catch(() => null), + ).then((result) => result.flat().filter(Boolean)) + }) + }) + + const references = Effect.fn("LSP.references")(function* (input: { + file: string + line: number + character: number + }) { + return yield* Effect.promise(async () => { + return runForFile(input.file, (client) => + client.connection + .sendRequest("textDocument/references", { + textDocument: { uri: pathToFileURL(input.file).href }, + position: { line: input.line, character: input.character }, + context: { includeDeclaration: true }, + }) + .catch(() => []), + ).then((result) => result.flat().filter(Boolean)) + }) + }) + + const implementation = Effect.fn("LSP.implementation")(function* (input: { + file: string + line: number + character: number + }) { + return yield* Effect.promise(async () => { + return runForFile(input.file, (client) => + client.connection + .sendRequest("textDocument/implementation", { + textDocument: { uri: pathToFileURL(input.file).href }, + position: { line: input.line, character: input.character }, + }) + .catch(() => null), + ).then((result) => result.flat().filter(Boolean)) + }) + }) + + const prepareCallHierarchy = Effect.fn("LSP.prepareCallHierarchy")(function* (input: { + file: string + line: number + character: number + }) { + return yield* Effect.promise(async () => { + return runForFile(input.file, (client) => + client.connection + .sendRequest("textDocument/prepareCallHierarchy", { + textDocument: { uri: pathToFileURL(input.file).href }, + position: { line: input.line, character: input.character }, + }) + .catch(() => []), + ).then((result) => result.flat().filter(Boolean)) + }) + }) + + const incomingCalls = Effect.fn("LSP.incomingCalls")(function* (input: { + file: string + line: number + character: number + }) { + return yield* Effect.promise(async () => { + return runForFile(input.file, async (client) => { + const items = (await client.connection + .sendRequest("textDocument/prepareCallHierarchy", { + textDocument: { uri: pathToFileURL(input.file).href }, + position: { line: input.line, character: input.character }, + }) + .catch(() => [])) as any[] + if (!items?.length) return [] + return client.connection + .sendRequest("callHierarchy/incomingCalls", { item: items[0] }) + .catch(() => []) + }).then((result) => result.flat().filter(Boolean)) + }) + }) + + const outgoingCalls = Effect.fn("LSP.outgoingCalls")(function* (input: { + file: string + line: number + character: number + }) { + return yield* Effect.promise(async () => { + return runForFile(input.file, async (client) => { + const items = (await client.connection + .sendRequest("textDocument/prepareCallHierarchy", { + textDocument: { uri: pathToFileURL(input.file).href }, + position: { line: input.line, character: input.character }, + }) + .catch(() => [])) as any[] + if (!items?.length) return [] + return client.connection + .sendRequest("callHierarchy/outgoingCalls", { item: items[0] }) + .catch(() => []) + }).then((result) => result.flat().filter(Boolean)) + }) + }) + + log.info("init") + return Service.of({ + init, + status, + hasClients, + touchFile, + diagnostics, + hover, + workspaceSymbol, + documentSymbol, + definition, + references, + implementation, + prepareCallHierarchy, + incomingCalls, + outgoingCalls, + }) + }), + ) + + // Async facades + export async function init() { + return runPromiseInstance(Service.use((svc) => svc.init())) + } + + export async function status() { + return runPromiseInstance(Service.use((svc) => svc.status())) + } + + export async function hasClients(file: string) { + return runPromiseInstance(Service.use((svc) => svc.hasClients(file))) + } + + export async function touchFile(input: string, waitForDiagnostics?: boolean) { + return runPromiseInstance(Service.use((svc) => svc.touchFile(input, waitForDiagnostics))) + } + + export async function diagnostics() { + return runPromiseInstance(Service.use((svc) => svc.diagnostics())) + } + + export async function hover(input: { file: string; line: number; character: number }) { + return runPromiseInstance(Service.use((svc) => svc.hover(input))) + } + + export async function workspaceSymbol(query: string) { + return runPromiseInstance(Service.use((svc) => svc.workspaceSymbol(query))) } export async function documentSymbol(uri: string) { - const file = fileURLToPath(uri) - return run(file, (client) => - client.connection - .sendRequest("textDocument/documentSymbol", { - textDocument: { - uri, - }, - }) - .catch(() => []), - ) - .then((result) => result.flat() as (LSP.DocumentSymbol | LSP.Symbol)[]) - .then((result) => result.filter(Boolean)) + return runPromiseInstance(Service.use((svc) => svc.documentSymbol(uri))) } export async function definition(input: { file: string; line: number; character: number }) { - return run(input.file, (client) => - client.connection - .sendRequest("textDocument/definition", { - textDocument: { uri: pathToFileURL(input.file).href }, - position: { line: input.line, character: input.character }, - }) - .catch(() => null), - ).then((result) => result.flat().filter(Boolean)) + return runPromiseInstance(Service.use((svc) => svc.definition(input))) } export async function references(input: { file: string; line: number; character: number }) { - return run(input.file, (client) => - client.connection - .sendRequest("textDocument/references", { - textDocument: { uri: pathToFileURL(input.file).href }, - position: { line: input.line, character: input.character }, - context: { includeDeclaration: true }, - }) - .catch(() => []), - ).then((result) => result.flat().filter(Boolean)) + return runPromiseInstance(Service.use((svc) => svc.references(input))) } export async function implementation(input: { file: string; line: number; character: number }) { - return run(input.file, (client) => - client.connection - .sendRequest("textDocument/implementation", { - textDocument: { uri: pathToFileURL(input.file).href }, - position: { line: input.line, character: input.character }, - }) - .catch(() => null), - ).then((result) => result.flat().filter(Boolean)) + return runPromiseInstance(Service.use((svc) => svc.implementation(input))) } export async function prepareCallHierarchy(input: { file: string; line: number; character: number }) { - return run(input.file, (client) => - client.connection - .sendRequest("textDocument/prepareCallHierarchy", { - textDocument: { uri: pathToFileURL(input.file).href }, - position: { line: input.line, character: input.character }, - }) - .catch(() => []), - ).then((result) => result.flat().filter(Boolean)) + return runPromiseInstance(Service.use((svc) => svc.prepareCallHierarchy(input))) } export async function incomingCalls(input: { file: string; line: number; character: number }) { - return run(input.file, async (client) => { - const items = (await client.connection - .sendRequest("textDocument/prepareCallHierarchy", { - textDocument: { uri: pathToFileURL(input.file).href }, - position: { line: input.line, character: input.character }, - }) - .catch(() => [])) as any[] - if (!items?.length) return [] - return client.connection.sendRequest("callHierarchy/incomingCalls", { item: items[0] }).catch(() => []) - }).then((result) => result.flat().filter(Boolean)) + return runPromiseInstance(Service.use((svc) => svc.incomingCalls(input))) } export async function outgoingCalls(input: { file: string; line: number; character: number }) { - return run(input.file, async (client) => { - const items = (await client.connection - .sendRequest("textDocument/prepareCallHierarchy", { - textDocument: { uri: pathToFileURL(input.file).href }, - position: { line: input.line, character: input.character }, - }) - .catch(() => [])) as any[] - if (!items?.length) return [] - return client.connection.sendRequest("callHierarchy/outgoingCalls", { item: items[0] }).catch(() => []) - }).then((result) => result.flat().filter(Boolean)) - } - - async function runAll(input: (client: LSPClient.Info) => Promise): Promise { - const clients = await state().then((x) => x.clients) - const tasks = clients.map((x) => input(x)) - return Promise.all(tasks) - } - - async function run(file: string, input: (client: LSPClient.Info) => Promise): Promise { - const clients = await getClients(file) - const tasks = clients.map((x) => input(x)) - return Promise.all(tasks) + return runPromiseInstance(Service.use((svc) => svc.outgoingCalls(input))) } export namespace Diagnostic { From cb39863e952f8da7642d318f50efceadf7af49f6 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 20 Mar 2026 09:31:33 -0400 Subject: [PATCH 2/2] use forkScoped + Fiber.join for lazy LSP init (match old Instance.state behavior) --- packages/opencode/src/lsp/index.ts | 104 +++++++++++++++++------------ 1 file changed, 62 insertions(+), 42 deletions(-) diff --git a/packages/opencode/src/lsp/index.ts b/packages/opencode/src/lsp/index.ts index 4ab2f2214db..3b8b749ba12 100644 --- a/packages/opencode/src/lsp/index.ts +++ b/packages/opencode/src/lsp/index.ts @@ -12,7 +12,7 @@ import { Config } from "../config/config" import { Flag } from "@/flag/flag" import { Process } from "../util/process" import { spawn as lspspawn } from "./launch" -import { Effect, Layer, ServiceMap } from "effect" +import { Effect, Fiber, Layer, ServiceMap } from "effect" export namespace LSP { const log = Log.create({ service: "lsp" }) @@ -161,55 +161,62 @@ export namespace LSP { const broken = new Set() const spawning = new Map>() - // Load server configs - yield* Effect.promise(async () => { - const cfg = await Config.get() + // Load server configs lazily — forkScoped so it doesn't block layer construction + const load = Effect.fn("LSP.load")(function* () { + yield* Effect.promise(async () => { + const cfg = await Config.get() - if (cfg.lsp === false) { - log.info("all LSPs are disabled") - return - } + if (cfg.lsp === false) { + log.info("all LSPs are disabled") + return + } - for (const server of Object.values(LSPServer)) { - servers[server.id] = server - } + for (const server of Object.values(LSPServer)) { + servers[server.id] = server + } - filterExperimentalServers(servers) + filterExperimentalServers(servers) - for (const [name, item] of Object.entries(cfg.lsp ?? {})) { - const existing = servers[name] - if (item.disabled) { - log.info(`LSP server ${name} is disabled`) - delete servers[name] - continue - } - servers[name] = { - ...existing, - id: name, - root: existing?.root ?? (async () => directory), - extensions: item.extensions ?? existing?.extensions ?? [], - spawn: async (root) => { - return { - process: lspspawn(item.command[0], item.command.slice(1), { - cwd: root, - env: { - ...process.env, - ...item.env, - }, - }), - initialization: item.initialization, - } - }, + for (const [name, item] of Object.entries(cfg.lsp ?? {})) { + const existing = servers[name] + if (item.disabled) { + log.info(`LSP server ${name} is disabled`) + delete servers[name] + continue + } + servers[name] = { + ...existing, + id: name, + root: existing?.root ?? (async () => directory), + extensions: item.extensions ?? existing?.extensions ?? [], + spawn: async (root) => { + return { + process: lspspawn(item.command[0], item.command.slice(1), { + cwd: root, + env: { + ...process.env, + ...item.env, + }, + }), + initialization: item.initialization, + } + }, + } } - } - log.info("enabled LSP servers", { - serverIds: Object.values(servers) - .map((server) => server.id) - .join(", "), + log.info("enabled LSP servers", { + serverIds: Object.values(servers) + .map((server) => server.id) + .join(", "), + }) }) }) + const loadFiber = yield* load().pipe( + Effect.catchCause((cause) => Effect.sync(() => log.error("init failed", { cause }))), + Effect.forkScoped, + ) + // Cleanup: shut down all LSP clients when the scope is closed yield* Effect.addFinalizer(() => Effect.promise(async () => { @@ -314,10 +321,11 @@ export namespace LSP { } const init = Effect.fn("LSP.init")(function* () { - // State is initialized in the layer body above; this is a no-op now + yield* Fiber.join(loadFiber) }) const status = Effect.fn("LSP.status")(function* () { + yield* Fiber.join(loadFiber) const result: Status[] = [] for (const client of clients) { result.push({ @@ -331,6 +339,7 @@ export namespace LSP { }) const hasClients = Effect.fn("LSP.hasClients")(function* (file: string) { + yield* Fiber.join(loadFiber) return yield* Effect.promise(async () => { const extension = path.parse(file).ext || file for (const server of Object.values(servers)) { @@ -345,6 +354,7 @@ export namespace LSP { }) const touchFile = Effect.fn("LSP.touchFile")(function* (input: string, waitForDiagnostics?: boolean) { + yield* Fiber.join(loadFiber) yield* Effect.promise(async () => { log.info("touching file", { file: input }) const matched = await getClientsForFile(input) @@ -361,6 +371,7 @@ export namespace LSP { }) const diagnostics = Effect.fn("LSP.diagnostics")(function* () { + yield* Fiber.join(loadFiber) return yield* Effect.promise(async () => { const results: Record = {} for (const result of await runAllClients(async (client) => client.diagnostics)) { @@ -375,6 +386,7 @@ export namespace LSP { }) const hover = Effect.fn("LSP.hover")(function* (input: { file: string; line: number; character: number }) { + yield* Fiber.join(loadFiber) return yield* Effect.promise(async () => { return runForFile(input.file, (client) => { return client.connection @@ -393,6 +405,7 @@ export namespace LSP { }) const workspaceSymbol = Effect.fn("LSP.workspaceSymbol")(function* (query: string) { + yield* Fiber.join(loadFiber) return yield* Effect.promise(async () => { return runAllClients((client) => client.connection @@ -407,6 +420,7 @@ export namespace LSP { }) const documentSymbol = Effect.fn("LSP.documentSymbol")(function* (uri: string) { + yield* Fiber.join(loadFiber) return yield* Effect.promise(async () => { const file = fileURLToPath(uri) return runForFile(file, (client) => @@ -428,6 +442,7 @@ export namespace LSP { line: number character: number }) { + yield* Fiber.join(loadFiber) return yield* Effect.promise(async () => { return runForFile(input.file, (client) => client.connection @@ -445,6 +460,7 @@ export namespace LSP { line: number character: number }) { + yield* Fiber.join(loadFiber) return yield* Effect.promise(async () => { return runForFile(input.file, (client) => client.connection @@ -463,6 +479,7 @@ export namespace LSP { line: number character: number }) { + yield* Fiber.join(loadFiber) return yield* Effect.promise(async () => { return runForFile(input.file, (client) => client.connection @@ -480,6 +497,7 @@ export namespace LSP { line: number character: number }) { + yield* Fiber.join(loadFiber) return yield* Effect.promise(async () => { return runForFile(input.file, (client) => client.connection @@ -497,6 +515,7 @@ export namespace LSP { line: number character: number }) { + yield* Fiber.join(loadFiber) return yield* Effect.promise(async () => { return runForFile(input.file, async (client) => { const items = (await client.connection @@ -518,6 +537,7 @@ export namespace LSP { line: number character: number }) { + yield* Fiber.join(loadFiber) return yield* Effect.promise(async () => { return runForFile(input.file, async (client) => { const items = (await client.connection