Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 38 additions & 4 deletions src/Cli/dotnet/Commands/Test/MTP/TestApplication.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Collections.Concurrent;
using System.Diagnostics;
using System.Globalization;
using System.IO;
Expand Down Expand Up @@ -57,14 +58,47 @@ public async Task<int> RunAsync()
// Note: even with 'process.StandardOutput.ReadToEndAsync()' or 'process.BeginOutputReadLine()', we ended up with
// many TP threads just doing synchronous IO, slowing down the progress of the test run.
// We want to read requests coming through the pipe and sending responses back to the test app as fast as possible.
var stdOutTask = Task.Factory.StartNew(static standardOutput => ((StreamReader)standardOutput!).ReadToEnd(), process.StandardOutput, TaskCreationOptions.LongRunning);
var stdErrTask = Task.Factory.StartNew(static standardError => ((StreamReader)standardError!).ReadToEnd(), process.StandardError, TaskCreationOptions.LongRunning);
// We are using ConcurrentQueue to avoid thread-safety issues for the timeout case.
// In the timeout case, we leave stdOutTask and stdErrTask running, just we stop observing them.
var stdOutBuilder = new ConcurrentQueue<string>();
var stdErrBuilder = new ConcurrentQueue<string>();

var outputAndError = await Task.WhenAll(stdOutTask, stdErrTask);
var stdOutTask = Task.Factory.StartNew(() =>
{
var stdOut = process.StandardOutput;
string? currentLine;
while ((currentLine = stdOut.ReadLine()) is not null)
{
stdOutBuilder.Enqueue(currentLine);
}
}, TaskCreationOptions.LongRunning);

var stdErrTask = Task.Factory.StartNew(() =>
{
var stdErr = process.StandardError;
string? currentLine;
while ((currentLine = stdErr.ReadLine()) is not null)
{
stdErrBuilder.Enqueue(currentLine);
}
}, TaskCreationOptions.LongRunning);

// WaitForExitAsync only waits for process exit (and doesn't wait for output) for our usage here.
// If we use BeginOutputReadLine/BeginErrorReadLine, it will also wait for output which can deadlock.
await process.WaitForExitAsync();

// At this point, process already exited. Allow for 5 seconds to consume stdout/stderr.
// We might not be able to consume all the output if the test app has exited but left a child process alive.
try
{
await Task.WhenAll(stdOutTask, stdErrTask).WaitAsync(TimeSpan.FromSeconds(5));
}
catch (TimeoutException)
{
}

var exitCode = process.ExitCode;
_handler.OnTestProcessExited(exitCode, outputAndError[0], outputAndError[1]);
_handler.OnTestProcessExited(exitCode, string.Join(Environment.NewLine, stdOutBuilder), string.Join(Environment.NewLine, stdErrBuilder));

// This condition is to prevent considering the test app as successful when we didn't receive test session end.
// We don't produce the exception if the exit code is already non-zero to avoid surfacing this exception when there is already a known failure.
Expand Down
2 changes: 2 additions & 0 deletions test/Microsoft.NET.TestFramework/Commands/SdkCommandSpec.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ public class SdkCommandSpec

public bool RedirectStandardInput { get; set; }

public bool DisableOutputAndErrorRedirection { get; set; }

private string EscapeArgs()
{
// Note: this doesn't handle invoking .cmd files via "cmd /c" on Windows, which probably won't be necessary here
Expand Down
46 changes: 29 additions & 17 deletions test/Microsoft.NET.TestFramework/Commands/TestCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ public abstract class TestCommand

public bool RedirectStandardInput { get; set; }

public bool DisableOutputAndErrorRedirection { get; set; }

// These only work via Execute(), not when using GetProcessStartInfo()
public Action<string>? CommandOutputHandler { get; set; }
public Action<Process>? ProcessStartedHandler { get; set; }
Expand All @@ -47,6 +49,12 @@ public TestCommand WithWorkingDirectory(string workingDirectory)
return this;
}

public TestCommand WithDisableOutputAndErrorRedirection()
{
DisableOutputAndErrorRedirection = true;
return this;
}

public TestCommand WithStandardInput(string stdin)
{
Debug.Assert(ProcessStartedHandler == null);
Expand Down Expand Up @@ -107,6 +115,7 @@ private SdkCommandSpec CreateCommandSpec(IEnumerable<string> args)
}

commandSpec.RedirectStandardInput = RedirectStandardInput;
commandSpec.DisableOutputAndErrorRedirection = DisableOutputAndErrorRedirection;

return commandSpec;
}
Expand Down Expand Up @@ -147,24 +156,27 @@ public virtual CommandResult Execute(IEnumerable<string> args)
var spec = CreateCommandSpec(args);

var command = spec
.ToCommand(_doNotEscapeArguments)
.CaptureStdOut()
.CaptureStdErr();
.ToCommand(_doNotEscapeArguments);

command.OnOutputLine(line =>
if (!spec.DisableOutputAndErrorRedirection)
{
Log.WriteLine($"》{line}");
CommandOutputHandler?.Invoke(line);
});

command.OnErrorLine(line =>
{
Log.WriteLine($"❌{line}");
});

if (StandardOutputEncoding is not null)
{
command.StandardOutputEncoding(StandardOutputEncoding);
command
.CaptureStdOut()
.CaptureStdErr()
.OnOutputLine(line =>
{
Log.WriteLine($"》{line}");
CommandOutputHandler?.Invoke(line);
})
.OnErrorLine(line =>
{
Log.WriteLine($"❌{line}");
});

if (StandardOutputEncoding is not null)
{
command.StandardOutputEncoding(StandardOutputEncoding);
}
}

string fileToShow = Path.GetFileNameWithoutExtension(spec.FileName!).Equals("dotnet", StringComparison.OrdinalIgnoreCase) ?
Expand All @@ -173,7 +185,7 @@ public virtual CommandResult Execute(IEnumerable<string> args)
var display = $"{fileToShow} {string.Join(" ", spec.Arguments)}";

Log.WriteLine($"Executing '{display}':");
var result = ((Command)command).Execute(ProcessStartedHandler);
var result = command.Execute(ProcessStartedHandler);
Log.WriteLine($"Command '{display}' exited with exit code {result.ExitCode}.");

if (Environment.GetEnvironmentVariable("HELIX_WORKITEM_UPLOAD_ROOT") is string uploadRoot)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<Import Project="$([MSBuild]::GetDirectoryNameOfFileAbove($(MSBuildThisFileDirectory), testAsset.props))\testAsset.props" />

<PropertyGroup>
<TargetFramework>$(CurrentTargetFramework)</TargetFramework>
<OutputType>Exe</OutputType>

<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>

<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Testing.Platform" Version="$(MicrosoftTestingPlatformVersion)" />
</ItemGroup>
</Project>
47 changes: 47 additions & 0 deletions test/TestAssets/TestProjects/MTPChildProcessHangTest/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
using System.Diagnostics;
using Microsoft.Testing.Platform.Builder;
using Microsoft.Testing.Platform.Capabilities.TestFramework;
using Microsoft.Testing.Platform.Extensions.TestFramework;

if (args.Length == 1 && args[0] == "hang")
{
var @event = new ManualResetEvent(false);
@event.WaitOne();
return 0;
}

var builder = await TestApplication.CreateBuilderAsync(args);
builder.RegisterTestFramework(_ => new TestFrameworkCapabilities(), (_, _) => new MyTestFramework());
using var testApp = await builder.BuildAsync();
return await testApp.RunAsync();

internal class MyTestFramework : ITestFramework
{
public string Uid => nameof(MyTestFramework);
public string Version => "1.0.0";
public string DisplayName => nameof(MyTestFramework);
public string Description => DisplayName;
public Task<CloseTestSessionResult> CloseTestSessionAsync(CloseTestSessionContext context)
{
return Task.FromResult(new CloseTestSessionResult() { IsSuccess = true });
}
public Task<CreateTestSessionResult> CreateTestSessionAsync(CreateTestSessionContext context)
=> Task.FromResult(new CreateTestSessionResult() { IsSuccess = true });
public Task ExecuteRequestAsync(ExecuteRequestContext context)
{
var fileName = Process.GetCurrentProcess().MainModule.FileName;
var p = Process.Start(new ProcessStartInfo(fileName, "hang")
{
RedirectStandardOutput = true,
RedirectStandardError = true,
});
p.BeginOutputReadLine();
p.BeginErrorReadLine();
p.ErrorDataReceived += (sender, e) => { };
p.OutputDataReceived += (sender, e) => { };
context.Complete();
return Task.CompletedTask;
}
public Task<bool> IsEnabledAsync()
=> Task.FromResult(true);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"test": {
"runner": "Microsoft.Testing.Platform"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -588,5 +588,23 @@ public void RunTestProjectWithEnvVariable(string configuration)

result.ExitCode.Should().Be(ExitCodes.AtLeastOneTestFailed);
}

[InlineData(TestingConstants.Debug)]
[InlineData(TestingConstants.Release)]
[Theory]
public void DotnetTest_MTPChildProcessHangTestProject_ShouldNotHang(string configuration)
{
var testInstance = _testAssetsManager.CopyTestAsset("MTPChildProcessHangTest", Guid.NewGuid().ToString())
.WithSource();

var result = new DotnetTestCommand(Log, disableNewOutput: false)
.WithWorkingDirectory(testInstance.Path)
// We need to disable output and error redirection so that the test infra doesn't
// hang because of a hanging child process that keeps out/err open.
.WithDisableOutputAndErrorRedirection()
.Execute("-c", configuration);

result.ExitCode.Should().Be(ExitCodes.ZeroTests);
}
}
}
Loading