instance.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346
  1. /**
  2. * One language-server instance: a connection plus the initialize handshake, the serialized abortable
  3. * query queue, the transient `didOpen`→request→`didClose` lifecycle, and bounded teardown. One
  4. * instance owns one `(provider id, canonical workspace)` process. Queries serialize through a single
  5. * queue so a cancellation that fails to stop the server can terminate it without killing unrelated
  6. * work; distinct instances run in parallel.
  7. * @module @deepseek-ai/dsh-lsp-stdio/instance
  8. */
  9. import { LspError } from '@deepseek-ai/dsh-lsp'
  10. import type {
  11. LspOperation,
  12. LspProviderQuery,
  13. LspQueryResult,
  14. } from '@deepseek-ai/dsh-lsp'
  15. import { deadline } from '@deepseek-ai/dsh-timeout'
  16. import { abortable, abortError } from './abort.ts'
  17. import { LspConnection } from './connection.ts'
  18. import type { ConnectionSpawner, ConnectionSpec, ConnectionWriter } from './connection.ts'
  19. import type { HostSource } from './host.ts'
  20. import type { WireInitializeResult, WireServerCapabilities } from './protocol.ts'
  21. import {
  22. negotiatePositionEncoding,
  23. normalizeHover,
  24. normalizeLocations,
  25. requestMethod,
  26. supportsOperation,
  27. supportsTransientOpen,
  28. } from './translate.ts'
  29. /** Everything an instance needs beyond the connection spec. */
  30. export interface InstanceSpec extends ConnectionSpec {
  31. /** Canonical workspace file URI supplied by the filesystem provider. */
  32. readonly workspaceUri: string
  33. /** Static `initialize` options forwarded to the server. */
  34. readonly initializationOptions: unknown
  35. /** Graceful `shutdown`/`exit` budget before escalation (ms). */
  36. readonly shutdownTimeoutMs: number
  37. }
  38. /**
  39. * A single initialized server process. Not exported as a provider — the provider single-flights and
  40. * pools these. `query()` serializes; `dispose()` rejects queued work and tears the process down.
  41. */
  42. export class LspInstance {
  43. private readonly connection: LspConnection
  44. private capabilities: WireServerCapabilities | undefined
  45. /** The serialization tail: each query awaits the prior one, so lifecycles never interleave. */
  46. private queue: Promise<unknown> = Promise.resolve()
  47. private disposed = false
  48. /** The one teardown transaction shared by abort, failure, and explicit disposal. */
  49. private teardownPromise: Promise<void> | undefined
  50. /** Set once the process closes, so the pool can synchronously skip a dead instance. */
  51. private processClosed = false
  52. /** Populated once `initialize` succeeds; a failed handshake rejects every query. */
  53. private readonly ready: Promise<void>
  54. /**
  55. * @param spec - the launch, initialize, and teardown parameters.
  56. * @param spawner - the subprocess seam's spawn function.
  57. * @param writer - optional connection writer used by transport conformance tests.
  58. */
  59. constructor(private readonly spec: InstanceSpec, spawner: ConnectionSpawner, writer?: ConnectionWriter) {
  60. this.connection = new LspConnection(spec, spawner, (method, params) => this.answerServerRequest(method, params), writer)
  61. this.ready = this.initialize()
  62. // A handshake rejection must not surface as an unhandled rejection before the first query awaits
  63. // it; queries attach the real handler.
  64. this.ready.catch(() => {})
  65. void this.connection.closed.then(() => { this.processClosed = true })
  66. }
  67. /** Synchronous liveness check: true once the process has closed or the instance was disposed. */
  68. get dead(): boolean {
  69. return this.processClosed || this.disposed || this.connection.failed
  70. }
  71. /**
  72. * Test whether a caught query error came from this instance's transport.
  73. * @param error - error caught by the provider.
  74. * @returns `true` only for the connection's retained fatal transport cause.
  75. */
  76. isTransportFailure(error: unknown): boolean {
  77. return this.connection.failedWith(error)
  78. }
  79. /**
  80. * Run one query through the serialized queue.
  81. * @param request - the resolved provider query.
  82. * @param source - the pre-validated, already-read host source (the provider reads before spawning).
  83. * @param signal - optional cancellation for this query's full lifecycle.
  84. * @returns the normalized result.
  85. */
  86. query(request: LspProviderQuery, source: HostSource, signal?: AbortSignal): Promise<LspQueryResult> {
  87. // Serialize behind prior work, but observe abort DURING the queue wait too: if an earlier query
  88. // hangs (e.g. a signal-less service caller), a later tool's timeout must still be able to give up
  89. // rather than block on the shared tail forever.
  90. const run = abortable(this.queue, signal)
  91. .then(() => this.runQuery(request, source, signal))
  92. .catch(async (error: unknown) => {
  93. if (this.isTransportFailure(error)) await this.startTeardown()
  94. throw error
  95. })
  96. // Keep the tail alive regardless of this query's outcome so the next caller still serializes. The
  97. // tail follows the ACTUAL prior work (this.queue), not the abortable view, so a caller giving up
  98. // on the wait does not deserialize the queue.
  99. this.queue = this.queue.then(() => run).then(() => undefined, () => undefined)
  100. return run
  101. }
  102. private async initialize(): Promise<void> {
  103. const initializeResult = await this.connection.request('initialize', {
  104. // A subprocess provider may run in another PID namespace or machine;
  105. // the host PID would let the server monitor an unrelated process.
  106. processId: null,
  107. rootUri: this.spec.workspaceUri,
  108. workspaceFolders: [{ uri: this.spec.workspaceUri, name: 'workspace' }],
  109. capabilities: CLIENT_CAPABILITIES,
  110. initializationOptions: this.spec.initializationOptions,
  111. }) as WireInitializeResult
  112. const capabilities = initializeResult.capabilities
  113. // An omitted encoding defaults to utf-16; any other value is a protocol error we reject here.
  114. negotiatePositionEncoding(capabilities.positionEncoding)
  115. this.capabilities = capabilities
  116. await this.connection.notify('initialized', {})
  117. }
  118. private async runQuery(request: LspProviderQuery, source: HostSource, signal?: AbortSignal): Promise<LspQueryResult> {
  119. if (this.disposed) throw new LspError('LSP instance was disposed', 'LSP_DISPOSED')
  120. /* v8 ignore next -- the abortable queue wait rejects a pre-aborted signal before runQuery; this is a belt-and-suspenders guard. */
  121. if (signal?.aborted) throw abortError(signal)
  122. // Observe abort during the handshake wait, and never pool a poisoned instance: if the wait ends
  123. // in failure — an abort on a still-pending handshake, OR `initialize` rejecting (utf-8
  124. // negotiation, malformed result) without the process exiting — tear the instance down so a
  125. // permanently-rejecting/pending `ready` can't make every later query for this workspace fail.
  126. try {
  127. await abortable(this.ready, signal)
  128. } catch (error) {
  129. if (!this.dead) {
  130. await this.startTeardown()
  131. }
  132. throw error
  133. }
  134. const capabilities = this.capabilities
  135. /* v8 ignore next -- `ready` resolves only after capabilities are set, else it rejects above; defensive. */
  136. if (capabilities === undefined) throw new Error('LSP instance is not initialized')
  137. if (!supportsOperation(capabilities, request.operation)) {
  138. throw new LspError(`server does not support ${request.operation}`, 'LSP_UNSUPPORTED_OPERATION')
  139. }
  140. if (!supportsTransientOpen(capabilities.textDocumentSync)) {
  141. throw new LspError('server does not support the transient textDocument/didOpen this host requires', 'LSP_UNSUPPORTED_OPERATION')
  142. }
  143. const uri = source.fileUrl
  144. let opened = false
  145. try {
  146. /* v8 ignore next -- guards an abort landing between the ready wait and didOpen; not deterministically reproducible. */
  147. if (signal?.aborted) throw abortError(signal)
  148. try {
  149. await abortable(this.connection.notify('textDocument/didOpen', {
  150. textDocument: { uri, languageId: request.languageId, version: 1, text: source.text },
  151. }), signal)
  152. } catch (error) {
  153. // A canceled backpressured write or failed stdin leaves the protocol stream unusable before
  154. // `opened` can arm the didClose cleanup. Teardown here makes the pool evict the instance.
  155. await this.startTeardown()
  156. throw error
  157. }
  158. opened = true
  159. const payload = await this.sendRequest(request.operation, uri, request.position, signal)
  160. return this.normalize(request.operation, payload)
  161. } finally {
  162. // A disposed or closed instance (e.g. an aborted request whose server ignored
  163. // `$/cancelRequest`) is already tearing down; sending didClose would race that teardown and let
  164. // the next queued query's document lifecycle overlap the still-active request.
  165. if (opened && !this.dead) {
  166. try {
  167. await this.connection.notify('textDocument/didClose', { textDocument: { uri } })
  168. } catch {
  169. // A close-write failure does not replace the settled result/error, but the instance can no
  170. // longer be trusted: invalidate it and await bounded process termination.
  171. try {
  172. await this.startTeardown()
  173. } catch {
  174. /* v8 ignore next -- teardown owns all expected process races; this only preserves the
  175. already-settled query outcome if an unexpected cleanup primitive itself rejects. */
  176. }
  177. }
  178. }
  179. }
  180. }
  181. private async sendRequest(
  182. operation: LspOperation,
  183. uri: string,
  184. position: LspProviderQuery['position'],
  185. signal?: AbortSignal,
  186. ): Promise<unknown> {
  187. const params = {
  188. textDocument: { uri },
  189. position: { line: position.line, character: position.character },
  190. // findReferences always includes declarations: the caller gets no flag and impact analysis
  191. // never omits the defining site.
  192. ...(operation === 'findReferences' ? { context: { includeDeclaration: true } } : {}),
  193. }
  194. const requestId = this.connection.peekNextId()
  195. const send = this.connection.request(requestMethod(operation), params)
  196. if (signal === undefined) return send
  197. return this.raceAbort(send, requestId, signal)
  198. }
  199. /**
  200. * Race a pending request against abort. On abort, send `$/cancelRequest` and give the server a
  201. * bounded grace to acknowledge; if it does not settle in time, invalidate and tear down the
  202. * instance so the still-active request cannot overlap the next queued query's document lifecycle.
  203. */
  204. private async raceAbort(send: Promise<unknown>, requestId: number, signal: AbortSignal): Promise<unknown> {
  205. try {
  206. return await abortable(send, signal)
  207. } catch (error) {
  208. if (!signal.aborted) throw error
  209. this.connection.cancel(requestId)
  210. // Wait, bounded, for the server to honor the cancellation. If it does not, the request is still
  211. // running: terminate the instance (disposal awaits process close) so nothing outlives the query.
  212. const grace = deadline(undefined, this.spec.killGraceMs, 'LSP_CANCEL_GRACE')
  213. try {
  214. // `settled` is true if the request finished (either outcome) before the grace elapsed.
  215. const settled = await Promise.race([
  216. send.then(markSettled, markSettled),
  217. new Promise<boolean>((resolve) => {
  218. /* v8 ignore next -- the cancel-grace deadline signal is freshly armed and not yet aborted here; defensive. */
  219. if (grace.signal.aborted) { resolve(false); return }
  220. grace.signal.addEventListener('abort', () => { resolve(false) }, { once: true })
  221. }),
  222. ])
  223. if (!settled) await this.startTeardown()
  224. } finally {
  225. grace[Symbol.dispose]()
  226. }
  227. throw error
  228. }
  229. }
  230. private normalize(operation: LspOperation, payload: unknown): LspQueryResult {
  231. if (operation === 'hover') {
  232. return { kind: 'hover', hover: normalizeHover(payload) }
  233. }
  234. // The filesystem provider owns URI syntax for the execution platform, which may differ from the
  235. // harness host. Preserve that coordinate through rendering instead of reparsing `spec.cwd` there.
  236. return { kind: 'locations', locations: normalizeLocations(payload), resolvedWorkspaceUri: this.spec.workspaceUri }
  237. }
  238. private answerServerRequest(method: string, params: unknown): Promise<unknown> {
  239. if (method === 'workspace/configuration') {
  240. // Answer every requested item with the one static configuration value.
  241. const record = params as { items?: unknown[] } | null
  242. /* v8 ignore next -- a configuration request always carries an items array; the empty fallback is defensive. */
  243. const items = Array.isArray(record?.items) ? record.items : []
  244. return Promise.resolve(items.map(() => this.spec.configuration))
  245. }
  246. if (LIFECYCLE_NOOP_METHODS.has(method)) {
  247. // Accept lifecycle bookkeeping requests with an empty result; we register nothing dynamic.
  248. return Promise.resolve(null)
  249. }
  250. if (method === 'workspace/applyEdit') {
  251. // This host never applies edits or runs commands.
  252. return Promise.reject(new Error('workspace/applyEdit is not permitted by this host'))
  253. }
  254. return Promise.reject(new Error(`unsupported server request: ${method}`))
  255. }
  256. /**
  257. * Reject queued work, attempt graceful `shutdown`/`exit`, then request the
  258. * provider's termination procedure and await process close and range quiescence.
  259. */
  260. async dispose(): Promise<void> {
  261. await this.startTeardown()
  262. }
  263. /** Publish disposal once and make every caller await the same quiescence boundary. */
  264. private startTeardown(): Promise<void> {
  265. this.disposed = true
  266. this.teardownPromise ??= this.tearDown()
  267. return this.teardownPromise
  268. }
  269. private async tearDown(): Promise<void> {
  270. const shutdownDeadline = deadline(undefined, this.spec.shutdownTimeoutMs, 'LSP_SHUTDOWN')
  271. try {
  272. await this.gracefulShutdown(shutdownDeadline.signal)
  273. } catch {
  274. // Graceful shutdown failed or timed out; managed-range cleanup below remains authoritative.
  275. } finally {
  276. shutdownDeadline[Symbol.dispose]()
  277. }
  278. await this.forceTerminate()
  279. }
  280. /** Best-effort LSP `shutdown`/`exit`, including process close, bounded by `signal`. */
  281. private async gracefulShutdown(signal: AbortSignal): Promise<void> {
  282. await abortable(this.connection.request('shutdown', null), signal)
  283. await this.connection.notify('exit', null)
  284. await abortable(this.connection.closed, signal)
  285. }
  286. /**
  287. * Start the provider's termination procedure, then await command transport
  288. * and managed-range exit. The awaits are unbounded on purpose: quiescence,
  289. * not another consumer timer, is the postcondition disposal owes its callers.
  290. */
  291. private async forceTerminate(): Promise<void> {
  292. this.connection.terminate()
  293. await Promise.all([
  294. this.connection.closed,
  295. this.connection.waitForProcessTreeExit(),
  296. ])
  297. }
  298. }
  299. /** Server→client request methods this host acknowledges with an empty result (no dynamic registration). */
  300. const LIFECYCLE_NOOP_METHODS = new Set([
  301. 'window/workDoneProgress/create',
  302. 'client/registerCapability',
  303. 'client/unregisterCapability',
  304. ])
  305. /** Mark a settled request in the cancel-grace race (either outcome means the request finished). */
  306. function markSettled(): boolean {
  307. return true
  308. }
  309. /**
  310. * The client capabilities advertised at `initialize`: UTF-16 positions, workspace folders and
  311. * configuration, markdown/plaintext hover, and link support for definition/implementation. No
  312. * dynamic registration; the server's returned capabilities are authoritative.
  313. */
  314. const CLIENT_CAPABILITIES = {
  315. general: { positionEncodings: ['utf-16'] },
  316. workspace: { workspaceFolders: true, configuration: true },
  317. textDocument: {
  318. synchronization: { dynamicRegistration: false },
  319. hover: { contentFormat: ['markdown', 'plaintext'] },
  320. definition: { linkSupport: true },
  321. implementation: { linkSupport: true },
  322. references: {},
  323. },
  324. } as const