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

@@ -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 {