refactor(lsp): simplify effect timeout flow

This commit is contained in:
Kit Langton
2026-04-17 20:51:48 -04:00
parent 370e47a5d0
commit bd5b892234
4 changed files with 93 additions and 99 deletions

View File

@@ -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<typeof setTimeout> | undefined
yield* Effect.promise(() =>
withTimeout(
new Promise<void>((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)

View File

@@ -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<State>(
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(() => {

View File

@@ -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
}

View File

@@ -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",