Selaa lähdekoodia

Apply PR #23210: refactor(lsp): effectify client and server boundaries

opencode-agent[bot] 20 tuntia sitten
vanhempi
sitoutus
3b2ce92516

+ 1 - 1
packages/opencode/src/config/lsp.ts

@@ -29,7 +29,7 @@ export const requiresExtensionsForCustomServers = Schema.makeFilter<
   boolean | Record<string, Schema.Schema.Type<typeof Entry>>
 >((data) => {
   if (typeof data === "boolean") return undefined
-  const serverIds = new Set(Object.values(LSPServer).map((server) => server.id))
+  const serverIds = new Set(Object.values(LSPServer.Builtins).map((server) => server.id))
   const ok = Object.entries(data).every(([id, config]) => {
     if ("disabled" in config && config.disabled) return true
     if (serverIds.has(id)) return true

+ 138 - 125
packages/opencode/src/lsp/client.ts

@@ -4,20 +4,32 @@ import path from "path"
 import { pathToFileURL, fileURLToPath } from "url"
 import { createMessageConnection, StreamMessageReader, StreamMessageWriter } from "vscode-jsonrpc/node"
 import type { Diagnostic as VSCodeDiagnostic } from "vscode-languageserver-types"
+import { Effect } from "effect"
 import { Log } from "../util"
 import { Process } from "../util"
 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 { Filesystem } from "../util"
 
 const DIAGNOSTICS_DEBOUNCE_MS = 150
 
 const log = Log.create({ service: "lsp.client" })
 
-export type Info = NonNullable<Awaited<ReturnType<typeof create>>>
+type Connection = ReturnType<typeof createMessageConnection>
+
+export interface Info {
+  readonly root: string
+  readonly serverID: string
+  readonly connection: Connection
+  readonly notify: {
+    readonly open: (input: { path: string }) => Effect.Effect<void>
+  }
+  readonly diagnostics: Map<string, Diagnostic[]>
+  readonly waitForDiagnostics: (input: { path: string }) => Effect.Effect<void>
+  readonly shutdown: () => Effect.Effect<void>
+}
 
 export type Diagnostic = VSCodeDiagnostic
 
@@ -38,7 +50,12 @@ export const Event = {
   ),
 }
 
-export async function create(input: { serverID: string; server: LSPServer.Handle; root: string; directory: string }) {
+export const create = Effect.fn("LSPClient.create")(function* (input: {
+  serverID: string
+  server: LSPServer.Handle
+  root: string
+  directory: string
+}) {
   const l = log.clone().tag("serverID", input.serverID)
   l.info("starting client")
 
@@ -63,10 +80,7 @@ export async function create(input: { serverID: string; server: LSPServer.Handle
     l.info("window/workDoneProgress/create", params)
     return null
   })
-  connection.onRequest("workspace/configuration", async () => {
-    // Return server initialization options
-    return [input.server.initialization ?? {}]
-  })
+  connection.onRequest("workspace/configuration", async () => [input.server.initialization ?? {}])
   connection.onRequest("client/registerCapability", async () => {})
   connection.onRequest("client/unregisterCapability", async () => {})
   connection.onRequest("workspace/workspaceFolders", async () => [
@@ -78,7 +92,7 @@ export async function create(input: { serverID: string; server: LSPServer.Handle
   connection.listen()
 
   l.info("sending initialize")
-  await withTimeout(
+  yield* Effect.tryPromise(() =>
     connection.sendRequest("initialize", {
       rootUri: pathToFileURL(input.root).href,
       processId: input.server.process.pid,
@@ -112,138 +126,137 @@ export async function create(input: { serverID: string; server: LSPServer.Handle
         },
       },
     }),
-    45_000,
-  ).catch((err) => {
-    l.error("initialize error", { error: err })
-    throw new InitializeError(
-      { serverID: input.serverID },
-      {
-        cause: err,
-      },
-    )
-  })
+  ).pipe(
+    Effect.timeout(45_000),
+    Effect.mapError((cause) => new InitializeError({ serverID: input.serverID }, { cause })),
+    Effect.tapError((error) => Effect.sync(() => l.error("initialize error", { error }))),
+  )
 
-  await connection.sendNotification("initialized", {})
+  yield* Effect.tryPromise(() => connection.sendNotification("initialized", {}))
 
   if (input.server.initialization) {
-    await connection.sendNotification("workspace/didChangeConfiguration", {
-      settings: input.server.initialization,
-    })
+    yield* Effect.tryPromise(() =>
+      connection.sendNotification("workspace/didChangeConfiguration", {
+        settings: input.server.initialization,
+      }),
+    )
   }
 
-  const files: {
-    [path: string]: number
-  } = {}
+  const files: Record<string, number> = {}
 
-  const result = {
-    root: input.root,
-    get serverID() {
-      return input.serverID
-    },
-    get connection() {
-      return connection
-    },
-    notify: {
-      async open(request: { path: string }) {
-        request.path = path.isAbsolute(request.path) ? request.path : path.resolve(input.directory, request.path)
-        const text = await Filesystem.readText(request.path)
-        const extension = path.extname(request.path)
-        const languageId = LANGUAGE_EXTENSIONS[extension] ?? "plaintext"
-
-        const version = files[request.path]
-        if (version !== undefined) {
-          log.info("workspace/didChangeWatchedFiles", request)
-          await connection.sendNotification("workspace/didChangeWatchedFiles", {
-            changes: [
-              {
-                uri: pathToFileURL(request.path).href,
-                type: 2, // Changed
-              },
-            ],
-          })
-
-          const next = version + 1
-          files[request.path] = next
-          log.info("textDocument/didChange", {
-            path: request.path,
-            version: next,
-          })
-          await connection.sendNotification("textDocument/didChange", {
-            textDocument: {
-              uri: pathToFileURL(request.path).href,
-              version: next,
-            },
-            contentChanges: [{ text }],
-          })
-          return
-        }
+  const open = Effect.fn("LSPClient.notify.open")(function* (next: { path: string }) {
+    next.path = path.isAbsolute(next.path) ? next.path : path.resolve(input.directory, next.path)
+    const text = yield* Effect.promise(() => Filesystem.readText(next.path)).pipe(Effect.orDie)
+    const extension = path.extname(next.path)
+    const languageId = LANGUAGE_EXTENSIONS[extension] ?? "plaintext"
 
-        log.info("workspace/didChangeWatchedFiles", request)
-        await connection.sendNotification("workspace/didChangeWatchedFiles", {
+    const version = files[next.path]
+    if (version !== undefined) {
+      log.info("workspace/didChangeWatchedFiles", next)
+      yield* Effect.tryPromise(() =>
+        connection.sendNotification("workspace/didChangeWatchedFiles", {
           changes: [
             {
-              uri: pathToFileURL(request.path).href,
-              type: 1, // Created
+              uri: pathToFileURL(next.path).href,
+              type: 2,
             },
           ],
-        })
+        }),
+      ).pipe(Effect.orDie)
 
-        log.info("textDocument/didOpen", request)
-        diagnostics.delete(request.path)
-        await connection.sendNotification("textDocument/didOpen", {
+      const nextVersion = version + 1
+      files[next.path] = nextVersion
+      log.info("textDocument/didChange", {
+        path: next.path,
+        version: nextVersion,
+      })
+      yield* Effect.tryPromise(() =>
+        connection.sendNotification("textDocument/didChange", {
           textDocument: {
-            uri: pathToFileURL(request.path).href,
-            languageId,
-            version: 0,
-            text,
+            uri: pathToFileURL(next.path).href,
+            version: nextVersion,
           },
-        })
-        files[request.path] = 0
-        return
-      },
-    },
-    get diagnostics() {
-      return diagnostics
-    },
-    async waitForDiagnostics(request: { path: string }) {
-      const normalizedPath = Filesystem.normalizePath(
-        path.isAbsolute(request.path) ? request.path : path.resolve(input.directory, request.path),
-      )
-      log.info("waiting for diagnostics", { path: normalizedPath })
-      let unsub: () => void
-      let debounceTimer: ReturnType<typeof setTimeout> | undefined
-      return await withTimeout(
-        new Promise<void>((resolve) => {
-          unsub = Bus.subscribe(Event.Diagnostics, (event) => {
-            if (event.properties.path === normalizedPath && event.properties.serverID === result.serverID) {
-              // Debounce to allow LSP to send follow-up diagnostics (e.g., semantic after syntax)
-              if (debounceTimer) clearTimeout(debounceTimer)
-              debounceTimer = setTimeout(() => {
-                log.info("got diagnostics", { path: normalizedPath })
-                unsub?.()
-                resolve()
-              }, DIAGNOSTICS_DEBOUNCE_MS)
-            }
-          })
+          contentChanges: [{ text }],
         }),
-        3000,
-      )
-        .catch(() => {})
-        .finally(() => {
-          if (debounceTimer) clearTimeout(debounceTimer)
-          unsub?.()
-        })
-    },
-    async shutdown() {
-      l.info("shutting down")
-      connection.end()
-      connection.dispose()
-      await Process.stop(input.server.process)
-      l.info("shutdown")
-    },
-  }
+      ).pipe(Effect.orDie)
+      return
+    }
+
+    log.info("workspace/didChangeWatchedFiles", next)
+    yield* Effect.tryPromise(() =>
+      connection.sendNotification("workspace/didChangeWatchedFiles", {
+        changes: [
+          {
+            uri: pathToFileURL(next.path).href,
+            type: 1,
+          },
+        ],
+      }),
+    ).pipe(Effect.orDie)
+
+    log.info("textDocument/didOpen", next)
+    diagnostics.delete(next.path)
+    yield* Effect.tryPromise(() =>
+      connection.sendNotification("textDocument/didOpen", {
+        textDocument: {
+          uri: pathToFileURL(next.path).href,
+          languageId,
+          version: 0,
+          text,
+        },
+      }),
+    ).pipe(Effect.orDie)
+    files[next.path] = 0
+  })
+
+  const waitForDiagnostics = Effect.fn("LSPClient.waitForDiagnostics")(function* (next: { path: string }) {
+    const normalizedPath = Filesystem.normalizePath(
+      path.isAbsolute(next.path) ? next.path : path.resolve(input.directory, next.path),
+    )
+    log.info("waiting for diagnostics", { path: normalizedPath })
+    yield* Effect.callback<void>((resume) => {
+      let debounceTimer: ReturnType<typeof setTimeout> | undefined
+      const unsub = Bus.subscribe(Event.Diagnostics, (event) => {
+        if (event.properties.path !== normalizedPath || event.properties.serverID !== input.serverID) return
+        if (debounceTimer) clearTimeout(debounceTimer)
+        debounceTimer = setTimeout(() => {
+          log.info("got diagnostics", { path: normalizedPath })
+          resume(Effect.void)
+        }, DIAGNOSTICS_DEBOUNCE_MS)
+      })
+
+      return Effect.sync(() => {
+        if (debounceTimer) clearTimeout(debounceTimer)
+        unsub()
+      })
+    }).pipe(Effect.timeoutOption(3000), Effect.asVoid)
+  })
+
+  const shutdown = Effect.fn("LSPClient.shutdown")(function* () {
+    l.info("shutting down")
+    connection.end()
+    connection.dispose()
+    yield* Effect.promise(() => Process.stop(input.server.process)).pipe(Effect.orDie)
+    l.info("shutdown")
+  })
 
   l.info("initialized")
 
-  return result
-}
+  return {
+    root: input.root,
+    get serverID() {
+      return input.serverID
+    },
+    get connection() {
+      return connection
+    },
+    notify: {
+      open,
+    },
+    get diagnostics() {
+      return diagnostics
+    },
+    waitForDiagnostics,
+    shutdown,
+  } satisfies Info
+})

+ 166 - 148
packages/opencode/src/lsp/lsp.ts

@@ -10,9 +10,10 @@ import { Config } from "../config"
 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"
 import { AppFileSystem } from "@opencode-ai/shared/filesystem"
+import type { InstanceContext } from "@/project/instance"
 
 const log = Log.create({ service: "lsp" })
 
@@ -134,7 +135,7 @@ interface State {
   clients: LSPClient.Info[]
   servers: Record<string, LSPServer.Info>
   broken: Set<string>
-  spawning: Map<string, Promise<LSPClient.Info | undefined>>
+  spawning: Map<string, Effect.Effect<LSPClient.Info | undefined>>
 }
 
 export interface Interface {
@@ -160,6 +161,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* (ctx) {
@@ -170,7 +172,7 @@ export const layer = Layer.effect(
         if (!cfg.lsp) {
           log.info("all LSPs are disabled")
         } else {
-          for (const server of Object.values(LSPServer)) {
+          for (const server of Object.values(LSPServer.Builtins)) {
             servers[server.id] = server
           }
 
@@ -189,13 +191,14 @@ export const layer = Layer.effect(
                 id: name,
                 root: existing?.root ?? (async (_file, ctx) => ctx.directory),
                 extensions: item.extensions ?? existing?.extensions ?? [],
-                spawn: async (root) => ({
-                  process: lspspawn(item.command[0], item.command.slice(1), {
-                    cwd: root,
-                    env: { ...process.env, ...item.env },
-                  }),
-                  initialization: item.initialization,
-                }),
+                spawn: (root) =>
+                  Effect.sync(() => ({
+                    process: lspspawn(item.command[0], item.command.slice(1), {
+                      cwd: root,
+                      env: { ...process.env, ...item.env },
+                    }),
+                    initialization: item.initialization,
+                  })),
               }
             }
           }
@@ -215,114 +218,137 @@ export const layer = Layer.effect(
         }
 
         yield* Effect.addFinalizer(() =>
-          Effect.promise(async () => {
-            await Promise.all(s.clients.map((client) => client.shutdown()))
-          }),
+          Effect.forEach(s.clients, (client) => client.shutdown(), { concurrency: "unbounded", discard: true }),
         )
 
         return s
       }),
     )
 
-    const getClients = Effect.fnUntraced(function* (file: string) {
-      const ctx = yield* InstanceState.context
-      if (!AppFileSystem.contains(ctx.directory, file) && (ctx.worktree === "/" || !AppFileSystem.contains(ctx.worktree, file))) {
-        return [] as LSPClient.Info[]
+    const request = Effect.fnUntraced(function* <A>(
+      client: LSPClient.Info,
+      method: string,
+      params: unknown,
+      fallback: A,
+    ) {
+      return yield* (Effect.tryPromise(() => client.connection.sendRequest<A>(method, params)).pipe(
+        Effect.catch(() => Effect.succeed(fallback)),
+      ))
+    })
+
+    const scheduleClient = Effect.fnUntraced(function* (
+      s: State,
+      ctx: InstanceContext,
+      server: LSPServer.Info,
+      root: string,
+      key: string,
+    ) {
+      const handle = yield* (server.spawn(root, ctx).pipe(
+        Effect.catch((error: unknown) =>
+          Effect.sync(() => {
+            s.broken.add(key)
+            log.error(`Failed to spawn LSP server ${server.id}`, { error })
+          }).pipe(Effect.as(undefined)),
+        ),
+      ))
+      if (!handle) {
+        s.broken.add(key)
+        return undefined
       }
-      const s = yield* InstanceState.get(state)
-      return yield* Effect.promise(async () => {
-        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, ctx)
-            .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, root })
-
-          const client = await LSPClient.create({
-            serverID: server.id,
-            server: handle,
-            root,
-            directory: ctx.directory,
-          }).catch(async (err) => {
+
+      log.info("spawned lsp server", { serverID: server.id, root })
+
+      const client = yield* LSPClient.create({
+        serverID: server.id,
+        server: handle,
+        root,
+        directory: ctx.directory,
+      }).pipe(
+        Effect.catch((error: unknown) =>
+          Effect.gen(function* () {
             s.broken.add(key)
-            await Process.stop(handle.process)
-            log.error(`Failed to initialize LSP client ${server.id}`, { error: err })
+            yield* (Effect.promise(() => Process.stop(handle.process)).pipe(Effect.catch(() => Effect.void)))
+            log.error(`Failed to initialize LSP client ${server.id}`, { error })
             return undefined
-          })
-
-          if (!client) 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
-          }
+      const existing = s.clients.find((x) => x.root === root && x.serverID === server.id)
+      if (existing) {
+        yield* (Effect.promise(() => Process.stop(handle.process)).pipe(Effect.catch(() => Effect.void)))
+        return existing
+      }
 
-          s.clients.push(client)
-          return client
-        }
+      s.clients.push(client)
+      return client
+    })
 
-        for (const server of Object.values(s.servers)) {
-          if (server.extensions.length && !server.extensions.includes(extension)) continue
+    const awaitSpawn = Effect.fnUntraced(function* (
+      s: State,
+      ctx: InstanceContext,
+      server: LSPServer.Info,
+      root: string,
+      key: string,
+    ) {
+      const inflight = s.spawning.get(key)
+      if (inflight) return yield* inflight
+
+      const task = yield* Effect.cached(scheduleClient(s, ctx, server, root, key))
+      s.spawning.set(key, task)
+      return yield* task.pipe(
+        Effect.ensuring(
+          Effect.sync(() => {
+            if (s.spawning.get(key) === task) s.spawning.delete(key)
+          }),
+        ),
+      )
+    })
 
-          const root = await server.root(file, ctx)
-          if (!root) continue
-          if (s.broken.has(root + server.id)) continue
+    const getClients = Effect.fnUntraced(function* (file: string) {
+      const ctx = yield* InstanceState.context
+      if (!AppFileSystem.contains(ctx.directory, file) && (ctx.worktree === "/" || !AppFileSystem.contains(ctx.worktree, file))) {
+        return [] as LSPClient.Info[]
+      }
+      const s = yield* InstanceState.get(state)
+      const extension = path.parse(file).ext || file
+      const result: LSPClient.Info[] = []
 
-          const match = s.clients.find((x) => x.root === root && x.serverID === server.id)
-          if (match) {
-            result.push(match)
-            continue
-          }
+      for (const server of Object.values(s.servers)) {
+        if (server.extensions.length && !server.extensions.includes(extension)) continue
 
-          const inflight = s.spawning.get(root + server.id)
-          if (inflight) {
-            const client = await inflight
-            if (!client) continue
-            result.push(client)
-            continue
-          }
+        const root = yield* server.root(file, ctx)
+        if (!root) continue
 
-          const task = schedule(server, root, root + server.id)
-          s.spawning.set(root + server.id, task)
+        const key = root + server.id
+        if (s.broken.has(key)) continue
 
-          task.finally(() => {
-            if (s.spawning.get(root + server.id) === task) {
-              s.spawning.delete(root + server.id)
-            }
-          })
+        const match = s.clients.find((x) => x.root === root && x.serverID === server.id)
+        if (match) {
+          result.push(match)
+          continue
+        }
 
-          const client = await task
-          if (!client) continue
+        const hadInflight = s.spawning.has(key)
+        const client = yield* awaitSpawn(s, ctx, server, root, key)
+        if (!client) continue
 
-          result.push(client)
-          Bus.publish(Event.Updated, {})
-        }
+        result.push(client)
+        if (!hadInflight) Bus.publish(Event.Updated, {})
+      }
 
-        return result
-      })
+      return result
     })
 
-    const run = Effect.fnUntraced(function* <T>(file: string, fn: (client: LSPClient.Info) => Promise<T>) {
+    const run = Effect.fnUntraced(function* <T>(file: string, fn: (client: LSPClient.Info) => Effect.Effect<T>) {
       const clients = yield* getClients(file)
-      return yield* Effect.promise(() => Promise.all(clients.map((x) => fn(x))))
+      return yield* Effect.forEach(clients, fn, { concurrency: "unbounded" })
     })
 
-    const runAll = Effect.fnUntraced(function* <T>(fn: (client: LSPClient.Info) => Promise<T>) {
+    const runAll = Effect.fnUntraced(function* <T>(fn: (client: LSPClient.Info) => Effect.Effect<T>) {
       const s = yield* InstanceState.get(state)
-      return yield* Effect.promise(() => Promise.all(s.clients.map((x) => fn(x))))
+      return yield* Effect.forEach(s.clients, fn, { concurrency: "unbounded" })
     })
 
     const init = Effect.fn("LSP.init")(function* () {
@@ -347,38 +373,43 @@ export const layer = Layer.effect(
     const hasClients = Effect.fn("LSP.hasClients")(function* (file: string) {
       const ctx = yield* InstanceState.context
       const s = yield* InstanceState.get(state)
-      return yield* Effect.promise(async () => {
-        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, ctx)
-          if (!root) continue
-          if (s.broken.has(root + server.id)) continue
-          return true
-        }
-        return false
-      })
+      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 = yield* server.root(file, ctx)
+        if (!root) continue
+        if (s.broken.has(root + server.id)) continue
+        return true
+      }
+      return false
     })
 
     const touchFile = Effect.fn("LSP.touchFile")(function* (input: string, waitForDiagnostics?: boolean) {
       log.info("touching file", { file: input })
       const clients = yield* getClients(input)
-      yield* Effect.promise(() =>
-        Promise.all(
-          clients.map(async (client) => {
-            const wait = waitForDiagnostics ? client.waitForDiagnostics({ path: input }) : Promise.resolve()
-            await 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)
           }),
-        ).catch((err) => {
-          log.error("failed to touch file", { err, file: input })
-        }),
+        { concurrency: "unbounded", discard: true },
+      ).pipe(
+        Effect.catch((err: unknown) =>
+          Effect.sync(() => {
+            log.error("failed to touch file", { err, file: input })
+          }),
+        ),
       )
     })
 
     const diagnostics = Effect.fn("LSP.diagnostics")(function* () {
       const results: Record<string, LSPClient.Diagnostic[]> = {}
-      const all = yield* runAll(async (client) => client.diagnostics)
+      const all = yield* runAll((client) => Effect.succeed(client.diagnostics))
       for (const result of all) {
         for (const [p, diags] of result.entries()) {
           const arr = results[p] || []
@@ -391,78 +422,65 @@ export const layer = Layer.effect(
 
     const hover = Effect.fn("LSP.hover")(function* (input: LocInput) {
       return yield* run(input.file, (client) =>
-        client.connection
-          .sendRequest("textDocument/hover", {
+        request(client, "textDocument/hover", {
             textDocument: { uri: pathToFileURL(input.file).href },
             position: { line: input.line, character: input.character },
-          })
-          .catch(() => null),
+          }, null),
       )
     })
 
     const definition = Effect.fn("LSP.definition")(function* (input: LocInput) {
       const results = yield* run(input.file, (client) =>
-        client.connection
-          .sendRequest("textDocument/definition", {
+        request(client, "textDocument/definition", {
             textDocument: { uri: pathToFileURL(input.file).href },
             position: { line: input.line, character: input.character },
-          })
-          .catch(() => null),
+          }, null),
       )
       return results.flat().filter(Boolean)
     })
 
     const references = Effect.fn("LSP.references")(function* (input: LocInput) {
       const results = yield* run(input.file, (client) =>
-        client.connection
-          .sendRequest("textDocument/references", {
+        request(client, "textDocument/references", {
             textDocument: { uri: pathToFileURL(input.file).href },
             position: { line: input.line, character: input.character },
             context: { includeDeclaration: true },
-          })
-          .catch(() => []),
+          }, [] as any[]),
       )
       return results.flat().filter(Boolean)
     })
 
     const implementation = Effect.fn("LSP.implementation")(function* (input: LocInput) {
       const results = yield* run(input.file, (client) =>
-        client.connection
-          .sendRequest("textDocument/implementation", {
+        request(client, "textDocument/implementation", {
             textDocument: { uri: pathToFileURL(input.file).href },
             position: { line: input.line, character: input.character },
-          })
-          .catch(() => null),
+          }, null),
       )
       return results.flat().filter(Boolean)
     })
 
     const documentSymbol = Effect.fn("LSP.documentSymbol")(function* (uri: string) {
       const file = fileURLToPath(uri)
-      const results = yield* run(file, (client) =>
-        client.connection.sendRequest("textDocument/documentSymbol", { textDocument: { uri } }).catch(() => []),
-      )
+      const results = yield* run(file, (client) => request(client, "textDocument/documentSymbol", { textDocument: { uri } }, [] as any[]))
       return (results.flat() as (DocumentSymbol | Symbol)[]).filter(Boolean)
     })
 
     const workspaceSymbol = Effect.fn("LSP.workspaceSymbol")(function* (query: string) {
       const results = yield* runAll((client) =>
-        client.connection
-          .sendRequest<Symbol[]>("workspace/symbol", { query })
-          .then((result) => result.filter((x) => kinds.includes(x.kind)).slice(0, 10))
-          .catch(() => [] as Symbol[]),
+        request(client, "workspace/symbol", { query }, [] as Symbol[]).pipe(
+          Effect.map((result) => result.filter((x) => kinds.includes(x.kind)).slice(0, 10)),
+        ),
       )
       return results.flat()
     })
 
     const prepareCallHierarchy = Effect.fn("LSP.prepareCallHierarchy")(function* (input: LocInput) {
       const results = yield* run(input.file, (client) =>
-        client.connection
-          .sendRequest("textDocument/prepareCallHierarchy", {
+        request(client, "textDocument/prepareCallHierarchy", {
             textDocument: { uri: pathToFileURL(input.file).href },
             position: { line: input.line, character: input.character },
-          })
-          .catch(() => []),
+          }, [] as any[]),
       )
       return results.flat().filter(Boolean)
     })
@@ -471,16 +489,16 @@ export const layer = Layer.effect(
       input: LocInput,
       direction: "callHierarchy/incomingCalls" | "callHierarchy/outgoingCalls",
     ) {
-      const results = yield* run(input.file, async (client) => {
-        const items = await client.connection
-          .sendRequest<unknown[] | null>("textDocument/prepareCallHierarchy", {
+      const results = yield* run(input.file, (client) =>
+        Effect.gen(function* () {
+          const items = yield* request(client, "textDocument/prepareCallHierarchy", {
             textDocument: { uri: pathToFileURL(input.file).href },
             position: { line: input.line, character: input.character },
-          })
-          .catch(() => [] as unknown[])
-        if (!items?.length) return []
-        return client.connection.sendRequest(direction, { item: items[0] }).catch(() => [])
-      })
+          }, [] as unknown[])
+          if (!items.length) return []
+          return yield* request(client, direction, { item: items[0] }, [] as unknown[])
+        }),
+      )
       return results.flat().filter(Boolean)
     })
 

+ 105 - 47
packages/opencode/src/lsp/server.ts

@@ -14,13 +14,9 @@ import { which } from "../util/which"
 import { Module } from "@opencode-ai/shared/util/module"
 import { spawn } from "./launch"
 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 })
 
@@ -29,9 +25,10 @@ export interface Handle {
   initialization?: Record<string, any>
 }
 
-type RootFunction = (file: string, ctx: InstanceContext) => Promise<string | undefined>
+type RawRootFunction = (file: string, ctx: InstanceContext) => Promise<string | undefined>
+type RootFunction = (file: string, ctx: InstanceContext) => Effect.Effect<string | undefined>
 
-const NearestRoot = (includePatterns: string[], excludePatterns?: string[]): RootFunction => {
+const NearestRoot = (includePatterns: string[], excludePatterns?: string[]): RawRootFunction => {
   return async (file, ctx) => {
     if (excludePatterns) {
       const excludedFiles = Filesystem.up({
@@ -55,15 +52,36 @@ const NearestRoot = (includePatterns: string[], excludePatterns?: string[]): Roo
   }
 }
 
+export interface RawInfo {
+  id: string
+  extensions: string[]
+  global?: boolean
+  root: RawRootFunction
+  spawn(root: string, ctx: InstanceContext): Promise<Handle | undefined>
+}
+
 export interface Info {
   id: string
   extensions: string[]
   global?: boolean
   root: RootFunction
-  spawn(root: string, ctx: InstanceContext): Promise<Handle | undefined>
+  spawn(root: string, ctx: InstanceContext): Effect.Effect<Handle | undefined>
 }
 
-export const Deno: Info = {
+const effectify = (info: RawInfo): Info => ({
+  ...info,
+  root: (file, ctx) => Effect.promise(() => info.root(file, ctx)),
+  spawn: (root, ctx) => Effect.promise(() => info.spawn(root, ctx)),
+})
+
+const effectifyAll = <T extends Record<string, RawInfo>>(infos: T): { [K in keyof T]: Info } =>
+  Object.fromEntries(Object.entries(infos).map(([key, value]) => [key, effectify(value)])) as { [K in keyof T]: Info }
+
+// Temporary migration bridge: `Builtins` exposes Effect-shaped `root` / `spawn`
+// while the per-server definitions still use their older Promise bodies.
+// Follow-up: convert the individual server definitions in place and delete this wrapper.
+
+export const Deno: RawInfo = {
   id: "deno",
   root: async (file, ctx) => {
     const files = Filesystem.up({
@@ -91,7 +109,7 @@ export const Deno: Info = {
   },
 }
 
-export const Typescript: Info = {
+export const Typescript: RawInfo = {
   id: "typescript",
   root: NearestRoot(
     ["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"],
@@ -121,7 +139,7 @@ export const Typescript: Info = {
   },
 }
 
-export const Vue: Info = {
+export const Vue: RawInfo = {
   id: "vue",
   extensions: [".vue"],
   root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]),
@@ -150,7 +168,7 @@ export const Vue: Info = {
   },
 }
 
-export const ESLint: Info = {
+export const ESLint: RawInfo = {
   id: "eslint",
   root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]),
   extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue"],
@@ -207,7 +225,7 @@ export const ESLint: Info = {
   },
 }
 
-export const Oxlint: Info = {
+export const Oxlint: RawInfo = {
   id: "oxlint",
   root: NearestRoot([
     ".oxlintrc.json",
@@ -280,7 +298,7 @@ export const Oxlint: Info = {
   },
 }
 
-export const Biome: Info = {
+export const Biome: RawInfo = {
   id: "biome",
   root: NearestRoot([
     "biome.json",
@@ -342,7 +360,7 @@ export const Biome: Info = {
   },
 }
 
-export const Gopls: Info = {
+export const Gopls: RawInfo = {
   id: "gopls",
   root: async (file, ctx) => {
     const work = await NearestRoot(["go.work"])(file, ctx)
@@ -381,7 +399,7 @@ export const Gopls: Info = {
   },
 }
 
-export const Rubocop: Info = {
+export const Rubocop: RawInfo = {
   id: "ruby-lsp",
   root: NearestRoot(["Gemfile"]),
   extensions: [".rb", ".rake", ".gemspec", ".ru"],
@@ -419,7 +437,7 @@ export const Rubocop: Info = {
   },
 }
 
-export const Ty: Info = {
+export const Ty: RawInfo = {
   id: "ty",
   extensions: [".py", ".pyi"],
   root: NearestRoot([
@@ -481,7 +499,7 @@ export const Ty: Info = {
   },
 }
 
-export const Pyright: Info = {
+export const Pyright: RawInfo = {
   id: "pyright",
   extensions: [".py", ".pyi"],
   root: NearestRoot(["pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "Pipfile", "pyrightconfig.json"]),
@@ -525,7 +543,7 @@ export const Pyright: Info = {
   },
 }
 
-export const ElixirLS: Info = {
+export const ElixirLS: RawInfo = {
   id: "elixir-ls",
   extensions: [".ex", ".exs"],
   root: NearestRoot(["mix.exs", "mix.lock"]),
@@ -588,7 +606,7 @@ export const ElixirLS: Info = {
   },
 }
 
-export const Zls: Info = {
+export const Zls: RawInfo = {
   id: "zls",
   extensions: [".zig", ".zon"],
   root: NearestRoot(["build.zig"]),
@@ -700,7 +718,7 @@ export const Zls: Info = {
   },
 }
 
-export const CSharp: Info = {
+export const CSharp: RawInfo = {
   id: "csharp",
   root: NearestRoot([".slnx", ".sln", ".csproj", "global.json"]),
   extensions: [".cs"],
@@ -737,7 +755,7 @@ export const CSharp: Info = {
   },
 }
 
-export const FSharp: Info = {
+export const FSharp: RawInfo = {
   id: "fsharp",
   root: NearestRoot([".slnx", ".sln", ".fsproj", "global.json"]),
   extensions: [".fs", ".fsi", ".fsx", ".fsscript"],
@@ -774,7 +792,7 @@ export const FSharp: Info = {
   },
 }
 
-export const SourceKit: Info = {
+export const SourceKit: RawInfo = {
   id: "sourcekit-lsp",
   extensions: [".swift", ".objc", "objcpp"],
   root: NearestRoot(["Package.swift", "*.xcodeproj", "*.xcworkspace"]),
@@ -808,7 +826,7 @@ export const SourceKit: Info = {
   },
 }
 
-export const RustAnalyzer: Info = {
+export const RustAnalyzer: RawInfo = {
   id: "rust",
   root: async (file, ctx) => {
     const crateRoot = await NearestRoot(["Cargo.toml", "Cargo.lock"])(file, ctx)
@@ -854,7 +872,7 @@ export const RustAnalyzer: Info = {
   },
 }
 
-export const Clangd: Info = {
+export const Clangd: RawInfo = {
   id: "clangd",
   root: NearestRoot(["compile_commands.json", "compile_flags.txt", ".clangd"]),
   extensions: [".c", ".cpp", ".cc", ".cxx", ".c++", ".h", ".hpp", ".hh", ".hxx", ".h++"],
@@ -1000,7 +1018,7 @@ export const Clangd: Info = {
   },
 }
 
-export const Svelte: Info = {
+export const Svelte: RawInfo = {
   id: "svelte",
   extensions: [".svelte"],
   root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]),
@@ -1027,7 +1045,7 @@ export const Svelte: Info = {
   },
 }
 
-export const Astro: Info = {
+export const Astro: RawInfo = {
   id: "astro",
   extensions: [".astro"],
   root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]),
@@ -1065,7 +1083,7 @@ export const Astro: Info = {
   },
 }
 
-export const JDTLS: Info = {
+export const JDTLS: RawInfo = {
   id: "jdtls",
   root: async (file, ctx) => {
     // Without exclusions, NearestRoot defaults to instance directory so we can't
@@ -1108,7 +1126,7 @@ export const JDTLS: Info = {
     }
     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.")
@@ -1140,7 +1158,7 @@ export const JDTLS: Info = {
         .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
     }
@@ -1186,7 +1204,7 @@ export const JDTLS: Info = {
   },
 }
 
-export const KotlinLS: Info = {
+export const KotlinLS: RawInfo = {
   id: "kotlin-ls",
   extensions: [".kt", ".kts"],
   root: async (file, ctx) => {
@@ -1285,7 +1303,7 @@ export const KotlinLS: Info = {
   },
 }
 
-export const YamlLS: Info = {
+export const YamlLS: RawInfo = {
   id: "yaml-ls",
   extensions: [".yaml", ".yml"],
   root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]),
@@ -1311,7 +1329,7 @@ export const YamlLS: Info = {
   },
 }
 
-export const LuaLS: Info = {
+export const LuaLS: RawInfo = {
   id: "lua-ls",
   root: NearestRoot([
     ".luarc.json",
@@ -1452,7 +1470,7 @@ export const LuaLS: Info = {
   },
 }
 
-export const PHPIntelephense: Info = {
+export const PHPIntelephense: RawInfo = {
   id: "php intelephense",
   extensions: [".php"],
   root: NearestRoot(["composer.json", "composer.lock", ".php-version"]),
@@ -1483,7 +1501,7 @@ export const PHPIntelephense: Info = {
   },
 }
 
-export const Prisma: Info = {
+export const Prisma: RawInfo = {
   id: "prisma",
   extensions: [".prisma"],
   root: NearestRoot(["schema.prisma", "prisma/schema.prisma", "prisma"], ["package.json"]),
@@ -1501,7 +1519,7 @@ export const Prisma: Info = {
   },
 }
 
-export const Dart: Info = {
+export const Dart: RawInfo = {
   id: "dart",
   extensions: [".dart"],
   root: NearestRoot(["pubspec.yaml", "analysis_options.yaml"]),
@@ -1519,7 +1537,7 @@ export const Dart: Info = {
   },
 }
 
-export const Ocaml: Info = {
+export const Ocaml: RawInfo = {
   id: "ocaml-lsp",
   extensions: [".ml", ".mli"],
   root: NearestRoot(["dune-project", "dune-workspace", ".merlin", "opam"]),
@@ -1536,7 +1554,7 @@ export const Ocaml: Info = {
     }
   },
 }
-export const BashLS: Info = {
+export const BashLS: RawInfo = {
   id: "bash",
   extensions: [".sh", ".bash", ".zsh", ".ksh"],
   root: async (_file, ctx) => ctx.directory,
@@ -1562,7 +1580,7 @@ export const BashLS: Info = {
   },
 }
 
-export const TerraformLS: Info = {
+export const TerraformLS: RawInfo = {
   id: "terraform",
   extensions: [".tf", ".tfvars"],
   root: NearestRoot([".terraform.lock.hcl", "terraform.tfstate", "*.tf"]),
@@ -1643,7 +1661,7 @@ export const TerraformLS: Info = {
   },
 }
 
-export const TexLab: Info = {
+export const TexLab: RawInfo = {
   id: "texlab",
   extensions: [".tex", ".bib"],
   root: NearestRoot([".latexmkrc", "latexmkrc", ".texlabroot", "texlabroot"]),
@@ -1731,7 +1749,7 @@ export const TexLab: Info = {
   },
 }
 
-export const DockerfileLS: Info = {
+export const DockerfileLS: RawInfo = {
   id: "dockerfile",
   extensions: [".dockerfile", "Dockerfile"],
   root: async (_file, ctx) => ctx.directory,
@@ -1757,7 +1775,7 @@ export const DockerfileLS: Info = {
   },
 }
 
-export const Gleam: Info = {
+export const Gleam: RawInfo = {
   id: "gleam",
   extensions: [".gleam"],
   root: NearestRoot(["gleam.toml"]),
@@ -1775,7 +1793,7 @@ export const Gleam: Info = {
   },
 }
 
-export const Clojure: Info = {
+export const Clojure: RawInfo = {
   id: "clojure-lsp",
   extensions: [".clj", ".cljs", ".cljc", ".edn"],
   root: NearestRoot(["deps.edn", "project.clj", "shadow-cljs.edn", "bb.edn", "build.boot"]),
@@ -1796,7 +1814,7 @@ export const Clojure: Info = {
   },
 }
 
-export const Nixd: Info = {
+export const Nixd: RawInfo = {
   id: "nixd",
   extensions: [".nix"],
   root: async (file, ctx) => {
@@ -1827,7 +1845,7 @@ export const Nixd: Info = {
   },
 }
 
-export const Tinymist: Info = {
+export const Tinymist: RawInfo = {
   id: "tinymist",
   extensions: [".typ", ".typc"],
   root: NearestRoot(["typst.toml"]),
@@ -1919,7 +1937,7 @@ export const Tinymist: Info = {
   },
 }
 
-export const HLS: Info = {
+export const HLS: RawInfo = {
   id: "haskell-language-server",
   extensions: [".hs", ".lhs"],
   root: NearestRoot(["stack.yaml", "cabal.project", "hie.yaml", "*.cabal"]),
@@ -1937,7 +1955,7 @@ export const HLS: Info = {
   },
 }
 
-export const JuliaLS: Info = {
+export const JuliaLS: RawInfo = {
   id: "julials",
   extensions: [".jl"],
   root: NearestRoot(["Project.toml", "Manifest.toml", "*.jl"]),
@@ -1954,3 +1972,43 @@ export const JuliaLS: Info = {
     }
   },
 }
+
+export const Builtins = effectifyAll({
+  Deno,
+  Typescript,
+  Vue,
+  ESLint,
+  Oxlint,
+  Biome,
+  Gopls,
+  Rubocop,
+  Ty,
+  Pyright,
+  ElixirLS,
+  Zls,
+  CSharp,
+  FSharp,
+  SourceKit,
+  RustAnalyzer,
+  Clangd,
+  Svelte,
+  Astro,
+  JDTLS,
+  KotlinLS,
+  YamlLS,
+  LuaLS,
+  PHPIntelephense,
+  Prisma,
+  Dart,
+  Ocaml,
+  BashLS,
+  TerraformLS,
+  TexLab,
+  DockerfileLS,
+  Gleam,
+  Clojure,
+  Nixd,
+  Tinymist,
+  HLS,
+  JuliaLS,
+})

+ 50 - 40
packages/opencode/test/lsp/client.test.ts

@@ -1,9 +1,11 @@
 import { describe, expect, test, beforeEach } from "bun:test"
 import path from "path"
+import { Effect } from "effect"
+import { Bus } from "../../src/bus"
 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() {
@@ -16,24 +18,28 @@ function spawnFakeServer() {
   }
 }
 
+async function createClient() {
+  const handle = spawnFakeServer() as any
+  const cwd = process.cwd()
+  const client = await Effect.runPromise(
+    LSPClient.create({
+      serverID: "fake",
+      server: handle as unknown as LSPServer.Handle,
+      root: cwd,
+      directory: cwd,
+    }).pipe(provideInstance(cwd)),
+  )
+
+  return { client, cwd }
+}
+
 describe("LSPClient interop", () => {
   beforeEach(async () => {
     await Log.init({ print: true })
   })
 
   test("handles workspace/workspaceFolders request", async () => {
-    const handle = spawnFakeServer() as any
-
-    const client = await Instance.provide({
-      directory: process.cwd(),
-      fn: () =>
-        LSPClient.create({
-          serverID: "fake",
-          server: handle as unknown as LSPServer.Handle,
-          root: process.cwd(),
-          directory: process.cwd(),
-        }),
-    })
+    const { client } = await createClient()
 
     await client.connection.sendNotification("test/trigger", {
       method: "workspace/workspaceFolders",
@@ -43,22 +49,11 @@ describe("LSPClient interop", () => {
 
     expect(client.connection).toBeDefined()
 
-    await client.shutdown()
+    await Effect.runPromise(client.shutdown())
   })
 
   test("handles client/registerCapability request", async () => {
-    const handle = spawnFakeServer() as any
-
-    const client = await Instance.provide({
-      directory: process.cwd(),
-      fn: () =>
-        LSPClient.create({
-          serverID: "fake",
-          server: handle as unknown as LSPServer.Handle,
-          root: process.cwd(),
-          directory: process.cwd(),
-        }),
-    })
+    const { client } = await createClient()
 
     await client.connection.sendNotification("test/trigger", {
       method: "client/registerCapability",
@@ -68,22 +63,11 @@ describe("LSPClient interop", () => {
 
     expect(client.connection).toBeDefined()
 
-    await client.shutdown()
+    await Effect.runPromise(client.shutdown())
   })
 
   test("handles client/unregisterCapability request", async () => {
-    const handle = spawnFakeServer() as any
-
-    const client = await Instance.provide({
-      directory: process.cwd(),
-      fn: () =>
-        LSPClient.create({
-          serverID: "fake",
-          server: handle as unknown as LSPServer.Handle,
-          root: process.cwd(),
-          directory: process.cwd(),
-        }),
-    })
+    const { client } = await createClient()
 
     await client.connection.sendNotification("test/trigger", {
       method: "client/unregisterCapability",
@@ -93,6 +77,32 @@ describe("LSPClient interop", () => {
 
     expect(client.connection).toBeDefined()
 
-    await client.shutdown()
+    await Effect.runPromise(client.shutdown())
+  })
+
+  test("waitForDiagnostics() resolves when a matching diagnostic event is published", async () => {
+    const { client, cwd } = await createClient()
+    const file = path.join(cwd, "fixture.ts")
+
+    const waiting = Effect.runPromise(client.waitForDiagnostics({ path: file }).pipe(provideInstance(cwd)))
+
+    await Effect.runPromise(Effect.sleep(20))
+    await Effect.runPromise(Effect.promise(() => Bus.publish(LSPClient.Event.Diagnostics, { path: file, serverID: "fake" })).pipe(provideInstance(cwd)))
+    await waiting
+
+    await Effect.runPromise(client.shutdown())
+  })
+
+  test("waitForDiagnostics() times out without throwing when no event arrives", async () => {
+    const { client, cwd } = await createClient()
+    const started = Date.now()
+
+    await Effect.runPromise(client.waitForDiagnostics({ path: path.join(cwd, "never.ts") }).pipe(provideInstance(cwd)))
+
+    const elapsed = Date.now() - started
+    expect(elapsed).toBeGreaterThanOrEqual(2900)
+    expect(elapsed).toBeLessThan(5000)
+
+    await Effect.runPromise(client.shutdown())
   })
 })

+ 30 - 1
packages/opencode/test/lsp/lifecycle.test.ts

@@ -1,6 +1,6 @@
 import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"
 import path from "path"
-import { Effect, Layer } from "effect"
+import { Effect, Fiber, Layer, Scope } from "effect"
 import { LSP } from "../../src/lsp"
 import { LSPServer } from "../../src/lsp"
 import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
@@ -153,6 +153,35 @@ describe("LSP service lifecycle", () => {
       ),
     ),
   )
+
+  it.live("touchFile() dedupes concurrent spawn attempts for the same file", () =>
+    provideTmpdirInstance(
+      (dir) =>
+        LSP.Service.use((lsp) =>
+          Effect.gen(function* () {
+            const gate = Promise.withResolvers<void>()
+            const scope = yield* Scope.Scope
+            const file = path.join(dir, "src", "inside.ts")
+
+            spawnSpy.mockImplementation(async () => {
+              await gate.promise
+              return undefined
+            })
+
+            const fiber = yield* Effect.all([lsp.touchFile(file, false), lsp.touchFile(file, false)], {
+              concurrency: "unbounded",
+            }).pipe(Effect.forkIn(scope))
+
+            yield* Effect.sleep(20)
+            expect(spawnSpy).toHaveBeenCalledTimes(1)
+
+            gate.resolve()
+            yield* Fiber.join(fiber)
+          }),
+        ),
+      { config: { lsp: true } },
+    ),
+  )
 })
 
 describe("LSP.Diagnostic", () => {