diff --git a/packages/core/src/config/lsp.ts b/packages/core/src/config/lsp.ts index 651597befdde..2f7a28ccb214 100644 --- a/packages/core/src/config/lsp.ts +++ b/packages/core/src/config/lsp.ts @@ -12,6 +12,14 @@ export class Server extends Schema.Class("ConfigV2.LSP.Server")({ disabled: Schema.Boolean.pipe(Schema.optional), env: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), initialization: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), + // How long to wait for diagnostics, in milliseconds. Defaults suit servers + // that respond quickly; a large C++ translation unit can take far longer to + // parse than the default wait, in which case the client gives up and reports + // no diagnostics at all — indistinguishable from a clean file. + timeout: Schema.Struct({ + document: Schema.Number.pipe(Schema.optional), + full: Schema.Number.pipe(Schema.optional), + }).pipe(Schema.optional), }) {} export const Entry = Schema.Union([Disabled, Server]) diff --git a/packages/core/src/v1/config/lsp.ts b/packages/core/src/v1/config/lsp.ts index 89a58b5b2c9b..bdcc9a7298c2 100644 --- a/packages/core/src/v1/config/lsp.ts +++ b/packages/core/src/v1/config/lsp.ts @@ -14,6 +14,15 @@ export const Entry = Schema.Union([ disabled: Schema.optional(Schema.Boolean), env: Schema.optional(Schema.Record(Schema.String, Schema.String)), initialization: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), + // Must mirror the v2 schema. A v1 config is decoded against this struct + // before migrate.ts passes `lsp` through untouched, so any field absent + // here is silently stripped and never reaches the LSP client. + timeout: Schema.optional( + Schema.Struct({ + document: Schema.optional(Schema.Number), + full: Schema.optional(Schema.Number), + }), + ), }), ]).pipe((schema) => schema) diff --git a/packages/opencode/src/lsp/client.ts b/packages/opencode/src/lsp/client.ts index 08d8a53d9be0..2d9751543ab9 100644 --- a/packages/opencode/src/lsp/client.ts +++ b/packages/opencode/src/lsp/client.ts @@ -129,6 +129,13 @@ export async function create(input: { }) { const instance = input.instance + // Servers parsing very large translation units (e.g. clangd on an Unreal + // Engine codebase) can take far longer than the defaults to publish + // diagnostics. When the wait expires the client reports nothing, which is + // indistinguishable from a clean file, so allow per-server overrides. + const documentWaitTimeout = input.server.timeout?.document ?? DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS + const fullWaitTimeout = input.server.timeout?.full ?? DIAGNOSTICS_FULL_WAIT_TIMEOUT_MS + const connection = createMessageConnection( new StreamMessageReader(input.server.process.stdout as any), new StreamMessageWriter(input.server.process.stdin as any), @@ -502,13 +509,13 @@ export async function create(input: { path: request.path, version: request.version, after: startedAt, - timeout: DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS, + timeout: documentWaitTimeout, }) - while (Date.now() - startedAt < DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS) { + while (Date.now() - startedAt < documentWaitTimeout) { const result = await requestDocumentDiagnostics(request.path) if (result.matched) return - const remaining = DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS - (Date.now() - startedAt) + const remaining = documentWaitTimeout - (Date.now() - startedAt) if (remaining <= 0) return const next = await Promise.race([ pushWait.then((ready) => (ready ? "push" : ("timeout" as const))), @@ -524,13 +531,13 @@ export async function create(input: { path: request.path, version: request.version, after: startedAt, - timeout: DIAGNOSTICS_FULL_WAIT_TIMEOUT_MS, + timeout: fullWaitTimeout, }) - while (Date.now() - startedAt < DIAGNOSTICS_FULL_WAIT_TIMEOUT_MS) { + while (Date.now() - startedAt < fullWaitTimeout) { const result = await requestFullDiagnostics(request.path) if (result.handled || result.matched) return - const remaining = DIAGNOSTICS_FULL_WAIT_TIMEOUT_MS - (Date.now() - startedAt) + const remaining = fullWaitTimeout - (Date.now() - startedAt) if (remaining <= 0) return const next = await Promise.race([ pushWait.then((ready) => (ready ? "push" : ("timeout" as const))), diff --git a/packages/opencode/src/lsp/lsp.ts b/packages/opencode/src/lsp/lsp.ts index 1ab3a0b82369..c7092d4967ce 100644 --- a/packages/opencode/src/lsp/lsp.ts +++ b/packages/opencode/src/lsp/lsp.ts @@ -176,6 +176,7 @@ const layer = Layer.effect( env: { ...process.env, ...item.env }, }), initialization: item.initialization, + timeout: item.timeout, }), } } diff --git a/packages/opencode/src/lsp/server.ts b/packages/opencode/src/lsp/server.ts index 2be68aa5a92d..146a1406ab7c 100644 --- a/packages/opencode/src/lsp/server.ts +++ b/packages/opencode/src/lsp/server.ts @@ -25,6 +25,10 @@ const output = (cmd: string[], opts: Process.RunOptions = {}) => Process.text(cm export interface Handle { process: ChildProcessWithoutNullStreams initialization?: Record + timeout?: { + document?: number + full?: number + } } type RootFunction = (file: string, ctx: InstanceContext) => Promise