instance.spec.ts 19 KB

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