fix(lsp): make transport recovery ownership-safe
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user