Skip to content

Commit 7dafa79

Browse files
authored
Stricter implementation of test session events for MTP (#50703)
1 parent d030bbc commit 7dafa79

23 files changed

+460
-95
lines changed

src/Cli/dotnet/Commands/CliCommandStrings.resx

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -117,9 +117,6 @@
117117
<resheader name="writer">
118118
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
119119
</resheader>
120-
<data name="Aborted" xml:space="preserve">
121-
<value>Aborted</value>
122-
</data>
123120
<data name="ActiveTestsRunning_FullTestsCount" xml:space="preserve">
124121
<value>{0} tests running</value>
125122
</data>
@@ -2666,4 +2663,23 @@ Proceed?</value>
26662663
<data name="DotnetTestIncompatibleHandshakeVersion" xml:space="preserve">
26672664
<value>Supported protocol versions sent by Microsoft.Testing.Platform are '{0}'. The SDK supports '{1}', which is incompatible.</value>
26682665
</data>
2669-
</root>
2666+
<data name="ErrorRunningTestModule" xml:space="preserve">
2667+
<value>The following exception occurred when running the test module with RunCommand '{0}' and RunArguments '{1}':
2668+
2669+
{2}</value>
2670+
<comment>{Locked="RunCommand"}{Locked="RunArguments"}
2671+
{2} is the exception that occurred.</comment>
2672+
</data>
2673+
<data name="UnexpectedTestSessionEnd" xml:space="preserve">
2674+
<value>A test session end event was received without a corresponding test session start.</value>
2675+
</data>
2676+
<data name="MissingTestSessionEnd" xml:space="preserve">
2677+
<value>A test session start event was received without a corresponding test session end.</value>
2678+
</data>
2679+
<data name="Aborted" xml:space="preserve">
2680+
<value>Aborted</value>
2681+
</data>
2682+
<data name="UnexpectedHelpMessage" xml:space="preserve">
2683+
<value>An unexpected help-related message was received when `--help` wasn't used.</value>
2684+
</data>
2685+
</root>

src/Cli/dotnet/Commands/Test/MTP/CustomEventArgs.cs

Lines changed: 0 additions & 13 deletions
This file was deleted.

src/Cli/dotnet/Commands/Test/MTP/MicrosoftTestingPlatformTestCommand.Help.cs

Lines changed: 12 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,14 @@
55

66
using System.Collections.Concurrent;
77
using System.CommandLine;
8+
using Microsoft.DotNet.Cli.Commands.Test.IPC.Models;
89
using Microsoft.TemplateEngine.Cli.Help;
910

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

1213
internal partial class MicrosoftTestingPlatformTestCommand
1314
{
14-
private readonly ConcurrentDictionary<string, CommandLineOption> _commandLineOptionNameToModuleNames = [];
15+
private readonly ConcurrentDictionary<string, CommandLineOptionMessage> _commandLineOptionNameToModuleNames = [];
1516
private readonly ConcurrentDictionary<bool, List<(string, string[])>> _moduleNamesToCommandLineOptions = [];
1617
private static readonly string Indent = " ";
1718

@@ -29,9 +30,9 @@ public IEnumerable<Action<HelpContext>> CustomHelpLayout()
2930
return;
3031
}
3132

32-
Dictionary<bool, List<CommandLineOption>> allOptions = GetAllOptions(context.Command.Options);
33-
allOptions.TryGetValue(true, out List<CommandLineOption> builtInOptions);
34-
allOptions.TryGetValue(false, out List<CommandLineOption> nonBuiltInOptions);
33+
Dictionary<bool, List<CommandLineOptionMessage>> allOptions = GetAllOptions(context.Command.Options);
34+
allOptions.TryGetValue(true, out List<CommandLineOptionMessage> builtInOptions);
35+
allOptions.TryGetValue(false, out List<CommandLineOptionMessage> nonBuiltInOptions);
3536

3637
Dictionary<bool, List<(string[], string[])>> moduleToMissingOptions = GetModulesToMissingOptions(_moduleNamesToCommandLineOptions, builtInOptions.Select(option => option.Name), nonBuiltInOptions.Select(option => option.Name));
3738

@@ -92,22 +93,14 @@ private static string FormatHelpOption(string option)
9293
return $"[{option.Trim(':').ToLower()}]";
9394
}
9495

95-
private void OnHelpRequested(object sender, HelpEventArgs args)
96+
private void OnHelpRequested(CommandLineOptionMessages commandLineOptionMessages)
9697
{
97-
var testApp = (TestApplication)sender;
98-
if (!testApp.TestOptions.IsHelp)
99-
{
100-
// TODO: Better to throw exception?
101-
return;
102-
}
103-
104-
CommandLineOption[] commandLineOptionMessages = args.CommandLineOptions;
105-
string moduleName = args.ModulePath;
98+
string moduleName = commandLineOptionMessages.ModulePath;
10699

107100
List<string> builtInOptions = [];
108101
List<string> nonBuiltInOptions = [];
109102

110-
foreach (CommandLineOption commandLineOption in commandLineOptionMessages)
103+
foreach (CommandLineOptionMessage commandLineOption in commandLineOptionMessages.CommandLineOptionMessageList)
111104
{
112105
if (commandLineOption.IsHidden.HasValue && commandLineOption.IsHidden.Value) continue;
113106

@@ -135,19 +128,19 @@ private void OnHelpRequested(object sender, HelpEventArgs args)
135128
(isBuiltIn, value) => [.. value, (moduleName, nonBuiltInOptions.ToArray())]);
136129
}
137130

138-
private Dictionary<bool, List<CommandLineOption>> GetAllOptions(IList<Option> commandOptions)
131+
private Dictionary<bool, List<CommandLineOptionMessage>> GetAllOptions(IList<Option> commandOptions)
139132
{
140-
Dictionary<bool, List<CommandLineOption>> filteredOptions = [];
133+
Dictionary<bool, List<CommandLineOptionMessage>> filteredOptions = [];
141134

142135
// Create a set of option names from the command's options for efficient lookup
143136
var commandOptionNames = commandOptions.Select(o => o.Name.TrimStart('-')).ToHashSet(StringComparer.OrdinalIgnoreCase);
144137

145-
foreach (KeyValuePair<string, CommandLineOption> option in _commandLineOptionNameToModuleNames)
138+
foreach (KeyValuePair<string, CommandLineOptionMessage> option in _commandLineOptionNameToModuleNames)
146139
{
147140
// Only include options that are NOT already present in the command's options
148141
if (!commandOptionNames.Contains(option.Value.Name))
149142
{
150-
if (!filteredOptions.TryGetValue(option.Value.IsBuiltIn.Value, out List<CommandLineOption> value))
143+
if (!filteredOptions.TryGetValue(option.Value.IsBuiltIn.Value, out List<CommandLineOptionMessage> value))
151144
{
152145
filteredOptions.Add(option.Value.IsBuiltIn.Value, [option.Value]);
153146
}

src/Cli/dotnet/Commands/Test/MTP/MicrosoftTestingPlatformTestCommand.cs

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,7 @@ private int RunInternal(ParseResult parseResult, bool isHelp)
4242
ValidationUtility.ValidateSolutionOrProjectOrDirectoryOrModulesArePassedCorrectly(parseResult);
4343

4444
int degreeOfParallelism = GetDegreeOfParallelism(parseResult);
45-
bool filterModeEnabled = parseResult.HasOption(MicrosoftTestingPlatformOptions.TestModulesFilterOption);
46-
var testOptions = new TestOptions(filterModeEnabled, IsHelp: isHelp, IsDiscovery: parseResult.HasOption(MicrosoftTestingPlatformOptions.ListTestsOption));
45+
var testOptions = new TestOptions(IsHelp: isHelp, IsDiscovery: parseResult.HasOption(MicrosoftTestingPlatformOptions.ListTestsOption));
4746

4847
InitializeOutput(degreeOfParallelism, parseResult, testOptions);
4948

@@ -55,7 +54,8 @@ private int RunInternal(ParseResult parseResult, bool isHelp)
5554

5655
var msBuildHandler = new MSBuildHandler(buildOptions, actionQueue, _output);
5756

58-
if (testOptions.HasFilterMode)
57+
bool filterModeEnabled = parseResult.HasOption(MicrosoftTestingPlatformOptions.TestModulesFilterOption);
58+
if (filterModeEnabled)
5959
{
6060
var testModulesFilterHandler = new TestModulesFilterHandler(actionQueue, _output);
6161
if (!testModulesFilterHandler.RunWithTestModulesFilter(parseResult))
@@ -93,11 +93,7 @@ private int RunInternal(ParseResult parseResult, bool isHelp)
9393

9494
private TestApplicationActionQueue InitializeActionQueue(int degreeOfParallelism, TestOptions testOptions, BuildOptions buildOptions)
9595
{
96-
return new TestApplicationActionQueue(degreeOfParallelism, buildOptions, testOptions, _output, async (TestApplication testApp) =>
97-
{
98-
testApp.HelpRequested += OnHelpRequested;
99-
return await testApp.RunAsync();
100-
});
96+
return new TestApplicationActionQueue(degreeOfParallelism, buildOptions, testOptions, _output, OnHelpRequested);
10197
}
10298

10399
private void SetupCancelKeyPressHandler()

src/Cli/dotnet/Commands/Test/MTP/Models.cs

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,5 +113,3 @@ public bool MoveNext()
113113

114114

115115
internal sealed record TestModule(RunProperties RunProperties, string? ProjectFullPath, string? TargetFramework, bool IsTestingPlatformApplication, bool IsTestProject, ProjectLaunchSettingsModel? LaunchSettings, string TargetPath, string? DotnetRootArchVariableName);
116-
117-
internal sealed record CommandLineOption(string Name, string Description, bool? IsHidden, bool? IsBuiltIn);

src/Cli/dotnet/Commands/Test/MTP/Options.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
namespace Microsoft.DotNet.Cli.Commands.Test;
55

6-
internal record TestOptions(bool HasFilterMode, bool IsHelp, bool IsDiscovery);
6+
internal record TestOptions(bool IsHelp, bool IsDiscovery);
77

88
internal record PathOptions(string? ProjectPath, string? SolutionPath, string? ResultsDirectoryPath, string? ConfigFilePath, string? DiagnosticOutputDirectoryPath);
99

src/Cli/dotnet/Commands/Test/MTP/Terminal/TerminalTestReporter.cs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
using System.Text.RegularExpressions;
77
using Microsoft.CodeAnalysis;
88
using Microsoft.Testing.Platform.OutputDevice.Terminal;
9+
using Microsoft.DotNet.Cli.Commands.Test.IPC.Models;
910

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

@@ -999,8 +1000,8 @@ public void TestInProgress(
9991000
}
10001001

10011002
public void WritePlatformAndExtensionOptions(HelpContext context,
1002-
IEnumerable<CommandLineOption> builtInOptions,
1003-
IEnumerable<CommandLineOption> nonBuiltInOptions,
1003+
IEnumerable<CommandLineOptionMessage> builtInOptions,
1004+
IEnumerable<CommandLineOptionMessage> nonBuiltInOptions,
10041005
Dictionary<bool, List<(string[], string[])>> moduleToMissingOptions)
10051006
{
10061007
if (_wasCancelled)
@@ -1022,15 +1023,15 @@ public void WritePlatformAndExtensionOptions(HelpContext context,
10221023
WriteModulesToMissingOptionsToConsole(moduleToMissingOptions);
10231024
}
10241025

1025-
private void WriteOtherOptionsSection(HelpContext context, string title, IEnumerable<CommandLineOption> options)
1026+
private void WriteOtherOptionsSection(HelpContext context, string title, IEnumerable<CommandLineOptionMessage> options)
10261027
{
10271028
List<TwoColumnHelpRow> optionRows = [];
10281029

10291030
foreach (var option in options)
10301031
{
10311032
if (option.IsHidden != true)
10321033
{
1033-
optionRows.Add(new TwoColumnHelpRow($"--{option.Name}", option.Description));
1034+
optionRows.Add(new TwoColumnHelpRow($"--{option.Name}", option.Description ?? string.Empty));
10341035
}
10351036
}
10361037

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

Lines changed: 26 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,11 @@ internal sealed class TestApplication(
1818
TestModule module,
1919
BuildOptions buildOptions,
2020
TestOptions testOptions,
21-
TerminalTestReporter output) : IDisposable
21+
TerminalTestReporter output,
22+
Action<CommandLineOptionMessages> onHelpRequested) : IDisposable
2223
{
2324
private readonly BuildOptions _buildOptions = buildOptions;
25+
private readonly Action<CommandLineOptionMessages> _onHelpRequested = onHelpRequested;
2426
private readonly TestApplicationHandler _handler = new(output, module, testOptions);
2527

2628
private readonly List<string> _outputData = [];
@@ -32,8 +34,6 @@ internal sealed class TestApplication(
3234
private readonly List<NamedPipeServer> _testAppPipeConnections = [];
3335
private readonly Dictionary<NamedPipeServer, HandshakeMessage> _handshakes = new();
3436

35-
public event EventHandler<HelpEventArgs> HelpRequested;
36-
3737
public TestModule Module { get; } = module;
3838
public TestOptions TestOptions { get; } = testOptions;
3939

@@ -43,19 +43,19 @@ public async Task<int> RunAsync()
4343
{
4444
// TODO: RunAsync is probably expected to be executed exactly once on each TestApplication instance.
4545
// Consider throwing an exception if it's called more than once.
46-
if (TestOptions.HasFilterMode && !ModulePathExists())
47-
{
48-
return ExitCode.GenericFailure;
49-
}
50-
5146
var processStartInfo = CreateProcessStartInfo();
5247

5348
_testAppPipeConnectionLoop = Task.Run(async () => await WaitConnectionAsync(_cancellationToken.Token), _cancellationToken.Token);
54-
var testProcessResult = await StartProcess(processStartInfo);
49+
var testProcessExitCode = await StartProcess(processStartInfo);
5550

5651
WaitOnTestApplicationPipeConnectionLoop();
5752

58-
return testProcessResult;
53+
if (_handler.HasMismatchingTestSessionEventCount())
54+
{
55+
throw new InvalidOperationException(CliCommandStrings.MissingTestSessionEnd);
56+
}
57+
58+
return testProcessExitCode;
5959
}
6060

6161
private ProcessStartInfo CreateProcessStartInfo()
@@ -221,6 +221,15 @@ private Task<IResponse> OnRequest(NamedPipeServer server, IRequest request)
221221
}
222222
catch (Exception ex)
223223
{
224+
// BE CAREFUL:
225+
// When handling some of the messages, we may throw an exception in unexpected state.
226+
// (e.g, OnSessionEvent may throw if we receive TestSessionEnd without TestSessionStart).
227+
// (or if we receive help-related messages when not in help mode)
228+
// In that case, we FailFast.
229+
// The lack of FailFast *might* have unintended consequences, such as breaking the internal loop of pipe server.
230+
// In that case, maybe MTP app will continue waiting for response, but we don't send the response and are waiting for
231+
// MTP app process exit (which doesn't happen).
232+
// So, we explicitly FailFast here.
224233
string exAsString = ex.ToString();
225234
Logger.LogTrace(exAsString);
226235
Environment.FailFast(exAsString);
@@ -298,24 +307,17 @@ private void StoreOutputAndErrorData(Process process)
298307
process.BeginErrorReadLine();
299308
}
300309

301-
private bool ModulePathExists()
302-
{
303-
if (!File.Exists(Module.RunProperties.Command))
304-
{
305-
// TODO: The error should be shown to the user, not just logged to trace.
306-
Logger.LogTrace($"Test module '{Module.RunProperties.Command}' not found. Build the test application before or run 'dotnet test'.");
307-
308-
return false;
309-
}
310-
return true;
311-
}
312-
313310
public void OnHandshakeMessage(HandshakeMessage handshakeMessage, bool gotSupportedVersion)
314311
=> _handler.OnHandshakeReceived(handshakeMessage, gotSupportedVersion);
315312

316313
private void OnCommandLineOptionMessages(CommandLineOptionMessages commandLineOptionMessages)
317314
{
318-
HelpRequested?.Invoke(this, new HelpEventArgs { ModulePath = commandLineOptionMessages.ModulePath, CommandLineOptions = [.. commandLineOptionMessages.CommandLineOptionMessageList.Select(message => new CommandLineOption(message.Name, message.Description, message.IsHidden, message.IsBuiltIn))] });
315+
if (!TestOptions.IsHelp)
316+
{
317+
throw new InvalidOperationException(CliCommandStrings.UnexpectedHelpMessage);
318+
}
319+
320+
_onHelpRequested(commandLineOptionMessages);
319321
}
320322

321323
private void OnDiscoveredTestMessages(DiscoveredTestMessages discoveredTestMessages)
@@ -324,7 +326,7 @@ private void OnDiscoveredTestMessages(DiscoveredTestMessages discoveredTestMessa
324326
private void OnTestResultMessages(TestResultMessages testResultMessage)
325327
=> _handler.OnTestResultsReceived(testResultMessage);
326328

327-
internal void OnFileArtifactMessages(FileArtifactMessages fileArtifactMessages)
329+
private void OnFileArtifactMessages(FileArtifactMessages fileArtifactMessages)
328330
=> _handler.OnFileArtifactsReceived(fileArtifactMessages);
329331

330332
private void OnSessionEvent(TestSessionEvent sessionEvent)

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

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44
#nullable disable
55

66
using System.Threading.Channels;
7+
using Microsoft.DotNet.Cli.Commands.Test.IPC.Models;
78
using Microsoft.DotNet.Cli.Commands.Test.Terminal;
9+
using Microsoft.DotNet.Cli.Utils;
810

911
namespace Microsoft.DotNet.Cli.Commands.Test;
1012

@@ -17,14 +19,14 @@ internal class TestApplicationActionQueue
1719

1820
private static readonly Lock _lock = new();
1921

20-
public TestApplicationActionQueue(int degreeOfParallelism, BuildOptions buildOptions, TestOptions testOptions, TerminalTestReporter output, Func<TestApplication, Task<int>> action)
22+
public TestApplicationActionQueue(int degreeOfParallelism, BuildOptions buildOptions, TestOptions testOptions, TerminalTestReporter output, Action<CommandLineOptionMessages> onHelpRequested)
2123
{
2224
_channel = Channel.CreateUnbounded<ParallelizableTestModuleGroupWithSequentialInnerModules>(new UnboundedChannelOptions { SingleReader = false, SingleWriter = false });
2325
_readers = new Task[degreeOfParallelism];
2426

2527
for (int i = 0; i < degreeOfParallelism; i++)
2628
{
27-
_readers[i] = Task.Run(async () => await Read(action, buildOptions, testOptions, output));
29+
_readers[i] = Task.Run(async () => await Read(buildOptions, testOptions, output, onHelpRequested));
2830
}
2931
}
3032

@@ -51,23 +53,27 @@ public void EnqueueCompleted()
5153
_channel.Writer.Complete();
5254
}
5355

54-
private async Task Read(Func<TestApplication, Task<int>> action, BuildOptions buildOptions, TestOptions testOptions, TerminalTestReporter output)
56+
private async Task Read(BuildOptions buildOptions, TestOptions testOptions, TerminalTestReporter output, Action<CommandLineOptionMessages> onHelpRequested)
5557
{
5658
await foreach (var nonParallelizedGroup in _channel.Reader.ReadAllAsync())
5759
{
5860
foreach (var module in nonParallelizedGroup)
5961
{
6062
int result = ExitCode.GenericFailure;
61-
var testApp = new TestApplication(module, buildOptions, testOptions, output);
62-
using (testApp)
63+
var testApp = new TestApplication(module, buildOptions, testOptions, output, onHelpRequested);
64+
try
6365
{
64-
// TODO: If this throws, we will loose "one" degree of parallelism.
65-
// Then, if we ended up losing all degree of parallelisms, we will not be able to process all test apps.
66-
// At the end, WaitAllActions will likely just throw this exception wrapped in AggregateException, and
67-
// it will bubble up to CLI Main and crash the process.
68-
// We should try to work on a better handling for this, and better understand what are the possibilities for this to throw.
69-
// e.g, RunCommand pointing out to some file that doesn't exist? Process.Start failing for some reason?
70-
result = await action(testApp);
66+
using (testApp)
67+
{
68+
result = await testApp.RunAsync();
69+
}
70+
}
71+
catch (Exception ex)
72+
{
73+
var exAsString = ex.ToString();
74+
Logger.LogTrace($"Exception running test module {module.RunProperties?.Command} {module.RunProperties?.Arguments}: {exAsString}");
75+
Reporter.Error.WriteLine(string.Format(CliCommandStrings.ErrorRunningTestModule, module.RunProperties?.Command, module.RunProperties?.Arguments, exAsString));
76+
result = ExitCode.GenericFailure;
7177
}
7278

7379
if (result == ExitCode.Success && testApp.HasFailureDuringDispose)

0 commit comments

Comments
 (0)