@@ -226,30 +226,46 @@ async Task<Connection> StartCoreAsync(CancellationToken ct)
226226 _logger . LogDebug ( "Starting Copilot client" ) ;
227227 _disconnected = false ;
228228
229- Task < Connection > result ;
229+ Connection ? connection = null ;
230+ Process ? cliProcess = null ;
230231
231- if ( _optionsHost is not null && _optionsPort is not null )
232- {
233- // External server (TCP)
234- _actualPort = _optionsPort ;
235- result = ConnectToServerAsync ( null , _optionsHost , _optionsPort , null , ct ) ;
236- }
237- else
232+ try
238233 {
239- // Child process (stdio or TCP)
240- var ( cliProcess , portOrNull , stderrBuffer ) = await StartCliServerAsync ( _options , _effectiveConnectionToken , _logger , ct ) ;
241- _actualPort = portOrNull ;
242- result = ConnectToServerAsync ( cliProcess , portOrNull is null ? null : "localhost" , portOrNull , stderrBuffer , ct ) ;
243- }
234+ if ( _optionsHost is not null && _optionsPort is not null )
235+ {
236+ // External server (TCP)
237+ _actualPort = _optionsPort ;
238+ connection = await ConnectToServerAsync ( null , _optionsHost , _optionsPort , null , ct ) ;
239+ }
240+ else
241+ {
242+ // Child process (stdio or TCP)
243+ var ( startedProcess , portOrNull , stderrBuffer ) = await StartCliServerAsync ( _options , _effectiveConnectionToken , _logger , ct ) ;
244+ cliProcess = startedProcess ;
245+ _actualPort = portOrNull ;
246+ connection = await ConnectToServerAsync ( cliProcess , portOrNull is null ? null : "localhost" , portOrNull , stderrBuffer , ct ) ;
247+ }
244248
245- var connection = await result ;
249+ // Verify protocol version compatibility
250+ await VerifyProtocolVersionAsync ( connection , ct ) ;
251+ await ConfigureSessionFsAsync ( ct ) ;
246252
247- // Verify protocol version compatibility
248- await VerifyProtocolVersionAsync ( connection , ct ) ;
249- await ConfigureSessionFsAsync ( ct ) ;
253+ _logger . LogInformation ( "Copilot client connected" ) ;
254+ return connection ;
255+ }
256+ catch
257+ {
258+ if ( connection is not null )
259+ {
260+ await CleanupConnectionAsync ( connection , errors : null ) ;
261+ }
262+ else if ( cliProcess is not null )
263+ {
264+ await CleanupCliProcessAsync ( cliProcess , errors : null , _logger ) ;
265+ }
250266
251- _logger . LogInformation ( "Copilot client connected" ) ;
252- return connection ;
267+ throw ;
268+ }
253269 }
254270 }
255271
@@ -353,11 +369,27 @@ private async Task CleanupConnectionAsync(List<Exception>? errors)
353369 return ;
354370 }
355371
356- var ctx = await _connectionTask ;
372+ var connectionTask = _connectionTask ;
357373 _connectionTask = null ;
358374
375+ Connection ctx ;
376+ try
377+ {
378+ ctx = await connectionTask ;
379+ }
380+ catch ( Exception ex )
381+ {
382+ _logger . LogDebug ( ex , "Ignoring failed Copilot client startup during cleanup" ) ;
383+ return ;
384+ }
385+
386+ await CleanupConnectionAsync ( ctx , errors ) ;
387+ }
388+
389+ private async Task CleanupConnectionAsync ( Connection ctx , List < Exception > ? errors )
390+ {
359391 try { ctx . Rpc . Dispose ( ) ; }
360- catch ( Exception ex ) { errors ? . Add ( ex ) ; }
392+ catch ( Exception ex ) { AddCleanupError ( errors , ex , _logger ) ; }
361393
362394 // Clear RPC and models cache
363395 _serverRpc = null ;
@@ -366,17 +398,47 @@ private async Task CleanupConnectionAsync(List<Exception>? errors)
366398 if ( ctx . NetworkStream is not null )
367399 {
368400 try { await ctx . NetworkStream . DisposeAsync ( ) ; }
369- catch ( Exception ex ) { errors ? . Add ( ex ) ; }
401+ catch ( Exception ex ) { AddCleanupError ( errors , ex , _logger ) ; }
370402 }
371403
372404 if ( ctx . CliProcess is { } childProcess )
405+ {
406+ await CleanupCliProcessAsync ( childProcess , errors , _logger ) ;
407+ }
408+ }
409+
410+ private static async Task CleanupCliProcessAsync ( Process childProcess , List < Exception > ? errors , ILogger ? logger )
411+ {
412+ try
373413 {
374414 try
375415 {
376- if ( ! childProcess . HasExited ) childProcess . Kill ( ) ;
416+ if ( ! childProcess . HasExited )
417+ {
418+ childProcess . Kill ( entireProcessTree : true ) ;
419+ await childProcess . WaitForExitAsync ( ) ;
420+ }
421+ }
422+ finally
423+ {
377424 childProcess . Dispose ( ) ;
378425 }
379- catch ( Exception ex ) { errors ? . Add ( ex ) ; }
426+ }
427+ catch ( Exception ex )
428+ {
429+ AddCleanupError ( errors , ex , logger ) ;
430+ }
431+ }
432+
433+ private static void AddCleanupError ( List < Exception > ? errors , Exception ex , ILogger ? logger )
434+ {
435+ if ( errors is not null )
436+ {
437+ errors . Add ( ex ) ;
438+ }
439+ else
440+ {
441+ logger ? . LogDebug ( ex , "Error while cleaning up Copilot CLI connection" ) ;
380442 }
381443 }
382444
@@ -1090,7 +1152,7 @@ internal static async Task<T> InvokeRpcAsync<T>(JsonRpc rpc, string method, obje
10901152
10911153 if ( ! string . IsNullOrEmpty ( stderrOutput ) )
10921154 {
1093- throw new IOException ( $ "CLI process exited unexpectedly.\n stderr: { stderrOutput } " , ex ) ;
1155+ throw new IOException ( FormatCliExitedMessage ( "CLI process exited unexpectedly." , stderrOutput ) , ex ) ;
10941156 }
10951157 throw new IOException ( $ "Communication error with Copilot CLI: { ex . Message } ", ex ) ;
10961158 }
@@ -1100,6 +1162,24 @@ internal static async Task<T> InvokeRpcAsync<T>(JsonRpc rpc, string method, obje
11001162 }
11011163 }
11021164
1165+ private static string FormatCliExitedMessage ( string message , string stderrOutput )
1166+ {
1167+ return string . IsNullOrEmpty ( stderrOutput )
1168+ ? message
1169+ : $ "{ message } \n stderr: { stderrOutput } ";
1170+ }
1171+
1172+ private static IOException CreateCliExitedException ( string message , StringBuilder stderrBuffer )
1173+ {
1174+ string stderrOutput ;
1175+ lock ( stderrBuffer )
1176+ {
1177+ stderrOutput = stderrBuffer . ToString ( ) . Trim ( ) ;
1178+ }
1179+
1180+ return new IOException ( FormatCliExitedMessage ( message , stderrOutput ) ) ;
1181+ }
1182+
11031183 private Task < Connection > EnsureConnectedAsync ( CancellationToken cancellationToken )
11041184 {
11051185 if ( _connectionTask is null && ! _options . AutoStart )
@@ -1152,7 +1232,7 @@ private async Task VerifyProtocolVersionAsync(Connection connection, Cancellatio
11521232 connection . Rpc , "connect" , [ new ConnectRequest { Token = _effectiveConnectionToken } ] , connection . StderrBuffer , cancellationToken ) ;
11531233 serverVersion = ( int ) connectResponse . ProtocolVersion ;
11541234 }
1155- catch ( RemoteRpcException ex ) when ( ex . ErrorCode == RemoteRpcException . MethodNotFoundErrorCode )
1235+ catch ( IOException ex ) when ( ex . InnerException is RemoteRpcException remoteEx && IsUnsupportedConnectMethod ( remoteEx ) )
11561236 {
11571237 // Legacy server without `connect`; fall back to `ping`. A token, if any,
11581238 // is silently dropped — the legacy server can't enforce one.
@@ -1180,6 +1260,12 @@ private async Task VerifyProtocolVersionAsync(Connection connection, Cancellatio
11801260 _negotiatedProtocolVersion = serverVersion . Value ;
11811261 }
11821262
1263+ private static bool IsUnsupportedConnectMethod ( RemoteRpcException ex )
1264+ {
1265+ return ex . ErrorCode == RemoteRpcException . MethodNotFoundErrorCode
1266+ || string . Equals ( ex . Message , "Unhandled method connect" , StringComparison . Ordinal ) ;
1267+ }
1268+
11831269 private static async Task < ( Process Process , int ? DetectedLocalhostTcpPort , StringBuilder StderrBuffer ) > StartCliServerAsync ( CopilotClientOptions options , string ? connectionToken , ILogger logger , CancellationToken cancellationToken )
11841270 {
11851271 // Use explicit path, COPILOT_CLI_PATH env var (from options.Environment or process env), or bundled CLI - no PATH fallback
@@ -1277,18 +1363,24 @@ private async Task VerifyProtocolVersionAsync(Connection connection, Cancellatio
12771363 if ( telemetry . CaptureContent is { } capture ) startInfo . Environment [ "OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT" ] = capture ? "true" : "false" ;
12781364 }
12791365
1280- var cliProcess = new Process { StartInfo = startInfo } ;
1281- cliProcess . Start ( ) ;
1282-
1283- // Capture stderr for error messages and forward to logger
1284- var stderrBuffer = new StringBuilder ( ) ;
1285- _ = Task . Run ( async ( ) =>
1366+ Process ? cliProcess = null ;
1367+ try
12861368 {
1287- while ( cliProcess != null && ! cliProcess . HasExited )
1369+ cliProcess = new Process { StartInfo = startInfo } ;
1370+ cliProcess . Start ( ) ;
1371+
1372+ // Capture stderr for error messages and forward to logger
1373+ var stderrBuffer = new StringBuilder ( ) ;
1374+ var stderrReader = Task . Run ( async ( ) =>
12881375 {
1289- var line = await cliProcess . StandardError . ReadLineAsync ( cancellationToken ) ;
1290- if ( line != null )
1376+ while ( true )
12911377 {
1378+ var line = await cliProcess . StandardError . ReadLineAsync ( cancellationToken ) ;
1379+ if ( line is null )
1380+ {
1381+ break ;
1382+ }
1383+
12921384 lock ( stderrBuffer )
12931385 {
12941386 stderrBuffer . AppendLine ( line ) ;
@@ -1299,28 +1391,43 @@ private async Task VerifyProtocolVersionAsync(Connection connection, Cancellatio
12991391 logger . LogDebug ( "[CLI] {Line}" , line ) ;
13001392 }
13011393 }
1302- }
1303- } , cancellationToken ) ;
1394+ } , cancellationToken ) ;
13041395
1305- var detectedLocalhostTcpPort = ( int ? ) null ;
1306- if ( options . UseStdio != true )
1307- {
1308- // Wait for port announcement
1309- using var cts = CancellationTokenSource . CreateLinkedTokenSource ( cancellationToken ) ;
1310- cts . CancelAfter ( TimeSpan . FromSeconds ( 30 ) ) ;
1311-
1312- while ( ! cts . Token . IsCancellationRequested )
1396+ var detectedLocalhostTcpPort = ( int ? ) null ;
1397+ if ( options . UseStdio != true )
13131398 {
1314- var line = await cliProcess . StandardOutput . ReadLineAsync ( cts . Token ) ?? throw new IOException ( "CLI process exited unexpectedly" ) ;
1315- if ( ListeningOnPortRegex ( ) . Match ( line ) is { Success : true } match )
1399+ // Wait for port announcement
1400+ using var cts = CancellationTokenSource . CreateLinkedTokenSource ( cancellationToken ) ;
1401+ cts . CancelAfter ( TimeSpan . FromSeconds ( 30 ) ) ;
1402+
1403+ while ( ! cts . Token . IsCancellationRequested )
13161404 {
1317- detectedLocalhostTcpPort = int . Parse ( match . Groups [ 1 ] . Value , CultureInfo . InvariantCulture ) ;
1318- break ;
1405+ var line = await cliProcess . StandardOutput . ReadLineAsync ( cts . Token ) ;
1406+ if ( line is null )
1407+ {
1408+ await stderrReader ;
1409+ throw CreateCliExitedException ( "CLI process exited unexpectedly" , stderrBuffer ) ;
1410+ }
1411+
1412+ if ( ListeningOnPortRegex ( ) . Match ( line ) is { Success : true } match )
1413+ {
1414+ detectedLocalhostTcpPort = int . Parse ( match . Groups [ 1 ] . Value , CultureInfo . InvariantCulture ) ;
1415+ break ;
1416+ }
13191417 }
13201418 }
1419+
1420+ return ( cliProcess , detectedLocalhostTcpPort , stderrBuffer ) ;
13211421 }
1422+ catch
1423+ {
1424+ if ( cliProcess is not null )
1425+ {
1426+ await CleanupCliProcessAsync ( cliProcess , errors : null , logger ) ;
1427+ }
13221428
1323- return ( cliProcess , detectedLocalhostTcpPort , stderrBuffer ) ;
1429+ throw ;
1430+ }
13241431 }
13251432
13261433 private static string ? GetBundledCliPath ( out string searchedPath )
0 commit comments