fix(lsp): harden local provider lifecycle

This commit is contained in:
Dudu-0223
2026-07-16 16:42:32 +08:00
parent 3368f7924f
commit 0d8e7e98f7
21 changed files with 192 additions and 52 deletions

View File

@@ -55,7 +55,6 @@ describe.skipIf(!built)('built lib real load path (plain node)', () => {
const result = await ctx.lsp.query({ operation: 'definition', filePath: 'a.ts', position: { line: 0, character: 6 }, workspaceRoot: ${JSON.stringify(ws)} })
console.log(JSON.stringify(result))
await ctx.fiber.dispose()
process.exit(0)
`
const child = spawn(process.execPath, ['--input-type=module', '-e', script], { cwd: pkgDir, stdio: ['ignore', 'pipe', 'pipe'] })
let stdout = ''

View File

@@ -190,6 +190,13 @@ describe('LspConnection edge behavior', () => {
expect(conn.stderrTail.length).toBe(100)
})
it('caps the retained stderr tail by bytes for multibyte UTF-8', async () => {
const conn = connectScript('process.stderr.write("😀😀")', 4)
await conn.closed
expect(conn.stderrTail).toBe('😀')
expect(Buffer.byteLength(conn.stderrTail)).toBe(4)
})
it('rejects with a fallback message when the error response has no message string', async () => {
const script = 'let b=Buffer.alloc(0);'
+ 'const fr=(s)=>{const x=Buffer.from(s);return Buffer.concat([Buffer.from(`Content-Length: ${x.length}\\r\\n\\r\\n`),x]);};'

View File

@@ -10,6 +10,9 @@
* - LSP_FAKE_DEF / LSP_FAKE_REFS / LSP_FAKE_IMPL / LSP_FAKE_HOVER: JSON result per request.
* - LSP_FAKE_HANG: "1" makes textDocument/* requests never respond (for abort/timeout tests).
* - 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_EXIT_DELAY_MS / LSP_FAKE_EXIT_MARKER: delay protocol exit and record exit/termination.
* - LSP_FAKE_NO_SHUTDOWN: "1" ignores the shutdown request (forces kill escalation).
* - LSP_FAKE_ON_OPEN: server→client request to emit when a didOpen arrives, one of
* "configuration" | "applyEdit" | "notification" | "unknown"; the reply is logged to stderr.
@@ -19,11 +22,16 @@
* Run: node --import tsx fixture-server.ts
*/
import { appendFileSync } from 'node:fs'
const enc = process.env.LSP_FAKE_ENCODING ?? 'utf-16'
const sync: unknown = process.env.LSP_FAKE_SYNC !== undefined ? JSON.parse(process.env.LSP_FAKE_SYNC) : 1
const extraCaps: unknown = process.env.LSP_FAKE_CAPS !== undefined ? JSON.parse(process.env.LSP_FAKE_CAPS) : {}
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 exitDelayMs = Number(process.env.LSP_FAKE_EXIT_DELAY_MS ?? 0)
const exitMarker = process.env.LSP_FAKE_EXIT_MARKER
const noShutdown = process.env.LSP_FAKE_NO_SHUTDOWN === '1'
const onOpen = process.env.LSP_FAKE_ON_OPEN
const errorReply = process.env.LSP_FAKE_ERROR === '1'
@@ -32,6 +40,11 @@ const garbage = process.env.LSP_FAKE_GARBAGE === '1'
let serverRequestId = 10_000
const pendingServerRequests = new Map<number, string>()
process.on('SIGTERM', () => {
markExit('TERM')
process.exit(0)
})
function resultFor(method: string): unknown {
switch (method) {
case 'textDocument/definition': return envJson('LSP_FAKE_DEF', null)
@@ -98,6 +111,15 @@ function handle(message: { id?: number; method?: string; params?: unknown; resul
return
}
if (method === 'exit') {
markExit('EXIT')
if (exitDelayMs > 0) {
setTimeout(() => {
markExit('CLEAN')
process.exit(0)
}, exitDelayMs)
return
}
markExit('CLEAN')
process.exit(0)
}
if (method === 'textDocument/didOpen') {
@@ -110,12 +132,20 @@ function handle(message: { id?: number; method?: string; params?: unknown; resul
if (hang) return
if (errorReply) { send({ id, error: { code: -32000, message: 'server refused the request' } }); return }
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)
return
}
// Unknown request with an id: answer null so the client never stalls.
if (id !== undefined) send({ id, result: null })
}
/** Append one teardown event when the fixture is configured to expose process ordering. */
function markExit(event: string): void {
if (exitMarker !== undefined) appendFileSync(exitMarker, `${event}\n`)
}
/** Emit a server→client request and log the client's reply to stderr for the test to assert. */
function emitServerRequest(kind: string): void {
if (kind === 'notification') {

View File

@@ -69,6 +69,12 @@ describe('MessageDecoder', () => {
expect(() => decoder.push(huge)).toThrow(/exceeded .* bytes without a terminator/)
})
it('rejects an oversized header block that includes its terminator', () => {
const decoder = new MessageDecoder(1_000)
const huge = Buffer.from(`Content-Length: 2\r\nX-Fill: ${'a'.repeat(70_000)}\r\n\r\n{}`, 'ascii')
expect(() => decoder.push(huge)).toThrow(/header exceeded .* bytes/)
})
it('rejects a non-JSON body', () => {
const decoder = new MessageDecoder(1_000)
expect(() => decoder.push(frame('not json'))).toThrow(/not valid JSON/)

View File

@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mkdtemp, mkdir, rm, writeFile, realpath } from 'node:fs/promises'
import { mkdtemp, mkdir, readFile, rm, writeFile, realpath } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL, fileURLToPath } from 'node:url'
@@ -96,17 +96,17 @@ describe('LspInstance server-request handling', () => {
it('accepts a lifecycle client/registerCapability request', async () => {
const instance = makeInstance({ LSP_FAKE_ON_OPEN: 'lifecycle', LSP_FAKE_DEF: 'null' })
await expect(run(instance, 'definition')).resolves.toEqual({ kind: 'locations', locations: [] })
await expect(run(instance, 'definition')).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws })
})
it('rejects a workspace/applyEdit request but keeps serving', async () => {
const instance = makeInstance({ LSP_FAKE_ON_OPEN: 'applyEdit', LSP_FAKE_DEF: 'null' })
await expect(run(instance, 'definition')).resolves.toEqual({ kind: 'locations', locations: [] })
await expect(run(instance, 'definition')).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws })
})
it('rejects an unknown server request but keeps serving', async () => {
const instance = makeInstance({ LSP_FAKE_ON_OPEN: 'unknown', LSP_FAKE_DEF: 'null' })
await expect(run(instance, 'definition')).resolves.toEqual({ kind: 'locations', locations: [] })
await expect(run(instance, 'definition')).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws })
})
})
@@ -195,6 +195,18 @@ describe('LspInstance query and abort', () => {
})
describe('LspInstance disposal', () => {
it('lets a server finish protocol exit before signal escalation', async () => {
const marker = join(root, 'graceful-exit.log')
const instance = makeInstance({
LSP_FAKE_DEF: 'null',
LSP_FAKE_EXIT_DELAY_MS: '75',
LSP_FAKE_EXIT_MARKER: marker,
}, { shutdownTimeoutMs: 500 })
await run(instance, 'definition')
await instance.dispose()
expect(await readFile(marker, 'utf8')).toBe('EXIT\nCLEAN\n')
})
it('is idempotent — a second dispose awaits close without error', async () => {
const instance = makeInstance({ LSP_FAKE_DEF: 'null' })
await run(instance, 'definition')

View File

@@ -59,6 +59,7 @@ describe('lsp-local end to end over a fake server', () => {
expect(result).toEqual<LspQueryResult>({
kind: 'locations',
locations: [{ uri: pathToFileURL(join(ws, 'a.ts')).href, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 3 } } }],
resolvedWorkspaceRoot: ws,
})
await ctx.fiber.dispose()
})
@@ -89,7 +90,7 @@ describe('lsp-local end to end over a fake server', () => {
it('returns an empty locations result for a null definition', async () => {
const ctx = await mount({ LSP_FAKE_DEF: 'null' })
expect(await ctx.lsp.query(query('definition'))).toEqual({ kind: 'locations', locations: [] })
expect(await ctx.lsp.query(query('definition'))).toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws })
await ctx.fiber.dispose()
})
@@ -123,7 +124,7 @@ describe('lsp-local end to end over a fake server', () => {
it('accepts openClose options sync', async () => {
const ctx = await mount({ LSP_FAKE_SYNC: JSON.stringify({ openClose: true, change: 2 }), LSP_FAKE_DEF: 'null' })
expect(await ctx.lsp.query(query('definition'))).toEqual({ kind: 'locations', locations: [] })
expect(await ctx.lsp.query(query('definition'))).toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws })
await ctx.fiber.dispose()
})
@@ -195,6 +196,31 @@ describe('lsp-local end to end over a fake server', () => {
await ctx.fiber.dispose()
})
it('evicts a pooled server that died while idle and serves the next query from a fresh one', async () => {
// The first query succeeds, then the server exits before the second arrives, leaving a dead
// instance in the pool. The next query must evict-and-replace it and still succeed, rather than
// failing once on the closed connection first.
const ctx = await mount({ LSP_FAKE_EXIT_AFTER_REPLY: '1', LSP_FAKE_DEF: JSON.stringify(locationJson(0)) })
expect(await ctx.lsp.query(query('definition'))).toMatchObject({ kind: 'locations' })
// Wait past the fixture's post-reply exit so the pooled instance is observably dead.
await new Promise(resolve => setTimeout(resolve, 60))
expect(await ctx.lsp.query(query('definition'))).toMatchObject({ kind: 'locations' })
await ctx.fiber.dispose()
})
it('does not spawn a server when the signal aborts during source read', async () => {
// Abort right after issuing the query: the abort lands while canonicalizeWorkspace/readHostSource
// are awaited, so the pre-spawn recheck must reject without ever creating a pooled instance.
const ctx = await mount({ LSP_FAKE_DEF: 'null' })
const controller = new AbortController()
const pending = ctx.lsp.query(query('definition'), controller.signal)
controller.abort(new Error('mid-read cancel'))
await expect(pending).rejects.toThrow(/mid-read cancel/)
// A subsequent live query still works, proving no half-created instance poisoned the pool.
expect(await ctx.lsp.query(query('definition'))).toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws })
await ctx.fiber.dispose()
})
it('runs distinct workspaces in parallel instances', async () => {
const ws2 = join(root, 'ws2')
await mkdir(ws2)

View File

@@ -102,4 +102,16 @@ describe('lsp-local provider resolution', () => {
})).rejects.toThrow(/is not an executable file/)
await ctx.fiber.dispose()
})
it('rejects an executable directory as a command at load', async () => {
const ctx = new Context()
await ctx.plugin(Lsp)
await expect(ctx.plugin(LspLocal, {
providerId: 'abs-directory',
command: ws,
args: [],
extensionToLanguage: { '.ts': 'typescript' },
})).rejects.toThrow(/is not an executable file/)
await ctx.fiber.dispose()
})
})