Skip to content

Commit afd06de

Browse files
authored
MTP dotnet test: Better handling for pipe failures (#50571)
1 parent 0770b20 commit afd06de

17 files changed

+421
-35
lines changed

src/Cli/dotnet/Commands/CliCommandStrings.resx

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2648,4 +2648,19 @@ Proceed?</value>
26482648
<data name="CmdMinimumExpectedTestsDescription" xml:space="preserve">
26492649
<value>Specifies the minimum number of tests that are expected to run.</value>
26502650
</data>
2651-
</root>
2651+
<data name="DotnetTestPipeOverlapping" xml:space="preserve">
2652+
<value>'dotnet' unexpectedly received overlapping messages from the 'dotnet test' named pipe.</value>
2653+
</data>
2654+
<data name="DotnetTestPipeIncompleteSize" xml:space="preserve">
2655+
<value>'dotnet' unexpectedly received less than 4 bytes from the 'dotnet test' named pipe.</value>
2656+
</data>
2657+
<data name="InternalLoopAsyncDidNotExitSuccessfullyErrorMessage" xml:space="preserve">
2658+
<value>Method '{0}' did not exit successfully</value>
2659+
</data>
2660+
<data name="DotnetTestPipeFailureHasHandshake" xml:space="preserve">
2661+
<value>Error disposing 'NamedPipeServer' corresponding to handshake:</value>
2662+
</data>
2663+
<data name="DotnetTestPipeFailureWithoutHandshake" xml:space="preserve">
2664+
<value>Error disposing 'NamedPipeServer', and no handshake was found.</value>
2665+
</data>
2666+
</root>

src/Cli/dotnet/Commands/Test/IPC/NamedPipeServer.cs

Lines changed: 32 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,15 @@
44
#nullable disable
55

66
using System.Buffers;
7+
using System.Diagnostics;
78
using System.Globalization;
89
using System.IO.Pipes;
910

1011
namespace Microsoft.DotNet.Cli.Commands.Test.IPC;
1112

1213
internal sealed class NamedPipeServer : NamedPipeBase
1314
{
14-
private readonly Func<IRequest, Task<IResponse>> _callback;
15+
private readonly Func<NamedPipeServer, IRequest, Task<IResponse>> _callback;
1516
private readonly NamedPipeServerStream _namedPipeServerStream;
1617
private readonly CancellationToken _cancellationToken;
1718
private readonly MemoryStream _serializationBuffer = new();
@@ -24,7 +25,7 @@ internal sealed class NamedPipeServer : NamedPipeBase
2425

2526
public NamedPipeServer(
2627
PipeNameDescription pipeNameDescription,
27-
Func<IRequest, Task<IResponse>> callback,
28+
Func<NamedPipeServer, IRequest, Task<IResponse>> callback,
2829
int maxNumberOfServerInstances,
2930
CancellationToken cancellationToken,
3031
bool skipUnknownMessages)
@@ -55,14 +56,6 @@ public async Task WaitConnectionAsync(CancellationToken cancellationToken)
5556
// We are being cancelled, so we don't need to wait anymore
5657
return;
5758
}
58-
catch (Exception ex)
59-
{
60-
// TODO: it might be better idea to let this exception bubble to
61-
// TestApplication. There, we should handle it by printing the exception without failing
62-
// the whole dotnet process, and provide more info about the specific test app that failed.
63-
// Potentially even knowing whether it's test host or test host controller that failed?
64-
Environment.FailFast($"[NamedPipeServer] Unhandled exception:{Environment.NewLine}{ex}", ex);
65-
}
6659
}, cancellationToken);
6760
}
6861

@@ -94,6 +87,11 @@ private async Task InternalLoopAsync(CancellationToken cancellationToken)
9487
if (currentMessageSize == 0)
9588
{
9689
// We need to read the message size, first 4 bytes
90+
if (currentReadBytes < sizeof(int))
91+
{
92+
throw new UnreachableException(CliCommandStrings.DotnetTestPipeIncompleteSize);
93+
}
94+
9795
currentMessageSize = BitConverter.ToInt32(_readBuffer, 0);
9896
missingBytesToReadOfCurrentChunk = currentReadBytes - sizeof(int);
9997
missingBytesToReadOfWholeMessage = currentMessageSize;
@@ -107,6 +105,11 @@ private async Task InternalLoopAsync(CancellationToken cancellationToken)
107105
missingBytesToReadOfWholeMessage -= missingBytesToReadOfCurrentChunk;
108106
}
109107

108+
if (missingBytesToReadOfWholeMessage < 0)
109+
{
110+
throw new UnreachableException(CliCommandStrings.DotnetTestPipeOverlapping);
111+
}
112+
110113
// If we have read all the message, we can deserialize it
111114
if (missingBytesToReadOfWholeMessage == 0)
112115
{
@@ -124,7 +127,7 @@ private async Task InternalLoopAsync(CancellationToken cancellationToken)
124127
var deserializedObject = (IRequest)requestNamedPipeSerializer.Deserialize(_messageBuffer);
125128

126129
// Call the callback
127-
IResponse response = await _callback(deserializedObject);
130+
IResponse response = await _callback(this, deserializedObject);
128131

129132
// Write the message size
130133
_messageBuffer.Position = 0;
@@ -205,21 +208,28 @@ public void Dispose()
205208
return;
206209
}
207210

208-
if (WasConnected)
211+
try
209212
{
210-
// If the loop task is null at this point we have race condition, means that the task didn't start yet and we already dispose.
211-
// This is unexpected and we throw an exception.
212-
213-
// To close gracefully we need to ensure that the client closed the stream line 103.
214-
if (!_loopTask.Wait(TimeSpan.FromSeconds(90)))
213+
if (WasConnected)
215214
{
216-
throw new InvalidOperationException("InternalLoopAsyncDidNotExitSuccessfullyErrorMessage");
215+
// If the loop task is null at this point we have race condition, means that the task didn't start yet and we already dispose.
216+
// This is unexpected and we throw an exception.
217+
218+
// To close gracefully we need to ensure that the client closed the stream line 103.
219+
if (!_loopTask.Wait(TimeSpan.FromSeconds(90)))
220+
{
221+
throw new InvalidOperationException(CliCommandStrings.InternalLoopAsyncDidNotExitSuccessfullyErrorMessage);
222+
}
217223
}
218224
}
225+
finally
226+
{
227+
// Ensure we are still disposing the resouces correctly, even if _loopTask completes with
228+
// an exception, or if the task doesn't complete within the 90 seconds limit.
229+
_namedPipeServerStream.Dispose();
230+
PipeName.Dispose();
219231

220-
_namedPipeServerStream.Dispose();
221-
PipeName.Dispose();
222-
223-
_disposed = true;
232+
_disposed = true;
233+
}
224234
}
225235
}

src/Cli/dotnet/Commands/Test/TestApplication.cs

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ internal sealed class TestApplication(TestModule module, BuildOptions buildOptio
2323

2424
private Task _testAppPipeConnectionLoop;
2525
private readonly List<NamedPipeServer> _testAppPipeConnections = [];
26+
private readonly Dictionary<NamedPipeServer, HandshakeMessage> _handshakes = new();
2627

2728
public event EventHandler<HandshakeArgs> HandshakeReceived;
2829
public event EventHandler<HelpEventArgs> HelpRequested;
@@ -35,6 +36,8 @@ internal sealed class TestApplication(TestModule module, BuildOptions buildOptio
3536

3637
public TestModule Module { get; } = module;
3738

39+
public bool HasFailureDuringDispose { get; private set; }
40+
3841
public async Task<int> RunAsync(TestOptions testOptions)
3942
{
4043
if (testOptions.HasFilterMode && !ModulePathExists())
@@ -146,7 +149,7 @@ private async Task WaitConnectionAsync(CancellationToken token)
146149
{
147150
while (!token.IsCancellationRequested)
148151
{
149-
NamedPipeServer pipeConnection = new(_pipeNameDescription, OnRequest, NamedPipeServerStream.MaxAllowedServerInstances, token, skipUnknownMessages: true);
152+
var pipeConnection = new NamedPipeServer(_pipeNameDescription, OnRequest, NamedPipeServerStream.MaxAllowedServerInstances, token, skipUnknownMessages: true);
150153
pipeConnection.RegisterAllSerializers();
151154

152155
await pipeConnection.WaitConnectionAsync(token);
@@ -173,20 +176,16 @@ private async Task WaitConnectionAsync(CancellationToken token)
173176
}
174177
}
175178

176-
private Task<IResponse> OnRequest(IRequest request)
179+
private Task<IResponse> OnRequest(NamedPipeServer server, IRequest request)
177180
{
178181
try
179182
{
180183
switch (request)
181184
{
182185
case HandshakeMessage handshakeMessage:
183-
if (handshakeMessage.Properties.TryGetValue(HandshakeMessagePropertyNames.ModulePath, out string value))
184-
{
185-
OnHandshakeMessage(handshakeMessage);
186-
187-
return Task.FromResult((IResponse)CreateHandshakeMessage(GetSupportedProtocolVersion(handshakeMessage)));
188-
}
189-
break;
186+
_handshakes.Add(server, handshakeMessage);
187+
OnHandshakeMessage(handshakeMessage);
188+
return Task.FromResult((IResponse)CreateHandshakeMessage(GetSupportedProtocolVersion(handshakeMessage)));
190189

191190
case CommandLineOptionMessages commandLineOptionMessages:
192191
OnCommandLineOptionMessages(commandLineOptionMessages);
@@ -387,7 +386,35 @@ public void Dispose()
387386
{
388387
foreach (var namedPipeServer in _testAppPipeConnections)
389388
{
390-
namedPipeServer.Dispose();
389+
try
390+
{
391+
namedPipeServer.Dispose();
392+
}
393+
catch (Exception ex)
394+
{
395+
StringBuilder messageBuilder;
396+
if (_handshakes.TryGetValue(namedPipeServer, out var handshake))
397+
{
398+
messageBuilder = new StringBuilder(CliCommandStrings.DotnetTestPipeFailureHasHandshake);
399+
messageBuilder.AppendLine();
400+
foreach (var kvp in handshake.Properties)
401+
{
402+
messageBuilder.AppendLine($"{kvp.Key}: {kvp.Value}");
403+
}
404+
}
405+
else
406+
{
407+
messageBuilder = new StringBuilder(CliCommandStrings.DotnetTestPipeFailureWithoutHandshake);
408+
messageBuilder.AppendLine();
409+
}
410+
411+
messageBuilder.AppendLine($"RunCommand: {Module.RunProperties.Command}");
412+
messageBuilder.AppendLine($"RunArguments: {Module.RunProperties.Arguments}");
413+
messageBuilder.AppendLine(ex.ToString());
414+
415+
HasFailureDuringDispose = true;
416+
Reporter.Error.WriteLine(messageBuilder.ToString());
417+
}
391418
}
392419

393420
WaitOnTestApplicationPipeConnectionLoop();

src/Cli/dotnet/Commands/Test/TestApplicationActionQueue.cs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,18 @@ private async Task Read(Func<TestApplication, Task<int>> action, BuildOptions bu
5656
{
5757
foreach (var module in nonParallelizedGroup)
5858
{
59+
int result = ExitCode.GenericFailure;
5960
var testApp = new TestApplication(module, buildOptions);
60-
int result = await action(testApp);
61-
testApp.Dispose();
61+
using (testApp)
62+
{
63+
result = await action(testApp);
64+
}
65+
66+
if (result == ExitCode.Success && testApp.HasFailureDuringDispose)
67+
{
68+
result = ExitCode.GenericFailure;
69+
}
70+
6271
lock (_lock)
6372
{
6473
if (_aggregateExitCode is null)

src/Cli/dotnet/Commands/xlf/CliCommandStrings.cs.xlf

Lines changed: 25 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Cli/dotnet/Commands/xlf/CliCommandStrings.de.xlf

Lines changed: 25 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Cli/dotnet/Commands/xlf/CliCommandStrings.es.xlf

Lines changed: 25 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)