From bd5b892234d58e2f7ca2315cba0d0b1db3ec2096 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 17 Apr 2026 20:51:48 -0400 Subject: [PATCH] refactor(lsp): simplify effect timeout flow --- packages/opencode/src/lsp/client.ts | 107 ++++++++++++---------- packages/opencode/src/lsp/lsp.ts | 20 ++-- packages/opencode/src/lsp/server.ts | 9 +- packages/opencode/test/lsp/client.test.ts | 56 +++++------ 4 files changed, 93 insertions(+), 99 deletions(-) diff --git a/packages/opencode/src/lsp/client.ts b/packages/opencode/src/lsp/client.ts index bfa60f9583..177f3cb119 100644 --- a/packages/opencode/src/lsp/client.ts +++ b/packages/opencode/src/lsp/client.ts @@ -11,7 +11,6 @@ import { LANGUAGE_EXTENSIONS } from "./language" import z from "zod" import type * as LSPServer from "./server" import { NamedError } from "@opencode-ai/shared/util/error" -import { withTimeout } from "../util/timeout" import { Instance } from "../project/instance" import { Filesystem } from "../util" @@ -93,54 +92,65 @@ export const create = Effect.fn("LSPClient.create")(function* (input: { connection.listen() l.info("sending initialize") - yield* Effect.tryPromise({ - try: () => - withTimeout( - connection.sendRequest("initialize", { - rootUri: pathToFileURL(input.root).href, - processId: input.server.process.pid, - workspaceFolders: [ - { - name: "workspace", - uri: pathToFileURL(input.root).href, - }, - ], - initializationOptions: { - ...input.server.initialization, - }, - capabilities: { - window: { - workDoneProgress: true, - }, - workspace: { - configuration: true, - didChangeWatchedFiles: { - dynamicRegistration: true, - }, - }, - textDocument: { - synchronization: { - didOpen: true, - didChange: true, - }, - publishDiagnostics: { - versionSupport: true, - }, - }, - }, - }), - 45_000, - ), - catch: (error) => { - l.error("initialize error", { error }) - return new InitializeError( - { serverID: input.serverID }, + yield* Effect.tryPromise(() => + connection.sendRequest("initialize", { + rootUri: pathToFileURL(input.root).href, + processId: input.server.process.pid, + workspaceFolders: [ { - cause: error, + name: "workspace", + uri: pathToFileURL(input.root).href, }, + ], + initializationOptions: { + ...input.server.initialization, + }, + capabilities: { + window: { + workDoneProgress: true, + }, + workspace: { + configuration: true, + didChangeWatchedFiles: { + dynamicRegistration: true, + }, + }, + textDocument: { + synchronization: { + didOpen: true, + didChange: true, + }, + publishDiagnostics: { + versionSupport: true, + }, + }, + }, + }), + ).pipe( + Effect.timeoutOrElse({ + duration: 45_000, + orElse: () => + Effect.fail( + new InitializeError( + { serverID: input.serverID }, + { cause: new Error("LSP initialize timed out after 45 seconds") }, + ), + ), + }), + Effect.catch((error) => { + l.error("initialize error", { error }) + return Effect.fail( + error instanceof InitializeError + ? error + : new InitializeError( + { serverID: input.serverID }, + { + cause: error, + }, + ), ) - }, - }) + }), + ) yield* Effect.tryPromise(() => connection.sendNotification("initialized", {})) @@ -227,7 +237,6 @@ export const create = Effect.fn("LSPClient.create")(function* (input: { let unsub: (() => void) | undefined let debounceTimer: ReturnType | undefined yield* Effect.promise(() => - withTimeout( new Promise((resolve) => { unsub = Bus.subscribe(Event.Diagnostics, (event) => { if (event.properties.path === normalizedPath && event.properties.serverID === input.serverID) { @@ -240,10 +249,8 @@ export const create = Effect.fn("LSPClient.create")(function* (input: { } }) }), - 3000, - ), ).pipe( - Effect.catch(() => Effect.void), + Effect.timeoutOrElse({ duration: 3000, orElse: () => Effect.void }), Effect.ensuring( Effect.sync(() => { if (debounceTimer) clearTimeout(debounceTimer) diff --git a/packages/opencode/src/lsp/lsp.ts b/packages/opencode/src/lsp/lsp.ts index 08a0efb40e..34d7a412b1 100644 --- a/packages/opencode/src/lsp/lsp.ts +++ b/packages/opencode/src/lsp/lsp.ts @@ -11,7 +11,7 @@ import { Instance } from "../project/instance" import { Flag } from "@/flag/flag" import { Process } from "../util" import { spawn as lspspawn } from "./launch" -import { Effect, Layer, Context } from "effect" +import { Effect, Fiber, Layer, Context, Scope } from "effect" import { InstanceState } from "@/effect" const log = Log.create({ service: "lsp" }) @@ -160,6 +160,7 @@ export const layer = Layer.effect( Service, Effect.gen(function* () { const config = yield* Config.Service + const scope = yield* Scope.Scope const state = yield* InstanceState.make( Effect.fn("LSP.state")(function* () { @@ -367,14 +368,17 @@ export const layer = Layer.effect( const touchFile = Effect.fn("LSP.touchFile")(function* (input: string, waitForDiagnostics?: boolean) { log.info("touching file", { file: input }) const clients = yield* getClients(input) - yield* Effect.tryPromise(() => - Promise.all( - clients.map(async (client) => { - const wait = waitForDiagnostics ? Effect.runPromise(client.waitForDiagnostics({ path: input })) : Promise.resolve() - await Effect.runPromise(client.notify.open({ path: input })) - return wait + yield* Effect.forEach( + clients, + (client) => + Effect.gen(function* () { + const waiting = waitForDiagnostics + ? yield* client.waitForDiagnostics({ path: input }).pipe(Effect.forkIn(scope)) + : undefined + yield* client.notify.open({ path: input }) + if (waiting) yield* Fiber.join(waiting) }), - ), + { concurrency: "unbounded", discard: true }, ).pipe( Effect.catch((err: unknown) => Effect.sync(() => { diff --git a/packages/opencode/src/lsp/server.ts b/packages/opencode/src/lsp/server.ts index c00faef3dd..fd2ecdf3fc 100644 --- a/packages/opencode/src/lsp/server.ts +++ b/packages/opencode/src/lsp/server.ts @@ -17,11 +17,6 @@ import { Npm } from "../npm" import { Effect } from "effect" const log = Log.create({ service: "lsp.server" }) -const pathExists = async (p: string) => - fs - .stat(p) - .then(() => true) - .catch(() => false) const run = (cmd: string[], opts: Process.RunOptions = {}) => Process.run(cmd, { ...opts, nothrow: true }) const output = (cmd: string[], opts: Process.RunOptions = {}) => Process.text(cmd, { ...opts, nothrow: true }) @@ -1131,7 +1126,7 @@ export const JDTLS: RawInfo = { } const distPath = path.join(Global.Path.bin, "jdtls") const launcherDir = path.join(distPath, "plugins") - const installed = await pathExists(launcherDir) + const installed = await Filesystem.exists(launcherDir) if (!installed) { if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return log.info("Downloading JDTLS LSP server.") @@ -1163,7 +1158,7 @@ export const JDTLS: RawInfo = { .find((item) => /^org\.eclipse\.equinox\.launcher_.*\.jar$/.test(item)) ?.trim() ?? "" const launcherJar = path.join(launcherDir, jarFileName) - if (!(await pathExists(launcherJar))) { + if (!(await Filesystem.exists(launcherJar))) { log.error(`Failed to locate the JDTLS launcher module in the installed directory: ${distPath}.`) return } diff --git a/packages/opencode/test/lsp/client.test.ts b/packages/opencode/test/lsp/client.test.ts index 1bb2f1ff9a..d496b85842 100644 --- a/packages/opencode/test/lsp/client.test.ts +++ b/packages/opencode/test/lsp/client.test.ts @@ -3,8 +3,8 @@ import path from "path" import { Effect } from "effect" import { LSPClient } from "../../src/lsp" import { LSPServer } from "../../src/lsp" -import { Instance } from "../../src/project/instance" import { Log } from "../../src/util" +import { provideInstance } from "../fixture/fixture" // Minimal fake LSP server that speaks JSON-RPC over stdio function spawnFakeServer() { @@ -25,17 +25,13 @@ describe("LSPClient interop", () => { test("handles workspace/workspaceFolders request", async () => { const handle = spawnFakeServer() as any - const client = await Instance.provide({ - directory: process.cwd(), - fn: () => - Effect.runPromise( - LSPClient.create({ - serverID: "fake", - server: handle as unknown as LSPServer.Handle, - root: process.cwd(), - }), - ), - }) + const client = await Effect.runPromise( + LSPClient.create({ + serverID: "fake", + server: handle as unknown as LSPServer.Handle, + root: process.cwd(), + }).pipe(provideInstance(process.cwd())), + ) await client.connection.sendNotification("test/trigger", { method: "workspace/workspaceFolders", @@ -51,17 +47,13 @@ describe("LSPClient interop", () => { test("handles client/registerCapability request", async () => { const handle = spawnFakeServer() as any - const client = await Instance.provide({ - directory: process.cwd(), - fn: () => - Effect.runPromise( - LSPClient.create({ - serverID: "fake", - server: handle as unknown as LSPServer.Handle, - root: process.cwd(), - }), - ), - }) + const client = await Effect.runPromise( + LSPClient.create({ + serverID: "fake", + server: handle as unknown as LSPServer.Handle, + root: process.cwd(), + }).pipe(provideInstance(process.cwd())), + ) await client.connection.sendNotification("test/trigger", { method: "client/registerCapability", @@ -77,17 +69,13 @@ describe("LSPClient interop", () => { test("handles client/unregisterCapability request", async () => { const handle = spawnFakeServer() as any - const client = await Instance.provide({ - directory: process.cwd(), - fn: () => - Effect.runPromise( - LSPClient.create({ - serverID: "fake", - server: handle as unknown as LSPServer.Handle, - root: process.cwd(), - }), - ), - }) + const client = await Effect.runPromise( + LSPClient.create({ + serverID: "fake", + server: handle as unknown as LSPServer.Handle, + root: process.cwd(), + }).pipe(provideInstance(process.cwd())), + ) await client.connection.sendNotification("test/trigger", { method: "client/unregisterCapability",