fix(lsp): address codex review round 2

Further lifecycle/safety hardening of the local provider:
- Tear the instance down when the initialize handshake is aborted, so a
  poisoned pending `ready` can't make later queries for that workspace re-wait.
- Make the serialized-queue wait itself abortable, so a query blocked behind
  hung earlier work can still observe its own timeout.
- Spawn the server detached and signal the whole process group on teardown, so
  helper processes (e.g. tsserver) can't outlive dispose().
- Open the source with O_NOFOLLOW and cap the read at maxDocumentBytes+1, closing
  the symlink-swap and concurrent-grow windows the fd-based read left open.
- Honor an already-aborted signal before any host I/O or startup.
- Validate maxStderrBytes positive at load; surface the retained stderr tail in
  the "language server exited" error so a fatal startup diagnostic is visible.
This commit is contained in:
Dudu-0223
2026-07-16 13:34:37 +08:00
parent 8e8f90e235
commit 0f3f0efd9c
5 changed files with 109 additions and 15 deletions

View File

@@ -24,7 +24,7 @@ import type {
// Side-effect type import: declaration-merges `ctx.lsp` onto Context.
import type {} from '@deepseek-ai/dsh-lsp'
import { canonicalizeWorkspace, readHostSource } from './host.ts'
import { LspInstance } from './instance.ts'
import { abortError, LspInstance } from './instance.ts'
import type { InstanceSpec } from './instance.ts'
export { canonicalizeWorkspace, readHostSource } from './host.ts'
@@ -114,6 +114,8 @@ export function apply(ctx: Context, config: Config): void {
// nonpositive value would let a server that ignores shutdown hang disposal forever. Fail at load.
assertPositiveInteger('shutdownTimeoutMs', resolved.shutdownTimeoutMs)
assertPositiveInteger('killGraceMs', resolved.killGraceMs)
// A nonpositive stderr cap defeats the retained-tail bound (`slice(-0)` keeps everything).
assertPositiveInteger('maxStderrBytes', resolved.maxStderrBytes)
const childEnv = buildChildEnv(resolved.env)
// Resolve the executable eagerly so a misconfigured command fails at load, not on first query.
const executable = resolveExecutable(resolved.command, childEnv)
@@ -160,6 +162,9 @@ class LocalLspProvider implements LspProvider {
async query(request: LspProviderQuery, signal?: AbortSignal): Promise<LspQueryResult> {
/* v8 ignore next -- the seam unregisters this provider on dispose, so a query never reaches it disposed; defensive. */
if (this.isDisposed()) throw new Error('lsp-local provider is disposed')
// Honor an already-aborted signal before any host I/O so a canceled request neither reads nor
// spawns a server.
if (signal?.aborted) throw abortError(signal)
const workspace = await canonicalizeWorkspace(request.workspaceRoot)
// Validate and read the source BEFORE spawning a server: a missing/external/non-regular/oversized
// source must fail without leaving an idle process pooled (the pre-start rejection contract), and