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

@@ -55,14 +55,17 @@ export class LspConnection {
private readonly onServerRequest: (method: string, params: unknown) => Promise<unknown>,
) {
this.decoder = new MessageDecoder(spec.maxMessageBytes)
// `detached` puts the server in its own process group so teardown can signal the WHOLE group
// (via `process.kill(-pid)`), reaching helper processes a language server spawns (e.g. tsserver).
this.child = spawn(spec.command, [...spec.args], {
cwd: spec.cwd,
env: spec.env,
stdio: ['pipe', 'pipe', 'pipe'],
detached: true,
})
this.closed = new Promise<void>((resolve) => {
this.child.on('close', () => {
const reason = this.closeReason ?? new Error('language server exited')
const reason = this.closeReason ?? new Error(this.exitMessage())
// Record the reason so any request issued AFTER close rejects immediately instead of hanging
// (a closed process sends no further responses).
this.closeReason = reason
@@ -150,14 +153,33 @@ export class LspConnection {
return this.nextId
}
/** Send SIGTERM to the child (idempotent-safe; a dead child ignores it). */
/** Send SIGTERM to the server's process group (idempotent-safe; a dead group ignores it). */
terminate(): void {
this.child.kill('SIGTERM')
this.signalGroup('SIGTERM')
}
/** Send SIGKILL to the child. */
/** Send SIGKILL to the server's process group. */
kill(): void {
this.child.kill('SIGKILL')
this.signalGroup('SIGKILL')
}
/**
* Signal the whole process group (negative pid) so helper processes are reached; fall back to the
* direct child if the group send fails. Never throws — teardown races process exit.
*/
private signalGroup(sig: NodeJS.Signals): void {
const pid = this.child.pid
if (pid === undefined) return
try {
process.kill(-pid, sig)
} catch {
// The group is gone (already exited) or could not be signalled; try the direct child.
try {
this.child.kill(sig)
} catch {
// Already dead; nothing to signal.
}
}
}
private onStdout(chunk: Buffer): void {
@@ -221,6 +243,12 @@ export class LspConnection {
this.child.stdin.write(encodeMessage(message))
}
/** The exit-close error message, appending the retained stderr tail when the server wrote any. */
private exitMessage(): string {
const tail = this.stderr.trim()
return tail === '' ? 'language server exited' : `language server exited; stderr: ${tail}`
}
private fail(error: Error): void {
/* v8 ignore next -- the second arm (closeReason already set) needs two fail() calls before close; defensive. */
if (this.closeReason === undefined) this.closeReason = error