Browse Source

test(lsp): hold queued source reads behind a response barrier

_Kerman 2 tuần trước cách đây
mục cha
commit
3aee29c657

+ 7 - 13
packages/lsp/lsp-stdio/tests/fixture-server.ts

@@ -12,7 +12,6 @@
  * - LSP_FAKE_CRASH_ON_OPEN: "1" exits the process when a didOpen arrives (crash test).
  * - LSP_FAKE_EXIT_AFTER_REPLY: "1" exits the process right after answering a textDocument/* request,
  *   simulating a server that dies while idle so the pool holds a dead instance (eviction test).
- * - LSP_FAKE_REPLY_DELAY_MS: delays each textDocument/* response by this many milliseconds.
  * - LSP_FAKE_OPEN_MARKER: appends each didOpen document text as one JSON line to this path.
  * - LSP_FAKE_INITIALIZED_MARKER: records when the initialized notification is received.
  * - LSP_FAKE_PAUSE_STDIN_AFTER_INITIALIZED: "1" stops consuming stdin after initialized.
@@ -34,7 +33,6 @@ const extraCaps: unknown = process.env.LSP_FAKE_CAPS !== undefined ? JSON.parse(
 const hang = process.env.LSP_FAKE_HANG === '1'
 const crashOnOpen = process.env.LSP_FAKE_CRASH_ON_OPEN === '1'
 const exitAfterReply = process.env.LSP_FAKE_EXIT_AFTER_REPLY === '1'
-const replyDelayMs = Number(process.env.LSP_FAKE_REPLY_DELAY_MS ?? 0)
 const openMarker = process.env.LSP_FAKE_OPEN_MARKER
 const initializedMarker = process.env.LSP_FAKE_INITIALIZED_MARKER
 const pauseStdinAfterInitialized = process.env.LSP_FAKE_PAUSE_STDIN_AFTER_INITIALIZED === '1'
@@ -153,18 +151,14 @@ function handle(message: { id?: number; method?: string; params?: unknown; resul
   if (method === 'textDocument/didClose') return
   if (method?.startsWith('textDocument/')) {
     if (hang) return
-    const reply = (): void => {
-      if (errorReply) {
-        send({ id, error: { code: -32000, message: 'server refused the request' } })
-      } else {
-        send({ id, result: resultFor(method) })
-      }
-      // Simulate an idle death: answer this request, then exit before the next one arrives so the
-      // pool is left holding a dead instance.
-      if (exitAfterReply) setTimeout(() => process.exit(0), 20)
+    if (errorReply) {
+      send({ id, error: { code: -32000, message: 'server refused the request' } })
+    } else {
+      send({ id, result: resultFor(method) })
     }
-    if (replyDelayMs > 0) setTimeout(reply, replyDelayMs)
-    else reply()
+    // Simulate an idle death: answer this request, then exit before the next one arrives so the
+    // pool is left holding a dead instance.
+    if (exitAfterReply) setTimeout(() => process.exit(0), 20)
     return
   }
   // Unknown request with an id: answer null so the client never stalls.

+ 37 - 11
packages/lsp/lsp-stdio/tests/lifecycle.spec.ts

@@ -299,21 +299,47 @@ describe('lsp-stdio end to end over a fake server', () => {
 
   it('reads a queued query source only when its lifecycle starts', async () => {
     const marker = join(root, 'opened.jsonl')
+    let provider: LspProvider | undefined
     const ctx = await mount({
       LSP_FAKE_DEF: 'null',
-      LSP_FAKE_REPLY_DELAY_MS: '300',
       LSP_FAKE_OPEN_MARKER: marker,
+    }, {}, (registered) => { provider = registered })
+    const release = Promise.withResolvers<undefined>()
+    const request = Object.getOwnPropertyDescriptor(LspConnection.prototype, 'request')?.value as LspConnection['request']
+    let holdFirst = true
+    const requestSpy = vi.spyOn(LspConnection.prototype, 'request').mockImplementation(async function (this: LspConnection, method, params) {
+      const hold = method === 'textDocument/definition' && holdFirst
+      if (hold) holdFirst = false
+      const result = await request.call(this, method, params)
+      if (hold) await release.promise
+      return result
     })
-    const first = ctx.lsp.query(query('goToDefinition'))
-    await waitFor(async () => (await markerLines(marker)).length === 1)
-    const second = ctx.lsp.query(query('goToDefinition'))
-    await writeFile(join(ws, 'a.ts'), 'const changed = 2\n')
-    await Promise.all([first, second])
-    expect(await markerLines(marker)).toEqual([
-      'const x = 1\nconst y = x\n',
-      'const changed = 2\n',
-    ])
-    await ctx.fiber.dispose()
+    const pending: Promise<unknown>[] = []
+    try {
+      const first = ctx.lsp.query(query('goToDefinition'))
+      pending.push(Promise.allSettled([first]))
+      await waitFor(async () => (await markerLines(marker)).length === 1)
+      // The changed tail proves the second query entered the provider queue
+      // while the first response is held, before the source rewrite starts.
+      const queues = (provider as unknown as { queues: ReadonlyMap<unknown, Promise<void>> }).queues
+      const firstTail = [...queues.values()][0]
+      expect(firstTail).toBeDefined()
+      const second = ctx.lsp.query(query('goToDefinition'))
+      pending.push(Promise.allSettled([second]))
+      await vi.waitFor(() => { expect([...queues.values()][0]).not.toBe(firstTail) }, { timeout: 3000 })
+      await writeFile(join(ws, 'a.ts'), 'const changed = 2\n')
+      release.resolve(undefined)
+      await Promise.all([first, second])
+      expect(await markerLines(marker)).toEqual([
+        'const x = 1\nconst y = x\n',
+        'const changed = 2\n',
+      ])
+    } finally {
+      release.resolve(undefined)
+      await Promise.all(pending)
+      requestSpy.mockRestore()
+      await ctx.fiber.dispose()
+    }
   })
 
   it('aborts an in-flight query when the signal fires', async () => {