Skip to content

Commit 3452e5b

Browse files
committed
Improve TCP startup failure cleanup
1 parent 066b970 commit 3452e5b

1 file changed

Lines changed: 81 additions & 50 deletions

File tree

dotnet/src/Client.cs

Lines changed: 81 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1328,15 +1328,22 @@ private async Task VerifyProtocolVersionAsync(Connection connection, Cancellatio
13281328
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
13291329
cts.CancelAfter(TimeSpan.FromSeconds(30));
13301330

1331-
while (!cts.Token.IsCancellationRequested)
1331+
try
13321332
{
1333-
var line = await cliProcess.StandardOutput.ReadLineAsync(cts.Token) ?? throw new IOException("CLI process exited unexpectedly");
1334-
if (ListeningOnPortRegex().Match(line) is { Success: true } match)
1333+
while (true)
13351334
{
1336-
detectedLocalhostTcpPort = int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture);
1337-
break;
1335+
var line = await cliProcess.StandardOutput.ReadLineAsync(cts.Token) ?? throw new IOException("CLI process exited unexpectedly");
1336+
if (ListeningOnPortRegex().Match(line) is { Success: true } match)
1337+
{
1338+
detectedLocalhostTcpPort = int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture);
1339+
break;
1340+
}
13381341
}
13391342
}
1343+
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested && cts.IsCancellationRequested)
1344+
{
1345+
throw new IOException("Timed out waiting for Copilot CLI to report its TCP listening port.");
1346+
}
13401347
}
13411348

13421349
return (cliProcess, detectedLocalhostTcpPort, stderrPump);
@@ -1397,63 +1404,87 @@ private static (string FileName, IEnumerable<string> Args) ResolveCliCommand(str
13971404

13981405
private async Task<Connection> ConnectToServerAsync(Process? cliProcess, string? tcpHost, int? tcpPort, ProcessStderrPump? stderrPump, CancellationToken cancellationToken)
13991406
{
1400-
Stream inputStream, outputStream;
14011407
TcpClient? tcpClient = null;
14021408
NetworkStream? networkStream = null;
1409+
JsonRpc? rpc = null;
14031410

1404-
if (_options.UseStdio)
1405-
{
1406-
if (cliProcess == null) throw new InvalidOperationException("CLI process not started");
1407-
inputStream = cliProcess.StandardOutput.BaseStream;
1408-
outputStream = cliProcess.StandardInput.BaseStream;
1409-
}
1410-
else
1411+
try
14111412
{
1412-
if (tcpHost is null || tcpPort is null)
1413+
Stream inputStream, outputStream;
1414+
1415+
if (_options.UseStdio)
14131416
{
1414-
throw new InvalidOperationException("Cannot connect because TCP host or port are not available");
1417+
if (cliProcess == null) throw new InvalidOperationException("CLI process not started");
1418+
inputStream = cliProcess.StandardOutput.BaseStream;
1419+
outputStream = cliProcess.StandardInput.BaseStream;
14151420
}
1421+
else
1422+
{
1423+
if (tcpHost is null || tcpPort is null)
1424+
{
1425+
throw new InvalidOperationException("Cannot connect because TCP host or port are not available");
1426+
}
14161427

1417-
tcpClient = new();
1418-
await tcpClient.ConnectAsync(tcpHost, tcpPort.Value, cancellationToken);
1419-
networkStream = tcpClient.GetStream();
1420-
inputStream = networkStream;
1421-
outputStream = networkStream;
1422-
}
1428+
tcpClient = new();
1429+
await tcpClient.ConnectAsync(tcpHost, tcpPort.Value, cancellationToken);
1430+
networkStream = tcpClient.GetStream();
1431+
inputStream = networkStream;
1432+
outputStream = networkStream;
1433+
}
14231434

1424-
var rpc = new JsonRpc(new HeaderDelimitedMessageHandler(
1425-
outputStream,
1426-
inputStream,
1427-
CreateSystemTextJsonFormatter()))
1428-
{
1429-
TraceSource = new LoggerTraceSource(_logger),
1430-
};
1435+
rpc = new JsonRpc(new HeaderDelimitedMessageHandler(
1436+
outputStream,
1437+
inputStream,
1438+
CreateSystemTextJsonFormatter()))
1439+
{
1440+
TraceSource = new LoggerTraceSource(_logger),
1441+
};
14311442

1432-
var handler = new RpcHandler(this);
1433-
rpc.AddLocalRpcMethod("session.event", handler.OnSessionEvent);
1434-
rpc.AddLocalRpcMethod("session.lifecycle", handler.OnSessionLifecycle);
1435-
// Protocol v3 servers send tool calls / permission requests as broadcast events.
1436-
// Protocol v2 servers use the older tool.call / permission.request RPC model.
1437-
// We always register v2 adapters because handlers are set up before version
1438-
// negotiation; a v3 server will simply never send these requests.
1439-
rpc.AddLocalRpcMethod("tool.call", handler.OnToolCallV2);
1440-
rpc.AddLocalRpcMethod("permission.request", handler.OnPermissionRequestV2);
1441-
rpc.AddLocalRpcMethod("userInput.request", handler.OnUserInputRequest);
1442-
rpc.AddLocalRpcMethod("hooks.invoke", handler.OnHooksInvoke);
1443-
rpc.AddLocalRpcMethod("systemMessage.transform", handler.OnSystemMessageTransform);
1444-
ClientSessionApiRegistration.RegisterClientSessionApiHandlers(rpc, sessionId =>
1445-
{
1446-
var session = GetSession(sessionId) ?? throw new ArgumentException($"Unknown session {sessionId}");
1447-
return session.ClientSessionApis;
1448-
});
1449-
rpc.StartListening();
1443+
var handler = new RpcHandler(this);
1444+
rpc.AddLocalRpcMethod("session.event", handler.OnSessionEvent);
1445+
rpc.AddLocalRpcMethod("session.lifecycle", handler.OnSessionLifecycle);
1446+
// Protocol v3 servers send tool calls / permission requests as broadcast events.
1447+
// Protocol v2 servers use the older tool.call / permission.request RPC model.
1448+
// We always register v2 adapters because handlers are set up before version
1449+
// negotiation; a v3 server will simply never send these requests.
1450+
rpc.AddLocalRpcMethod("tool.call", handler.OnToolCallV2);
1451+
rpc.AddLocalRpcMethod("permission.request", handler.OnPermissionRequestV2);
1452+
rpc.AddLocalRpcMethod("userInput.request", handler.OnUserInputRequest);
1453+
rpc.AddLocalRpcMethod("hooks.invoke", handler.OnHooksInvoke);
1454+
rpc.AddLocalRpcMethod("systemMessage.transform", handler.OnSystemMessageTransform);
1455+
ClientSessionApiRegistration.RegisterClientSessionApiHandlers(rpc, sessionId =>
1456+
{
1457+
var session = GetSession(sessionId) ?? throw new ArgumentException($"Unknown session {sessionId}");
1458+
return session.ClientSessionApis;
1459+
});
1460+
rpc.StartListening();
1461+
1462+
// Transition state to Disconnected if the JSON-RPC connection drops
1463+
_ = rpc.Completion.ContinueWith(_ => _disconnected = true, TaskScheduler.Default);
1464+
1465+
_rpc = new ServerRpc(rpc);
1466+
1467+
return new Connection(rpc, cliProcess, tcpClient, networkStream, stderrPump);
1468+
}
1469+
catch
1470+
{
1471+
try { rpc?.Dispose(); }
1472+
catch (Exception ex) { _logger.LogDebug(ex, "Failed to dispose JSON-RPC connection after startup failure"); }
14501473

1451-
// Transition state to Disconnected if the JSON-RPC connection drops
1452-
_ = rpc.Completion.ContinueWith(_ => _disconnected = true, TaskScheduler.Default);
1474+
if (networkStream is not null)
1475+
{
1476+
try { await networkStream.DisposeAsync(); }
1477+
catch (Exception ex) { _logger.LogDebug(ex, "Failed to dispose TCP stream after startup failure"); }
1478+
}
14531479

1454-
_rpc = new ServerRpc(rpc);
1480+
if (tcpClient is not null)
1481+
{
1482+
try { tcpClient.Dispose(); }
1483+
catch (Exception ex) { _logger.LogDebug(ex, "Failed to dispose TCP client after startup failure"); }
1484+
}
14551485

1456-
return new Connection(rpc, cliProcess, tcpClient, networkStream, stderrPump);
1486+
throw;
1487+
}
14571488
}
14581489

14591490
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Using happy path from https://microsoft.github.io/vs-streamjsonrpc/docs/nativeAOT.html")]

0 commit comments

Comments
 (0)