fix(lsp): make transport recovery ownership-safe

This commit is contained in:
Tianyi Cui
2026-07-22 15:56:29 +08:00
parent 2a8e7c661d
commit 769710cfb9
10 changed files with 94 additions and 35 deletions

View File

@@ -52,7 +52,7 @@ export type ConnectionWriter = (
export interface ProcessTreeOperations {
/** Signal a POSIX process group. */
readonly signal: (target: number, signal: NodeJS.Signals) => void
/** Signal the direct child when group/tree signalling is unavailable. */
/** Signal the direct child when POSIX group signaling is unavailable. */
readonly killChild: (signal: NodeJS.Signals) => void
/** Terminate a Windows process tree by root pid. */
readonly taskkill: (pid: number) => void
@@ -78,6 +78,9 @@ export type ProcessSignalRunner = (target: number, signal: NodeJS.Signals) => bo
const processSignalRunner: ProcessSignalRunner = process.kill.bind(process)
/** taskkill status for "process not found": the requested process tree is already absent. */
const TASKKILL_TREE_NOT_FOUND_STATUS = 128
const writeConnectionMessage: ConnectionWriter = (stdin, message, done) => {
stdin.write(encodeMessage(message), done)
}
@@ -93,6 +96,7 @@ export function taskkillProcessTree(
): void {
const result = run('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' })
if (result.error !== undefined) throw result.error
if (result.status === TASKKILL_TREE_NOT_FOUND_STATUS) return
if (result.status !== 0) throw new Error(`taskkill exited with status ${String(result.status)}`)
}
@@ -130,7 +134,8 @@ export async function waitForTreeExit(
}
/**
* Signal a detached process tree with platform-correct semantics and a direct-child fallback.
* Signal a detached process tree with platform-correct semantics. POSIX falls back to the direct
* child; Windows requires taskkill to reach the full tree.
* @param platform - host platform.
* @param pid - detached root process id.
* @param signal - requested termination signal.
@@ -142,9 +147,12 @@ export function signalProcessTree(
signal: NodeJS.Signals,
operations: ProcessTreeOperations,
): void {
if (platform === 'win32') {
operations.taskkill(pid)
return
}
try {
if (platform === 'win32') operations.taskkill(pid)
else operations.signal(-pid, signal)
operations.signal(-pid, signal)
} catch {
try {
operations.killChild(signal)
@@ -219,6 +227,15 @@ export class LspConnection {
return this.closeReason !== undefined
}
/**
* Test whether a caught error is this connection's retained fatal transport cause.
* @param error - error caught by the instance or provider.
* @returns `true` only when this connection produced that exact failure.
*/
failedWith(error: unknown): boolean {
return this.closeReason === error
}
/**
* Send a request and await its result.
* @param method - the JSON-RPC method.
@@ -291,10 +308,7 @@ export class LspConnection {
return await waitForTreeExit(this.processTreeAlive.bind(this), signal)
}
/**
* Signal the whole process tree so helper processes are reached; fall back to the direct child if
* tree signaling fails. Never throws because teardown races process exit.
*/
/** Signal the whole process tree. */
private signalTree(sig: NodeJS.Signals): void {
const pid = this.child.pid
if (pid === undefined) return

View File

@@ -2,8 +2,8 @@
* Generic stdio language-server backend for `ctx.lsp`. One plugin instance configures a named table
* of server commands and registers one isolated provider for each entry. Every provider lazily
* single-flights one server process per canonical workspace realpath, serves transient-open queries
* through it, and replaces a transport that dies between a pool liveness check and the next
* read-only query. Providers read sources through Node APIs in the host namespace (not `ctx.fs`)
* through it, and replaces a selected transport that fails before or during the next read-only
* query. Providers read sources through Node APIs in the host namespace (not `ctx.fs`)
* and trust their configured servers — no sandbox confinement.
*
* Namespace plugin (named exports, no default export). Lifecycle is effect-scoped: disposal
@@ -224,16 +224,20 @@ class LocalLspProvider implements LspProvider {
try {
return await instance.query(request, source, signal)
} catch (error) {
// A child can die after the pre-query liveness check but before or during the next write.
// Queries are read-only, so replace a newly failed transport once and retry transparently.
if (!instance.dead) throw error
// A selected child can have died while idle or fail during the next write. Queries are
// read-only, so replace that transport once and retry transparently.
if (!instance.isTransportFailure(error)) throw error
await instance.dispose()
this.evictIfCurrent(workspace, instance)
this.assertActive(signal)
instance = this.instanceFor(workspace)
return await instance.query(request, source, signal)
} finally {
// Drop a crashed slot only when it still owns this instance; a replacement must survive.
if (instance.dead) this.evictIfCurrent(workspace, instance)
// Reach quiescence before dropping a dead slot; a replacement must survive this ownership check.
if (instance.dead) {
await instance.dispose()
this.evictIfCurrent(workspace, instance)
}
}
})
}

View File

@@ -83,6 +83,15 @@ export class LspInstance {
return this.processClosed || this.disposed || this.connection.failed
}
/**
* Test whether a caught query error came from this instance's transport.
* @param error - error caught by the provider.
* @returns `true` only for the connection's retained fatal transport cause.
*/
isTransportFailure(error: unknown): boolean {
return this.connection.failedWith(error)
}
/**
* Run one query through the serialized queue.
* @param request - the resolved provider query.
@@ -94,7 +103,12 @@ export class LspInstance {
// Serialize behind prior work, but observe abort DURING the queue wait too: if an earlier query
// hangs (e.g. a signal-less seam caller), a later tool's timeout must still be able to give up
// rather than block on the shared tail forever.
const run = abortable(this.queue, signal).then(() => this.runQuery(request, source, signal))
const run = abortable(this.queue, signal)
.then(() => this.runQuery(request, source, signal))
.catch(async (error: unknown) => {
if (this.isTransportFailure(error)) await this.startTeardown()
throw error
})
// Keep the tail alive regardless of this query's outcome so the next caller still serializes. The
// tail follows the ACTUAL prior work (this.queue), not the abortable view, so a caller giving up
// on the wait does not deserialize the queue.