instance.spec.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381
  1. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  2. import { mkdtemp, mkdir, readFile, rm, writeFile, realpath } from 'node:fs/promises'
  3. import { tmpdir } from 'node:os'
  4. import { join } from 'node:path'
  5. import { pathToFileURL, fileURLToPath } from 'node:url'
  6. import { LspInstance, readHostSource } from '@deepseek-ai/dsh-lsp-local'
  7. import { encodeMessage } from '@deepseek-ai/dsh-lsp-local'
  8. import type { ConnectionWriter } from '@deepseek-ai/dsh-lsp-local/src/connection.ts'
  9. import { escalateProcessTree } from '@deepseek-ai/dsh-lsp-local/src/instance.ts'
  10. import type { InstanceSpec } from '@deepseek-ai/dsh-lsp-local/src/instance.ts'
  11. import type { LspProviderQuery, LspQueryResult } from '@deepseek-ai/dsh-lsp'
  12. const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url))
  13. let root: string
  14. let ws: string
  15. let live: LspInstance[] = []
  16. beforeEach(async () => {
  17. root = await realpath(await mkdtemp(join(tmpdir(), 'lsp-inst-')))
  18. ws = join(root, 'ws')
  19. await mkdir(ws)
  20. await writeFile(join(ws, 'a.ts'), 'const x = 1\n')
  21. })
  22. afterEach(async () => {
  23. for (const instance of live) await instance.dispose()
  24. live = []
  25. await rm(root, { recursive: true, force: true })
  26. })
  27. function makeInstance(
  28. env: Record<string, string> = {},
  29. overrides: Partial<InstanceSpec> = {},
  30. writer?: ConnectionWriter,
  31. ): LspInstance {
  32. const instance = new LspInstance({
  33. command: process.execPath,
  34. args: [fixtureServer],
  35. cwd: ws,
  36. env: { ...process.env as Record<string, string>, ...env },
  37. configuration: { setting: 42 },
  38. initializationOptions: { init: true },
  39. maxMessageBytes: 16_000_000,
  40. maxStderrBytes: 100_000,
  41. shutdownTimeoutMs: 200,
  42. killGraceMs: 200,
  43. ...overrides,
  44. }, writer)
  45. live.push(instance)
  46. return instance
  47. }
  48. function query(operation: LspProviderQuery['operation'] = 'goToDefinition'): LspProviderQuery {
  49. return { operation, filePath: 'a.ts', position: { line: 0, character: 6 }, workspaceRoot: ws, languageId: 'typescript' }
  50. }
  51. /** Run a query against an instance, reading the source first the way the provider does. */
  52. async function run(instance: LspInstance, operation: LspProviderQuery['operation'] = 'goToDefinition', signal?: AbortSignal): Promise<LspQueryResult> {
  53. const source = await readHostSource('a.ts', ws, 4_000_000)
  54. return instance.query(query(operation), source, signal)
  55. }
  56. /** Build an instance whose "server" is an inline node script (for teardown-escalation control). */
  57. function scriptInstance(script: string, overrides: Partial<InstanceSpec> = {}): LspInstance {
  58. const instance = new LspInstance({
  59. command: process.execPath,
  60. args: ['-e', script],
  61. cwd: ws,
  62. env: { ...process.env as Record<string, string> },
  63. configuration: null,
  64. initializationOptions: null,
  65. maxMessageBytes: 16_000_000,
  66. maxStderrBytes: 100_000,
  67. shutdownTimeoutMs: 150,
  68. killGraceMs: 150,
  69. ...overrides,
  70. })
  71. live.push(instance)
  72. return instance
  73. }
  74. /** An inline server that answers initialize + definition and echoes a location. */
  75. const RESPONDING_SERVER =
  76. 'let b=Buffer.alloc(0);'
  77. + 'const fr=(o)=>{const x=Buffer.from(JSON.stringify({jsonrpc:"2.0",...o}));return Buffer.concat([Buffer.from(`Content-Length: ${x.length}\\r\\n\\r\\n`),x]);};'
  78. + 'process.stdin.on("data",c=>{b=Buffer.concat([b,c]);for(;;){const s=b.indexOf("\\r\\n\\r\\n");if(s<0)break;const len=Number(/(\\d+)/.exec(b.toString("ascii",0,s))[1]);if(b.length<s+4+len)break;const m=JSON.parse(b.toString("utf8",s+4,s+4+len));b=b.subarray(s+4+len);'
  79. + 'if(m.method==="initialize")process.stdout.write(fr({id:m.id,result:{capabilities:{positionEncoding:"utf-16",textDocumentSync:1,definitionProvider:true}}}));'
  80. + 'else if(m.method==="textDocument/definition")process.stdout.write(fr({id:m.id,result:null}));'
  81. + '}});'
  82. const locJson = () => JSON.stringify({ uri: pathToFileURL(join(ws, 'a.ts')).href, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 3 } } })
  83. describe('LspInstance server-request handling', () => {
  84. it('answers workspace/configuration with the static config per item', async () => {
  85. const instance = makeInstance({ LSP_FAKE_ON_OPEN: 'configuration', LSP_FAKE_DEF: locJson() })
  86. // The query drives didOpen, which makes the fake emit workspace/configuration; a healthy answer
  87. // keeps the query working.
  88. await expect(run(instance, 'goToDefinition')).resolves.toMatchObject({ kind: 'locations' })
  89. })
  90. it('accepts a lifecycle client/registerCapability request', async () => {
  91. const instance = makeInstance({ LSP_FAKE_ON_OPEN: 'lifecycle', LSP_FAKE_DEF: 'null' })
  92. await expect(run(instance, 'goToDefinition')).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws })
  93. })
  94. it('rejects a workspace/applyEdit request but keeps serving', async () => {
  95. const instance = makeInstance({ LSP_FAKE_ON_OPEN: 'applyEdit', LSP_FAKE_DEF: 'null' })
  96. await expect(run(instance, 'goToDefinition')).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws })
  97. })
  98. it('rejects an unknown server request but keeps serving', async () => {
  99. const instance = makeInstance({ LSP_FAKE_ON_OPEN: 'unknown', LSP_FAKE_DEF: 'null' })
  100. await expect(run(instance, 'goToDefinition')).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws })
  101. })
  102. })
  103. describe('LspInstance query and abort', () => {
  104. it('sends includeDeclaration for references', async () => {
  105. const instance = makeInstance({ LSP_FAKE_REFS: JSON.stringify([JSON.parse(locJson())]) })
  106. await expect(run(instance, 'findReferences')).resolves.toMatchObject({ kind: 'locations' })
  107. })
  108. it('rejects a query aborted before it starts', async () => {
  109. const instance = makeInstance({ LSP_FAKE_DEF: 'null' })
  110. const controller = new AbortController()
  111. controller.abort(new Error('pre-abort'))
  112. await expect(run(instance, 'goToDefinition', controller.signal)).rejects.toThrow(/pre-abort/)
  113. })
  114. it('cancels an in-flight request on abort and rejects', async () => {
  115. const instance = makeInstance({ LSP_FAKE_HANG: '1' })
  116. const controller = new AbortController()
  117. // Warm the instance first so the abort lands during the hanging request, not during startup.
  118. const pending = run(instance, 'goToDefinition', controller.signal)
  119. await new Promise<void>(resolve => setTimeout(resolve, 300))
  120. controller.abort(new Error('mid-flight'))
  121. await expect(pending).rejects.toThrow(/mid-flight/)
  122. })
  123. it('terminates the instance when the server ignores $/cancelRequest past the grace', async () => {
  124. // The hang server never honors cancellation, so after the bounded grace the instance must be torn
  125. // down (its process closed) rather than left with an active request.
  126. const instance = makeInstance({ LSP_FAKE_HANG: '1' }, { killGraceMs: 100 })
  127. const controller = new AbortController()
  128. const pending = run(instance, 'goToDefinition', controller.signal)
  129. await new Promise<void>(resolve => setTimeout(resolve, 300))
  130. controller.abort(new Error('mid-flight'))
  131. await expect(pending).rejects.toThrow(/mid-flight/)
  132. expect(instance.dead).toBe(true)
  133. })
  134. it('resolves the cancel grace when the server honors $/cancelRequest', async () => {
  135. // A server that answers $/cancelRequest by settling the pending request lets the grace race
  136. // resolve via the request rather than the timeout, so the instance is NOT force-terminated.
  137. const script = 'let b=Buffer.alloc(0),reqId=null;'
  138. + 'const fr=(o)=>{const x=Buffer.from(JSON.stringify({jsonrpc:"2.0",...o}));return Buffer.concat([Buffer.from(`Content-Length: ${x.length}\\r\\n\\r\\n`),x]);};'
  139. + 'process.stdin.on("data",c=>{b=Buffer.concat([b,c]);for(;;){const s=b.indexOf("\\r\\n\\r\\n");if(s<0)break;const len=Number(/(\\d+)/.exec(b.toString("ascii",0,s))[1]);if(b.length<s+4+len)break;const m=JSON.parse(b.toString("utf8",s+4,s+4+len));b=b.subarray(s+4+len);'
  140. + 'if(m.method==="initialize")process.stdout.write(fr({id:m.id,result:{capabilities:{positionEncoding:"utf-16",textDocumentSync:1,definitionProvider:true}}}));'
  141. + 'else if(m.method==="textDocument/definition")reqId=m.id;'
  142. + 'else if(m.method==="$/cancelRequest"&&reqId!==null)process.stdout.write(fr({id:reqId,error:{code:-32800,message:"request cancelled"}}));'
  143. + 'else if(m.method==="shutdown")process.stdout.write(fr({id:m.id,result:null}));'
  144. + 'else if(m.method==="exit")process.exit(0);'
  145. + '}});'
  146. const instance = scriptInstance(script, { killGraceMs: 2_000 })
  147. const controller = new AbortController()
  148. const pending = run(instance, 'goToDefinition', controller.signal)
  149. await new Promise<void>(resolve => setTimeout(resolve, 300))
  150. controller.abort(new Error('mid-flight'))
  151. await expect(pending).rejects.toThrow(/mid-flight/)
  152. // The server acknowledged cancellation within grace, so the instance was not force-killed.
  153. expect(instance.dead).toBe(false)
  154. await instance.dispose()
  155. })
  156. it('observes abort while awaiting a slow initialize handshake', async () => {
  157. // A server that answers nothing (not even initialize) leaves `ready` pending; an abort must be
  158. // observed during that wait instead of hanging the tool-timeout signal.
  159. const instance = scriptInstance('setInterval(()=>{},1000)', { killGraceMs: 100 })
  160. const controller = new AbortController()
  161. const pending = run(instance, 'goToDefinition', controller.signal)
  162. await new Promise<void>(resolve => setTimeout(resolve, 150))
  163. controller.abort(new Error('handshake-abort'))
  164. await expect(pending).rejects.toThrow(/handshake-abort/)
  165. await instance.dispose()
  166. })
  167. it('terminates when abort interrupts a backpressured didOpen write', async () => {
  168. // The fixture consumes initialized, then stops reading. A document larger than the stdio pipe
  169. // keeps didOpen's write callback pending until cancellation forces bounded process teardown.
  170. await writeFile(join(ws, 'a.ts'), 'x'.repeat(2_000_000))
  171. const marker = join(root, 'initialized.log')
  172. const instance = makeInstance({
  173. LSP_FAKE_INITIALIZED_MARKER: marker,
  174. LSP_FAKE_PAUSE_STDIN_AFTER_INITIALIZED: '1',
  175. }, {
  176. shutdownTimeoutMs: 100,
  177. killGraceMs: 100,
  178. })
  179. const controller = new AbortController()
  180. const pending = run(instance, 'goToDefinition', controller.signal)
  181. await waitForFile(marker)
  182. // Let the client enter the large didOpen write after the fixture has paused stdin.
  183. await new Promise<void>(resolve => setTimeout(resolve, 100))
  184. controller.abort(new Error('didOpen-abort'))
  185. await expect(pending).rejects.toThrow(/didOpen-abort/)
  186. expect(instance.dead).toBe(true)
  187. })
  188. it('terminates when stdin fails during the didOpen write', async () => {
  189. const instance = makeInstance({}, {
  190. shutdownTimeoutMs: 100,
  191. killGraceMs: 100,
  192. }, failingWriter('textDocument/didOpen'))
  193. await expect(run(instance, 'goToDefinition')).rejects.toThrow()
  194. expect(instance.dead).toBe(true)
  195. })
  196. it('awaits process exit before rejecting a request write failure', async () => {
  197. const instance = makeInstance({}, {
  198. shutdownTimeoutMs: 100,
  199. killGraceMs: 100,
  200. }, failingWriter('textDocument/definition'))
  201. // The pid is observed only to prove the owned subprocess reached quiescence before rejection.
  202. const pid = (instance as unknown as { connection: { pid: number } }).connection.pid
  203. await expect(run(instance, 'goToDefinition')).rejects.toThrow(/fixture textDocument\/definition failure/)
  204. expect(processAlive(pid)).toBe(false)
  205. })
  206. it('rejects when the server lacks the operation capability', async () => {
  207. const instance = makeInstance({ LSP_FAKE_CAPS: JSON.stringify({ definitionProvider: false }), LSP_FAKE_DEF: 'null' })
  208. await expect(run(instance, 'goToDefinition')).rejects.toThrow(/does not support goToDefinition/)
  209. })
  210. it('propagates a server error response even when a signal is supplied (not an abort)', async () => {
  211. // A live signal is passed, but the request fails for a server reason; the catch must rethrow
  212. // without treating it as an abort.
  213. const instance = makeInstance({ LSP_FAKE_ERROR: '1' })
  214. const controller = new AbortController()
  215. await expect(run(instance, 'goToDefinition', controller.signal)).rejects.toThrow(/server refused/)
  216. })
  217. it('keeps a settled result but awaits teardown when didClose cannot be written', async () => {
  218. const instance = makeInstance({
  219. LSP_FAKE_DEF: 'null',
  220. }, { shutdownTimeoutMs: 100, killGraceMs: 100 }, failingWriter('textDocument/didClose'))
  221. await expect(run(instance, 'goToDefinition')).resolves.toEqual({
  222. kind: 'locations',
  223. locations: [],
  224. resolvedWorkspaceRoot: ws,
  225. })
  226. expect(instance.dead).toBe(true)
  227. })
  228. })
  229. describe('LspInstance disposal', () => {
  230. it('escalates only when the process tree survives its grace period', () => {
  231. const forceKill = vi.fn()
  232. escalateProcessTree(false, forceKill)
  233. expect(forceKill).toHaveBeenCalledOnce()
  234. escalateProcessTree(true, forceKill)
  235. expect(forceKill).toHaveBeenCalledOnce()
  236. })
  237. it('lets a server finish protocol exit before signal escalation', async () => {
  238. const marker = join(root, 'graceful-exit.log')
  239. const instance = makeInstance({
  240. LSP_FAKE_DEF: 'null',
  241. LSP_FAKE_EXIT_DELAY_MS: '75',
  242. LSP_FAKE_EXIT_MARKER: marker,
  243. }, { shutdownTimeoutMs: 500 })
  244. await run(instance, 'goToDefinition')
  245. await instance.dispose()
  246. expect(await readFile(marker, 'utf8')).toBe('EXIT\nCLEAN\n')
  247. })
  248. it('is idempotent — a second dispose awaits close without error', async () => {
  249. const instance = makeInstance({ LSP_FAKE_DEF: 'null' })
  250. await run(instance, 'goToDefinition')
  251. await instance.dispose()
  252. await expect(instance.dispose()).resolves.toBeUndefined()
  253. })
  254. it('rejects a query after disposal', async () => {
  255. const instance = makeInstance({ LSP_FAKE_DEF: 'null' })
  256. await run(instance, 'goToDefinition')
  257. await instance.dispose()
  258. await expect(run(instance, 'goToDefinition')).rejects.toThrow(expect.objectContaining({ code: 'LSP_DISPOSED' }))
  259. })
  260. it('reports dead after the process closes', async () => {
  261. const instance = makeInstance({ LSP_FAKE_DEF: 'null' })
  262. await run(instance, 'goToDefinition')
  263. await instance.dispose()
  264. expect(instance.dead).toBe(true)
  265. })
  266. it('escalates to SIGKILL when the server ignores shutdown and SIGTERM', async () => {
  267. // Server answers initialize, ignores shutdown, and traps SIGTERM so only SIGKILL stops it.
  268. const script = RESPONDING_SERVER + 'process.on("SIGTERM",()=>{});'
  269. const instance = scriptInstance(script, { shutdownTimeoutMs: 100, killGraceMs: 100 })
  270. await run(instance, 'goToDefinition')
  271. await expect(instance.dispose()).resolves.toBeUndefined()
  272. })
  273. it('awaits a surviving process-tree helper on every concurrent dispose', async () => {
  274. const marker = join(root, 'helper.pid')
  275. const helper = 'process.on("SIGTERM",()=>{});setInterval(()=>{},1000);'
  276. const script = 'const{spawn}=require("node:child_process");const{writeFileSync}=require("node:fs");'
  277. + `const helper=spawn(process.execPath,["-e",${JSON.stringify(helper)}],{stdio:"ignore"});`
  278. + `writeFileSync(${JSON.stringify(marker)},String(helper.pid));`
  279. + RESPONDING_SERVER
  280. const instance = scriptInstance(script, { shutdownTimeoutMs: 100, killGraceMs: 100 })
  281. await run(instance, 'goToDefinition')
  282. const helperPid = Number(await readFile(marker, 'utf8'))
  283. try {
  284. const first = instance.dispose()
  285. await instance.dispose()
  286. expect(processAlive(helperPid)).toBe(false)
  287. await first
  288. } finally {
  289. if (processAlive(helperPid)) process.kill(helperPid, 'SIGKILL')
  290. await waitForProcessExit(helperPid)
  291. }
  292. })
  293. it('carries a non-Error abort reason as a generic aborted error', async () => {
  294. const instance = makeInstance({ LSP_FAKE_HANG: '1' })
  295. const controller = new AbortController()
  296. const pending = run(instance, 'goToDefinition', controller.signal)
  297. await new Promise<void>(resolve => setTimeout(resolve, 200))
  298. controller.abort('a string reason, not an Error')
  299. await expect(pending).rejects.toThrow(/aborted/)
  300. })
  301. })
  302. /** Probe a pid without changing its state. */
  303. function processAlive(pid: number): boolean {
  304. try {
  305. process.kill(pid, 0)
  306. return true
  307. } catch (error) {
  308. if ((error as NodeJS.ErrnoException).code === 'ESRCH') return false
  309. throw error
  310. }
  311. }
  312. /** Wait until a process id disappears so temporary-workspace cleanup cannot race handle release. */
  313. async function waitForProcessExit(pid: number, timeoutMs = 3_000): Promise<void> {
  314. const started = Date.now()
  315. while (processAlive(pid)) {
  316. if (Date.now() - started > timeoutMs) throw new Error(`process ${pid} did not exit`)
  317. await new Promise<void>(resolve => setTimeout(resolve, 10))
  318. }
  319. }
  320. /** Write normally except for one method whose callback receives a deterministic transport error. */
  321. function failingWriter(method: string): ConnectionWriter {
  322. return (stdin, message, done) => {
  323. if ((message as { method?: unknown }).method === method) {
  324. queueMicrotask(() => { done(new Error(`fixture ${method} failure`)) })
  325. return
  326. }
  327. stdin.write(encodeMessage(message), done)
  328. }
  329. }
  330. /** Wait until a fixture marker exists, bounded so a broken handshake cannot hang the test. */
  331. async function waitForFile(path: string, timeoutMs = 3000): Promise<void> {
  332. const started = Date.now()
  333. for (;;) {
  334. try {
  335. await readFile(path)
  336. return
  337. } catch (error) {
  338. if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
  339. }
  340. if (Date.now() - started > timeoutMs) throw new Error('waitForFile timed out')
  341. await new Promise<void>(resolve => setTimeout(resolve, 10))
  342. }
  343. }