/** * 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 { LspError } from '@deepseek-ai/dsh-lsp' import type { LspOperation, LspProviderQuery, LspQueryResult, } from '@deepseek-ai/dsh-lsp' import { deadline } from '@deepseek-ai/dsh-timeout' import { abortable, abortError } from './abort.ts' import { LspConnection } from './connection.ts' import type { ConnectionSpawner, ConnectionSpec, ConnectionWriter } from './connection.ts' import type { HostSource } 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 /** Graceful `shutdown`/`exit` budget before escalation (ms). */ readonly shutdownTimeoutMs: 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 = Promise.resolve() private disposed = false /** The one teardown transaction shared by abort, failure, and explicit disposal. */ private teardownPromise: Promise | undefined /** 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 /** * @param spec - the launch, initialize, and teardown parameters. * @param spawner - the subprocess seam's spawn function. * @param writer - optional connection writer used by transport conformance tests. */ constructor(private readonly spec: InstanceSpec, spawner: ConnectionSpawner, writer?: ConnectionWriter) { this.connection = new LspConnection(spec, spawner, (method, params) => this.answerServerRequest(method, params), writer) 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 || this.connection.failed } /** * Test whether a caught query error came from this instance's transport. * @param error - error caught by the provider. * @returns `true` only for the connection's retained fatal transport cause. */ isTransportFailure(error: unknown): boolean { return this.connection.failedWith(error) } /** * Run one query through the serialized queue. * @param request - the resolved provider query. * @param source - the pre-validated, already-read host source (the provider reads before spawning). * @param signal - optional cancellation for this query's full lifecycle. * @returns the normalized result. */ query(request: LspProviderQuery, source: HostSource, signal?: AbortSignal): Promise { // Serialize behind prior work, but observe abort DURING the queue wait too: if an earlier query // hangs (e.g. a signal-less seam caller), a later tool's timeout must still be able to give up // rather than block on the shared tail forever. const run = abortable(this.queue, signal) .then(() => this.runQuery(request, source, signal)) .catch(async (error: unknown) => { if (this.isTransportFailure(error)) await this.startTeardown() throw error }) // Keep the tail alive regardless of this query's outcome so the next caller still serializes. The // tail follows the ACTUAL prior work (this.queue), not the abortable view, so a caller giving up // on the wait does not deserialize the queue. this.queue = this.queue.then(() => run).then(() => undefined, () => undefined) return run } private async initialize(): Promise { 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 await this.connection.notify('initialized', {}) } private async runQuery(request: LspProviderQuery, source: HostSource, signal?: AbortSignal): Promise { if (this.disposed) throw new LspError('LSP instance was disposed', 'LSP_DISPOSED') /* v8 ignore next -- the abortable queue wait rejects a pre-aborted signal before runQuery; this is a belt-and-suspenders guard. */ if (signal?.aborted) throw abortError(signal) // Observe abort during the handshake wait, and never pool a poisoned instance: if the wait ends // in failure — an abort on a still-pending handshake, OR `initialize` rejecting (utf-8 // negotiation, malformed result) without the process exiting — tear the instance down so a // permanently-rejecting/pending `ready` can't make every later query for this workspace fail. try { await abortable(this.ready, signal) } catch (error) { if (!this.dead) { await this.startTeardown() } throw error } 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 LspError(`server does not support ${request.operation}`, 'LSP_UNSUPPORTED_OPERATION') } if (!supportsTransientOpen(capabilities.textDocumentSync)) { throw new LspError('server does not support the transient textDocument/didOpen this host requires', 'LSP_UNSUPPORTED_OPERATION') } const uri = pathToFileURL(source.canonicalPath).href let opened = false try { /* v8 ignore next -- guards an abort landing between the ready wait and didOpen; not deterministically reproducible. */ if (signal?.aborted) throw abortError(signal) try { await abortable(this.connection.notify('textDocument/didOpen', { textDocument: { uri, languageId: request.languageId, version: 1, text: source.text }, }), signal) } catch (error) { // A canceled backpressured write or failed stdin leaves the protocol stream unusable before // `opened` can arm the didClose cleanup. Teardown here makes the pool evict the instance. await this.startTeardown() throw error } opened = true const payload = await this.sendRequest(request.operation, uri, request.position, signal) return this.normalize(request.operation, payload) } finally { // A disposed or closed instance (e.g. an aborted request whose server ignored // `$/cancelRequest`) is already tearing down; sending didClose would race that teardown and let // the next queued query's document lifecycle overlap the still-active request. if (opened && !this.dead) { try { await this.connection.notify('textDocument/didClose', { textDocument: { uri } }) } catch { // 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. try { await this.startTeardown() } catch { /* v8 ignore next -- teardown owns all expected process races; this only preserves the already-settled query outcome if an unexpected cleanup primitive itself rejects. */ } } } } } private async sendRequest( operation: LspOperation, uri: string, position: LspProviderQuery['position'], signal?: AbortSignal, ): Promise { const params = { textDocument: { uri }, position: { line: position.line, character: position.character }, // findReferences always includes declarations: the caller gets no flag and impact analysis // never omits the defining site. ...(operation === 'findReferences' ? { 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 give the server a * bounded grace to acknowledge; if it does not settle in time, invalidate and tear down the * instance so the still-active request cannot overlap the next queued query's document lifecycle. */ private async raceAbort(send: Promise, requestId: number, signal: AbortSignal): Promise { try { return await abortable(send, signal) } catch (error) { if (!signal.aborted) throw error this.connection.cancel(requestId) // Wait, bounded, for the server to honor the cancellation. If it does not, the request is still // running: terminate the instance (disposal awaits process close) so nothing outlives the query. const grace = deadline(undefined, this.spec.killGraceMs, 'LSP_CANCEL_GRACE') try { // `settled` is true if the request finished (either outcome) before the grace elapsed. const settled = await Promise.race([ send.then(markSettled, markSettled), new Promise((resolve) => { /* v8 ignore next -- the cancel-grace deadline signal is freshly armed and not yet aborted here; defensive. */ if (grace.signal.aborted) { resolve(false); return } grace.signal.addEventListener('abort', () => { resolve(false) }, { once: true }) }), ]) if (!settled) await this.startTeardown() } finally { grace[Symbol.dispose]() } throw error } } private normalize(operation: LspOperation, payload: unknown): LspQueryResult { if (operation === 'hover') { return { kind: 'hover', hover: normalizeHover(payload) } } // `spec.cwd` is the canonical workspace realpath (the provider canonicalizes before spawning), // and every `file:` location URI is relative to it — so it is the root a caller must relativize // display paths against, not the request's possibly-symlinked workspaceRoot. return { kind: 'locations', locations: normalizeLocations(payload), resolvedWorkspaceRoot: this.spec.cwd } } private answerServerRequest(method: string, params: unknown): Promise { 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 { await this.startTeardown() } /** Publish disposal once and make every caller await the same quiescence boundary. */ private startTeardown(): Promise { this.disposed = true this.teardownPromise ??= this.tearDown() return this.teardownPromise } private async tearDown(): Promise { const shutdownDeadline = deadline(undefined, this.spec.shutdownTimeoutMs, 'LSP_SHUTDOWN') try { await this.gracefulShutdown(shutdownDeadline.signal) } catch { // Graceful shutdown failed or timed out; process-tree cleanup below remains authoritative. } finally { shutdownDeadline[Symbol.dispose]() } await this.forceTerminate() } /** Best-effort LSP `shutdown`/`exit`, including process close, bounded by `signal`. */ private async gracefulShutdown(signal: AbortSignal): Promise { await abortable(this.connection.request('shutdown', null), signal) await this.connection.notify('exit', null) await abortable(this.connection.closed, signal) } /** * Terminate the tree (the seam escalates SIGTERM→`killGraceMs`→SIGKILL), * then await leader and helper exit. The awaits are unbounded on purpose: * the seam's escalation already committed to SIGKILL, so quiescence — not * another timer — is the postcondition disposal owes its callers. */ private async forceTerminate(): Promise { this.connection.terminate() await Promise.all([ this.connection.closed, this.connection.waitForProcessTreeExit(), ]) } } /** 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', ]) /** Mark a settled request in the cancel-grace race (either outcome means the request finished). */ function markSettled(): boolean { return true } /** * 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