Skip to content
Open
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
1 change: 1 addition & 0 deletions packages/opencode/src/flag/flag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export namespace Flag {
export const OPENCODE_EXPERIMENTAL_OXFMT = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_OXFMT")
export const OPENCODE_EXPERIMENTAL_LSP_TY = truthy("OPENCODE_EXPERIMENTAL_LSP_TY")
export const OPENCODE_EXPERIMENTAL_LSP_TOOL = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_LSP_TOOL")
export const OPENCODE_EXPERIMENTAL_LSP_DIAGNOSTICS_TIMEOUT_MS = number("OPENCODE_EXPERIMENTAL_LSP_DIAGNOSTICS_TIMEOUT_MS")
export const OPENCODE_DISABLE_FILETIME_CHECK = truthy("OPENCODE_DISABLE_FILETIME_CHECK")
export const OPENCODE_EXPERIMENTAL_PLAN_MODE = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE")
export const OPENCODE_EXPERIMENTAL_MARKDOWN = truthy("OPENCODE_EXPERIMENTAL_MARKDOWN")
Expand Down
46 changes: 46 additions & 0 deletions packages/opencode/src/lsp/client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { afterEach, describe, expect, test } from "bun:test"
import { Flag } from "@/flag/flag"

describe("LSP Client diagnostics timeout configuration", () => {
const TIMEOUT_KEY =
"OPENCODE_EXPERIMENTAL_LSP_DIAGNOSTICS_TIMEOUT_MS" as const

afterEach(() => {
// Restore to the original module-load-time value (undefined when env var isn't set)
Object.defineProperty(Flag, TIMEOUT_KEY, {
value: undefined,
configurable: true,
writable: true,
})
})

test("defaults to 10_000ms when flag is undefined", () => {
// Flag value is undefined at module load time (env var not set)
const timeout = Flag[TIMEOUT_KEY] ?? 10_000
expect(timeout).toBe(10_000)
})

test("uses custom timeout when flag is set", () => {
Object.defineProperty(Flag, TIMEOUT_KEY, {
value: 5_000,
configurable: true,
writable: true,
})

const timeout = Flag[TIMEOUT_KEY] ?? 10_000
expect(timeout).toBe(5_000)
})

test("falls back to 10_000ms when flag value is invalid", () => {
// number() helper returns undefined for 0, negative, or non-integer values
// so Flag[TIMEOUT_KEY] would be undefined, triggering the ?? fallback
Object.defineProperty(Flag, TIMEOUT_KEY, {
value: undefined,
configurable: true,
writable: true,
})

const timeout = Flag[TIMEOUT_KEY] ?? 10_000
expect(timeout).toBe(10_000)
})
})
3 changes: 2 additions & 1 deletion packages/opencode/src/lsp/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { NamedError } from "@opencode-ai/util/error"
import { withTimeout } from "../util/timeout"
import { Instance } from "../project/instance"
import { Filesystem } from "../util/filesystem"
import { Flag } from "@/flag/flag"

const DIAGNOSTICS_DEBOUNCE_MS = 150

Expand Down Expand Up @@ -228,7 +229,7 @@ export namespace LSPClient {
}
})
}),
3000,
Flag.OPENCODE_EXPERIMENTAL_LSP_DIAGNOSTICS_TIMEOUT_MS ?? 10_000,
)
.catch(() => {})
.finally(() => {
Expand Down
278 changes: 278 additions & 0 deletions packages/opencode/src/lsp/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,278 @@
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"
import * as BusModule from "@/bus"
import { Config } from "../config/config"
import { LSPClient } from "./client"
import { LSPServer } from "./server"
import { Instance } from "../project/instance"
import { tmpdir } from "../../test/fixture/fixture"

type ClientLike = Awaited<ReturnType<typeof LSPClient.create>>
type ServerLike = {
id: string
extensions: string[]
root: (file: string) => Promise<string | undefined>
spawn: (root: string) => Promise<{ process: { kill: () => void } } | undefined>
}

const filePath = "/project/file.ts"
const rootPath = "/project"

let publishCalls: Array<[unknown, unknown]>
let createCalls: Array<{ serverID: string; server: unknown; root: string }>
let spawnCalls: string[]
let openCalls: string[]
let killCalls: string[]

let configSpy: ReturnType<typeof spyOn>
let createSpy: ReturnType<typeof spyOn>
let publishSpy: ReturnType<typeof spyOn>

let originalServers: [string, unknown][]

function makeClient(root: string): ClientLike {
return {
root,
serverID: "mock",
notify: {
async open(input: { path: string }) {
openCalls.push(input.path)
},
},
diagnostics: new Map(),
async waitForDiagnostics() {},
async shutdown() {},
connection: {} as any,
} as ClientLike
}

function defer<T>() {
let resolve: (value: T) => void = () => {}
let reject: (reason?: unknown) => void = () => {}
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}

async function flush(times = 3) {
for (let i = 0; i < times; i++) {
await Promise.resolve()
await new Promise((resolve) => setTimeout(resolve, 0))
}
}

function setMockServers(server: ServerLike) {
const entries = Object.entries(LSPServer)
for (const [key, value] of entries) {
if (!value || typeof value !== "object") continue
if (!("id" in (value as any) && "root" in (value as any) && "spawn" in (value as any))) continue
delete (LSPServer as any)[key]
}
;(LSPServer as any).Mock = server
}

function restoreServers() {
for (const key of Object.keys(LSPServer)) {
delete (LSPServer as any)[key]
}
for (const [key, value] of originalServers) {
;(LSPServer as any)[key] = value
}
}

async function loadLSP() {
const mod = (await import(`./index?test=${Math.random().toString(36).slice(2)}`)) as {
LSP: {
Event: { Updated: unknown }
touchFile: (file: string) => Promise<void>
}
}
return mod.LSP
}

beforeEach(() => {
publishCalls = []
createCalls = []
spawnCalls = []
openCalls = []
killCalls = []

originalServers = Object.entries(LSPServer)

const server: ServerLike = {
id: "mock",
extensions: [".ts"],
root: async () => rootPath,
spawn: async (root: string) => {
spawnCalls.push(root)
return {
process: {
kill() {
killCalls.push(root)
},
},
}
},
}
setMockServers(server)

configSpy = spyOn(Config, "get").mockImplementation(async () => ({ lsp: {} } as any))
createSpy = spyOn(LSPClient, "create").mockImplementation(async (input: any) => {
createCalls.push(input)
return makeClient(input.root)
})
publishSpy = spyOn(BusModule.Bus, "publish").mockImplementation((event: unknown, payload: unknown) => {
publishCalls.push([event, payload])
return undefined as any
})
})

afterEach(async () => {
publishSpy.mockRestore()
createSpy.mockRestore()
configSpy.mockRestore()
restoreServers()
await Instance.disposeAll()
})

describe("LSP non-blocking initialization", () => {
test("getClients returns immediately during initialization", async () => {
configSpy.mockImplementation(
() =>
new Promise((resolve) => {
setTimeout(() => resolve({ lsp: {} } as any), 250)
}),
)

const LSP = await loadLSP()
const tmp = await tmpdir({ git: true })

await Instance.provide({
directory: tmp.path,
fn: async () => {
const started = Date.now()
await LSP.touchFile(filePath)
expect(Date.now() - started).toBeLessThan(100)
},
})

expect(createCalls.length).toBe(0)
expect(spawnCalls.length).toBe(0)
})

test("getClients returns [] when state is not cached", async () => {
const LSP = await loadLSP()
const tmp = await tmpdir({ git: true })

await Instance.provide({
directory: tmp.path,
fn: async () => {
await LSP.touchFile(filePath)
},
})

expect(createCalls.length).toBe(0)
expect(spawnCalls.length).toBe(0)
expect(openCalls.length).toBe(0)
})

test("getClients returns ready clients after initialization completes", async () => {
const LSP = await loadLSP()
const tmp = await tmpdir({ git: true })

await Instance.provide({
directory: tmp.path,
fn: async () => {
await LSP.touchFile(filePath)
await flush()
await LSP.touchFile(filePath)
await flush()
await LSP.touchFile(filePath)
},
})

expect(createCalls.length).toBe(1)
expect(openCalls).toEqual([filePath])
})

test("Bus.publish(Event.Updated, {}) fires when client becomes ready", async () => {
const pending = defer<ClientLike | undefined>()
createSpy.mockImplementation(async (input: any) => {
createCalls.push(input)
return pending.promise
})

const LSP = await loadLSP()
const tmp = await tmpdir({ git: true })

await Instance.provide({
directory: tmp.path,
fn: async () => {
await LSP.touchFile(filePath)
await flush()
await LSP.touchFile(filePath)
await flush()
expect(publishCalls.length).toBe(0)
pending.resolve(makeClient(rootPath))
await flush()
},
})

expect(publishCalls.length).toBe(1)
expect(publishCalls[0][0]).toBe(LSP.Event.Updated)
expect(publishCalls[0][1]).toEqual({})
})

test("broken set prevents retries of failed servers", async () => {
createSpy.mockImplementation(async (input: any) => {
createCalls.push(input)
throw new Error("create failed")
})

const LSP = await loadLSP()
const tmp = await tmpdir({ git: true })

await Instance.provide({
directory: tmp.path,
fn: async () => {
await LSP.touchFile(filePath)
await flush()
await LSP.touchFile(filePath)
await flush()
await LSP.touchFile(filePath)
await flush()
},
})

expect(createCalls.length).toBe(1)
expect(spawnCalls.length).toBe(1)
expect(killCalls.length).toBe(2)
})

test("spawning map prevents duplicate spawn attempts", async () => {
const pending = defer<ClientLike | undefined>()
createSpy.mockImplementation(async (input: any) => {
createCalls.push(input)
return pending.promise
})

const LSP = await loadLSP()
const tmp = await tmpdir({ git: true })

await Instance.provide({
directory: tmp.path,
fn: async () => {
await LSP.touchFile(filePath)
await flush()
await Promise.all([LSP.touchFile(filePath), LSP.touchFile(filePath)])
await flush()
pending.resolve(makeClient(rootPath))
await flush()
},
})

expect(createCalls.length).toBe(1)
expect(spawnCalls.length).toBe(1)
})
})
Loading
Loading