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

@@ -41,7 +41,7 @@ export class LspConnection {
private readonly decoder: MessageDecoder
private readonly pending = new Map<number, Pending>()
private nextId = 1
private stderr = ''
private stderr = Buffer.alloc(0)
private closeReason: Error | undefined
/** Set once the process has fully exited; the instance awaits it during teardown. */
readonly closed: Promise<void>
@@ -90,7 +90,7 @@ export class LspConnection {
/** The retained stderr tail, for diagnostics on a failed server. */
get stderrTail(): string {
return this.stderr
return this.stderr.toString('utf8')
}
/**
@@ -199,7 +199,17 @@ export class LspConnection {
private onStderr(chunk: Buffer): void {
// Retain the TAIL, not the prefix: a language server's fatal diagnostic usually appears just
// before it exits, so the final bounded segment is the useful one.
this.stderr = (this.stderr + chunk.toString('utf8')).slice(-this.spec.maxStderrBytes)
const cap = this.spec.maxStderrBytes
if (chunk.length >= cap) {
// Copy the bounded suffix so retaining it does not pin an arbitrarily large incoming buffer.
this.stderr = Buffer.from(chunk.subarray(chunk.length - cap))
return
}
const retainedBytes = Math.min(this.stderr.length, cap - chunk.length)
this.stderr = Buffer.concat([
this.stderr.subarray(this.stderr.length - retainedBytes),
chunk,
], retainedBytes + chunk.length)
}
private dispatch(message: unknown): void {
@@ -246,7 +256,7 @@ export class LspConnection {
/** The exit-close error message, appending the retained stderr tail when the server wrote any. */
private exitMessage(): string {
const tail = this.stderr.trim()
const tail = this.stderrTail.trim()
return tail === '' ? 'language server exited' : `language server exited; stderr: ${tail}`
}

View File

@@ -64,6 +64,9 @@ export class MessageDecoder {
}
return { ready: false }
}
if (separator > MAX_HEADER_BYTES) {
throw new Error(`LSP header exceeded ${MAX_HEADER_BYTES} bytes`)
}
const headerText = this.buffer.toString('ascii', 0, separator)
const contentLength = parseContentLength(headerText)
if (contentLength > this.maxMessageBytes) {

View File

@@ -11,7 +11,7 @@
* @module @deepseek-ai/dsh-lsp-local
*/
import { accessSync, constants } from 'node:fs'
import { accessSync, constants, statSync } from 'node:fs'
import { delimiter, isAbsolute, join } from 'node:path'
import type { Context } from 'cordis'
import z from 'schemastery'
@@ -178,19 +178,23 @@ class LocalLspProvider implements LspProvider {
// were canonicalizing/reading, so creating a server now would leave it unowned by teardown.
/* v8 ignore next -- guards a dispose landing during the canonicalize/read await; not a reproducible unit race. */
if (this.isDisposed()) throw new Error('lsp-local provider is disposed')
const instance = await this.instanceFor(workspace)
// Re-check cancellation too: an abort during the canonicalize/read awaits must not go on to spawn
// (or pool) a server solely for an operation the caller already gave up on.
if (signal?.aborted) throw abortError(signal)
let instance = await this.instanceFor(workspace)
// A pooled server that exited while idle resolves to a dead instance: evict it and create a fresh
// one before dispatch, so this query does not have to fail on a closed connection first. One retry
// suffices — the replacement was just constructed and has not been used.
if (instance.dead) {
await this.evictIfCurrent(workspace, instance)
instance = await this.instanceFor(workspace)
}
try {
return await instance.query(request, source, signal)
} finally {
// A crashed/closed process must not be reused: drop its slot so the next query starts fresh,
// but only if the slot still holds THIS instance (a concurrent replacement must survive).
if (instance.dead) {
const slot = this.instances.get(workspace)
/* v8 ignore next -- the slot-undefined arm needs a concurrent eviction of the same slot; defensive. */
if (slot !== undefined && (await settledInstance(slot)) === instance) {
this.instances.delete(workspace)
}
}
if (instance.dead) await this.evictIfCurrent(workspace, instance)
}
}
@@ -208,6 +212,15 @@ class LocalLspProvider implements LspProvider {
return created
}
/** Drop the slot for `workspace` iff it still resolves to `instance` (a concurrent replacement survives). */
private async evictIfCurrent(workspace: string, instance: LspInstance): Promise<void> {
const slot = this.instances.get(workspace)
/* v8 ignore next -- the slot-undefined/mismatch arm needs a concurrent eviction of the same slot; defensive. */
if (slot !== undefined && (await settledInstance(slot)) === instance) {
this.instances.delete(workspace)
}
}
private createInstance(workspace: string): LspInstance {
const spec: InstanceSpec = {
command: this.executable,
@@ -265,7 +278,7 @@ function buildChildEnv(extra: Record<string, string>): Record<string, string> {
function resolveExecutable(command: string, childEnv: Record<string, string>): string {
if (isAbsolute(command)) {
// Verify an absolute command too, so an unavailable one fails at load, not on the first query.
if (!isExecutableSync(command)) {
if (!isExecutableFileSync(command)) {
throw new Error(`lsp-local: command "${command}" is not an executable file`)
}
return command
@@ -275,14 +288,15 @@ function resolveExecutable(command: string, childEnv: Record<string, string>): s
for (const dir of pathValue.split(delimiter)) {
if (dir === '') continue
const candidate = join(dir, command)
if (isExecutableSync(candidate)) return candidate
if (isExecutableFileSync(candidate)) return candidate
}
throw new Error(`lsp-local: command "${command}" was not found on PATH`)
}
/** Synchronous executable check used only at load-time resolution. */
function isExecutableSync(path: string): boolean {
/** Synchronous regular-file and executable check used only at load-time resolution. */
function isExecutableFileSync(path: string): boolean {
try {
if (!statSync(path).isFile()) return false
accessSync(path, constants.X_OK)
return true
} catch {

View File

@@ -169,7 +169,7 @@ export class LspInstance {
*/
private abortable<T>(work: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
if (signal === undefined) return work
/* v8 ignore next -- runQuery checks signal.aborted before each abortable() call, so it is not already aborted here; defensive. */
/* v8 ignore next -- callers either pre-check the signal or pass a freshly armed teardown deadline. */
if (signal.aborted) return Promise.reject(abortError(signal))
return new Promise<T>((resolve, reject) => {
const onAbort = (): void => { reject(abortError(signal)) }
@@ -232,7 +232,10 @@ export class LspInstance {
if (operation === 'hover') {
return { kind: 'hover', hover: normalizeHover(payload) }
}
return { kind: 'locations', locations: normalizeLocations(payload) }
// `spec.cwd` is the canonical workspace realpath (the provider canonicalizes before spawning),
// and every `file:` location URI is relative to it — so it is the root a caller must relativize
// display paths against, not the request's possibly-symlinked workspaceRoot.
return { kind: 'locations', locations: normalizeLocations(payload), resolvedWorkspaceRoot: this.spec.cwd }
}
private answerServerRequest(method: string, params: unknown): Promise<unknown> {
@@ -271,24 +274,18 @@ export class LspInstance {
try {
using shutdownDeadline = deadline(undefined, this.spec.shutdownTimeoutMs, 'LSP_SHUTDOWN')
await this.gracefulShutdown(shutdownDeadline.signal)
return
} catch {
// Graceful shutdown failed or timed out: fall through to signal escalation.
}
await this.forceTerminate()
}
/** Best-effort LSP `shutdown` request then `exit` notification, bounded by `signal`. */
/** Best-effort LSP `shutdown`/`exit`, including process close, bounded by `signal`. */
private async gracefulShutdown(signal: AbortSignal): Promise<void> {
const shutdown = this.connection.request('shutdown', null)
await Promise.race([
shutdown,
new Promise<never>((_, reject) => {
/* v8 ignore next -- the shutdown deadline signal is freshly armed and not yet aborted here; defensive. */
if (signal.aborted) { reject(abortError(signal)); return }
signal.addEventListener('abort', () => { reject(abortError(signal)) }, { once: true })
}),
])
await this.abortable(this.connection.request('shutdown', null), signal)
this.connection.notify('exit', null)
await this.abortable(this.connection.closed, signal)
}
/** SIGTERM, wait `killGraceMs` for close, then SIGKILL; await full process close either way. */

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()
})
})