From c0a2f77b28ce05c20822d446b96d92df42b35194 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arthur=20F=C3=BCcher?= Date: Wed, 25 Mar 2026 08:32:31 -0300 Subject: [PATCH] Fix stop/restart hanging when ECA server is unresponsive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stop() method awaited a shutdown JSON-RPC request with no timeout and never forcefully killed the child process. When ECA was stuck (e.g. during MCP initialization retries with 401 errors), stop() would hang forever, leaving the user unable to stop or restart from the UI. - Add timeout (5s) on the graceful shutdown request - Add killProcess() with SIGTERM → SIGKILL escalation - Always clean up process/connection refs and update status in finally - Guard against missing connection (stuck in Starting state) 🤖 Generated with [eca](https://eca.dev) Co-Authored-By: eca --- src/server.ts | 86 ++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 81 insertions(+), 5 deletions(-) diff --git a/src/server.ts b/src/server.ts index 77e6697..f6c34ba 100644 --- a/src/server.ts +++ b/src/server.ts @@ -9,6 +9,20 @@ import * as ecaApi from './ecaApi'; import * as s from './session'; import * as util from './util'; +const SHUTDOWN_REQUEST_TIMEOUT_MS = 5000; +const SIGTERM_GRACE_PERIOD_MS = 3000; +const KILL_SAFETY_NET_TIMEOUT_MS = 5000; + +function withTimeout(promise: Promise, ms: number, label: string): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms); + promise.then( + (val) => { clearTimeout(timer); resolve(val); }, + (err) => { clearTimeout(timer); reject(err); }, + ); + }); +} + export enum EcaServerStatus { Stopped = 'Stopped', Starting = 'Starting', @@ -31,6 +45,7 @@ interface EcaServerArgs { class EcaServer { private _proc?: cp.ChildProcessWithoutNullStreams; private _connection?: rpc.MessageConnection; + private _stopping = false; private _serverPathFinder: EcaServerPathFinder; private _channel: vscode.OutputChannel; @@ -147,13 +162,74 @@ class EcaServer { ); } + private async killProcess(): Promise { + const proc = this._proc; + if (!proc || proc.killed) { + return; + } + + return new Promise((resolve) => { + let exited = false; + let escalationTimer: NodeJS.Timeout; + let safetyTimer: NodeJS.Timeout; + const cleanup = () => { + clearTimeout(escalationTimer); + clearTimeout(safetyTimer); + }; + + proc.once('close', () => { exited = true; cleanup(); resolve(); }); + + this._channel.appendLine('[VSCODE] Sending SIGTERM to server process'); + proc.kill('SIGTERM'); + + escalationTimer = setTimeout(() => { + if (!exited) { + this._channel.appendLine('[VSCODE] SIGTERM did not stop the process, sending SIGKILL'); + proc.kill('SIGKILL'); + } + }, SIGTERM_GRACE_PERIOD_MS); + + safetyTimer = setTimeout(() => { cleanup(); resolve(); }, KILL_SAFETY_NET_TIMEOUT_MS); + }); + } + async stop() { - if (isClosable(this._status)) { - await this.connection.sendRequest(ecaApi.shutdown, {}); - this.connection.sendNotification(ecaApi.exit, {}); - this.connection.dispose(); + if (this._stopping) { + return; + } + this._stopping = true; + + try { + if (!isClosable(this._status)) { + return; + } + + if (this._connection) { + try { + await withTimeout( + this._connection.sendRequest(ecaApi.shutdown, {}), + SHUTDOWN_REQUEST_TIMEOUT_MS, + 'shutdown request', + ); + this._connection.sendNotification(ecaApi.exit, {}); + } catch (err) { + this._channel.appendLine(`[VSCODE] Graceful shutdown failed: ${err}`); + } + + try { + this._connection.dispose(); + } catch (_) { /* ignore dispose errors */ } + } + + this._proc?.removeAllListeners('close'); + this._proc?.removeAllListeners('error'); + await this.killProcess(); + } finally { + this._proc = undefined; + this._connection = undefined; + this._stopping = false; + this.changeStatus(EcaServerStatus.Stopped); } - this.changeStatus(EcaServerStatus.Stopped); } async restart() {