Skip to content
Closed
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
2 changes: 1 addition & 1 deletion src/Cli/dotnet/Commands/Test/MTP/TestApplication.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Licensed to the .NET Foundation under one or more agreements.
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Diagnostics;
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;
}
Comment on lines +6 to +11
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "hang" mode blocks indefinitely on a ManualResetEvent and the parent never cleans it up. If the dotnet test side stops hanging (or is killed by a timeout), this can leave an orphaned process running forever in CI. Make the child self-terminate after a bounded timeout (or implement explicit cleanup/kill logic) to avoid leaking processes.

Copilot uses AI. Check for mistakes.

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,21 @@ 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)
.WithDisableOutputAndErrorRedirection()
.Execute("-c", configuration);

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