Skip to content

Commit 3a8a9cf

Browse files
Implement Channel Concept in dotnet test (dotnet#42233)
Co-authored-by: Amaury Levé <amauryleve@microsoft.com>
1 parent 53896c3 commit 3a8a9cf

File tree

7 files changed

+163
-36
lines changed

7 files changed

+163
-36
lines changed

src/Cli/dotnet/commands/dotnet-test/CliConstants.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ internal static class CliConstants
1010
public const string NoBuildOptionKey = "--no-build";
1111
public const string ServerOptionKey = "--server";
1212
public const string DotNetTestPipeOptionKey = "--dotnet-test-pipe";
13+
public const string DegreeOfParallelismOptionKey = "--degree-of-parallelism";
14+
public const string DOPOptionKey = "--dop";
1315

1416
public const string ServerOptionValue = "dotnettestcli";
1517

src/Cli/dotnet/commands/dotnet-test/TestApplication.cs

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,20 +15,19 @@ internal class TestApplication
1515
public event EventHandler<HelpEventArgs> HelpRequested;
1616
public event EventHandler<ErrorEventArgs> ErrorReceived;
1717

18-
public string ModuleName => _modulePath;
18+
public string ModulePath => _modulePath;
1919

20-
public TestApplication(string moduleName, string pipeName, string[] args)
20+
public TestApplication(string modulePath, string pipeName, string[] args)
2121
{
22-
_modulePath = moduleName;
22+
_modulePath = modulePath;
2323
_pipeName = pipeName;
2424
_args = args;
2525
}
2626

2727
public async Task RunAsync()
2828
{
29-
if (!File.Exists(_modulePath))
29+
if (!ModulePathExists())
3030
{
31-
ErrorReceived.Invoke(this, new ErrorEventArgs { ErrorMessage = $"Test module '{_modulePath}' not found. Build the test application before or run 'dotnet test'." });
3231
return;
3332
}
3433

@@ -46,12 +45,45 @@ public async Task RunAsync()
4645
await Process.Start(processStartInfo).WaitForExitAsync();
4746
}
4847

48+
public async Task RunHelpAsync()
49+
{
50+
if (!ModulePathExists())
51+
{
52+
return;
53+
}
54+
55+
bool isDll = _modulePath.EndsWith(".dll");
56+
ProcessStartInfo processStartInfo = new()
57+
{
58+
FileName = isDll ?
59+
Environment.ProcessPath :
60+
_modulePath,
61+
Arguments = BuildHelpArgs(isDll)
62+
};
63+
64+
VSTestTrace.SafeWriteTrace(() => $"Updated args: {processStartInfo.Arguments}");
65+
66+
await Process.Start(processStartInfo).WaitForExitAsync();
67+
}
68+
69+
private bool ModulePathExists()
70+
{
71+
if (!File.Exists(_modulePath))
72+
{
73+
ErrorReceived.Invoke(this, new ErrorEventArgs { ErrorMessage = $"Test module '{_modulePath}' not found. Build the test application before or run 'dotnet test'." });
74+
return false;
75+
}
76+
return true;
77+
}
78+
4979
private string BuildArgs(bool isDll)
5080
{
5181
StringBuilder builder = new();
5282

5383
if (isDll)
84+
{
5485
builder.Append($"exec {_modulePath} ");
86+
}
5587

5688
builder.Append(_args.Length != 0
5789
? _args.Aggregate((a, b) => $"{a} {b}")
@@ -62,6 +94,20 @@ private string BuildArgs(bool isDll)
6294
return builder.ToString();
6395
}
6496

97+
private string BuildHelpArgs(bool isDll)
98+
{
99+
StringBuilder builder = new();
100+
101+
if (isDll)
102+
{
103+
builder.Append($"exec {_modulePath} ");
104+
}
105+
106+
builder.Append($" {CliConstants.HelpOptionKey} {CliConstants.ServerOptionKey} {CliConstants.ServerOptionValue} {CliConstants.DotNetTestPipeOptionKey} {_pipeName}");
107+
108+
return builder.ToString();
109+
}
110+
65111
public void OnCommandLineOptionMessages(CommandLineOptionMessages commandLineOptionMessages)
66112
{
67113
HelpRequested?.Invoke(this, new HelpEventArgs { CommandLineOptionMessages = commandLineOptionMessages });
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.Threading.Channels;
5+
6+
namespace Microsoft.DotNet.Cli
7+
{
8+
internal class TestApplicationActionQueue
9+
{
10+
private readonly Channel<TestApplication> _channel = Channel.CreateUnbounded<TestApplication>(new UnboundedChannelOptions() { SingleReader = false, SingleWriter = false });
11+
private readonly List<Task> _readers = [];
12+
13+
public TestApplicationActionQueue(int dop, Func<TestApplication, Task> action)
14+
{
15+
// Add readers to the channel, to read the test applications
16+
for (int i = 0; i < dop; i++)
17+
{
18+
_readers.Add(Task.Run(async () => await Read(action)));
19+
}
20+
}
21+
22+
public void Enqueue(TestApplication testApplication)
23+
{
24+
if (!_channel.Writer.TryWrite(testApplication))
25+
throw new InvalidOperationException($"Failed to write to channel for test application: {testApplication.ModulePath}");
26+
}
27+
28+
public void WaitAllActions()
29+
{
30+
Task.WaitAll([.. _readers]);
31+
}
32+
33+
public void EnqueueCompleted()
34+
{
35+
//Notify readers that no more data will be written
36+
_channel.Writer.Complete();
37+
}
38+
39+
private async Task Read(Func<TestApplication, Task> action)
40+
{
41+
while (await _channel.Reader.WaitToReadAsync())
42+
{
43+
if (_channel.Reader.TryRead(out TestApplication testApp))
44+
{
45+
await action(testApp);
46+
}
47+
}
48+
}
49+
}
50+
}

src/Cli/dotnet/commands/dotnet-test/TestCommandParser.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@ internal static class TestCommandParser
1111
{
1212
public static readonly string DocsLink = "https://aka.ms/dotnet-test";
1313

14+
public static readonly CliOption<string> DegreeOfParallelism = new ForwardedOption<string>("--degree-of-parallelism", "-dop")
15+
{
16+
Description = "degree of parallelism",
17+
HelpName = "dop"
18+
}.ForwardAs("-property:VSTestNoLogo=true");
19+
1420
public static readonly CliOption<string> SettingsOption = new ForwardedOption<string>("--settings", "-s")
1521
{
1622
Description = LocalizableStrings.CmdSettingsDescription,
@@ -152,6 +158,8 @@ private static CliOption<string> CreateBlameHangDumpOption()
152158

153159
private static readonly CliCommand Command = ConstructCommand();
154160

161+
162+
155163
public static CliCommand GetCommand()
156164
{
157165
return Command;
@@ -189,6 +197,7 @@ private static CliCommand GetTestingPlatformCliCommand()
189197
{
190198
var command = new TestingPlatformCommand("test");
191199
command.SetAction((parseResult) => command.Run(parseResult));
200+
command.Options.Add(DegreeOfParallelism);
192201

193202
return command;
194203
}

src/Cli/dotnet/commands/dotnet-test/TestingPlatformCommand.cs

Lines changed: 48 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
using System.Collections.Concurrent;
55
using System.CommandLine;
6+
using System.Diagnostics;
67
using System.IO.Pipes;
78
using Microsoft.DotNet.Cli.Utils;
89
using Microsoft.DotNet.Tools.Test;
@@ -20,6 +21,7 @@ internal partial class TestingPlatformCommand : CliCommand, ICustomHelp
2021
private readonly ConcurrentDictionary<string, TestApplication> _testApplications = [];
2122
private readonly PipeNameDescription _pipeNameDescription = NamedPipeServer.GetPipeName(Guid.NewGuid().ToString("N"));
2223
private readonly CancellationTokenSource _cancellationToken = new();
24+
private TestApplicationActionQueue _actionQueue;
2325

2426
private Task _namedPipeConnectionLoop;
2527
private string[] _args;
@@ -31,7 +33,32 @@ public TestingPlatformCommand(string name, string description = null) : base(nam
3133

3234
public int Run(ParseResult parseResult)
3335
{
34-
_args = parseResult.GetArguments().Except(parseResult.UnmatchedTokens).ToArray();
36+
_args = parseResult.GetArguments();
37+
38+
// User can decide what the degree of parallelism should be
39+
// If not specified, we will default to the number of processors
40+
if (!int.TryParse(parseResult.GetValue(TestCommandParser.DegreeOfParallelism), out int degreeOfParallelism))
41+
degreeOfParallelism = Environment.ProcessorCount;
42+
43+
if (ContainsHelpOption(_args))
44+
{
45+
_actionQueue = new(degreeOfParallelism, async (TestApplication testApp) =>
46+
{
47+
testApp.HelpRequested += OnHelpRequested;
48+
testApp.ErrorReceived += OnErrorReceived;
49+
50+
await testApp.RunHelpAsync();
51+
});
52+
}
53+
else
54+
{
55+
_actionQueue = new(degreeOfParallelism, async (TestApplication testApp) =>
56+
{
57+
testApp.ErrorReceived += OnErrorReceived;
58+
59+
await testApp.RunAsync();
60+
});
61+
}
3562

3663
VSTestTrace.SafeWriteTrace(() => $"Wait for connection(s) on pipe = {_pipeNameDescription.Name}");
3764
_namedPipeConnectionLoop = Task.Run(async () => await WaitConnectionAsync(_cancellationToken.Token));
@@ -41,13 +68,21 @@ public int Run(ParseResult parseResult)
4168
ForwardingAppImplementation msBuildForwardingApp = new(
4269
GetMSBuildExePath(),
4370
[$"-t:{(containsNoBuild ? string.Empty : "Build;")}_GetTestsProject",
44-
$"-p:GetTestsProjectPipeName={_pipeNameDescription.Name}",
45-
"-verbosity:q"]);
46-
int getTestsProjectResult = msBuildForwardingApp.Execute();
71+
$"-p:GetTestsProjectPipeName={_pipeNameDescription.Name}",
72+
"-verbosity:q"]);
73+
int testsProjectResult = msBuildForwardingApp.Execute();
74+
75+
if (testsProjectResult != 0)
76+
{
77+
VSTestTrace.SafeWriteTrace(() => $"MSBuild task _GetTestsProject didn't execute properly.");
78+
return testsProjectResult;
79+
}
80+
81+
_actionQueue.EnqueueCompleted();
82+
83+
_actionQueue.WaitAllActions();
4784

4885
// Above line will block till we have all connections and all GetTestsProject msbuild task complete.
49-
Task.WaitAll([.. _taskModuleName]);
50-
Task.WaitAll([.. _testsRun]);
5186
_cancellationToken.Cancel();
5287
_namedPipeConnectionLoop.Wait();
5388

@@ -81,28 +116,28 @@ private async Task WaitConnectionAsync(CancellationToken token)
81116

82117
private Task<IResponse> OnRequest(IRequest request)
83118
{
84-
if (TryGetModuleName(request, out string moduleName))
119+
if (TryGetModulePath(request, out string modulePath))
85120
{
86-
TestApplication testApplication = GenerateTestApplication(moduleName);
87-
_testApplications[moduleName] = testApplication;
88-
89-
_testsRun.Add(Task.Run(async () => await testApplication.RunAsync()));
121+
_testApplications[modulePath] = new TestApplication(modulePath, _pipeNameDescription.Name, _args);
122+
// Write the test application to the channel
123+
_actionQueue.Enqueue(_testApplications[modulePath]);
90124

91125
return Task.FromResult((IResponse)VoidResponse.CachedInstance);
92126
}
93127

94128
if (TryGetHelpResponse(request, out CommandLineOptionMessages commandLineOptionMessages))
95129
{
96130
var testApplication = _testApplications[commandLineOptionMessages.ModulePath];
97-
testApplication?.OnCommandLineOptionMessages(commandLineOptionMessages);
131+
Debug.Assert(testApplication is not null);
132+
testApplication.OnCommandLineOptionMessages(commandLineOptionMessages);
98133

99134
return Task.FromResult((IResponse)VoidResponse.CachedInstance);
100135
}
101136

102137
throw new NotSupportedException($"Request '{request.GetType()}' is unsupported.");
103138
}
104139

105-
private static bool TryGetModuleName(IRequest request, out string modulePath)
140+
private static bool TryGetModulePath(IRequest request, out string modulePath)
106141
{
107142
if (request is Module module)
108143
{
@@ -126,19 +161,6 @@ private static bool TryGetHelpResponse(IRequest request, out CommandLineOptionMe
126161
return false;
127162
}
128163

129-
private TestApplication GenerateTestApplication(string moduleName)
130-
{
131-
var testApplication = new TestApplication(moduleName, _pipeNameDescription.Name, _args);
132-
133-
if (ContainsHelpOption(_args))
134-
{
135-
testApplication.HelpRequested += OnHelpRequested;
136-
}
137-
testApplication.ErrorReceived += OnErrorReceived;
138-
139-
return testApplication;
140-
}
141-
142164
private void OnErrorReceived(object sender, ErrorEventArgs args)
143165
{
144166
VSTestTrace.SafeWriteTrace(() => args.ErrorMessage);

src/Layout/redist/MSBuildImports/Current/Microsoft.Common.targets/ImportAfter/Microsoft.TestPlatform.ImportAfter.targets

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ Copyright (c) .NET Foundation. All rights reserved.
1818
</PropertyGroup>
1919
<Import Condition="Exists('$(VSTestTargets)')" Project="$(VSTestTargets)" />
2020
<!-- Register TestingPlatform task -->
21-
<UsingTask TaskName="GetTestsProject" AssemblyFile="$(MSBuildThisFileDirectory)../../../Microsoft.NET.Build.Tasks.dll" />
21+
<UsingTask TaskName="GetTestsProject" AssemblyFile="$(MicrosoftNETBuildTasksDirectory)Microsoft.NET.Build.Tasks.dll" />
2222
<Target Name="_GetTestsProject" Condition=" $(IsTestProject) == 'true' OR '$(IsTestingPlatformApplication)' == 'true' " >
2323
<GetTestsProject TargetPath="$(TargetPath)" GetTestsProjectPipeName="$(GetTestsProjectPipeName)" ProjectFullPath="$(MSBuildProjectFullPath)" />
2424
</Target>

src/Tasks/Microsoft.NET.Build.Tasks/GetTestsProject.cs

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,10 @@ public override bool Execute()
3434
}
3535
catch (Exception ex)
3636
{
37-
Log.LogMessage(ex.Message);
37+
Log.LogErrorFromException(ex);
3838

39-
throw;
4039
}
41-
42-
return true;
40+
return !Log.HasLoggedErrors;
4341
}
4442
}
4543
}

0 commit comments

Comments
 (0)