lifecycle.spec.ts 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620
  1. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  2. import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
  3. import { 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 Lsp, { type LspProvider, type LspQueryRequest, type LspQueryResult } from '@deepseek-ai/dsh-lsp'
  9. import { deadline } from '@deepseek-ai/dsh-timeout'
  10. import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
  11. import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
  12. import * as LspLocal from '@deepseek-ai/dsh-lsp-stdio'
  13. import type { LspLocalServerConfig } from '@deepseek-ai/dsh-lsp-stdio'
  14. import { LspConnection } from '../src/connection.ts'
  15. const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url))
  16. let root: string
  17. let ws: string
  18. beforeEach(async () => {
  19. root = await realpath(await mkdtemp(join(tmpdir(), 'lsp-local-')))
  20. ws = join(root, 'ws')
  21. await mkdir(ws)
  22. await writeFile(join(ws, 'a.ts'), 'const x = 1\nconst y = x\n')
  23. })
  24. afterEach(async () => {
  25. await rm(root, { recursive: true, force: true })
  26. })
  27. /** One fake stdio server entry with optional behavior and host-bound overrides. */
  28. function fakeServer(fakeEnv: Record<string, string> = {}, overrides: Partial<LspLocalServerConfig> = {}): LspLocalServerConfig {
  29. return {
  30. command: process.execPath,
  31. args: [fixtureServer],
  32. env: { ...fakeEnv },
  33. extensionToLanguage: { '.ts': 'typescript' },
  34. ...overrides,
  35. }
  36. }
  37. /** Mount the real seam + lsp-stdio plugin driving one fake server. */
  38. async function mount(
  39. fakeEnv: Record<string, string> = {},
  40. overrides: Partial<LspLocalServerConfig> = {},
  41. captureProvider?: (provider: LspProvider) => void,
  42. configureSubprocess?: (ctx: Context) => void,
  43. ): Promise<Context> {
  44. const ctx = new Context()
  45. await ctx.plugin(Lsp)
  46. await ctx.plugin(LocalSubprocessRuntime)
  47. configureSubprocess?.(ctx)
  48. await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
  49. const register = ctx.lsp.registerProvider.bind(ctx.lsp)
  50. const registrationSpy = captureProvider === undefined
  51. ? undefined
  52. : vi.spyOn(ctx.lsp, 'registerProvider').mockImplementation((provider) => {
  53. captureProvider(provider)
  54. return register(provider)
  55. })
  56. try {
  57. await ctx.plugin(LspLocal, {
  58. servers: { fake: fakeServer(fakeEnv, overrides) },
  59. })
  60. } finally {
  61. registrationSpy?.mockRestore()
  62. }
  63. return ctx
  64. }
  65. function query(operation: LspQueryRequest['operation'], filePath = 'a.ts'): LspQueryRequest {
  66. return { operation, filePath, position: { line: 0, character: 6 }, workspaceRoot: ws }
  67. }
  68. /** A single Location JSON pointing into the workspace. */
  69. function locationJson(line: number): unknown {
  70. return { uri: pathToFileURL(join(ws, 'a.ts')).href, range: { start: { line, character: 0 }, end: { line, character: 3 } } }
  71. }
  72. describe('lsp-stdio end to end over a fake server', () => {
  73. it('routes different extensions to independent configured servers', async () => {
  74. await writeFile(join(ws, 'a.py'), 'x = 1\n')
  75. const ctx = new Context()
  76. await ctx.plugin(Lsp)
  77. await ctx.plugin(LocalSubprocessRuntime)
  78. await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
  79. await ctx.plugin(LspLocal, {
  80. servers: {
  81. typescript: fakeServer({ LSP_FAKE_HOVER: JSON.stringify({ contents: 'ts' }) }),
  82. python: fakeServer(
  83. { LSP_FAKE_HOVER: JSON.stringify({ contents: 'py' }) },
  84. { extensionToLanguage: { '.py': 'python' } },
  85. ),
  86. },
  87. })
  88. expect(await ctx.lsp.query(query('hover', 'a.ts'))).toEqual({ kind: 'hover', hover: { contents: 'ts' } })
  89. expect(await ctx.lsp.query(query('hover', 'a.py'))).toEqual({ kind: 'hover', hover: { contents: 'py' } })
  90. await ctx.fiber.dispose()
  91. })
  92. it('resolves definition to normalized locations', async () => {
  93. const ctx = await mount({ LSP_FAKE_DEF: JSON.stringify(locationJson(0)) })
  94. const result = await ctx.lsp.query(query('goToDefinition'))
  95. expect(result).toEqual<LspQueryResult>({
  96. kind: 'locations',
  97. locations: [{ uri: pathToFileURL(join(ws, 'a.ts')).href, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 3 } } }],
  98. resolvedWorkspaceUri: pathToFileURL(ws).href,
  99. })
  100. await ctx.fiber.dispose()
  101. })
  102. it('maps a LocationLink for implementation', async () => {
  103. const link = { targetUri: pathToFileURL(join(ws, 'a.ts')).href, targetSelectionRange: { start: { line: 1, character: 0 }, end: { line: 1, character: 2 } } }
  104. const ctx = await mount({ LSP_FAKE_IMPL: JSON.stringify([link]) })
  105. const result = await ctx.lsp.query(query('goToImplementation'))
  106. expect(result).toMatchObject({ kind: 'locations', locations: [{ range: { start: { line: 1, character: 0 } } }] })
  107. await ctx.fiber.dispose()
  108. })
  109. it('returns references (server includes the declaration)', async () => {
  110. const ctx = await mount({ LSP_FAKE_REFS: JSON.stringify([locationJson(0), locationJson(1)]) })
  111. const result = await ctx.lsp.query(query('findReferences'))
  112. expect(result).toMatchObject({ kind: 'locations' })
  113. if (result.kind !== 'locations') throw new Error('expected locations')
  114. expect(result.locations).toHaveLength(2)
  115. await ctx.fiber.dispose()
  116. })
  117. it('normalizes a hover MarkupContent', async () => {
  118. const ctx = await mount({ LSP_FAKE_HOVER: JSON.stringify({ contents: { kind: 'markdown', value: 'docs' } }) })
  119. const result = await ctx.lsp.query(query('hover'))
  120. expect(result).toEqual({ kind: 'hover', hover: { contents: 'docs' } })
  121. await ctx.fiber.dispose()
  122. })
  123. it('returns an empty locations result for a null definition', async () => {
  124. const ctx = await mount({ LSP_FAKE_DEF: 'null' })
  125. expect(await ctx.lsp.query(query('goToDefinition'))).toEqual({ kind: 'locations', locations: [], resolvedWorkspaceUri: pathToFileURL(ws).href })
  126. await ctx.fiber.dispose()
  127. })
  128. it('returns a null hover for a null result', async () => {
  129. const ctx = await mount({ LSP_FAKE_HOVER: 'null' })
  130. expect(await ctx.lsp.query(query('hover'))).toEqual({ kind: 'hover', hover: null })
  131. await ctx.fiber.dispose()
  132. })
  133. it('rejects a non-utf-16 position encoding at initialize without retrying', async () => {
  134. const marker = join(root, 'initialize-rejection-exit.log')
  135. const ctx = await mount({
  136. LSP_FAKE_ENCODING: 'utf-8',
  137. LSP_FAKE_DEF: 'null',
  138. LSP_FAKE_EXIT_MARKER: marker,
  139. })
  140. await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toThrow(/unsupported position encoding/)
  141. expect(await readFile(marker, 'utf8')).toBe('EXIT\nCLEAN\n')
  142. await ctx.fiber.dispose()
  143. })
  144. it('does not pool a poisoned instance when initialize rejects', async () => {
  145. // A utf-8 server makes `initialize` reject; the instance must be torn down (not left with a
  146. // permanently-rejecting `ready`) so a later query starts a fresh process rather than reusing it.
  147. const ctx = await mount({ LSP_FAKE_ENCODING: 'utf-8', LSP_FAKE_DEF: 'null' })
  148. await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toThrow(/unsupported position encoding/)
  149. // A second query must also fail the same way (fresh instance), and must NOT hang on a poisoned one.
  150. await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toThrow(/unsupported position encoding/)
  151. await ctx.fiber.dispose()
  152. })
  153. it('preserves a query failure with final disposal failure and evicts the instance', async () => {
  154. const teardownFailure = new Error('managed range observation failed')
  155. let provider: LspProvider | undefined
  156. let firstSpawn = true
  157. let restoreFirstWait: (() => void) | undefined
  158. const ctx = await mount(
  159. { LSP_FAKE_ENCODING: 'utf-8', LSP_FAKE_DEF: 'null' },
  160. { shutdownTimeoutMs: 100, killGraceMs: 100 },
  161. (registered) => { provider = registered },
  162. (mounted) => {
  163. const spawn = mounted.subprocess.spawn.bind(mounted.subprocess)
  164. vi.spyOn(mounted.subprocess, 'spawn').mockImplementation((spec) => {
  165. const handle = spawn(spec)
  166. if (!firstSpawn) return handle
  167. firstSpawn = false
  168. const waitForExit = handle.waitForExit.bind(handle)
  169. const waitSpy = vi.spyOn(handle, 'waitForExit')
  170. .mockImplementation(async (signal) => {
  171. await waitForExit(signal)
  172. throw teardownFailure
  173. })
  174. restoreFirstWait = () => { waitSpy.mockRestore() }
  175. return handle
  176. })
  177. },
  178. )
  179. const failure = await ctx.lsp.query(query('goToDefinition')).then(
  180. () => undefined,
  181. (error: unknown) => error,
  182. )
  183. expect(failure).toBeInstanceOf(AggregateError)
  184. const errors = (failure as AggregateError).errors as unknown[]
  185. expect(errors).toHaveLength(2)
  186. expect(errors[0]).toBeInstanceOf(Error)
  187. expect((errors[0] as Error).message).toContain('unsupported position encoding')
  188. expect(errors[1]).toBe(teardownFailure)
  189. expect((failure as AggregateError).message).toBe('LSP operation and teardown failed')
  190. restoreFirstWait?.()
  191. if (provider === undefined) throw new Error('expected lsp-stdio to register a provider')
  192. const instances = (provider as unknown as { readonly instances: ReadonlyMap<string, unknown> }).instances
  193. expect(instances.size).toBe(0)
  194. await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toThrow(/unsupported position encoding/)
  195. expect(instances.size).toBe(0)
  196. await ctx.fiber.dispose()
  197. })
  198. it('reports final disposal failure after a settled query and evicts the instance', async () => {
  199. const closeFailure = new Error('fixture textDocument/didClose failure')
  200. const teardownFailure = new Error('managed range observation failed')
  201. const notify = Object.getOwnPropertyDescriptor(LspConnection.prototype, 'notify')?.value as LspConnection['notify']
  202. const notifySpy = vi.spyOn(LspConnection.prototype, 'notify').mockImplementation(function (this: LspConnection, method, params) {
  203. if (method === 'textDocument/didClose') return Promise.reject(closeFailure)
  204. return notify.call(this, method, params)
  205. })
  206. let provider: LspProvider | undefined
  207. let restoreFirstWait: (() => void) | undefined
  208. const ctx = await mount(
  209. { LSP_FAKE_DEF: 'null' },
  210. { shutdownTimeoutMs: 100, killGraceMs: 100 },
  211. (registered) => { provider = registered },
  212. (mounted) => {
  213. const spawn = mounted.subprocess.spawn.bind(mounted.subprocess)
  214. let firstSpawn = true
  215. vi.spyOn(mounted.subprocess, 'spawn').mockImplementation((spec) => {
  216. const handle = spawn(spec)
  217. if (!firstSpawn) return handle
  218. firstSpawn = false
  219. const waitForExit = handle.waitForExit.bind(handle)
  220. const waitSpy = vi.spyOn(handle, 'waitForExit').mockImplementation(async (signal) => {
  221. await waitForExit(signal)
  222. throw teardownFailure
  223. })
  224. restoreFirstWait = () => { waitSpy.mockRestore() }
  225. return handle
  226. })
  227. },
  228. )
  229. try {
  230. await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toBe(teardownFailure)
  231. if (provider === undefined) throw new Error('expected lsp-stdio to register a provider')
  232. const instances = (provider as unknown as { readonly instances: ReadonlyMap<string, unknown> }).instances
  233. expect(instances.size).toBe(0)
  234. restoreFirstWait?.()
  235. notifySpy.mockRestore()
  236. await expect(ctx.lsp.query(query('goToDefinition'))).resolves.toMatchObject({ kind: 'locations' })
  237. } finally {
  238. restoreFirstWait?.()
  239. notifySpy.mockRestore()
  240. await ctx.fiber.dispose()
  241. }
  242. })
  243. it('rejects a server without transient-open sync (None)', async () => {
  244. const ctx = await mount({ LSP_FAKE_SYNC: '0', LSP_FAKE_DEF: 'null' })
  245. await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toThrow(/transient textDocument\/didOpen/)
  246. await ctx.fiber.dispose()
  247. })
  248. it('accepts openClose options sync', async () => {
  249. const ctx = await mount({ LSP_FAKE_SYNC: JSON.stringify({ openClose: true, change: 2 }), LSP_FAKE_DEF: 'null' })
  250. expect(await ctx.lsp.query(query('goToDefinition'))).toEqual({ kind: 'locations', locations: [], resolvedWorkspaceUri: pathToFileURL(ws).href })
  251. await ctx.fiber.dispose()
  252. })
  253. it('fails a query for an unsupported operation', async () => {
  254. const ctx = await mount({ LSP_FAKE_CAPS: JSON.stringify({ hoverProvider: false }), LSP_FAKE_DEF: 'null' })
  255. await expect(ctx.lsp.query(query('hover'))).rejects.toThrow(/does not support hover/)
  256. await ctx.fiber.dispose()
  257. })
  258. it('rejects a source outside the workspace before startup', async () => {
  259. const outside = join(root, 'out.ts')
  260. await writeFile(outside, 'x')
  261. const ctx = await mount({ LSP_FAKE_DEF: 'null' })
  262. await expect(ctx.lsp.query({ ...query('goToDefinition'), filePath: outside })).rejects.toThrow(/outside the workspace/)
  263. await ctx.fiber.dispose()
  264. })
  265. it('serializes queries through one instance and runs them in order', async () => {
  266. const ctx = await mount({ LSP_FAKE_DEF: JSON.stringify(locationJson(0)) })
  267. const results = await Promise.all([
  268. ctx.lsp.query(query('goToDefinition')),
  269. ctx.lsp.query(query('goToDefinition')),
  270. ctx.lsp.query(query('goToDefinition')),
  271. ])
  272. for (const result of results) expect(result).toMatchObject({ kind: 'locations' })
  273. await ctx.fiber.dispose()
  274. })
  275. it('reads a queued query source only when its lifecycle starts', async () => {
  276. const marker = join(root, 'opened.jsonl')
  277. let provider: LspProvider | undefined
  278. const ctx = await mount({
  279. LSP_FAKE_DEF: 'null',
  280. LSP_FAKE_OPEN_MARKER: marker,
  281. }, {}, (registered) => { provider = registered })
  282. const firstReply = Promise.withResolvers<undefined>()
  283. const release = Promise.withResolvers<undefined>()
  284. const request = Object.getOwnPropertyDescriptor(LspConnection.prototype, 'request')?.value as LspConnection['request']
  285. let holdFirst = true
  286. const requestSpy = vi.spyOn(LspConnection.prototype, 'request').mockImplementation(async function (this: LspConnection, method, params) {
  287. const hold = method === 'textDocument/definition' && holdFirst
  288. if (hold) holdFirst = false
  289. const result = await request.call(this, method, params)
  290. if (hold) {
  291. firstReply.resolve(undefined)
  292. await release.promise
  293. }
  294. return result
  295. })
  296. const pending: Promise<unknown>[] = []
  297. try {
  298. const first = ctx.lsp.query(query('goToDefinition'))
  299. pending.push(Promise.allSettled([first]))
  300. await Promise.race([firstReply.promise, first])
  301. // The changed tail proves the second query entered the provider queue
  302. // while the first response is held, before the source rewrite starts.
  303. const queues = (provider as unknown as { queues: ReadonlyMap<unknown, Promise<void>> }).queues
  304. const firstTail = [...queues.values()][0]
  305. expect(firstTail).toBeDefined()
  306. const second = ctx.lsp.query(query('goToDefinition'))
  307. pending.push(Promise.allSettled([second]))
  308. await vi.waitFor(() => { expect([...queues.values()][0]).not.toBe(firstTail) }, { timeout: 3000 })
  309. await writeFile(join(ws, 'a.ts'), 'const changed = 2\n')
  310. release.resolve(undefined)
  311. await Promise.all([first, second])
  312. expect(await markerLines(marker)).toEqual([
  313. 'const x = 1\nconst y = x\n',
  314. 'const changed = 2\n',
  315. ])
  316. } finally {
  317. release.resolve(undefined)
  318. await Promise.all(pending)
  319. requestSpy.mockRestore()
  320. await ctx.fiber.dispose()
  321. }
  322. })
  323. it('aborts an in-flight query when the signal fires', async () => {
  324. const ctx = await mount({ LSP_FAKE_HANG: '1' })
  325. const controller = new AbortController()
  326. const pending = ctx.lsp.query(query('goToDefinition'), controller.signal)
  327. controller.abort(new Error('caller cancelled'))
  328. await expect(pending).rejects.toThrow(/cancelled/)
  329. await ctx.fiber.dispose()
  330. })
  331. it('honors an already-aborted signal before any host I/O or startup', async () => {
  332. const ctx = await mount({ LSP_FAKE_DEF: 'null' })
  333. const controller = new AbortController()
  334. controller.abort(new Error('pre-aborted'))
  335. await expect(ctx.lsp.query(query('goToDefinition'), controller.signal)).rejects.toThrow(/pre-aborted/)
  336. await ctx.fiber.dispose()
  337. })
  338. it('surfaces the server stderr tail in the exit error', async () => {
  339. // A server that writes to stderr then exits without answering: the query rejection carries the
  340. // retained stderr tail so the failure is diagnosable.
  341. const ctx = await mount({}, {
  342. command: process.execPath,
  343. args: ['-e', 'process.stderr.write("FATAL: boom\\n"); setTimeout(()=>process.exit(1), 50)'],
  344. })
  345. await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toThrow(/FATAL: boom/)
  346. await ctx.fiber.dispose()
  347. })
  348. it('classifies a timeout deadline as the abort reason', async () => {
  349. const ctx = await mount({ LSP_FAKE_HANG: '1' })
  350. using d = deadline(undefined, 50, 'TEST_TIMEOUT')
  351. await expect(ctx.lsp.query(query('goToDefinition'), d.signal)).rejects.toThrow(/TEST_TIMEOUT/)
  352. await ctx.fiber.dispose()
  353. })
  354. it('fails the active query when the server crashes on open, and replaces it next query', async () => {
  355. const ctx = await mount({ LSP_FAKE_CRASH_ON_OPEN: '1', LSP_FAKE_DEF: 'null' }, { shutdownTimeoutMs: 100, killGraceMs: 100 })
  356. await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toThrow()
  357. // A later query starts a fresh process; still crashes, but proves the slot was replaced (no hang).
  358. await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toThrow()
  359. await ctx.fiber.dispose()
  360. })
  361. it('evicts a pooled server that died while idle and serves the next query from a fresh one', async () => {
  362. // The first query succeeds, then the server exits before the second arrives, leaving a dead
  363. // instance in the pool. The next query must evict-and-replace it and still succeed, rather than
  364. // failing once on the closed connection first.
  365. let provider: LspProvider | undefined
  366. const ctx = await mount(
  367. { LSP_FAKE_EXIT_AFTER_REPLY: '1', LSP_FAKE_DEF: JSON.stringify(locationJson(0)) },
  368. {},
  369. (registered) => { provider = registered },
  370. )
  371. expect(await ctx.lsp.query(query('goToDefinition'))).toMatchObject({ kind: 'locations' })
  372. if (provider === undefined) throw new Error('expected lsp-stdio to register a provider')
  373. // This implementation-local test reaches the private pool only to synchronize with its actual
  374. // close state. A fixed wall-clock sleep can expire before a CPU-starved child runs its exit timer.
  375. const instances = (provider as unknown as {
  376. readonly instances: ReadonlyMap<string, { readonly dead: boolean }>
  377. }).instances
  378. const instance = [...instances.values()][0]
  379. // The query's finally may already have observed the exit and evicted the dead slot. When the
  380. // slot remains, synchronize with its close before proving the next query replaces it.
  381. if (instance !== undefined) await waitFor(async () => instance.dead)
  382. expect(await ctx.lsp.query(query('goToDefinition'))).toMatchObject({ kind: 'locations' })
  383. await ctx.fiber.dispose()
  384. })
  385. it('does not spawn a server when the signal aborts during source read', async () => {
  386. // Abort right after issuing the query: the abort lands while canonicalizeWorkspace/readHostSource
  387. // are awaited, so the pre-spawn recheck must reject without ever creating a pooled instance.
  388. const ctx = await mount({ LSP_FAKE_DEF: 'null' })
  389. const controller = new AbortController()
  390. const pending = ctx.lsp.query(query('goToDefinition'), controller.signal)
  391. controller.abort(new Error('mid-read cancel'))
  392. await expect(pending).rejects.toThrow(/mid-read cancel/)
  393. // A subsequent live query still works, proving no half-created instance poisoned the pool.
  394. expect(await ctx.lsp.query(query('goToDefinition'))).toEqual({ kind: 'locations', locations: [], resolvedWorkspaceUri: pathToFileURL(ws).href })
  395. await ctx.fiber.dispose()
  396. })
  397. it('aborts and awaits a workspace lookup when the provider is disposed', async () => {
  398. const ctx = await mount({ LSP_FAKE_DEF: 'null' })
  399. const fs = ctx.fs
  400. const resolve = fs.resolve.bind(fs)
  401. const started = Promise.withResolvers<AbortSignal>()
  402. const release = Promise.withResolvers<undefined>()
  403. vi.spyOn(fs, 'resolve').mockImplementation(async (path, options) => {
  404. if (path !== ws) return await resolve(path, options)
  405. const signal = options?.signal
  406. if (signal === undefined) throw new Error('workspace lookup missing provider lifetime signal')
  407. started.resolve(signal)
  408. return await rejectWhenAborted(signal, release.promise)
  409. })
  410. const pending = ctx.lsp.query(query('goToDefinition'))
  411. const signal = await started.promise
  412. let disposed = false
  413. const disposing = ctx.fiber.dispose().then(() => { disposed = true })
  414. await new Promise<void>(resolve => setImmediate(resolve))
  415. expect(signal.aborted).toBe(true)
  416. expect(disposed).toBe(false)
  417. release.resolve(undefined)
  418. await expect(pending).rejects.toThrow('provider is disposed')
  419. await expect(disposing).resolves.toBeUndefined()
  420. })
  421. it('aborts a queued source stream when the provider is disposed', async () => {
  422. const ctx = await mount({ LSP_FAKE_DEF: 'null' })
  423. const fs = ctx.fs
  424. const started = Promise.withResolvers<AbortSignal>()
  425. vi.spyOn(fs, 'streamText').mockImplementation(async (_target, signal) => {
  426. if (signal === undefined) throw new Error('source read missing provider lifetime signal')
  427. started.resolve(signal)
  428. return (async function* () {
  429. await rejectWhenAborted(signal)
  430. yield ''
  431. })()
  432. })
  433. const pending = ctx.lsp.query(query('goToDefinition'))
  434. const signal = await started.promise
  435. const disposing = ctx.fiber.dispose()
  436. await expect(pending).rejects.toThrow('provider is disposed')
  437. await expect(disposing).resolves.toBeUndefined()
  438. expect(signal.aborted).toBe(true)
  439. })
  440. it('waits for every owned teardown before aggregating instance failures', async () => {
  441. let provider: LspProvider | undefined
  442. const ctx = await mount({ LSP_FAKE_DEF: 'null' }, {}, (registered) => { provider = registered })
  443. if (provider === undefined) throw new Error('expected lsp-stdio to register a provider')
  444. const internals = provider as unknown as {
  445. readonly instances: Map<string, { dispose(): Promise<void> }>
  446. readonly queues: Map<string, Promise<void>>
  447. readonly workspaceLookups: Set<Promise<void>>
  448. disposeAll(): Promise<void>
  449. }
  450. const firstFailure = new Error('first instance cleanup failed')
  451. const secondFailure = new Error('second instance cleanup failed')
  452. const release = Promise.withResolvers<undefined>()
  453. internals.instances.set('first', { dispose: async () => { throw firstFailure } })
  454. internals.instances.set('second', { dispose: async () => { throw secondFailure } })
  455. internals.queues.set('pending', release.promise)
  456. internals.workspaceLookups.add(Promise.resolve())
  457. let settled = false
  458. const disposing = internals.disposeAll().finally(() => { settled = true })
  459. await new Promise<void>(resolve => setImmediate(resolve))
  460. expect(settled).toBe(false)
  461. release.resolve(undefined)
  462. await expect(disposing).rejects.toMatchObject({
  463. errors: [firstFailure, secondFailure],
  464. message: 'lsp-stdio instance teardown failed',
  465. })
  466. expect(internals.instances.size).toBe(0)
  467. expect(internals.queues.size).toBe(0)
  468. expect(internals.workspaceLookups.size).toBe(0)
  469. await ctx.fiber.dispose()
  470. })
  471. it('waits for every provider before reporting plugin teardown failure', async () => {
  472. const ctx = new Context()
  473. const disposalErrors: unknown[] = []
  474. ctx.logger.error = ((error: unknown) => { disposalErrors.push(error) }) as typeof ctx.logger.error
  475. await ctx.plugin(Lsp)
  476. await ctx.plugin(LocalSubprocessRuntime)
  477. await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
  478. const providers: LspProvider[] = []
  479. const register = ctx.lsp.registerProvider.bind(ctx.lsp)
  480. const registrationSpy = vi.spyOn(ctx.lsp, 'registerProvider').mockImplementation((provider) => {
  481. providers.push(provider)
  482. return register(provider)
  483. })
  484. const fiber = await ctx.plugin(LspLocal, {
  485. servers: {
  486. first: fakeServer(),
  487. second: fakeServer({}, { extensionToLanguage: { '.js': 'javascript' } }),
  488. },
  489. })
  490. registrationSpy.mockRestore()
  491. expect(providers).toHaveLength(2)
  492. const failure = new Error('provider cleanup failed')
  493. const release = Promise.withResolvers<undefined>()
  494. const first = providers[0] as LspProvider & { disposeAll(): Promise<void> }
  495. const second = providers[1] as LspProvider & { disposeAll(): Promise<void> }
  496. first.disposeAll = async () => { throw failure }
  497. second.disposeAll = async () => { await release.promise }
  498. let disposed = false
  499. const disposing = fiber.dispose().then(() => { disposed = true })
  500. await new Promise<void>(resolve => setImmediate(resolve))
  501. expect(disposed).toBe(false)
  502. expect(disposalErrors).toEqual([])
  503. release.resolve(undefined)
  504. await disposing
  505. expect(disposalErrors).toEqual([failure])
  506. await ctx.fiber.dispose()
  507. })
  508. it('runs distinct workspaces in parallel instances', async () => {
  509. const ws2 = join(root, 'ws2')
  510. await mkdir(ws2)
  511. await writeFile(join(ws2, 'a.ts'), 'const z = 2\n')
  512. const ctx = await mount({ LSP_FAKE_DEF: JSON.stringify(locationJson(0)) })
  513. const [r1, r2] = await Promise.all([
  514. ctx.lsp.query({ ...query('goToDefinition'), workspaceRoot: ws }),
  515. ctx.lsp.query({ ...query('goToDefinition'), workspaceRoot: ws2 }),
  516. ])
  517. expect(r1).toMatchObject({ kind: 'locations' })
  518. expect(r2).toMatchObject({ kind: 'locations' })
  519. await ctx.fiber.dispose()
  520. })
  521. it('disposes cleanly, terminating a server that ignores shutdown', async () => {
  522. const ctx = await mount({ LSP_FAKE_NO_SHUTDOWN: '1', LSP_FAKE_DEF: 'null' }, { killGraceMs: 100, shutdownTimeoutMs: 100 })
  523. await ctx.lsp.query(query('goToDefinition'))
  524. await expect(ctx.fiber.dispose()).resolves.toBeUndefined()
  525. })
  526. it('rejects at load when the command is not found', async () => {
  527. const ctx = new Context()
  528. await ctx.plugin(Lsp)
  529. await ctx.plugin(LocalSubprocessRuntime)
  530. await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
  531. await expect(ctx.plugin(LspLocal, {
  532. servers: {
  533. missing: {
  534. command: 'definitely-not-a-real-lsp-binary-xyz',
  535. args: [],
  536. extensionToLanguage: { '.ts': 'typescript' },
  537. },
  538. },
  539. })).rejects.toThrow(/was not found on PATH/)
  540. await ctx.fiber.dispose()
  541. })
  542. })
  543. /** Read the fixture's JSON-lines didOpen marker, returning no entries before it exists. */
  544. async function markerLines(path: string): Promise<string[]> {
  545. try {
  546. const text = await readFile(path, 'utf8')
  547. return text.trim().split('\n').filter(Boolean).map(line => JSON.parse(line) as string)
  548. } catch (error) {
  549. if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []
  550. throw error
  551. }
  552. }
  553. /** Poll an asynchronous condition until it succeeds or the test-local deadline expires. */
  554. async function waitFor(condition: () => Promise<boolean>, timeoutMs = 3000): Promise<void> {
  555. const started = Date.now()
  556. while (!await condition()) {
  557. if (Date.now() - started > timeoutMs) throw new Error('waitFor timed out')
  558. await new Promise<void>(resolve => setTimeout(resolve, 10))
  559. }
  560. }
  561. /** Hold one fake provider operation until cancellation, optionally behind a cleanup gate. */
  562. function rejectWhenAborted<T>(signal: AbortSignal, release: Promise<unknown> = Promise.resolve()): Promise<T> {
  563. return new Promise((_resolve, reject) => {
  564. const onAbort = (): void => {
  565. void release.then(() => {
  566. reject(signal.reason instanceof Error ? signal.reason : new Error(String(signal.reason)))
  567. })
  568. }
  569. signal.addEventListener('abort', onAbort, { once: true })
  570. if (signal.aborted) onAbort()
  571. })
  572. }