From cdb1d21f74be6c1037eee8a96a6209bbd91e70e7 Mon Sep 17 00:00:00 2001 From: Alexandre Mutel Date: Sat, 25 Apr 2026 08:52:19 +0200 Subject: [PATCH 1/4] Coordinate Copilot CLI stderr pump cleanup --- dotnet/src/Client.cs | 178 ++++++++++++++++++++++++++++++++++--------- 1 file changed, 144 insertions(+), 34 deletions(-) diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index 8e4d1eefb..a4dd725f4 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -218,9 +218,9 @@ async Task StartCoreAsync(CancellationToken ct) else { // Child process (stdio or TCP) - var (cliProcess, portOrNull, stderrBuffer) = await StartCliServerAsync(_options, _logger, ct); + var (cliProcess, portOrNull, stderrPump) = await StartCliServerAsync(_options, _logger, ct); _actualPort = portOrNull; - result = ConnectToServerAsync(cliProcess, portOrNull is null ? null : "localhost", portOrNull, stderrBuffer, ct); + result = ConnectToServerAsync(cliProcess, portOrNull is null ? null : "localhost", portOrNull, stderrPump, ct); } var connection = await result; @@ -358,12 +358,23 @@ private async Task CleanupConnectionAsync(List? errors) if (ctx.CliProcess is { } childProcess) { + ctx.StderrPump?.Cancel(); + try { if (!childProcess.HasExited) childProcess.Kill(); - childProcess.Dispose(); } catch (Exception ex) { errors?.Add(ex); } + + if (ctx.StderrPump is not null) + { + try { await ctx.StderrPump.WaitForCompletionAsync(); } + catch (Exception ex) { errors?.Add(ex); } + finally { ctx.StderrPump.Dispose(); } + } + + try { childProcess.Dispose(); } + catch (Exception ex) { errors?.Add(ex); } } } @@ -1152,7 +1163,7 @@ private async Task VerifyProtocolVersionAsync(Connection connection, Cancellatio _negotiatedProtocolVersion = serverVersion; } - private static async Task<(Process Process, int? DetectedLocalhostTcpPort, StringBuilder StderrBuffer)> StartCliServerAsync(CopilotClientOptions options, ILogger logger, CancellationToken cancellationToken) + private static async Task<(Process Process, int? DetectedLocalhostTcpPort, ProcessStderrPump StderrPump)> StartCliServerAsync(CopilotClientOptions options, ILogger logger, CancellationToken cancellationToken) { // Use explicit path, COPILOT_CLI_PATH env var (from options.Environment or process env), or bundled CLI - no PATH fallback var envCliPath = options.Environment is not null && options.Environment.TryGetValue("COPILOT_CLI_PATH", out var envValue) ? envValue @@ -1242,47 +1253,61 @@ private async Task VerifyProtocolVersionAsync(Connection connection, Cancellatio var cliProcess = new Process { StartInfo = startInfo }; cliProcess.Start(); - // Capture stderr for error messages and forward to logger - var stderrBuffer = new StringBuilder(); - _ = Task.Run(async () => + // Capture stderr for error messages and forward to logger. + // The pump has its own lifetime token and is later cancelled/observed + // by the owning Connection before the process is disposed. + var stderrPump = ProcessStderrPump.Start(cliProcess, logger); + + var detectedLocalhostTcpPort = (int?)null; + try { - while (cliProcess != null && !cliProcess.HasExited) + if (!options.UseStdio) { - var line = await cliProcess.StandardError.ReadLineAsync(cancellationToken); - if (line != null) - { - lock (stderrBuffer) - { - stderrBuffer.AppendLine(line); - } + // Wait for port announcement + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(TimeSpan.FromSeconds(30)); - if (logger.IsEnabled(LogLevel.Debug)) + while (!cts.Token.IsCancellationRequested) + { + var line = await cliProcess.StandardOutput.ReadLineAsync(cts.Token) ?? throw new IOException("CLI process exited unexpectedly"); + if (ListeningOnPortRegex().Match(line) is { Success: true } match) { - logger.LogDebug("[CLI] {Line}", line); + detectedLocalhostTcpPort = int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture); + break; } } } - }, cancellationToken); - var detectedLocalhostTcpPort = (int?)null; - if (!options.UseStdio) + return (cliProcess, detectedLocalhostTcpPort, stderrPump); + } + catch { - // Wait for port announcement - using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - cts.CancelAfter(TimeSpan.FromSeconds(30)); + stderrPump.Cancel(); - while (!cts.Token.IsCancellationRequested) + try { - var line = await cliProcess.StandardOutput.ReadLineAsync(cts.Token) ?? throw new IOException("CLI process exited unexpectedly"); - if (ListeningOnPortRegex().Match(line) is { Success: true } match) + if (!cliProcess.HasExited) { - detectedLocalhostTcpPort = int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture); - break; + cliProcess.Kill(); } } - } + catch (Exception ex) + { + logger.LogDebug(ex, "Failed to kill CLI process after startup failure"); + } - return (cliProcess, detectedLocalhostTcpPort, stderrBuffer); + try + { + await stderrPump.WaitForCompletionAsync(); + } + finally + { + stderrPump.Dispose(); + cliProcess.Dispose(); + } + + throw; + } } private static string? GetBundledCliPath(out string searchedPath) @@ -1326,7 +1351,7 @@ private static (string FileName, IEnumerable Args) ResolveCliCommand(str return (cliPath, args); } - private async Task ConnectToServerAsync(Process? cliProcess, string? tcpHost, int? tcpPort, StringBuilder? stderrBuffer, CancellationToken cancellationToken) + private async Task ConnectToServerAsync(Process? cliProcess, string? tcpHost, int? tcpPort, ProcessStderrPump? stderrPump, CancellationToken cancellationToken) { Stream inputStream, outputStream; TcpClient? tcpClient = null; @@ -1384,7 +1409,7 @@ private async Task ConnectToServerAsync(Process? cliProcess, string? _rpc = new ServerRpc(rpc); - return new Connection(rpc, cliProcess, tcpClient, networkStream, stderrBuffer); + return new Connection(rpc, cliProcess, tcpClient, networkStream, stderrPump); } [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Using happy path from https://microsoft.github.io/vs-streamjsonrpc/docs/nativeAOT.html")] @@ -1613,13 +1638,98 @@ private class Connection( Process? cliProcess, // Set if we created the child process TcpClient? tcpClient, // Set if using TCP NetworkStream? networkStream, // Set if using TCP - StringBuilder? stderrBuffer = null) // Captures stderr for error messages + ProcessStderrPump? stderrPump = null) // Captures stderr for error messages { public Process? CliProcess => cliProcess; public TcpClient? TcpClient => tcpClient; public JsonRpc Rpc => rpc; public NetworkStream? NetworkStream => networkStream; - public StringBuilder? StderrBuffer => stderrBuffer; + public ProcessStderrPump? StderrPump => stderrPump; + public StringBuilder? StderrBuffer => stderrPump?.Buffer; + } + + private sealed class ProcessStderrPump : IDisposable + { + private readonly CancellationTokenSource _cancellationTokenSource = new(); + private readonly Task _completion; + private bool _disposed; + + private ProcessStderrPump(Process process, ILogger logger) + { + _completion = Task.Run(() => PumpAsync(process, logger, _cancellationTokenSource.Token)); + } + + public StringBuilder Buffer { get; } = new(); + + public static ProcessStderrPump Start(Process process, ILogger logger) + { + return new ProcessStderrPump(process, logger); + } + + public void Cancel() + { + if (!_disposed) + { + _cancellationTokenSource.Cancel(); + } + } + + public async Task WaitForCompletionAsync() + { + await _completion; + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + _cancellationTokenSource.Dispose(); + } + + private async Task PumpAsync(Process process, ILogger logger, CancellationToken cancellationToken) + { + try + { + while (true) + { + var line = await process.StandardError.ReadLineAsync(cancellationToken); + if (line is null) + { + break; + } + + lock (Buffer) + { + Buffer.AppendLine(line); + } + + if (logger.IsEnabled(LogLevel.Debug)) + { + logger.LogDebug("[CLI] {Line}", line); + } + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } + catch (InvalidOperationException) when (cancellationToken.IsCancellationRequested) + { + } + catch (ObjectDisposedException) when (cancellationToken.IsCancellationRequested) + { + } + catch (IOException) when (cancellationToken.IsCancellationRequested) + { + } + catch (Exception ex) + { + logger.LogDebug(ex, "CLI stderr pump stopped unexpectedly"); + } + } } private static class ProcessArgumentEscaper From edf6f6fe117fda21924d4fc6ea435779714c0a96 Mon Sep 17 00:00:00 2001 From: Alexandre Mutel Date: Sat, 25 Apr 2026 11:35:23 +0200 Subject: [PATCH 2/4] Harden stderr pump cleanup paths --- dotnet/src/Client.cs | 165 +++++++++++++++++++++++++++---------------- 1 file changed, 106 insertions(+), 59 deletions(-) diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index a4dd725f4..1a804839f 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -63,6 +63,7 @@ public sealed partial class CopilotClient : IDisposable, IAsyncDisposable /// Minimum protocol version this SDK can communicate with. /// private const int MinProtocolVersion = 2; + private static readonly TimeSpan StderrPumpShutdownTimeout = TimeSpan.FromSeconds(5); private readonly ConcurrentDictionary _sessions = new(); private readonly CopilotClientOptions _options; @@ -207,30 +208,55 @@ async Task StartCoreAsync(CancellationToken ct) _logger.LogDebug("Starting Copilot client"); _disconnected = false; - Task result; + Connection? connection = null; + Process? cliProcess = null; + ProcessStderrPump? stderrPump = null; - if (_optionsHost is not null && _optionsPort is not null) + try { - // External server (TCP) - _actualPort = _optionsPort; - result = ConnectToServerAsync(null, _optionsHost, _optionsPort, null, ct); + if (_optionsHost is not null && _optionsPort is not null) + { + // External server (TCP) + _actualPort = _optionsPort; + connection = await ConnectToServerAsync(null, _optionsHost, _optionsPort, null, ct); + } + else + { + // Child process (stdio or TCP) + var portOrNull = (int?)null; + (cliProcess, portOrNull, stderrPump) = await StartCliServerAsync(_options, _logger, ct); + _actualPort = portOrNull; + connection = await ConnectToServerAsync(cliProcess, portOrNull is null ? null : "localhost", portOrNull, stderrPump, ct); + } + + // Verify protocol version compatibility + await VerifyProtocolVersionAsync(connection, ct); + await ConfigureSessionFsAsync(ct); + + _logger.LogInformation("Copilot client connected"); + return connection; } - else + catch { - // Child process (stdio or TCP) - var (cliProcess, portOrNull, stderrPump) = await StartCliServerAsync(_options, _logger, ct); - _actualPort = portOrNull; - result = ConnectToServerAsync(cliProcess, portOrNull is null ? null : "localhost", portOrNull, stderrPump, ct); - } + _connectionTask = null; - var connection = await result; + var cleanupErrors = new List(); + if (connection is not null) + { + await CleanupConnectionAsync(connection, cleanupErrors); + } + else if (cliProcess is not null) + { + await CleanupCliProcessAsync(cliProcess, stderrPump, _logger, cleanupErrors); + } - // Verify protocol version compatibility - await VerifyProtocolVersionAsync(connection, ct); - await ConfigureSessionFsAsync(ct); + foreach (var cleanupError in cleanupErrors) + { + _logger.LogDebug(cleanupError, "Failed to clean up Copilot client connection after startup failure"); + } - _logger.LogInformation("Copilot client connected"); - return connection; + throw; + } } } @@ -334,9 +360,21 @@ private async Task CleanupConnectionAsync(List? errors) return; } - var ctx = await _connectionTask; - _connectionTask = null; + Connection ctx; + try + { + ctx = await _connectionTask; + } + finally + { + _connectionTask = null; + } + + await CleanupConnectionAsync(ctx, errors); + } + private async Task CleanupConnectionAsync(Connection ctx, List? errors) + { try { ctx.Rpc.Dispose(); } catch (Exception ex) { errors?.Add(ex); } @@ -358,24 +396,34 @@ private async Task CleanupConnectionAsync(List? errors) if (ctx.CliProcess is { } childProcess) { - ctx.StderrPump?.Cancel(); + await CleanupCliProcessAsync(childProcess, ctx.StderrPump, _logger, errors); + } + } - try - { - if (!childProcess.HasExited) childProcess.Kill(); - } - catch (Exception ex) { errors?.Add(ex); } + private static async Task CleanupCliProcessAsync(Process childProcess, ProcessStderrPump? stderrPump, ILogger logger, List? errors) + { + stderrPump?.Cancel(); + + try + { + if (!childProcess.HasExited) childProcess.Kill(); + } + catch (Exception ex) { errors?.Add(ex); } - if (ctx.StderrPump is not null) + if (stderrPump is not null) + { + try { await stderrPump.WaitForCompletionAsync(StderrPumpShutdownTimeout); } + catch (TimeoutException ex) { - try { await ctx.StderrPump.WaitForCompletionAsync(); } - catch (Exception ex) { errors?.Add(ex); } - finally { ctx.StderrPump.Dispose(); } + logger.LogDebug(ex, "Timed out waiting for CLI stderr pump to stop"); + errors?.Add(ex); } - - try { childProcess.Dispose(); } catch (Exception ex) { errors?.Add(ex); } + finally { stderrPump.Dispose(); } } + + try { childProcess.Dispose(); } + catch (Exception ex) { errors?.Add(ex); } } private static (SystemMessageConfig? wireConfig, Dictionary>>? callbacks) ExtractTransformCallbacks(SystemMessageConfig? systemMessage) @@ -1282,28 +1330,11 @@ private async Task VerifyProtocolVersionAsync(Connection connection, Cancellatio } catch { - stderrPump.Cancel(); - - try - { - if (!cliProcess.HasExited) - { - cliProcess.Kill(); - } - } - catch (Exception ex) - { - logger.LogDebug(ex, "Failed to kill CLI process after startup failure"); - } - - try - { - await stderrPump.WaitForCompletionAsync(); - } - finally + var cleanupErrors = new List(); + await CleanupCliProcessAsync(cliProcess, stderrPump, logger, cleanupErrors); + foreach (var cleanupError in cleanupErrors) { - stderrPump.Dispose(); - cliProcess.Dispose(); + logger.LogDebug(cleanupError, "Failed to clean up Copilot CLI process after startup failure"); } throw; @@ -1652,7 +1683,7 @@ private sealed class ProcessStderrPump : IDisposable { private readonly CancellationTokenSource _cancellationTokenSource = new(); private readonly Task _completion; - private bool _disposed; + private int _disposeRequested; private ProcessStderrPump(Process process, ILogger logger) { @@ -1668,26 +1699,42 @@ public static ProcessStderrPump Start(Process process, ILogger logger) public void Cancel() { - if (!_disposed) + try { _cancellationTokenSource.Cancel(); } + catch (ObjectDisposedException) + { + } } - public async Task WaitForCompletionAsync() + public async Task WaitForCompletionAsync(TimeSpan timeout) { - await _completion; + await _completion.WaitAsync(timeout); } public void Dispose() { - if (_disposed) + if (Interlocked.Exchange(ref _disposeRequested, 1) != 0) { return; } - _disposed = true; - _cancellationTokenSource.Dispose(); + Cancel(); + + if (_completion.IsCompleted) + { + _cancellationTokenSource.Dispose(); + } + else + { + _ = _completion.ContinueWith( + static (_, state) => ((CancellationTokenSource)state!).Dispose(), + _cancellationTokenSource, + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + } } private async Task PumpAsync(Process process, ILogger logger, CancellationToken cancellationToken) From 066b97059962c499e7abd55ce9e0409a5a4371b7 Mon Sep 17 00:00:00 2001 From: Alexandre Mutel Date: Sat, 25 Apr 2026 13:04:29 +0200 Subject: [PATCH 3/4] Close stderr cleanup startup gaps --- dotnet/src/Client.cs | 37 +++++++++++++++++++++++++------------ 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index 1a804839f..76e2632a3 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -238,21 +238,26 @@ async Task StartCoreAsync(CancellationToken ct) } catch { - _connectionTask = null; - var cleanupErrors = new List(); - if (connection is not null) - { - await CleanupConnectionAsync(connection, cleanupErrors); - } - else if (cliProcess is not null) + try { - await CleanupCliProcessAsync(cliProcess, stderrPump, _logger, cleanupErrors); - } + if (connection is not null) + { + await CleanupConnectionAsync(connection, cleanupErrors); + } + else if (cliProcess is not null) + { + await CleanupCliProcessAsync(cliProcess, stderrPump, _logger, cleanupErrors); + } - foreach (var cleanupError in cleanupErrors) + foreach (var cleanupError in cleanupErrors) + { + _logger.LogDebug(cleanupError, "Failed to clean up Copilot client connection after startup failure"); + } + } + finally { - _logger.LogDebug(cleanupError, "Failed to clean up Copilot client connection after startup failure"); + _connectionTask = null; } throw; @@ -1299,7 +1304,15 @@ private async Task VerifyProtocolVersionAsync(Connection connection, Cancellatio } var cliProcess = new Process { StartInfo = startInfo }; - cliProcess.Start(); + try + { + cliProcess.Start(); + } + catch + { + cliProcess.Dispose(); + throw; + } // Capture stderr for error messages and forward to logger. // The pump has its own lifetime token and is later cancelled/observed From 3452e5bf714ca702044fd9ef81b566f39ff666ff Mon Sep 17 00:00:00 2001 From: Alexandre Mutel Date: Sat, 25 Apr 2026 13:51:08 +0200 Subject: [PATCH 4/4] Improve TCP startup failure cleanup --- dotnet/src/Client.cs | 131 ++++++++++++++++++++++++++----------------- 1 file changed, 81 insertions(+), 50 deletions(-) diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index 76e2632a3..24797770a 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -1328,15 +1328,22 @@ private async Task VerifyProtocolVersionAsync(Connection connection, Cancellatio using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); cts.CancelAfter(TimeSpan.FromSeconds(30)); - while (!cts.Token.IsCancellationRequested) + try { - var line = await cliProcess.StandardOutput.ReadLineAsync(cts.Token) ?? throw new IOException("CLI process exited unexpectedly"); - if (ListeningOnPortRegex().Match(line) is { Success: true } match) + while (true) { - detectedLocalhostTcpPort = int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture); - break; + var line = await cliProcess.StandardOutput.ReadLineAsync(cts.Token) ?? throw new IOException("CLI process exited unexpectedly"); + if (ListeningOnPortRegex().Match(line) is { Success: true } match) + { + detectedLocalhostTcpPort = int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture); + break; + } } } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested && cts.IsCancellationRequested) + { + throw new IOException("Timed out waiting for Copilot CLI to report its TCP listening port."); + } } return (cliProcess, detectedLocalhostTcpPort, stderrPump); @@ -1397,63 +1404,87 @@ private static (string FileName, IEnumerable Args) ResolveCliCommand(str private async Task ConnectToServerAsync(Process? cliProcess, string? tcpHost, int? tcpPort, ProcessStderrPump? stderrPump, CancellationToken cancellationToken) { - Stream inputStream, outputStream; TcpClient? tcpClient = null; NetworkStream? networkStream = null; + JsonRpc? rpc = null; - if (_options.UseStdio) - { - if (cliProcess == null) throw new InvalidOperationException("CLI process not started"); - inputStream = cliProcess.StandardOutput.BaseStream; - outputStream = cliProcess.StandardInput.BaseStream; - } - else + try { - if (tcpHost is null || tcpPort is null) + Stream inputStream, outputStream; + + if (_options.UseStdio) { - throw new InvalidOperationException("Cannot connect because TCP host or port are not available"); + if (cliProcess == null) throw new InvalidOperationException("CLI process not started"); + inputStream = cliProcess.StandardOutput.BaseStream; + outputStream = cliProcess.StandardInput.BaseStream; } + else + { + if (tcpHost is null || tcpPort is null) + { + throw new InvalidOperationException("Cannot connect because TCP host or port are not available"); + } - tcpClient = new(); - await tcpClient.ConnectAsync(tcpHost, tcpPort.Value, cancellationToken); - networkStream = tcpClient.GetStream(); - inputStream = networkStream; - outputStream = networkStream; - } + tcpClient = new(); + await tcpClient.ConnectAsync(tcpHost, tcpPort.Value, cancellationToken); + networkStream = tcpClient.GetStream(); + inputStream = networkStream; + outputStream = networkStream; + } - var rpc = new JsonRpc(new HeaderDelimitedMessageHandler( - outputStream, - inputStream, - CreateSystemTextJsonFormatter())) - { - TraceSource = new LoggerTraceSource(_logger), - }; + rpc = new JsonRpc(new HeaderDelimitedMessageHandler( + outputStream, + inputStream, + CreateSystemTextJsonFormatter())) + { + TraceSource = new LoggerTraceSource(_logger), + }; - var handler = new RpcHandler(this); - rpc.AddLocalRpcMethod("session.event", handler.OnSessionEvent); - rpc.AddLocalRpcMethod("session.lifecycle", handler.OnSessionLifecycle); - // Protocol v3 servers send tool calls / permission requests as broadcast events. - // Protocol v2 servers use the older tool.call / permission.request RPC model. - // We always register v2 adapters because handlers are set up before version - // negotiation; a v3 server will simply never send these requests. - rpc.AddLocalRpcMethod("tool.call", handler.OnToolCallV2); - rpc.AddLocalRpcMethod("permission.request", handler.OnPermissionRequestV2); - rpc.AddLocalRpcMethod("userInput.request", handler.OnUserInputRequest); - rpc.AddLocalRpcMethod("hooks.invoke", handler.OnHooksInvoke); - rpc.AddLocalRpcMethod("systemMessage.transform", handler.OnSystemMessageTransform); - ClientSessionApiRegistration.RegisterClientSessionApiHandlers(rpc, sessionId => - { - var session = GetSession(sessionId) ?? throw new ArgumentException($"Unknown session {sessionId}"); - return session.ClientSessionApis; - }); - rpc.StartListening(); + var handler = new RpcHandler(this); + rpc.AddLocalRpcMethod("session.event", handler.OnSessionEvent); + rpc.AddLocalRpcMethod("session.lifecycle", handler.OnSessionLifecycle); + // Protocol v3 servers send tool calls / permission requests as broadcast events. + // Protocol v2 servers use the older tool.call / permission.request RPC model. + // We always register v2 adapters because handlers are set up before version + // negotiation; a v3 server will simply never send these requests. + rpc.AddLocalRpcMethod("tool.call", handler.OnToolCallV2); + rpc.AddLocalRpcMethod("permission.request", handler.OnPermissionRequestV2); + rpc.AddLocalRpcMethod("userInput.request", handler.OnUserInputRequest); + rpc.AddLocalRpcMethod("hooks.invoke", handler.OnHooksInvoke); + rpc.AddLocalRpcMethod("systemMessage.transform", handler.OnSystemMessageTransform); + ClientSessionApiRegistration.RegisterClientSessionApiHandlers(rpc, sessionId => + { + var session = GetSession(sessionId) ?? throw new ArgumentException($"Unknown session {sessionId}"); + return session.ClientSessionApis; + }); + rpc.StartListening(); + + // Transition state to Disconnected if the JSON-RPC connection drops + _ = rpc.Completion.ContinueWith(_ => _disconnected = true, TaskScheduler.Default); + + _rpc = new ServerRpc(rpc); + + return new Connection(rpc, cliProcess, tcpClient, networkStream, stderrPump); + } + catch + { + try { rpc?.Dispose(); } + catch (Exception ex) { _logger.LogDebug(ex, "Failed to dispose JSON-RPC connection after startup failure"); } - // Transition state to Disconnected if the JSON-RPC connection drops - _ = rpc.Completion.ContinueWith(_ => _disconnected = true, TaskScheduler.Default); + if (networkStream is not null) + { + try { await networkStream.DisposeAsync(); } + catch (Exception ex) { _logger.LogDebug(ex, "Failed to dispose TCP stream after startup failure"); } + } - _rpc = new ServerRpc(rpc); + if (tcpClient is not null) + { + try { tcpClient.Dispose(); } + catch (Exception ex) { _logger.LogDebug(ex, "Failed to dispose TCP client after startup failure"); } + } - return new Connection(rpc, cliProcess, tcpClient, networkStream, stderrPump); + throw; + } } [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Using happy path from https://microsoft.github.io/vs-streamjsonrpc/docs/nativeAOT.html")]