Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).",
),
}),
]),
),
Expand Down
5 changes: 3 additions & 2 deletions packages/opencode/src/lsp/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -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(
Expand Down
32 changes: 31 additions & 1 deletion packages/opencode/src/lsp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({})),
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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()
Expand Down
1 change: 1 addition & 0 deletions packages/opencode/src/lsp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export namespace LSPServer {
global?: boolean
root: RootFunction
spawn(root: string): Promise<Handle | undefined>
timeout?: number
}

export const Deno: Info = {
Expand Down
79 changes: 79 additions & 0 deletions packages/opencode/test/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading