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:
roboomp
2026-07-23 18:58:15 +00:00
parent 130578aced
commit eeb3fa6ace
4 changed files with 201 additions and 15 deletions
+20 -8
View File
@@ -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);
}
// =============================================================================
+20 -5
View File
@@ -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}`;
}
}