diff --git a/src/Platform/Microsoft.Testing.Extensions.CrashDump/CrashDumpProcessLifetimeHandler.cs b/src/Platform/Microsoft.Testing.Extensions.CrashDump/CrashDumpProcessLifetimeHandler.cs index a8031da37d..6d1f7e378a 100644 --- a/src/Platform/Microsoft.Testing.Extensions.CrashDump/CrashDumpProcessLifetimeHandler.cs +++ b/src/Platform/Microsoft.Testing.Extensions.CrashDump/CrashDumpProcessLifetimeHandler.cs @@ -22,6 +22,24 @@ internal sealed class CrashDumpProcessLifetimeHandler : ITestHostProcessLifetime private readonly IOutputDevice _outputDisplay; private readonly CrashDumpConfiguration _netCoreCrashDumpGeneratorConfiguration; + // The dump enumeration relies on this set to identify dumps that pre-date the current test + // run so it can skip them when publishing artifacts. File paths are case-insensitive on + // Windows but case-sensitive on Linux/macOS, so use an OS-appropriate comparer to avoid + // treating freshly produced dumps as "pre-existing" merely because of casing differences. + // + // KNOWN LIMITATION: when multiple testhost processes share the same dump directory (e.g. a + // user running two `dotnet test` invocations into the same --results-directory), each + // handler instance snapshots only the files present at *its own* start time. If process B + // writes a dump after handler A's snapshot, handler A may publish B's dump as if it were + // its own. The previous "PID-only match" code had the same issue. A more robust fix would + // require tracking per-file creation times against the snapshot time, which we deliberately + // avoid here to keep the handler free of an `IClock` dependency. + private static readonly StringComparer PathComparer = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? StringComparer.OrdinalIgnoreCase + : StringComparer.Ordinal; + + private readonly HashSet _preExistingDumpFiles; + public CrashDumpProcessLifetimeHandler( ICommandLineOptions commandLineOptions, IMessageBus messageBus, @@ -32,6 +50,9 @@ public CrashDumpProcessLifetimeHandler( _messageBus = messageBus; _outputDisplay = outputDisplay; _netCoreCrashDumpGeneratorConfiguration = netCoreCrashDumpGeneratorConfiguration; +#pragma warning disable IDE0028 // Collection initialization can be simplified - target-typed `new` cannot pass the comparer in the same syntactic form expected. + _preExistingDumpFiles = new(PathComparer); +#pragma warning restore IDE0028 } /// @@ -56,7 +77,42 @@ public Task IsEnabledAsync() public Task BeforeTestHostProcessStartAsync(CancellationToken _) => Task.CompletedTask; - public Task OnTestHostProcessStartedAsync(ITestHostProcessInformation testHostProcessInformation, CancellationToken cancellationToken) => Task.CompletedTask; + public Task OnTestHostProcessStartedAsync(ITestHostProcessInformation testHostProcessInformation, CancellationToken cancellationToken) + { + // Snapshot any pre-existing files in the dump directory so we can later restrict dump publication + // to files that appeared during this run. Without this, when the results/dump directory is reused + // across runs, stale dumps from a previous crash whose names also match the configured pattern + // would be surfaced as artifacts of the current failure. + // + // We *union* into the existing set rather than reassign it, so multiple invocations of this + // callback (e.g. host restart) cannot drop entries that we have already classified as + // pre-existing. + ApplicationStateGuard.Ensure(_netCoreCrashDumpGeneratorConfiguration.DumpFileNamePattern is not null); + string dumpFileNamePattern = _netCoreCrashDumpGeneratorConfiguration.DumpFileNamePattern; + string dumpDirectory = GetDumpDirectory(dumpFileNamePattern); + if (Directory.Exists(dumpDirectory)) + { + // Narrow the snapshot to files that share the configured dump extension when one is + // present, matching the search pattern we use on exit. This avoids paying for an + // enumeration of every entry in the dump directory (which may also contain TRX files, + // logs, attachments, ...), especially when `dumpDirectory` resolves to "." or to a + // large shared --results-directory. + string dumpSearchPattern = GetDumpSearchPattern(dumpFileNamePattern); + foreach (string file in Directory.EnumerateFiles(dumpDirectory, dumpSearchPattern)) + { + cancellationToken.ThrowIfCancellationRequested(); + _preExistingDumpFiles.Add(file); + } + } + + return Task.CompletedTask; + } + + private static string GetDumpSearchPattern(string dumpFileNamePattern) + { + string dumpExtension = Path.GetExtension(Path.GetFileName(dumpFileNamePattern)); + return dumpExtension.Length == 0 ? "*" : $"*{dumpExtension}"; + } public async Task OnTestHostProcessExitedAsync(ITestHostProcessInformation testHostProcessInformation, CancellationToken cancellationToken) { @@ -71,20 +127,93 @@ public async Task OnTestHostProcessExitedAsync(ITestHostProcessInformation testH bool generateDump = _commandLineOptions.IsOptionSet(CrashDumpCommandLineOptions.CrashDumpOptionName); bool generateCrashReport = _commandLineOptions.IsOptionSet(CrashDumpCommandLineOptions.CrashReportOptionName); - // TODO: Crash dump supports more placeholders that we don't handle here. + // The crash dump file name pattern can contain placeholders such as %p (PID), %e (process exe name), + // %h (hostname), %t (timestamp), etc. that are expanded by the .NET runtime when it writes the dump. // See "Dump name formatting" in: // https://github.com/dotnet/runtime/blob/82742628310076fff22d7e7ee216a74384352056/docs/design/coreclr/botr/xplat-minidump-generation.md - string expectedDumpFile = _netCoreCrashDumpGeneratorConfiguration.DumpFileNamePattern.Replace("%p", testHostProcessInformation.PID.ToString(CultureInfo.InvariantCulture)); + // We convert the file name part of the pattern into a regular expression (escaping literal characters + // and turning %X placeholders into '.*') so we can collect not just the testhost dump but also dumps + // produced by any of its child processes that may have crashed alongside it. Using a regex (instead + // of passing the pattern as a glob to Directory.EnumerateFiles) ensures that any literal glob + // metacharacter (e.g. '*' or '?') in the configured file name is matched literally and not as a + // wildcard, which would otherwise cause unrelated files to be picked up on file systems that allow + // these characters in file names (e.g. Linux/macOS). + string dumpFileNamePattern = _netCoreCrashDumpGeneratorConfiguration.DumpFileNamePattern; + string expectedDumpFile = dumpFileNamePattern.Replace("%p", testHostProcessInformation.PID.ToString(CultureInfo.InvariantCulture)); string expectedCrashReportFile = $"{expectedDumpFile}{CrashReportFileExtension}"; + string dumpDirectory = GetDumpDirectory(dumpFileNamePattern); + string dumpFileNameOnly = Path.GetFileName(dumpFileNamePattern); + Regex dumpFileNameRegex = BuildDumpFileNameRegex(dumpFileNameOnly); + + // Stricter regex that bakes in the testhost PID for any '%p' placeholder before expanding + // the remaining placeholders as wildcards. We use this to recognize the testhost's own + // dump (versus a child process dump) regardless of whether the configured name relies on + // additional placeholders such as '%e', '%h' or '%t' - relying on `File.Exists` with the + // literal-`%p`-substituted path would only work when '%p' is the only placeholder. + // + // Note: when the configured pattern omits '%p', this regex collapses to `dumpFileNameRegex` + // (the `Replace("%p", ...)` call is a no-op) and we cannot distinguish testhost from child + // dumps by name — any matching dump is treated as the testhost's. The runtime in that case + // can only produce one dump per process matching the configured shape, so the practical + // impact is limited to setups that pre-create files with the same shape under the dump + // directory. + Regex testhostDumpRegex = BuildDumpFileNameRegex( + dumpFileNameOnly.Replace("%p", testHostProcessInformation.PID.ToString(CultureInfo.InvariantCulture))); + + // Narrow the file system enumeration to files that share the configured dump extension when + // one is present. This avoids scanning every entry of the dump directory (which may also + // contain TRX files, logs, attachments, ...). The placeholder-expanded regex above still + // applies to filter out anything that does not match the configured name pattern. + string dumpExtension = Path.GetExtension(dumpFileNameOnly); + string dumpSearchPattern = GetDumpSearchPattern(dumpFileNamePattern); + + bool publishedAnyDump = false; + bool testhostDumpProduced = false; + if (generateDump && Directory.Exists(dumpDirectory)) + { + foreach (string dumpFile in Directory.EnumerateFiles(dumpDirectory, dumpSearchPattern)) + { + // Filter by exact extension to defend against Windows' legacy 8.3 short-name + // matching where a pattern like '*.dmp' can also match files whose extension + // merely starts with '.dmp' (for example 'foo.dmp.crashreport.json'). + if (dumpExtension.Length != 0 + && !Path.GetExtension(dumpFile).Equals(dumpExtension, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + string dumpFileNameOnDisk = Path.GetFileName(dumpFile); + if (dumpFileNameRegex.IsMatch(dumpFileNameOnDisk) + && !_preExistingDumpFiles.Contains(dumpFile)) + { + await _messageBus.PublishAsync(this, new FileArtifact(new FileInfo(dumpFile), CrashDumpResources.CrashDumpArtifactDisplayName, CrashDumpResources.CrashDumpArtifactDescription)).ConfigureAwait(false); + publishedAnyDump = true; + + if (testhostDumpRegex.IsMatch(dumpFileNameOnDisk)) + { + testhostDumpProduced = true; + } + } + } + } + + // The crash banner and the "expected dump not found" warning are scoped to the testhost + // dump specifically. We must not suppress them just because we published a dump for a + // crashed child process: when only the child writes a dump, the user still needs to know + // that the testhost's own dump never materialized. + // Fall back to checking `expectedDumpFile` existence on disk to cover the edge case where + // a file matching the literal-`%p`-substituted name was already present at start time (and + // therefore skipped by the regex loop because it is in `_preExistingDumpFiles`) - we still + // want the banner to reflect that the testhost dump is, technically, present on disk. + testhostDumpProduced = generateDump && (testhostDumpProduced || File.Exists(expectedDumpFile)); + bool dumpArtifactProduced = generateDump && (testhostDumpProduced || publishedAnyDump); + bool crashReportArtifactProduced = generateCrashReport && File.Exists(expectedCrashReportFile); // Inspect the disk before emitting the crash banner so the message reflects // what was actually produced, not what was requested. The runtime may fail // to emit one (or both) of the artifacts, e.g. when EnableCrashReport is // unsupported on the current platform/version. - bool dumpArtifactProduced = generateDump && File.Exists(expectedDumpFile); - bool crashReportArtifactProduced = generateCrashReport && File.Exists(expectedCrashReportFile); - - string? processCrashedMessage = (dumpArtifactProduced, crashReportArtifactProduced) switch + string processCrashedMessage = (dumpArtifactProduced, crashReportArtifactProduced) switch { (true, true) => string.Format(CultureInfo.InvariantCulture, CrashDumpResources.CrashDumpProcessCrashedDumpAndReportFileCreated, testHostProcessInformation.PID), (false, true) => string.Format(CultureInfo.InvariantCulture, CrashDumpResources.CrashDumpProcessCrashedReportFileCreated, testHostProcessInformation.PID), @@ -93,23 +222,25 @@ public async Task OnTestHostProcessExitedAsync(ITestHostProcessInformation testH }; await _outputDisplay.DisplayAsync(this, new ErrorMessageOutputDeviceData(processCrashedMessage), cancellationToken).ConfigureAwait(false); - if (generateDump) + if (generateDump && !testhostDumpProduced) { - if (dumpArtifactProduced) - { - await _messageBus.PublishAsync(this, new FileArtifact(new FileInfo(expectedDumpFile), CrashDumpResources.CrashDumpArtifactDisplayName, CrashDumpResources.CrashDumpArtifactDescription)).ConfigureAwait(false); - } - else - { - await _outputDisplay.DisplayAsync(this, new ErrorMessageOutputDeviceData(string.Format(CultureInfo.InvariantCulture, CrashDumpResources.CannotFindExpectedCrashDumpFile, expectedDumpFile)), cancellationToken).ConfigureAwait(false); + await _outputDisplay.DisplayAsync(this, new ErrorMessageOutputDeviceData(string.Format(CultureInfo.InvariantCulture, CrashDumpResources.CannotFindExpectedCrashDumpFile, expectedDumpFile)), cancellationToken).ConfigureAwait(false); + // Only fall back to a directory-wide `*.dmp` scan when neither the testhost dump nor any + // other matching dump was published. This avoids re-enumerating the directory when we + // already published at least one dump (e.g. a child process dump) above. + if (!publishedAnyDump && Directory.Exists(dumpDirectory)) + { // Filter by exact extension to defend against Windows' legacy 8.3 short-name // matching where a pattern like '*.dmp' can also match files whose extension // merely starts with '.dmp' (for example 'foo.dmp.crashreport.json'). - foreach (string dumpFile in Directory.EnumerateFiles(Path.GetDirectoryName(expectedDumpFile)!, "*.dmp") + foreach (string dumpFile in Directory.EnumerateFiles(dumpDirectory, "*.dmp") .Where(static f => Path.GetExtension(f).Equals(".dmp", StringComparison.OrdinalIgnoreCase))) { - await _messageBus.PublishAsync(this, new FileArtifact(new FileInfo(dumpFile), CrashDumpResources.CrashDumpDisplayName, CrashDumpResources.CrashDumpArtifactDescription)).ConfigureAwait(false); + if (!_preExistingDumpFiles.Contains(dumpFile)) + { + await _messageBus.PublishAsync(this, new FileArtifact(new FileInfo(dumpFile), CrashDumpResources.CrashDumpArtifactDisplayName, CrashDumpResources.CrashDumpArtifactDescription)).ConfigureAwait(false); + } } } } @@ -135,4 +266,63 @@ public async Task OnTestHostProcessExitedAsync(ITestHostProcessInformation testH } } } + + internal static string GetDumpDirectory(string dumpFileNamePattern) + { + // Path.GetDirectoryName returns "" (not null) for a bare filename on .NET Core/5+ but throws + // ArgumentException for an empty string on .NET Framework; treat both as the current working + // directory so the dump enumeration is not silently skipped. + if (dumpFileNamePattern is null or "") + { + return "."; + } + + string? rawDirectory = Path.GetDirectoryName(dumpFileNamePattern); + return rawDirectory is null or "" ? "." : rawDirectory; + } + + internal static Regex BuildDumpFileNameRegex(string fileName) + => new(BuildDumpFileNameRegexPattern(fileName), RegexOptions.CultureInvariant); + + internal static string BuildDumpFileNameRegexPattern(string fileName) + { + var sb = new StringBuilder("^"); + bool lastWasWildcard = false; + for (int i = 0; i < fileName.Length; i++) + { + if (fileName[i] == '%' && i + 1 < fileName.Length) + { + // The .NET runtime's createdump tool treats "%%" as an escape for a literal '%'. + // Preserve that behavior so a configured name like "My%%App_%p.dmp" produces a regex + // that requires a literal '%' (rather than collapsing both characters into a wildcard + // and over-matching unrelated files). + if (fileName[i + 1] == '%') + { + // '%' is not a regex metacharacter so it does not need escaping. + sb.Append('%'); + lastWasWildcard = false; + i++; + continue; + } + + // Replace any other %X placeholder with ".*". Collapse consecutive wildcards to keep + // the regex simple and to avoid backtracking on patterns like "%p%t". + if (!lastWasWildcard) + { + sb.Append(".*"); + lastWasWildcard = true; + } + + i++; + } + else + { + sb.Append(Regex.Escape(fileName[i].ToString())); + lastWasWildcard = false; + } + } + + sb.Append('$'); + return sb.ToString(); + } } diff --git a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/CrashDumpTests.cs b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/CrashDumpTests.cs index bfcdaa7438..d3bf6465bd 100644 --- a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/CrashDumpTests.cs +++ b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/CrashDumpTests.cs @@ -18,6 +18,41 @@ public async Task CrashDump_DefaultSetting_CreateDump(string tfm) Assert.IsNotNull(dumpFile, $"Dump file not found '{tfm}'\n{testHostResult}'"); } + [DynamicData(nameof(TargetFrameworks.NetForDynamicData), typeof(TargetFrameworks))] + [TestMethod] + public async Task CrashDump_TesthostAndChildBothCrash_CollectsAllDumps(string tfm) + { + string resultDirectory = Path.Combine(AssetFixture.TargetAssetPath, Guid.NewGuid().ToString("N")); + var testHost = TestInfrastructure.TestHost.LocateFrom(AssetFixture.TargetAssetPath, "CrashDump", tfm); + TestHostResult testHostResult = await testHost.ExecuteAsync( + $"--crashdump --results-directory {resultDirectory}", + new Dictionary + { + { "CRASHDUMP_SPAWN_CHILD_THAT_CRASHES", "1" }, + }, + cancellationToken: TestContext.CancellationToken); + testHostResult.AssertExitCodeIs(ExitCode.TestHostProcessExitedNonGracefully); + + // Both the testhost and its child process crash with FailFast and must produce a dump each. + // Without the fix for https://github.com/microsoft/testfx/issues/4186, only the dump matching + // the testhost's PID was reported as an artifact and the child dump was silently dropped. + // + // Filter by exact extension after the wildcard enumeration to defend against Windows' legacy + // 8.3 short-name matching where the search pattern `CrashDump_*.dmp` can also match files + // whose extension merely starts with `.dmp` (for example `CrashDump_xxx.dmp.crashreport.json`). + string[] dumpFiles = [.. Directory + .GetFiles(resultDirectory, "CrashDump_*.dmp", SearchOption.AllDirectories) + .Where(f => Path.GetExtension(f).Equals(".dmp", StringComparison.OrdinalIgnoreCase))]; + Assert.HasCount(2, dumpFiles, $"Expected dumps for both the testhost and the child process '{tfm}'.\n{testHostResult}"); + + // Both dumps must also be reported as out-of-process file artifacts so they show up to the user. + testHostResult.AssertOutputContains("Out of process file artifacts produced:"); + foreach (string dumpFile in dumpFiles) + { + testHostResult.AssertOutputContains(Path.GetFileName(dumpFile)); + } + } + [TestMethod] public async Task CrashDump_CustomDumpName_CreateDump() { @@ -142,6 +177,9 @@ public override (string ID, string Name, string Code) GetAssetsToGenerate() => ( #file Program.cs using System; +using System.Diagnostics; +using System.IO; +using System.Reflection; using System.Threading; using System.Threading.Tasks; using System.Globalization; @@ -158,6 +196,19 @@ public class Startup { public static async Task Main(string[] args) { + // When invoked as a child process spawned by the testhost, just crash so we produce + // an additional dump in the same directory using the dump env vars inherited from the parent. + if (args.Length > 0 && args[0] == "--child-crash") + { + Environment.FailFast("ChildCrashDump"); +#if NETFRAMEWORK + // Environment.FailFast lacks the [DoesNotReturn] annotation on .NET Framework, so the + // compiler still requires the method to return on all paths. On .NET 6+ this branch is + // omitted to avoid an "unreachable code" warning. + return 0; +#endif + } + ITestApplicationBuilder builder = await TestApplication.CreateBuilderAsync(args); builder.RegisterTestFramework(_ => new TestFrameworkCapabilities(), (_,__) => new DummyTestFramework()); builder.AddCrashDumpProvider(); @@ -186,6 +237,77 @@ public Task CloseTestSessionAsync(CloseTestSessionContex public Task ExecuteRequestAsync(ExecuteRequestContext context) { + // Optionally spawn a child process that also crashes (and produces its own dump) so we can + // exercise the crashdump extension's ability to collect dumps from child processes. + if (Environment.GetEnvironmentVariable("CRASHDUMP_SPAWN_CHILD_THAT_CRASHES") == "1") + { + // Prefer Environment.ProcessPath (available since .NET 6) over Process.MainModule.FileName + // so we avoid loading the process module and any platform-specific failure modes that come + // with it. Fall back to MainModule for older runtimes. +#if NET6_0_OR_GREATER + string? path = Environment.ProcessPath; +#else + string? path = null; +#endif + if (string.IsNullOrEmpty(path)) + { + using Process self = Process.GetCurrentProcess(); + path = self.MainModule!.FileName!; + } + + string fileName = Path.GetFileName(path); + bool isDotnetMuxer = string.Equals(fileName, "dotnet", StringComparison.OrdinalIgnoreCase) + || string.Equals(fileName, "dotnet.exe", StringComparison.OrdinalIgnoreCase); + + var psi = new ProcessStartInfo(path) + { + UseShellExecute = false, + }; +#if NET6_0_OR_GREATER + // Use ArgumentList instead of a single argument string so the runtime quotes/escapes each + // argument correctly across Windows and Unix; this also makes the test asset robust to + // paths that contain spaces or special characters. + if (isDotnetMuxer) + { + psi.ArgumentList.Add("exec"); + psi.ArgumentList.Add(Assembly.GetEntryAssembly()!.Location); + } + + psi.ArgumentList.Add("--child-crash"); +#else + // .NET Framework does not have ProcessStartInfo.ArgumentList; fall back to a manually + // quoted argument string. This branch only compiles for net462; the test that exercises + // child-process crashes does not run on .NET Framework, but the asset must still compile + // for every TFM listed in `TargetFrameworks.All`. + if (isDotnetMuxer) + { + psi.Arguments = "exec \"" + Assembly.GetEntryAssembly()!.Location + "\" --child-crash"; + } + else + { + psi.Arguments = "--child-crash"; + } +#endif + + using Process child = Process.Start(psi)!; + + // Wait for the child to fully exit (with a bounded timeout to avoid hanging the test run) + // so its crash dump is written before we crash too. + if (!child.WaitForExit(60_000)) + { + try + { + child.Kill(); + } + catch + { + // Best effort: process may have just exited. + } + + throw new InvalidOperationException("Child process did not exit within the expected timeout (60s)."); + } + } + Environment.FailFast("CrashDump"); context.Complete(); return Task.CompletedTask; diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/CrashDumpTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/CrashDumpTests.cs index e2128bb003..a1d07892fc 100644 --- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/CrashDumpTests.cs +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/CrashDumpTests.cs @@ -5,6 +5,11 @@ using Microsoft.Testing.Extensions.Diagnostics.Resources; using Microsoft.Testing.Extensions.UnitTests.Helpers; using Microsoft.Testing.Platform.Extensions.CommandLine; +using Microsoft.Testing.Platform.Extensions.Messages; +using Microsoft.Testing.Platform.Extensions.OutputDevice; +using Microsoft.Testing.Platform.Extensions.TestHostControllers; +using Microsoft.Testing.Platform.Messages; +using Microsoft.Testing.Platform.OutputDevice; namespace Microsoft.Testing.Extensions.UnitTests; @@ -108,4 +113,332 @@ public async Task CrashReport_WithCrashDump_OnWindows_IsInvalid() Assert.IsFalse(validateOptionsResult.IsValid); Assert.Contains("'--crash-report' is not supported on Windows", validateOptionsResult.ErrorMessage); } + + [TestMethod] + [DataRow("MyApp_%p_crash.dmp", @"^MyApp_.*_crash\.dmp$")] + [DataRow("%e_%p_crash.dmp", @"^.*_.*_crash\.dmp$")] + [DataRow("%p%t_crash.dmp", @"^.*_crash\.dmp$")] + [DataRow("customdumpname.dmp", @"^customdumpname\.dmp$")] + [DataRow("dump_%p_%t_%h.dmp", @"^dump_.*_.*_.*\.dmp$")] + [DataRow("trailing%", "^trailing%$")] + // Glob metacharacters that may appear literally in a user-supplied filename must be escaped so they are + // matched literally, not treated as wildcards. This guards against picking up unrelated dump files on + // file systems that allow these characters in file names (e.g. Linux/macOS). + [DataRow("my*dump_%p.dmp", @"^my\*dump_.*\.dmp$")] + [DataRow("dump?_%p.dmp", @"^dump\?_.*\.dmp$")] + // The .NET runtime's createdump tool treats "%%" as an escape for a literal '%'. The regex builder + // must preserve that: "%%" stays a literal percent rather than collapsing into a ".*" wildcard + // (which would otherwise over-match unrelated files). + [DataRow("My%%App_%p.dmp", @"^My%App_.*\.dmp$")] + [DataRow("%%%p.dmp", @"^%.*\.dmp$")] + [DataRow("%p%%.dmp", @"^.*%\.dmp$")] + public void BuildDumpFileNameRegexPattern_ConvertsPlaceholdersToRegex(string fileName, string expected) + { + string actual = CrashDumpProcessLifetimeHandler.BuildDumpFileNameRegexPattern(fileName); + Assert.AreEqual(expected, actual); + } + + [TestMethod] + public void BuildDumpFileNameRegex_LiteralGlobMetacharactersInName_DoesNotOverMatch() + { + Regex regex = CrashDumpProcessLifetimeHandler.BuildDumpFileNameRegex("my*dump_%p.dmp"); + Assert.IsTrue(regex.IsMatch("my*dump_123.dmp"), "Literal '*' must be matched literally."); + Assert.IsFalse(regex.IsMatch("myXYZdump_123.dmp"), "Literal '*' must not act as a wildcard."); + Assert.IsFalse(regex.IsMatch("mydump_123.dmp"), "Literal '*' must require at least the '*' character to be present."); + } + + [TestMethod] + public void BuildDumpFileNameRegex_LiteralPercentInName_DoesNotOverMatch() + { + Regex regex = CrashDumpProcessLifetimeHandler.BuildDumpFileNameRegex("My%%App_%p.dmp"); + Assert.IsTrue(regex.IsMatch("My%App_123.dmp"), "Literal '%' must be matched literally."); + Assert.IsFalse(regex.IsMatch("MyApp_123.dmp"), "Literal '%' must require the '%' character to be present."); + Assert.IsFalse(regex.IsMatch("MyXApp_123.dmp"), "Literal '%' must not be treated as a wildcard."); + } + + [TestMethod] + [DataRow("dump_%p.dmp")] + [DataRow("")] + [DataRow("customdumpname.dmp")] + public void GetDumpDirectory_WhenPatternHasNoDirectoryComponent_ReturnsCurrentDirectory(string pattern) + { + // The CrashDump runtime writes dumps to the current working directory when the configured pattern + // contains no directory prefix. Previously the extension's enumeration was silently skipped in that + // case because Path.GetDirectoryName returns "" (not null), and Directory.Exists("") is false. + string actual = CrashDumpProcessLifetimeHandler.GetDumpDirectory(pattern); + + Assert.AreEqual(".", actual); + } + + [TestMethod] + public void GetDumpDirectory_WhenPatternHasDirectoryComponent_ReturnsDirectory() + { + string directory = Path.Combine(Path.GetTempPath(), "dumps"); + string pattern = Path.Combine(directory, "dump_%p.dmp"); + + string actual = CrashDumpProcessLifetimeHandler.GetDumpDirectory(pattern); + + Assert.AreEqual(directory, actual); + } + + [TestMethod] + public async Task OnTestHostProcessExitedAsync_OnlyPublishesDumpsThatAppearedDuringTheRun() + { + // Create an isolated dump directory so we can pre-populate it with stale files that simulate + // dumps left over from a previous run (the snapshot must filter them out) and then write a + // fresh dump that should be published as an artifact. + string dumpDirectory = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "crashdump-tests-" + Guid.NewGuid().ToString("N"))).FullName; + try + { + string stale1 = Path.Combine(dumpDirectory, "CrashDump_999_crash.dmp"); + string stale2 = Path.Combine(dumpDirectory, "CrashDump_888_crash.dmp"); + File.WriteAllText(stale1, "stale"); + File.WriteAllText(stale2, "stale"); + + string dumpPattern = Path.Combine(dumpDirectory, "CrashDump_%p_crash.dmp"); + var configuration = new CrashDumpConfiguration { DumpFileNamePattern = dumpPattern }; + var commandLineOptions = new TestCommandLineOptions(new Dictionary + { + { CrashDumpCommandLineOptions.CrashDumpOptionName, [] }, + }); + var messageBus = new RecordingMessageBus(); + var outputDevice = new NullOutputDevice(); + var handler = new CrashDumpProcessLifetimeHandler(commandLineOptions, messageBus, outputDevice, configuration); + + // Snapshot the directory; both stale dumps must be considered pre-existing. + await handler.OnTestHostProcessStartedAsync(new TestHostProcessInformation(pid: 123, exitCode: 1, hasExitedGracefully: false), CancellationToken.None); + + // Simulate the runtime writing two new dumps during the run: one for the testhost and one + // for a child process that crashed too. The expected testhost dump is also created on disk + // (so the "missing expected dump" warning is not triggered). + string fresh1 = Path.Combine(dumpDirectory, "CrashDump_123_crash.dmp"); + string fresh2 = Path.Combine(dumpDirectory, "CrashDump_456_crash.dmp"); + File.WriteAllText(fresh1, "fresh"); + File.WriteAllText(fresh2, "fresh"); + + await handler.OnTestHostProcessExitedAsync(new TestHostProcessInformation(pid: 123, exitCode: 1, hasExitedGracefully: false), CancellationToken.None); + + string[] publishedDumps = messageBus.Published + .OfType() + .Select(static a => a.FileInfo.FullName) + .OrderBy(static p => p, StringComparer.Ordinal) + .ToArray(); + string[] expected = new[] { fresh1, fresh2 }.OrderBy(static p => p, StringComparer.Ordinal).ToArray(); + CollectionAssert.AreEqual(expected, publishedDumps); + } + finally + { + try + { + Directory.Delete(dumpDirectory, recursive: true); + } + catch + { + // Best effort cleanup. + } + } + } + + [TestMethod] + public async Task OnTestHostProcessExitedAsync_PatternWithMultiplePlaceholders_DoesNotEmitFalseMissingDumpWarning() + { + // When the configured dump pattern relies on placeholders other than `%p` (here `%e`), + // the literal-`%p`-substituted path never exists on disk. The handler must therefore + // recognize the testhost dump from the regex match (PID-baked into the testhost-specific + // regex) and avoid emitting CannotFindExpectedCrashDumpFile, which would otherwise + // contradict the success banner above it. + string dumpDirectory = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "crashdump-tests-" + Guid.NewGuid().ToString("N"))).FullName; + try + { + string dumpPattern = Path.Combine(dumpDirectory, "Dump_%e_%p.dmp"); + var configuration = new CrashDumpConfiguration { DumpFileNamePattern = dumpPattern }; + var commandLineOptions = new TestCommandLineOptions(new Dictionary + { + { CrashDumpCommandLineOptions.CrashDumpOptionName, [] }, + }); + var messageBus = new RecordingMessageBus(); + var outputDevice = new CapturingOutputDevice(); + var handler = new CrashDumpProcessLifetimeHandler(commandLineOptions, messageBus, outputDevice, configuration); + + await handler.OnTestHostProcessStartedAsync(new TestHostProcessInformation(pid: 123, exitCode: 1, hasExitedGracefully: false), CancellationToken.None); + + // Runtime expands %e to "testhost" and %p to the actual PID. The resulting filename is + // matched by the testhost-specific regex even though the literal-%p substitution would + // produce `Dump_%e_123.dmp` (which does not exist on disk). + string testhostDump = Path.Combine(dumpDirectory, "Dump_testhost_123.dmp"); + File.WriteAllText(testhostDump, "fresh"); + + await handler.OnTestHostProcessExitedAsync(new TestHostProcessInformation(pid: 123, exitCode: 1, hasExitedGracefully: false), CancellationToken.None); + + string[] publishedDumps = messageBus.Published + .OfType() + .Select(static a => a.FileInfo.FullName) + .ToArray(); + CollectionAssert.AreEqual(new[] { testhostDump }, publishedDumps); + + // The "expected dump not found" warning must NOT be emitted: the testhost dump was + // recognized via the regex even though `expectedDumpFile` (literal `%p` substitution) + // would be `Dump_%e_123.dmp`, which does not exist on disk. + string captured = string.Join(" | ", outputDevice.Displayed); + Assert.DoesNotContain("Dump_%e_123.dmp", captured, "CannotFindExpectedCrashDumpFile must not be displayed when the testhost dump was recognized via the regex."); + } + finally + { + try + { + Directory.Delete(dumpDirectory, recursive: true); + } + catch + { + // Best effort cleanup. + } + } + } + + [TestMethod] + public async Task OnTestHostProcessExitedAsync_PatternWithRepeatedPidPlaceholder_RecognizesTesthostDump() + { + // A configured pattern can include `%p` more than once. Verify that the PID-substitution + // applies to every occurrence (string.Replace substitutes all matches) so the + // testhost-specific regex is anchored to the actual PID on both sides. + string dumpDirectory = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "crashdump-tests-" + Guid.NewGuid().ToString("N"))).FullName; + try + { + string dumpPattern = Path.Combine(dumpDirectory, "dump_%p_backup_%p.dmp"); + var configuration = new CrashDumpConfiguration { DumpFileNamePattern = dumpPattern }; + var commandLineOptions = new TestCommandLineOptions(new Dictionary + { + { CrashDumpCommandLineOptions.CrashDumpOptionName, [] }, + }); + var messageBus = new RecordingMessageBus(); + var outputDevice = new CapturingOutputDevice(); + var handler = new CrashDumpProcessLifetimeHandler(commandLineOptions, messageBus, outputDevice, configuration); + + await handler.OnTestHostProcessStartedAsync(new TestHostProcessInformation(pid: 555, exitCode: 1, hasExitedGracefully: false), CancellationToken.None); + + string testhostDump = Path.Combine(dumpDirectory, "dump_555_backup_555.dmp"); + File.WriteAllText(testhostDump, "fresh"); + + await handler.OnTestHostProcessExitedAsync(new TestHostProcessInformation(pid: 555, exitCode: 1, hasExitedGracefully: false), CancellationToken.None); + + string[] publishedDumps = messageBus.Published + .OfType() + .Select(static a => a.FileInfo.FullName) + .ToArray(); + CollectionAssert.AreEqual(new[] { testhostDump }, publishedDumps); + string captured = string.Join(" | ", outputDevice.Displayed); + Assert.DoesNotContain("dump_555_backup_555.dmp", captured, "CannotFindExpectedCrashDumpFile must not be displayed when the testhost dump was recognized via the regex."); + } + finally + { + try + { + Directory.Delete(dumpDirectory, recursive: true); + } + catch + { + // Best effort cleanup. + } + } + } + + [TestMethod] + public async Task OnTestHostProcessExitedAsync_TesthostAndChildBothCrashWithMultiPlaceholderPattern_PublishesBothAndSuppressesWarning() + { + // Regression coverage for the M1 vector: when both the testhost AND a child process write + // dumps using a pattern with non-`%p` placeholders, both dumps must be published, and the + // "expected dump not found" warning must NOT fire (because the testhost dump is identified + // via the testhost-specific regex, not via File.Exists on the literal-`%p`-substituted path). + string dumpDirectory = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "crashdump-tests-" + Guid.NewGuid().ToString("N"))).FullName; + try + { + string dumpPattern = Path.Combine(dumpDirectory, "Dump_%e_%p.dmp"); + var configuration = new CrashDumpConfiguration { DumpFileNamePattern = dumpPattern }; + var commandLineOptions = new TestCommandLineOptions(new Dictionary + { + { CrashDumpCommandLineOptions.CrashDumpOptionName, [] }, + }); + var messageBus = new RecordingMessageBus(); + var outputDevice = new CapturingOutputDevice(); + var handler = new CrashDumpProcessLifetimeHandler(commandLineOptions, messageBus, outputDevice, configuration); + + await handler.OnTestHostProcessStartedAsync(new TestHostProcessInformation(pid: 123, exitCode: 1, hasExitedGracefully: false), CancellationToken.None); + + string testhostDump = Path.Combine(dumpDirectory, "Dump_testhost_123.dmp"); + string childDump = Path.Combine(dumpDirectory, "Dump_child_456.dmp"); + File.WriteAllText(testhostDump, "fresh"); + File.WriteAllText(childDump, "fresh"); + + await handler.OnTestHostProcessExitedAsync(new TestHostProcessInformation(pid: 123, exitCode: 1, hasExitedGracefully: false), CancellationToken.None); + + string[] publishedDumps = messageBus.Published + .OfType() + .Select(static a => a.FileInfo.FullName) + .OrderBy(static p => p, StringComparer.Ordinal) + .ToArray(); + string[] expected = new[] { testhostDump, childDump }.OrderBy(static p => p, StringComparer.Ordinal).ToArray(); + CollectionAssert.AreEqual(expected, publishedDumps); + string captured = string.Join(" | ", outputDevice.Displayed); + Assert.DoesNotContain("Dump_%e_123.dmp", captured, "CannotFindExpectedCrashDumpFile must not be displayed when the testhost dump was recognized via the regex."); + } + finally + { + try + { + Directory.Delete(dumpDirectory, recursive: true); + } + catch + { + // Best effort cleanup. + } + } + } + + private sealed class RecordingMessageBus : IMessageBus + { + public List Published { get; } = []; + + public Task PublishAsync(IDataProducer dataProducer, IData data) + { + Published.Add(data); + return Task.CompletedTask; + } + } + + private sealed class NullOutputDevice : IOutputDevice + { + public Task DisplayAsync(IOutputDeviceDataProducer producer, IOutputDeviceData data, CancellationToken cancellationToken) + => Task.CompletedTask; + } + + private sealed class CapturingOutputDevice : IOutputDevice + { + public List Displayed { get; } = []; + + public Task DisplayAsync(IOutputDeviceDataProducer producer, IOutputDeviceData data, CancellationToken cancellationToken) + { + if (data is ErrorMessageOutputDeviceData errorData) + { + Displayed.Add(errorData.Message); + } + + return Task.CompletedTask; + } + } + + private sealed class TestHostProcessInformation : ITestHostProcessInformation + { + public TestHostProcessInformation(int pid, int exitCode, bool hasExitedGracefully) + { + PID = pid; + ExitCode = exitCode; + HasExitedGracefully = hasExitedGracefully; + } + + public int PID { get; } + + public int ExitCode { get; } + + public bool HasExitedGracefully { get; } + } }