instance.spec.ts 17 KB

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