Skip to content

Commit 72ac35f

Browse files
stephentoubCopilot
andcommitted
Wait for CLI stderr on stdio disconnect
When stdio startup fails quickly, wait briefly for the stderr reader before formatting the connection-lost exception so CLI argument errors are reported deterministically. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent e7ef740 commit 72ac35f

1 file changed

Lines changed: 42 additions & 21 deletions

File tree

dotnet/src/Client.cs

Lines changed: 42 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -232,14 +232,14 @@ async Task<Connection> StartCoreAsync(CancellationToken ct)
232232
{
233233
// External server (TCP)
234234
_actualPort = _optionsPort;
235-
result = ConnectToServerAsync(null, _optionsHost, _optionsPort, null, ct);
235+
result = ConnectToServerAsync(null, _optionsHost, _optionsPort, null, null, ct);
236236
}
237237
else
238238
{
239239
// Child process (stdio or TCP)
240-
var (cliProcess, portOrNull, stderrBuffer) = await StartCliServerAsync(_options, _effectiveConnectionToken, _logger, ct);
240+
var (cliProcess, portOrNull, stderrBuffer, stderrReader) = await StartCliServerAsync(_options, _effectiveConnectionToken, _logger, ct);
241241
_actualPort = portOrNull;
242-
result = ConnectToServerAsync(cliProcess, portOrNull is null ? null : "localhost", portOrNull, stderrBuffer, ct);
242+
result = ConnectToServerAsync(cliProcess, portOrNull is null ? null : "localhost", portOrNull, stderrBuffer, stderrReader, ct);
243243
}
244244

245245
var connection = await result;
@@ -1076,21 +1076,19 @@ internal static async Task InvokeRpcAsync(JsonRpc rpc, string method, object?[]?
10761076
}
10771077

10781078
internal static async Task<T> InvokeRpcAsync<T>(JsonRpc rpc, string method, object?[]? args, StringBuilder? stderrBuffer, CancellationToken cancellationToken)
1079+
{
1080+
return await InvokeRpcAsync<T>(rpc, method, args, stderrBuffer, stderrReader: null, cancellationToken);
1081+
}
1082+
1083+
internal static async Task<T> InvokeRpcAsync<T>(JsonRpc rpc, string method, object?[]? args, StringBuilder? stderrBuffer, Task? stderrReader, CancellationToken cancellationToken)
10791084
{
10801085
try
10811086
{
10821087
return await rpc.InvokeAsync<T>(method, args, cancellationToken);
10831088
}
10841089
catch (ConnectionLostException ex)
10851090
{
1086-
string? stderrOutput = null;
1087-
if (stderrBuffer is not null)
1088-
{
1089-
lock (stderrBuffer)
1090-
{
1091-
stderrOutput = stderrBuffer.ToString().Trim();
1092-
}
1093-
}
1091+
var stderrOutput = await GetStderrOutputAsync(stderrBuffer, stderrReader);
10941092

10951093
if (!string.IsNullOrEmpty(stderrOutput))
10961094
{
@@ -1111,13 +1109,34 @@ private static string FormatCliExitedMessage(string message, string stderrOutput
11111109
: $"{message}\nstderr: {stderrOutput}";
11121110
}
11131111

1114-
private static IOException CreateCliExitedException(string message, StringBuilder stderrBuffer)
1112+
private static async Task<string?> GetStderrOutputAsync(StringBuilder? stderrBuffer, Task? stderrReader)
1113+
{
1114+
if (stderrBuffer is null)
1115+
{
1116+
return null;
1117+
}
1118+
1119+
var stderrOutput = ReadStderrBuffer(stderrBuffer);
1120+
if (string.IsNullOrEmpty(stderrOutput) && stderrReader is not null)
1121+
{
1122+
await Task.WhenAny(stderrReader, Task.Delay(TimeSpan.FromMilliseconds(250)));
1123+
stderrOutput = ReadStderrBuffer(stderrBuffer);
1124+
}
1125+
1126+
return string.IsNullOrEmpty(stderrOutput) ? null : stderrOutput;
1127+
}
1128+
1129+
private static string ReadStderrBuffer(StringBuilder stderrBuffer)
11151130
{
1116-
string stderrOutput;
11171131
lock (stderrBuffer)
11181132
{
1119-
stderrOutput = stderrBuffer.ToString().Trim();
1133+
return stderrBuffer.ToString().Trim();
11201134
}
1135+
}
1136+
1137+
private static IOException CreateCliExitedException(string message, StringBuilder stderrBuffer)
1138+
{
1139+
var stderrOutput = ReadStderrBuffer(stderrBuffer);
11211140

11221141
return new IOException(FormatCliExitedMessage(message, stderrOutput));
11231142
}
@@ -1171,15 +1190,15 @@ private async Task VerifyProtocolVersionAsync(Connection connection, Cancellatio
11711190
try
11721191
{
11731192
var connectResponse = await InvokeRpcAsync<ConnectResult>(
1174-
connection.Rpc, "connect", [new ConnectRequest { Token = _effectiveConnectionToken }], connection.StderrBuffer, cancellationToken);
1193+
connection.Rpc, "connect", [new ConnectRequest { Token = _effectiveConnectionToken }], connection.StderrBuffer, connection.StderrReader, cancellationToken);
11751194
serverVersion = (int)connectResponse.ProtocolVersion;
11761195
}
11771196
catch (IOException ex) when (ex.InnerException is RemoteRpcException remoteEx && IsUnsupportedConnectMethod(remoteEx))
11781197
{
11791198
// Legacy server without `connect`; fall back to `ping`. A token, if any,
11801199
// is silently dropped — the legacy server can't enforce one.
11811200
var pingResponse = await InvokeRpcAsync<PingResponse>(
1182-
connection.Rpc, "ping", [new PingRequest()], connection.StderrBuffer, cancellationToken);
1201+
connection.Rpc, "ping", [new PingRequest()], connection.StderrBuffer, connection.StderrReader, cancellationToken);
11831202
serverVersion = pingResponse.ProtocolVersion;
11841203
}
11851204

@@ -1208,7 +1227,7 @@ private static bool IsUnsupportedConnectMethod(RemoteRpcException ex)
12081227
|| string.Equals(ex.Message, "Unhandled method connect", StringComparison.Ordinal);
12091228
}
12101229

1211-
private static async Task<(Process Process, int? DetectedLocalhostTcpPort, StringBuilder StderrBuffer)> StartCliServerAsync(CopilotClientOptions options, string? connectionToken, ILogger logger, CancellationToken cancellationToken)
1230+
private static async Task<(Process Process, int? DetectedLocalhostTcpPort, StringBuilder StderrBuffer, Task StderrReader)> StartCliServerAsync(CopilotClientOptions options, string? connectionToken, ILogger logger, CancellationToken cancellationToken)
12121231
{
12131232
// Use explicit path, COPILOT_CLI_PATH env var (from options.Environment or process env), or bundled CLI - no PATH fallback
12141233
var envCliPath = options.Environment is not null && options.Environment.TryGetValue("COPILOT_CLI_PATH", out var envValue) ? envValue
@@ -1356,7 +1375,7 @@ private static bool IsUnsupportedConnectMethod(RemoteRpcException ex)
13561375
}
13571376
}
13581377

1359-
return (cliProcess, detectedLocalhostTcpPort, stderrBuffer);
1378+
return (cliProcess, detectedLocalhostTcpPort, stderrBuffer, stderrReader);
13601379
}
13611380

13621381
private static string? GetBundledCliPath(out string searchedPath)
@@ -1400,7 +1419,7 @@ private static (string FileName, IEnumerable<string> Args) ResolveCliCommand(str
14001419
return (cliPath, args);
14011420
}
14021421

1403-
private async Task<Connection> ConnectToServerAsync(Process? cliProcess, string? tcpHost, int? tcpPort, StringBuilder? stderrBuffer, CancellationToken cancellationToken)
1422+
private async Task<Connection> ConnectToServerAsync(Process? cliProcess, string? tcpHost, int? tcpPort, StringBuilder? stderrBuffer, Task? stderrReader, CancellationToken cancellationToken)
14041423
{
14051424
Stream inputStream, outputStream;
14061425
NetworkStream? networkStream = null;
@@ -1466,7 +1485,7 @@ private async Task<Connection> ConnectToServerAsync(Process? cliProcess, string?
14661485

14671486
_serverRpc = new ServerRpc(rpc);
14681487

1469-
return new Connection(rpc, cliProcess, networkStream, stderrBuffer);
1488+
return new Connection(rpc, cliProcess, networkStream, stderrBuffer, stderrReader);
14701489
}
14711490

14721491
private static JsonSerializerOptions SerializerOptionsForMessageFormatter { get; } = CreateSerializerOptions();
@@ -1681,12 +1700,14 @@ private class Connection(
16811700
JsonRpc rpc,
16821701
Process? cliProcess, // Set if we created the child process
16831702
NetworkStream? networkStream, // Set if using TCP
1684-
StringBuilder? stderrBuffer = null) // Captures stderr for error messages
1703+
StringBuilder? stderrBuffer = null, // Captures stderr for error messages
1704+
Task? stderrReader = null)
16851705
{
16861706
public Process? CliProcess => cliProcess;
16871707
public JsonRpc Rpc => rpc;
16881708
public NetworkStream? NetworkStream => networkStream;
16891709
public StringBuilder? StderrBuffer => stderrBuffer;
1710+
public Task? StderrReader => stderrReader;
16901711
}
16911712

16921713
private static class ProcessArgumentEscaper

0 commit comments

Comments
 (0)