fix(coding-agent): propagated cancellation from lsp reload instead of false restart
reloadServer caught every error from both fallback mechanisms in bare catch blocks, so a caller cancel or tool timeout was swallowed and fell through to `proc.kill(); return "Restarted"` -- reporting a successful restart while killing the server with no replacement. - Propagate ToolAbortError/timeout from both the rust-analyzer request and the didChangeConfiguration notification fallback. - Gate the fallback on genuine method-not-found via isMethodNotFoundError instead of any error. - Replace the blind proc.kill with shutdownClientInstance: remove the client from the registry by identity and await confirmed process exit, surfacing a truthful teardown error when the process outlives the kill. Fixes #6369
This commit is contained in:
@@ -1181,9 +1181,19 @@ async function waitForExit(client: LspClient, timeoutMs: number): Promise<boolea
|
||||
}
|
||||
|
||||
/**
|
||||
* Shutdown a specific client instance using the LSP shutdown/exit handshake.
|
||||
* Tear down a specific client instance using the LSP shutdown/exit handshake.
|
||||
*
|
||||
* Removes the client from the registry by identity first (never evicting a
|
||||
* newer client already republished under the same key), then performs a bounded
|
||||
* graceful shutdown, force-killing and awaiting confirmed process exit.
|
||||
*
|
||||
* @returns `true` once the process is confirmed exited, `false` if it outlived
|
||||
* the shutdown budget — callers reporting a restart must treat `false` as a
|
||||
* failed teardown, not a completed restart.
|
||||
*/
|
||||
async function shutdownClientInstance(client: LspClient): Promise<void> {
|
||||
export async function shutdownClientInstance(client: LspClient): Promise<boolean> {
|
||||
if (clients.get(client.name) === client) clients.delete(client.name);
|
||||
|
||||
const err = new Error("LSP client shutdown");
|
||||
for (const pending of Array.from(client.pendingRequests.values())) {
|
||||
pending.reject(err);
|
||||
@@ -1196,21 +1206,23 @@ async function shutdownClientInstance(client: LspClient): Promise<void> {
|
||||
);
|
||||
if (shutdownCompleted) {
|
||||
await sendNotification(client, "exit", undefined).catch(() => {});
|
||||
if (await waitForExit(client, EXIT_TIMEOUT_MS)) return;
|
||||
if (await waitForExit(client, EXIT_TIMEOUT_MS)) return true;
|
||||
}
|
||||
|
||||
client.proc.kill();
|
||||
await waitForExit(client, EXIT_TIMEOUT_MS);
|
||||
return await waitForExit(client, EXIT_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shutdown a specific client by key.
|
||||
*
|
||||
* @returns `true` when the client is gone (already absent or confirmed exited),
|
||||
* `false` if a live process outlived the shutdown budget.
|
||||
*/
|
||||
export async function shutdownClient(key: string): Promise<void> {
|
||||
export async function shutdownClient(key: string): Promise<boolean> {
|
||||
const client = clients.get(key);
|
||||
if (!client) return;
|
||||
clients.delete(key);
|
||||
await shutdownClientInstance(client);
|
||||
if (!client) return true;
|
||||
return await shutdownClientInstance(client);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
sendNotification,
|
||||
sendRequest,
|
||||
setIdleTimeout,
|
||||
shutdownClientInstance,
|
||||
supportsDocumentDiagnostics,
|
||||
syncContent,
|
||||
WARMUP_TIMEOUT_MS,
|
||||
@@ -507,12 +508,18 @@ function isMethodNotFoundError(err: unknown): boolean {
|
||||
}
|
||||
|
||||
async function reloadServer(client: LspClient, serverName: string, signal?: AbortSignal): Promise<string> {
|
||||
// rust-analyzer exposes a real reload request.
|
||||
throwIfAborted(signal);
|
||||
// rust-analyzer exposes a real reload request. Every other server rejects it
|
||||
// with method-not-found — that alone justifies the generic fallback. A caller
|
||||
// cancel or tool timeout must propagate, never be mistaken for an unsupported
|
||||
// method and swallowed into a bogus "Restarted" (issue #6369).
|
||||
try {
|
||||
await sendRequest(client, "rust-analyzer/reloadWorkspace", null, signal);
|
||||
return `Reloaded ${serverName}`;
|
||||
} catch {
|
||||
// Method not supported — fall through.
|
||||
} catch (err) {
|
||||
throwIfAborted(signal);
|
||||
if (!isMethodNotFoundError(err)) throw err;
|
||||
// Method not supported — fall through to the generic reload.
|
||||
}
|
||||
// workspace/didChangeConfiguration is a notification per spec; sending it
|
||||
// as a request hangs until the tool deadline on servers that route it to
|
||||
@@ -520,8 +527,16 @@ async function reloadServer(client: LspClient, serverName: string, signal?: Abor
|
||||
try {
|
||||
await sendNotification(client, "workspace/didChangeConfiguration", { settings: {} }, signal);
|
||||
return `Reloaded ${serverName}`;
|
||||
} catch {
|
||||
client.proc.kill();
|
||||
} catch (err) {
|
||||
throwIfAborted(signal);
|
||||
// The reload notification could not be delivered — the connection is
|
||||
// wedged or the process already died. Tear the client down (removing it
|
||||
// from the registry by identity and awaiting confirmed process exit) so
|
||||
// the next request cold-starts a fresh client. A kill that never confirms
|
||||
// exit is not a restart: surface the teardown failure truthfully.
|
||||
if (!(await shutdownClientInstance(client))) {
|
||||
throw new Error(`Failed to restart ${serverName}: server process did not exit after kill`);
|
||||
}
|
||||
return `Restarted ${serverName}`;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user