deepseek-harness/packages/lsp/lsp-local/src/instance.ts
Dudu-0223 d0029d8d60 feat(lsp): LSP capability seam, generic stdio provider, and lsp tool
Implements the LSP capability seam RFC as three packages: dsh-lsp (the
ctx.lsp interface — provider registry by branded id + exclusive extension
mapping, per-query order-independent selection, closed request/result
vocabulary, LspError taxonomy), dsh-lsp-local (a generic stdio language-server
provider — Content-Length JSON-RPC framing, per-(provider, workspace) process
single-flight, transient didOpen/query/didClose, an abortable per-instance
queue, UTF-16 negotiation, host-namespace source reads outside ctx.fs, and
bounded shutdown/kill teardown), and dsh-tool-lsp (the model-facing lsp tool —
four operations, one-based UTF-16 cursor conversion, workspace-grouped location
rendering, hover capping, a required session workspace, and a timeout budget).

Why: an agent had text search and file reads but no way to identify a program
symbol — follow an alias, connect an interface to implementations, or read an
inferred type — before changing code. Splitting model contract, seam, and local
subprocess behavior keeps the four semantic queries stable across future remote
or sandbox-native providers without leaking a JSON-RPC escape hatch.
2026-07-16 12:05:35 +08:00

293 lines
13 KiB
TypeScript

/**
* One language-server instance: a connection plus the initialize handshake, the serialized abortable
* query queue, the transient `didOpen`→request→`didClose` lifecycle, and bounded teardown. One
* instance owns one `(provider id, canonical workspace)` process. Queries serialize through a single
* queue so a cancellation that fails to stop the server can terminate it without killing unrelated
* work; distinct instances run in parallel.
* @module @deepseek-ai/dsh-lsp-local/instance
*/
import { pathToFileURL } from 'node:url'
import type {
LspOperation,
LspProviderQuery,
LspQueryResult,
} from '@deepseek-ai/dsh-lsp'
import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
import { LspConnection } from './connection.ts'
import type { ConnectionSpec } from './connection.ts'
import { readHostSource } from './host.ts'
import type { WireInitializeResult, WireServerCapabilities } from './protocol.ts'
import {
negotiatePositionEncoding,
normalizeHover,
normalizeLocations,
requestMethod,
supportsOperation,
supportsTransientOpen,
} from './translate.ts'
/** Everything an instance needs beyond the connection spec. */
export interface InstanceSpec extends ConnectionSpec {
/** Static `initialize` options forwarded to the server. */
readonly initializationOptions: unknown
/** Largest source file this host will open (bytes). */
readonly maxDocumentBytes: number
/** Graceful `shutdown`/`exit` budget before escalation (ms). */
readonly shutdownTimeoutMs: number
/** SIGTERM→SIGKILL grace after graceful shutdown fails (ms). */
readonly killGraceMs: number
}
/**
* A single initialized server process. Not exported as a provider — the provider single-flights and
* pools these. `query()` serializes; `dispose()` rejects queued work and tears the process down.
*/
export class LspInstance {
private readonly connection: LspConnection
private capabilities: WireServerCapabilities | undefined
/** The serialization tail: each query awaits the prior one, so lifecycles never interleave. */
private queue: Promise<unknown> = Promise.resolve()
private disposed = false
/** Set once the process closes, so the pool can synchronously skip a dead instance. */
private processClosed = false
/** Populated once `initialize` succeeds; a failed handshake rejects every query. */
private readonly ready: Promise<void>
/**
* @param spec - the launch, initialize, and teardown parameters.
*/
constructor(private readonly spec: InstanceSpec) {
this.connection = new LspConnection(spec, (method, params) => this.answerServerRequest(method, params))
this.ready = this.initialize()
// A handshake rejection must not surface as an unhandled rejection before the first query awaits
// it; queries attach the real handler.
this.ready.catch(() => {})
void this.connection.closed.then(() => { this.processClosed = true })
}
/** Synchronous liveness check: true once the process has closed or the instance was disposed. */
get dead(): boolean {
return this.processClosed || this.disposed
}
/**
* Run one query through the serialized queue.
* @param request - the resolved provider query.
* @param signal - optional cancellation for this query's full lifecycle.
* @returns the normalized result.
*/
query(request: LspProviderQuery, signal?: AbortSignal): Promise<LspQueryResult> {
const run = this.queue.then(() => this.runQuery(request, signal))
// Keep the tail alive regardless of this query's outcome so the next caller still serializes.
this.queue = run.then(() => undefined, () => undefined)
return run
}
private async initialize(): Promise<void> {
const initializeResult = await this.connection.request('initialize', {
processId: process.pid,
rootUri: pathToFileURL(this.spec.cwd).href,
workspaceFolders: [{ uri: pathToFileURL(this.spec.cwd).href, name: 'workspace' }],
capabilities: CLIENT_CAPABILITIES,
initializationOptions: this.spec.initializationOptions,
}) as WireInitializeResult
const capabilities = initializeResult.capabilities
// An omitted encoding defaults to utf-16; any other value is a protocol error we reject here.
negotiatePositionEncoding(capabilities.positionEncoding)
this.capabilities = capabilities
this.connection.notify('initialized', {})
}
private async runQuery(request: LspProviderQuery, signal?: AbortSignal): Promise<LspQueryResult> {
if (this.disposed) throw new Error('LSP instance was disposed')
if (signal?.aborted) throw abortError(signal)
await this.ready
const capabilities = this.capabilities
/* v8 ignore next -- `ready` resolves only after capabilities are set, else it rejects above; defensive. */
if (capabilities === undefined) throw new Error('LSP instance is not initialized')
if (!supportsOperation(capabilities, request.operation)) {
throw new Error(`server does not support ${request.operation}`)
}
if (!supportsTransientOpen(capabilities.textDocumentSync)) {
throw new Error('server does not support the transient textDocument/didOpen this host requires')
}
const source = await readHostSource(request.filePath, this.spec.cwd, this.spec.maxDocumentBytes)
const uri = pathToFileURL(source.canonicalPath).href
let opened = false
try {
if (signal?.aborted) throw abortError(signal)
this.connection.notify('textDocument/didOpen', {
textDocument: { uri, languageId: request.languageId, version: 1, text: source.text },
})
opened = true
const payload = await this.sendRequest(request.operation, uri, request.position, signal)
return this.normalize(request.operation, payload)
} finally {
if (opened) {
try {
this.connection.notify('textDocument/didClose', { textDocument: { uri } })
} catch (error) {
/* v8 ignore start -- stdin write errors surface asynchronously via the swallowed 'error'
listener, so a synchronous didClose write failure is a defensive path. */
// A close-write failure does not replace the settled result/error, but the instance can no
// longer be trusted: invalidate it and await bounded process termination.
this.disposed = true
void this.tearDown(error instanceof Error ? error : new Error(String(error)))
/* v8 ignore stop */
}
}
}
}
private async sendRequest(
operation: LspOperation,
uri: string,
position: LspProviderQuery['position'],
signal?: AbortSignal,
): Promise<unknown> {
const params = {
textDocument: { uri },
position: { line: position.line, character: position.character },
// references always includes declarations: the caller gets no flag and impact analysis never
// omits the defining site.
...(operation === 'references' ? { context: { includeDeclaration: true } } : {}),
}
const requestId = this.connection.peekNextId()
const send = this.connection.request(requestMethod(operation), params)
if (signal === undefined) return send
return this.raceAbort(send, requestId, signal)
}
/** Race a pending request against abort; on abort, send `$/cancelRequest` and reject. */
private async raceAbort(send: Promise<unknown>, requestId: number, signal: AbortSignal): Promise<unknown> {
const abort = new Promise<never>((_, reject) => {
const onAbort = (): void => { reject(abortError(signal)) }
/* v8 ignore next -- runQuery checks signal.aborted before sending, so it is not yet aborted here; defensive. */
if (signal.aborted) { onAbort(); return }
signal.addEventListener('abort', onAbort, { once: true })
// Remove the abort listener once the request settles either way; the finally-promise inherits
// send's rejection, so catch it to avoid an unhandled rejection when abort already won.
send.finally(() => { signal.removeEventListener('abort', onAbort) }).catch(() => {})
})
try {
return await Promise.race([send, abort])
} catch (error) {
if (signal.aborted) this.connection.cancel(requestId)
throw error
}
}
private normalize(operation: LspOperation, payload: unknown): LspQueryResult {
if (operation === 'hover') {
return { kind: 'hover', hover: normalizeHover(payload) }
}
return { kind: 'locations', locations: normalizeLocations(payload) }
}
private answerServerRequest(method: string, params: unknown): Promise<unknown> {
if (method === 'workspace/configuration') {
// Answer every requested item with the one static configuration value.
const record = params as { items?: unknown[] } | null
/* v8 ignore next -- a configuration request always carries an items array; the empty fallback is defensive. */
const items = Array.isArray(record?.items) ? record.items : []
return Promise.resolve(items.map(() => this.spec.configuration))
}
if (LIFECYCLE_NOOP_METHODS.has(method)) {
// Accept lifecycle bookkeeping requests with an empty result; we register nothing dynamic.
return Promise.resolve(null)
}
if (method === 'workspace/applyEdit') {
// This host never applies edits or runs commands.
return Promise.reject(new Error('workspace/applyEdit is not permitted by this host'))
}
return Promise.reject(new Error(`unsupported server request: ${method}`))
}
/**
* Reject queued work, attempt graceful `shutdown`/`exit`, then escalate SIGTERM→SIGKILL, awaiting
* process close so nothing outlives disposal.
*/
async dispose(): Promise<void> {
if (this.disposed) {
await this.connection.closed
return
}
this.disposed = true
await this.tearDown(new Error('LSP instance disposed'))
}
private async tearDown(_reason: Error): Promise<void> {
try {
using shutdownDeadline = deadline(undefined, this.spec.shutdownTimeoutMs, 'LSP_SHUTDOWN')
await this.gracefulShutdown(shutdownDeadline.signal)
} catch {
// Graceful shutdown failed or timed out: fall through to signal escalation.
}
await this.forceTerminate()
}
/** Best-effort LSP `shutdown` request then `exit` notification, bounded by `signal`. */
private async gracefulShutdown(signal: AbortSignal): Promise<void> {
const shutdown = this.connection.request('shutdown', null)
await Promise.race([
shutdown,
new Promise<never>((_, reject) => {
/* v8 ignore next -- the shutdown deadline signal is freshly armed and not yet aborted here; defensive. */
if (signal.aborted) { reject(abortError(signal)); return }
signal.addEventListener('abort', () => { reject(abortError(signal)) }, { once: true })
}),
])
this.connection.notify('exit', null)
}
/** SIGTERM, wait `killGraceMs` for close, then SIGKILL; await full process close either way. */
private async forceTerminate(): Promise<void> {
this.connection.terminate()
using graceDeadline = deadline(undefined, this.spec.killGraceMs, 'LSP_KILL_GRACE')
const closedInTime = await Promise.race([
this.connection.closed.then(() => true),
new Promise<boolean>((resolve) => {
/* v8 ignore next -- the kill-grace deadline signal is freshly armed and not yet aborted here; defensive. */
if (graceDeadline.signal.aborted) { resolve(false); return }
graceDeadline.signal.addEventListener('abort', () => { resolve(false) }, { once: true })
}),
])
if (!closedInTime) this.connection.kill()
await this.connection.closed
}
}
/** Server→client request methods this host acknowledges with an empty result (no dynamic registration). */
const LIFECYCLE_NOOP_METHODS = new Set([
'window/workDoneProgress/create',
'client/registerCapability',
'client/unregisterCapability',
])
/** Build an abort Error carrying the signal's reason (preserving a timeout classification). */
function abortError(signal: AbortSignal): Error {
const timeout = timeoutOf(signal)
if (timeout !== undefined) return timeout
const reason: unknown = signal.reason
if (reason instanceof Error) return reason
return new Error('LSP query aborted')
}
/**
* The client capabilities advertised at `initialize`: UTF-16 positions, workspace folders and
* configuration, markdown/plaintext hover, and link support for definition/implementation. No
* dynamic registration; the server's returned capabilities are authoritative.
*/
const CLIENT_CAPABILITIES = {
general: { positionEncodings: ['utf-16'] },
workspace: { workspaceFolders: true, configuration: true },
textDocument: {
synchronization: { dynamicRegistration: false },
hover: { contentFormat: ['markdown', 'plaintext'] },
definition: { linkSupport: true },
implementation: { linkSupport: true },
references: {},
},
} as const