Skip to content

Commit 67334d3

Browse files
Copilotpatniko
andcommitted
Address code review feedback
- Fix stderr capture race condition by using synchronized list - Remove unused StderrCapture class - Improve ConnectionLostException error message with troubleshooting link - Make process exit detection more reliable (50ms instead of 100ms) - Fix platform-specific test to work on both Windows and Unix - Remove placeholder version numbers from documentation - Simplify connection error test assertions Co-authored-by: patniko <26906478+patniko@users.noreply.github.com>
1 parent d4b7dc0 commit 67334d3

File tree

3 files changed

+82
-95
lines changed

3 files changed

+82
-95
lines changed

docs/troubleshooting-connection-errors.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ client = CopilotClient({
7070
**Symptom**: Error mentioning "protocol version mismatch"
7171

7272
**Solution**: Update either the SDK or CLI to compatible versions
73-
- SDK version 0.1.x requires CLI version X.Y.Z or newer
73+
- Check the [release notes](https://github.com/github/copilot-sdk/releases) for compatibility information
7474
- Update CLI: Follow the installation guide to get the latest version
7575
- Update SDK: Install the latest SDK package
7676

@@ -179,7 +179,7 @@ copilot --server --port 4321
179179

180180
### Examine Error Output
181181

182-
Recent SDK versions (0.1.20+) include stderr output from the CLI in error messages. Look for:
182+
The latest versions of the SDK include stderr output from the CLI in error messages when processes fail. Look for:
183183
- Authentication errors
184184
- Missing file or permission errors
185185
- Node.js errors (if CLI is JS-based)

dotnet/src/Client.cs

Lines changed: 51 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -598,7 +598,11 @@ internal static async Task<T> InvokeRpcAsync<T>(JsonRpc rpc, string method, obje
598598
}
599599
catch (StreamJsonRpc.ConnectionLostException ex)
600600
{
601-
throw new IOException($"Communication error with Copilot CLI: The JSON-RPC connection with the remote party was lost before the request could complete. {ex.Message}", ex);
601+
throw new IOException(
602+
$"Communication error with Copilot CLI: The connection was lost. " +
603+
$"This usually means the CLI process crashed or exited unexpectedly. " +
604+
$"See the troubleshooting guide at https://github.com/github/copilot-sdk/blob/main/docs/troubleshooting-connection-errors.md for help. " +
605+
$"Details: {ex.Message}", ex);
602606
}
603607
catch (StreamJsonRpc.RemoteRpcException ex)
604608
{
@@ -700,26 +704,10 @@ private async Task VerifyProtocolVersionAsync(Connection connection, Cancellatio
700704
$"Error: {ex.Message}", ex);
701705
}
702706

703-
// Check if process exited immediately (indicates a startup failure)
704-
await Task.Delay(100, cancellationToken); // Small delay to detect immediate crashes
705-
if (cliProcess.HasExited)
706-
{
707-
var exitCode = cliProcess.ExitCode;
708-
var errorOutput = string.Empty;
709-
try
710-
{
711-
errorOutput = await cliProcess.StandardError.ReadToEndAsync(cancellationToken);
712-
}
713-
catch { /* ignore errors reading stderr */ }
714-
715-
throw new InvalidOperationException(
716-
$"Copilot CLI process exited immediately with code {exitCode}. " +
717-
$"This may indicate the CLI is not properly installed or configured. " +
718-
(string.IsNullOrEmpty(errorOutput) ? "" : $"Error output:{Environment.NewLine}{errorOutput}"));
719-
}
720-
721-
// Forward stderr to logger (don't await - let it run in background)
722-
_ = Task.Run(async () =>
707+
// Start capturing stderr immediately to avoid race conditions
708+
var stderrLines = new List<string>();
709+
var stderrLock = new object();
710+
var stderrTask = Task.Run(async () =>
723711
{
724712
try
725713
{
@@ -728,6 +716,15 @@ private async Task VerifyProtocolVersionAsync(Connection connection, Cancellatio
728716
var line = await cliProcess.StandardError.ReadLineAsync(cancellationToken);
729717
if (line != null)
730718
{
719+
lock (stderrLock)
720+
{
721+
stderrLines.Add(line);
722+
// Keep only last 50 lines to avoid unbounded growth
723+
if (stderrLines.Count > 50)
724+
{
725+
stderrLines.RemoveAt(0);
726+
}
727+
}
731728
logger.LogDebug("[CLI] {Line}", line);
732729
}
733730
}
@@ -738,6 +735,28 @@ private async Task VerifyProtocolVersionAsync(Connection connection, Cancellatio
738735
}
739736
}, cancellationToken);
740737

738+
// Check if process exited immediately (indicates a startup failure)
739+
// Use a shorter delay and check HasExited to minimize false positives
740+
await Task.Delay(50, cancellationToken);
741+
if (cliProcess.HasExited)
742+
{
743+
var exitCode = cliProcess.ExitCode;
744+
745+
// Give a moment for stderr task to capture any error output
746+
await Task.Delay(50, cancellationToken);
747+
748+
string errorOutput;
749+
lock (stderrLock)
750+
{
751+
errorOutput = stderrLines.Count > 0 ? string.Join(Environment.NewLine, stderrLines) : string.Empty;
752+
}
753+
754+
throw new InvalidOperationException(
755+
$"Copilot CLI process exited immediately with code {exitCode}. " +
756+
$"This may indicate the CLI is not properly installed or configured. " +
757+
(string.IsNullOrEmpty(errorOutput) ? "Run with debug logging enabled for more details." : $"Error output:{Environment.NewLine}{errorOutput}"));
758+
}
759+
741760
var detectedLocalhostTcpPort = (int?)null;
742761
if (!options.UseStdio)
743762
{
@@ -753,21 +772,21 @@ private async Task VerifyProtocolVersionAsync(Connection connection, Cancellatio
753772
if (line == null)
754773
{
755774
var exitCode = cliProcess.HasExited ? cliProcess.ExitCode : -1;
756-
var errorOutput = string.Empty;
757-
try
775+
776+
// Give a moment for stderr to be captured
777+
await Task.Delay(50, CancellationToken.None);
778+
779+
string errorOutput;
780+
lock (stderrLock)
758781
{
759-
if (cliProcess.HasExited)
760-
{
761-
errorOutput = await cliProcess.StandardError.ReadToEndAsync(CancellationToken.None);
762-
}
782+
errorOutput = stderrLines.Count > 0 ? string.Join(Environment.NewLine, stderrLines) : string.Empty;
763783
}
764-
catch { /* ignore */ }
765784

766785
throw new InvalidOperationException(
767786
$"CLI process exited unexpectedly" +
768787
(cliProcess.HasExited ? $" with code {exitCode}" : "") +
769788
". " +
770-
(string.IsNullOrEmpty(errorOutput) ? "" : $"Error output:{Environment.NewLine}{errorOutput}"));
789+
(string.IsNullOrEmpty(errorOutput) ? "Check CLI logs for details." : $"Error output:{Environment.NewLine}{errorOutput}"));
771790
}
772791

773792
var match = Regex.Match(line, @"listening on port (\d+)", RegexOptions.IgnoreCase);
@@ -813,7 +832,6 @@ private async Task<Connection> ConnectToServerAsync(Process? cliProcess, string?
813832
Stream inputStream, outputStream;
814833
TcpClient? tcpClient = null;
815834
NetworkStream? networkStream = null;
816-
StderrCapture? stderrCapture = null;
817835

818836
if (_options.UseStdio)
819837
{
@@ -822,17 +840,8 @@ private async Task<Connection> ConnectToServerAsync(Process? cliProcess, string?
822840
// Check if process has already exited
823841
if (cliProcess.HasExited)
824842
{
825-
var exitCode = cliProcess.ExitCode;
826-
var errorOutput = string.Empty;
827-
try
828-
{
829-
errorOutput = await cliProcess.StandardError.ReadToEndAsync(cancellationToken);
830-
}
831-
catch { /* ignore */ }
832-
833843
throw new InvalidOperationException(
834-
$"Cannot connect to CLI process - it has already exited with code {exitCode}. " +
835-
(string.IsNullOrEmpty(errorOutput) ? "" : $"Error output:{Environment.NewLine}{errorOutput}"));
844+
$"Cannot connect to CLI process - it has already exited with code {cliProcess.ExitCode}.");
836845
}
837846

838847
inputStream = cliProcess.StandardOutput.BaseStream;
@@ -865,7 +874,7 @@ private async Task<Connection> ConnectToServerAsync(Process? cliProcess, string?
865874
rpc.AddLocalRpcMethod("tool.call", handler.OnToolCall);
866875
rpc.AddLocalRpcMethod("permission.request", handler.OnPermissionRequest);
867876
rpc.StartListening();
868-
return new Connection(rpc, cliProcess, tcpClient, networkStream, stderrCapture);
877+
return new Connection(rpc, cliProcess, tcpClient, networkStream);
869878
}
870879

871880
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Using happy path from https://microsoft.github.io/vs-streamjsonrpc/docs/nativeAOT.html")]
@@ -1056,54 +1065,12 @@ private class Connection(
10561065
JsonRpc rpc,
10571066
Process? cliProcess, // Set if we created the child process
10581067
TcpClient? tcpClient, // Set if using TCP
1059-
NetworkStream? networkStream, // Set if using TCP
1060-
StderrCapture? stderrCapture = null) // Set if we capture stderr
1068+
NetworkStream? networkStream) // Set if using TCP
10611069
{
10621070
public Process? CliProcess => cliProcess;
10631071
public TcpClient? TcpClient => tcpClient;
10641072
public JsonRpc Rpc => rpc;
10651073
public NetworkStream? NetworkStream => networkStream;
1066-
public StderrCapture? StderrCapture => stderrCapture;
1067-
}
1068-
1069-
/// <summary>
1070-
/// Helper class to capture stderr output from the CLI process for better error diagnostics.
1071-
/// </summary>
1072-
private class StderrCapture
1073-
{
1074-
private readonly List<string> _lines = new();
1075-
private readonly object _lock = new();
1076-
private const int MaxLines = 100; // Keep last 100 lines
1077-
1078-
public void AddLine(string line)
1079-
{
1080-
lock (_lock)
1081-
{
1082-
_lines.Add(line);
1083-
if (_lines.Count > MaxLines)
1084-
{
1085-
_lines.RemoveAt(0);
1086-
}
1087-
}
1088-
}
1089-
1090-
public string GetOutput()
1091-
{
1092-
lock (_lock)
1093-
{
1094-
return _lines.Count > 0 ? string.Join(Environment.NewLine, _lines) : string.Empty;
1095-
}
1096-
}
1097-
1098-
public string GetLastLines(int count)
1099-
{
1100-
lock (_lock)
1101-
{
1102-
var linesToTake = Math.Min(count, _lines.Count);
1103-
var startIndex = _lines.Count - linesToTake;
1104-
return linesToTake > 0 ? string.Join(Environment.NewLine, _lines.Skip(startIndex)) : string.Empty;
1105-
}
1106-
}
11071074
}
11081075

11091076
private static class ProcessArgumentEscaper

dotnet/test/ErrorHandlingTests.cs

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,33 @@ public async Task Should_Provide_Helpful_Error_When_CLI_Not_Found()
3535
public async Task Should_Detect_Immediate_Process_Exit()
3636
{
3737
// Arrange: Use an executable that exits immediately
38-
// Using 'false' command which always exits with code 1
39-
var client = new CopilotClient(new CopilotClientOptions
38+
// Platform-specific command that exits with non-zero code
39+
string exitCommand;
40+
if (OperatingSystem.IsWindows())
41+
{
42+
exitCommand = "cmd";
43+
}
44+
else
45+
{
46+
exitCommand = "false"; // Unix command that exits immediately with code 1
47+
}
48+
49+
var clientOptions = new CopilotClientOptions
4050
{
41-
CliPath = "false", // Unix command that exits immediately with code 1
4251
AutoStart = true
43-
});
52+
};
53+
54+
if (OperatingSystem.IsWindows())
55+
{
56+
clientOptions.CliPath = exitCommand;
57+
clientOptions.CliArgs = ["/c", "exit", "1"]; // Exit with code 1
58+
}
59+
else
60+
{
61+
clientOptions.CliPath = exitCommand;
62+
}
63+
64+
var client = new CopilotClient(clientOptions);
4465

4566
// Act & Assert: Should detect the immediate exit
4667
var exception = await Assert.ThrowsAsync<InvalidOperationException>(
@@ -53,16 +74,15 @@ public async Task Should_Detect_Immediate_Process_Exit()
5374
[Fact]
5475
public async Task Should_Provide_Clear_Error_For_Connection_Issues()
5576
{
56-
// This test verifies that connection lost exceptions are wrapped with helpful messages
57-
// We can't easily simulate a real connection loss in a unit test,
58-
// but we verify that the error handling code is in place
77+
// Verify that ConnectionLostException is handled and wrapped properly
78+
// by checking that the InvokeRpcAsync method has proper exception handling
5979

60-
// Just verify the method exists and has the right signature
80+
// This is a minimal test that verifies the structure exists
81+
// More comprehensive testing would require integration tests with a real CLI
6182
var method = typeof(CopilotClient).GetMethod(
6283
"InvokeRpcAsync",
6384
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
6485

6586
Assert.NotNull(method);
66-
Assert.Equal("Task`1", method!.ReturnType.Name);
6787
}
6888
}

0 commit comments

Comments
 (0)