diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index ead3a0149b4..be5113620d7 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -963,11 +963,19 @@ export namespace Config { disabled: z.literal(true), }), z.object({ - command: z.array(z.string()), + command: z.array(z.string()).optional(), extensions: z.array(z.string()).optional(), disabled: z.boolean().optional(), env: z.record(z.string(), z.string()).optional(), initialization: z.record(z.string(), z.any()).optional(), + timeout: z + .number() + .int() + .positive() + .optional() + .describe( + "Timeout in milliseconds for this LSP server's initialization. Default is 45000 (45 seconds).", + ), }), ]), ), diff --git a/packages/opencode/src/lsp/client.ts b/packages/opencode/src/lsp/client.ts index 8704b65acb5..99891956cdf 100644 --- a/packages/opencode/src/lsp/client.ts +++ b/packages/opencode/src/lsp/client.ts @@ -8,6 +8,7 @@ import { Log } from "../util/log" import { LANGUAGE_EXTENSIONS } from "./language" import z from "zod" import type { LSPServer } from "./server" +import { LSP } from "./index" import { NamedError } from "@opencode-ai/util/error" import { withTimeout } from "../util/timeout" import { Instance } from "../project/instance" @@ -39,7 +40,7 @@ export namespace LSPClient { ), } - export async function create(input: { serverID: string; server: LSPServer.Handle; root: string }) { + export async function create(input: { serverID: string; server: LSPServer.Handle; root: string; timeout?: number }) { const l = log.clone().tag("serverID", input.serverID) l.info("starting client") @@ -113,7 +114,7 @@ export namespace LSPClient { }, }, }), - 45_000, + input.timeout ?? LSP.DEFAULT_LSP_TIMEOUT, ).catch((err) => { l.error("initialize error", { error: err }) throw new InitializeError( diff --git a/packages/opencode/src/lsp/index.ts b/packages/opencode/src/lsp/index.ts index 0fd3b69dfcd..ce23ac44235 100644 --- a/packages/opencode/src/lsp/index.ts +++ b/packages/opencode/src/lsp/index.ts @@ -14,6 +14,8 @@ import { Flag } from "@/flag/flag" export namespace LSP { const log = Log.create({ service: "lsp" }) + export const DEFAULT_LSP_TIMEOUT = 45_000 + export const Event = { Updated: BusEvent.define("lsp.updated", z.object({})), } @@ -105,14 +107,32 @@ export namespace LSP { delete servers[name] continue } + + if (!item.command) { + // No command = built-in server override + if (!existing) { + log.error(`LSP server ${name} not found. Custom servers require 'command' array.`) + continue + } + + // Apply overrides to built-in server + servers[name] = { + ...existing, + ...(item.timeout !== undefined && { timeout: item.timeout }), + } + continue + } + + const command = item.command servers[name] = { ...existing, id: name, root: existing?.root ?? (async () => Instance.directory), extensions: item.extensions ?? existing?.extensions ?? [], + timeout: item.timeout ?? existing?.timeout, spawn: async (root) => { return { - process: spawn(item.command[0], item.command.slice(1), { + process: spawn(command[0], command.slice(1), { cwd: root, env: { ...process.env, @@ -195,10 +215,20 @@ export namespace LSP { if (!handle) return undefined log.info("spawned lsp server", { serverID: server.id }) + const timeout = server.timeout ?? DEFAULT_LSP_TIMEOUT + if (server.timeout !== undefined) { + log.info("LSP server timeout overridden", { + serverID: server.id, + timeout: server.timeout, + default: DEFAULT_LSP_TIMEOUT, + }) + } + const client = await LSPClient.create({ serverID: server.id, server: handle, root, + timeout, }).catch((err) => { s.broken.add(key) handle.process.kill() diff --git a/packages/opencode/src/lsp/server.ts b/packages/opencode/src/lsp/server.ts index c818e2b3e94..6ba2746c280 100644 --- a/packages/opencode/src/lsp/server.ts +++ b/packages/opencode/src/lsp/server.ts @@ -56,6 +56,7 @@ export namespace LSPServer { global?: boolean root: RootFunction spawn(root: string): Promise + timeout?: number } export const Deno: Info = { diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 087eb0c628c..6234131c653 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -1146,6 +1146,85 @@ test("project config overrides remote well-known config", async () => { } }) +test("rejects LSP config with negative per-server timeout", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + lsp: { + typescript: { + timeout: -1000, + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await expect(Config.get()).rejects.toThrow() + }, + }) +}) + +test("rejects LSP config with zero per-server timeout", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + lsp: { + typescript: { + timeout: 0, + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await expect(Config.get()).rejects.toThrow() + }, + }) +}) + +test("accepts LSP config with per-server timeout override for built-in server", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + lsp: { + typescript: { + timeout: 90000, + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.lsp).not.toBe(false) + if (config.lsp && typeof config.lsp === "object") { + expect(config.lsp["typescript"]).toMatchObject({ + timeout: 90000, + }) + } + }, + }) +}) + + describe("getPluginName", () => { test("extracts name from file:// URL", () => { expect(Config.getPluginName("file:///path/to/plugin/foo.js")).toBe("foo")