Skip to content
Draft
Show file tree
Hide file tree
Changes from 7 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
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using Microsoft.Testing.Extensions.Diagnostics.Resources;
using Microsoft.Testing.Platform;
using Microsoft.Testing.Platform.CommandLine;
using Microsoft.Testing.Platform.Extensions.Messages;
using Microsoft.Testing.Platform.Extensions.OutputDevice;
Expand Down Expand Up @@ -66,17 +67,28 @@ public async Task OnTestHostProcessExitedAsync(ITestHostProcessInformation testH
await _outputDisplay.DisplayAsync(this, new ErrorMessageOutputDeviceData(string.Format(CultureInfo.InvariantCulture, CrashDumpResources.CrashDumpProcessCrashedDumpFileCreated, testHostProcessInformation.PID)), cancellation).ConfigureAwait(false);

string expectedDumpFile = _netCoreCrashDumpGeneratorConfiguration.DumpFileNamePattern.Replace("%p", testHostProcessInformation.PID.ToString(CultureInfo.InvariantCulture));
if (File.Exists(expectedDumpFile))
string? dumpDirectory = Path.GetDirectoryName(expectedDumpFile);
if (RoslynString.IsNullOrEmpty(dumpDirectory) || !Directory.Exists(dumpDirectory))
{
await _messageBus.PublishAsync(this, new FileArtifact(new FileInfo(expectedDumpFile), CrashDumpResources.CrashDumpArtifactDisplayName, CrashDumpResources.CrashDumpArtifactDescription)).ConfigureAwait(false);
await _outputDisplay.DisplayAsync(this, new ErrorMessageOutputDeviceData(string.Format(CultureInfo.InvariantCulture, CrashDumpResources.CannotFindExpectedCrashDumpFile, expectedDumpFile)), cancellation).ConfigureAwait(false);
return;
}
else

// Collect all dump files in the directory to capture crashes from child processes
bool foundExpectedDump = false;
foreach (string dumpFile in Directory.GetFiles(dumpDirectory, "*.dmp"))
{
await _outputDisplay.DisplayAsync(this, new ErrorMessageOutputDeviceData(string.Format(CultureInfo.InvariantCulture, CrashDumpResources.CannotFindExpectedCrashDumpFile, expectedDumpFile)), cancellation).ConfigureAwait(false);
foreach (string dumpFile in Directory.GetFiles(Path.GetDirectoryName(expectedDumpFile)!, "*.dmp"))
if (string.Equals(dumpFile, expectedDumpFile, StringComparison.OrdinalIgnoreCase))
{
await _messageBus.PublishAsync(this, new FileArtifact(new FileInfo(dumpFile), CrashDumpResources.CrashDumpDisplayName, CrashDumpResources.CrashDumpArtifactDescription)).ConfigureAwait(false);
foundExpectedDump = true;
Copy link
Member

Choose a reason for hiding this comment

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

This still looks wrong, what if the main process does not crash?

The reason why vstest crash dumper checks for crash (it does that in stupid way of comparing test counts, but whatever), is that we always collect dump on process exit when procdump crash dumper is used. This allows us to capture out of memory and stack overflow exceptions in .net framework processes. In .net core process this is handled in a better way, the process will be able to capture dump on OOM or on stack overflow, and you don't have to collect dump always.

So all that is needed here is making unique folder per run, and reporting all dumps from there.

If this later expands to use procdump, it would need to detect premature exit of the testhost process (at least), no matter what the exit code is, to be able to distunguish a crash of the process or just successful exit.

Copy link
Member

Choose a reason for hiding this comment

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

@copilot please update handling based on this comment

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Removed the HasExitedGracefully check and simplified the logic to always collect all dumps from the directory. The implementation now:

  • Always scans for .dmp files in the directory, even if main process exits gracefully
  • Removes error messages about missing expected dump (no longer relevant)
  • Relies on unique folder per run to ensure we're only collecting dumps from this test run

Commit: 40aac17

}

await _messageBus.PublishAsync(this, new FileArtifact(new FileInfo(dumpFile), CrashDumpResources.CrashDumpArtifactDisplayName, CrashDumpResources.CrashDumpArtifactDescription)).ConfigureAwait(false);
}

if (!foundExpectedDump)
{
await _outputDisplay.DisplayAsync(this, new ErrorMessageOutputDeviceData(string.Format(CultureInfo.InvariantCulture, CrashDumpResources.CannotFindExpectedCrashDumpFile, expectedDumpFile)), cancellation).ConfigureAwait(false);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,24 @@ public async Task CrashDump_InvalidFormat_ShouldFail()
testHostResult.AssertOutputContains("Option '--crashdump-type' has invalid arguments: 'invalid' is not a valid dump type. Valid options are 'Mini', 'Heap', 'Triage' and 'Full'");
}

[TestMethod]
public async Task CrashDump_WithChildProcess_CollectsMultipleDumps()
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
{
// TODO: Investigate failures on macos
return;
}

string resultDirectory = Path.Combine(AssetFixture.TargetAssetPath, Guid.NewGuid().ToString("N"));
var testHost = TestInfrastructure.TestHost.LocateFrom(AssetFixture.TargetAssetPath, "CrashDumpWithChild", TargetFrameworks.NetCurrent);
TestHostResult testHostResult = await testHost.ExecuteAsync($"--crashdump --results-directory {resultDirectory}", cancellationToken: TestContext.CancellationToken);
testHostResult.AssertExitCodeIs(ExitCodes.TestHostProcessExitedNonGracefully);

string[] dumpFiles = Directory.GetFiles(resultDirectory, "*.dmp", SearchOption.AllDirectories);
Assert.IsGreaterThanOrEqualTo(2, dumpFiles.Length, $"Expected at least 2 dump files (parent and child), but found {dumpFiles.Length}. Dumps: {string.Join(", ", dumpFiles.Select(Path.GetFileName))}\n{testHostResult}");
}

public sealed class TestAssetFixture() : TestAssetFixtureBase(AcceptanceFixture.NuGetGlobalPackagesFolder)
{
private const string AssetName = "CrashDumpFixture";
Expand All @@ -82,6 +100,11 @@ public sealed class TestAssetFixture() : TestAssetFixtureBase(AcceptanceFixture.
Sources
.PatchTargetFrameworks(TargetFrameworks.All)
.PatchCodeWithReplace("$MicrosoftTestingPlatformVersion$", MicrosoftTestingPlatformVersion));

yield return ("CrashDumpWithChildFixture", "CrashDumpWithChildFixture",
SourcesWithChild
.PatchTargetFrameworks(TargetFrameworks.All)
.PatchCodeWithReplace("$MicrosoftTestingPlatformVersion$", MicrosoftTestingPlatformVersion));
}

public string TargetAssetPath => GetAssetPath(AssetName);
Expand Down Expand Up @@ -152,6 +175,113 @@ public Task ExecuteRequestAsync(ExecuteRequestContext context)
return Task.CompletedTask;
}
}
""";

private const string SourcesWithChild = """
#file CrashDumpWithChild.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>$TargetFrameworks$</TargetFrameworks>
<OutputType>Exe</OutputType>
<UseAppHost>true</UseAppHost>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Testing.Extensions.CrashDump" Version="$MicrosoftTestingPlatformVersion$" />
</ItemGroup>
</Project>

#file Program.cs
using System;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Globalization;
using Microsoft.Testing.Platform;
using Microsoft.Testing.Platform.Extensions.TestFramework;
using Microsoft.Testing.Platform.Builder;
using Microsoft.Testing.Platform.Capabilities.TestFramework;
using Microsoft.Testing.Extensions;
using Microsoft.Testing.Platform.Messages;
using Microsoft.Testing.Platform.Requests;
using Microsoft.Testing.Platform.Services;

public class Startup
{
public static async Task<int> Main(string[] args)
{
Process self = Process.GetCurrentProcess();
string path = self.MainModule!.FileName!;

// Handle child process execution
if (args.Length > 0 && args[0] == "--child")
{
// Child process crashes immediately
Environment.FailFast("Child process crash");
return 1;
}

// Start a child process that will also crash (only when running as testhost controller)
Copy link
Member

Choose a reason for hiding this comment

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

This comment is wrong? The process that is not a testhost controller will have this parameter, so we are not running as testhost controller, we are running under testhost controller.

Might also be a good idea to crash this process in a different way than failfast. E.g. by allocating on the stack a bit of memory that is larger than the stack - immediate stack overflow.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fixed the comment from "running as testhost controller" to "running under testhost controller". Commit: 40aac17

if (args.Any(a => a == "--internal-testhostcontroller-pid"))
{
try
{
var childProcess = Process.Start(new ProcessStartInfo(path, "--child")
{
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
});

if (childProcess != null)
{
// Give child process time to start and crash
Thread.Sleep(500);
}
}
catch
{
// Ignore any errors starting child process
}
}

ITestApplicationBuilder builder = await TestApplication.CreateBuilderAsync(args);
builder.RegisterTestFramework(_ => new TestFrameworkCapabilities(), (_,__) => new DummyTestFramework());
builder.AddCrashDumpProvider();
using ITestApplication app = await builder.BuildAsync();
return await app.RunAsync();
}
}

public class DummyTestFramework : ITestFramework
{
public string Uid => nameof(DummyTestFramework);

public string Version => "2.0.0";

public string DisplayName => nameof(DummyTestFramework);

public string Description => nameof(DummyTestFramework);

public Task<bool> IsEnabledAsync() => Task.FromResult(true);

public Task<CreateTestSessionResult> CreateTestSessionAsync(CreateTestSessionContext context)
=> Task.FromResult(new CreateTestSessionResult() { IsSuccess = true });

public Task<CloseTestSessionResult> CloseTestSessionAsync(CloseTestSessionContext context)
=> Task.FromResult(new CloseTestSessionResult() { IsSuccess = true });

public Task ExecuteRequestAsync(ExecuteRequestContext context)
{
// Parent process crashes
Environment.FailFast("Parent process crash");
context.Complete();
return Task.CompletedTask;
}
}
""";
}

Expand Down
Loading