From 2b513c22b7217eb13e7e1ea9a55c6a5c1a85e2ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Tue, 9 Jun 2026 11:14:28 +0200 Subject: [PATCH 1/2] [CrashDump] Support {placeholder} tokens in --crashdump-filename via ArtifactNamingHelper Wires ArtifactNamingHelper into CrashDumpEnvironmentVariableProvider so users can use the same {pname}/{pid}/{asm}/{tfm}/{time} template tokens as HangDump and the report extensions. Because CrashDump only hands a filename pattern to the .NET runtime's createdump BEFORE the testhost launches, the testhost PID/exe name are not yet known. We resolve {asm}/{tfm}/{time} to literal values up-front and map {pid} -> %p and {pname} -> %e so the runtime expands them at crash-write time. Also fixes the existing crash-report lookup which previously did only a single .Replace(%p, PID): when the dump pattern contains %e (now possible via {pname}), the literal expected path would not exist on disk. Switched to regex-based enumeration that reuses the existing testhostDumpRegex (which already turns %X into .* wildcards). The sequence file name is now derived from the resolved user pattern (with {X} substituted) rather than the raw user input, keeping the sequence file aligned with the actual dump file name. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/glossary.md | 2 +- .../CrashDumpEnvironmentVariableProvider.cs | 43 +++++++++++-- .../CrashDumpExtensions.cs | 3 +- .../CrashDumpProcessLifetimeHandler.cs | 34 ++++++++++- ...rosoft.Testing.Extensions.CrashDump.csproj | 2 + .../Resources/CrashDumpResources.resx | 3 +- .../Resources/xlf/CrashDumpResources.cs.xlf | 5 +- .../Resources/xlf/CrashDumpResources.de.xlf | 5 +- .../Resources/xlf/CrashDumpResources.es.xlf | 5 +- .../Resources/xlf/CrashDumpResources.fr.xlf | 5 +- .../Resources/xlf/CrashDumpResources.it.xlf | 5 +- .../Resources/xlf/CrashDumpResources.ja.xlf | 5 +- .../Resources/xlf/CrashDumpResources.ko.xlf | 5 +- .../Resources/xlf/CrashDumpResources.pl.xlf | 5 +- .../xlf/CrashDumpResources.pt-BR.xlf | 5 +- .../Resources/xlf/CrashDumpResources.ru.xlf | 5 +- .../Resources/xlf/CrashDumpResources.tr.xlf | 5 +- .../xlf/CrashDumpResources.zh-Hans.xlf | 5 +- .../xlf/CrashDumpResources.zh-Hant.xlf | 5 +- .../HelpInfoAllExtensionsTests.cs | 6 +- .../CrashDumpTests.cs | 61 +++++++++++++++++++ 21 files changed, 179 insertions(+), 40 deletions(-) diff --git a/docs/glossary.md b/docs/glossary.md index 12602e7f7d..877f149ccb 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -14,7 +14,7 @@ An MTP struct (`ArgumentArity.cs`) that defines the minimum and maximum number o ### ArtifactNamingHelper -A shared static helper compiled into MTP extensions via file linking (no NuGet service registration or InternalsVisibleTo required) that provides template-based naming for test artifact files (dump files, report files, etc.). Templates are strings containing `{placeholder}` tokens (case-sensitive, lowercase): `{pname}` (process name), `{pid}` (process ID), `{asm}` (entry-assembly name), `{tfm}` (target framework moniker, best-effort runtime detection), and `{time}` (high-precision UTC timestamp). Legacy `%p` patterns from earlier hang-dump versions continue to work. Custom per-call overrides can replace default placeholder values via a `Dictionary`. Used by the [HangDump](#hangdump) and [CrashDump](#crashdump) extensions. +A shared static helper compiled into MTP extensions via file linking (no NuGet service registration or InternalsVisibleTo required) that provides template-based naming for test artifact files (dump files, report files, etc.). Templates are strings containing `{placeholder}` tokens (case-sensitive, lowercase): `{pname}` (process name), `{pid}` (process ID), `{asm}` (entry-assembly name), `{tfm}` (target framework moniker, best-effort runtime detection), and `{time}` (high-precision UTC timestamp). Legacy `%p` patterns from earlier hang-dump versions continue to work. Custom per-call overrides can replace default placeholder values via a `Dictionary`. Used directly by the [HangDump](#hangdump) and [CrashDump](#crashdump) extensions, and indirectly by the report extensions ([HtmlReport](#htmlreport), [JUnitReport](#junitreport), and TrxReport) via the shared `ReportFileNameHelper`. The CrashDump consumer maps `{pid}` to the .NET runtime's `%p` and `{pname}` to `%e` so they expand at crash-write time (the testhost PID is not known when the environment variables are configured). ### AzureDevOpsReport diff --git a/src/Platform/Microsoft.Testing.Extensions.CrashDump/CrashDumpEnvironmentVariableProvider.cs b/src/Platform/Microsoft.Testing.Extensions.CrashDump/CrashDumpEnvironmentVariableProvider.cs index b65f597a50..d7993af2a7 100644 --- a/src/Platform/Microsoft.Testing.Extensions.CrashDump/CrashDumpEnvironmentVariableProvider.cs +++ b/src/Platform/Microsoft.Testing.Extensions.CrashDump/CrashDumpEnvironmentVariableProvider.cs @@ -8,6 +8,7 @@ using Microsoft.Testing.Platform.Extensions.TestHostControllers; using Microsoft.Testing.Platform.Helpers; using Microsoft.Testing.Platform.Logging; +using Microsoft.Testing.Platform.Services; namespace Microsoft.Testing.Extensions.Diagnostics; @@ -27,6 +28,7 @@ internal sealed class CrashDumpEnvironmentVariableProvider : ITestHostEnvironmen private readonly ICommandLineOptions _commandLineOptions; private readonly CrashDumpConfiguration _crashDumpGeneratorConfiguration; private readonly ILogger _logger; + private readonly IClock _clock; private string? _miniDumpNameValue; private string? _sequenceFileValue; @@ -35,12 +37,14 @@ public CrashDumpEnvironmentVariableProvider( IConfiguration configuration, ICommandLineOptions commandLineOptions, CrashDumpConfiguration crashDumpGeneratorConfiguration, - ILoggerFactory loggerFactory) + ILoggerFactory loggerFactory, + IClock clock) { _logger = loggerFactory.CreateLogger(); _configuration = configuration; _commandLineOptions = commandLineOptions; _crashDumpGeneratorConfiguration = crashDumpGeneratorConfiguration; + _clock = clock; } /// @@ -137,9 +141,12 @@ public Task UpdateAsync(IEnvironmentVariables environmentVariables) } string testAppName = Assembly.GetEntryAssembly()?.GetName().Name ?? throw ApplicationStateGuard.Unreachable(); - _miniDumpNameValue = _commandLineOptions.TryGetOptionArgumentList(CrashDumpCommandLineOptions.CrashDumpFileNameOptionName, out string[]? dumpFileName) - ? Path.Combine(_configuration.GetTestResultDirectory(), dumpFileName[0]) - : Path.Combine(_configuration.GetTestResultDirectory(), $"{testAppName}_%p_crash.dmp"); + string? resolvedUserPattern = _commandLineOptions.TryGetOptionArgumentList(CrashDumpCommandLineOptions.CrashDumpFileNameOptionName, out string[]? dumpFileName) + ? ResolveUserDumpFileNameTemplate(dumpFileName[0]) + : null; + _miniDumpNameValue = Path.Combine( + _configuration.GetTestResultDirectory(), + resolvedUserPattern ?? $"{testAppName}_%p_crash.dmp"); _crashDumpGeneratorConfiguration.DumpFileNamePattern = _miniDumpNameValue; foreach (string prefix in Prefixes) { @@ -157,9 +164,14 @@ public Task UpdateAsync(IEnvironmentVariables environmentVariables) // launches targeting the same results directory cannot stomp on each other's sequence // files (a write collision would otherwise be silently lost; a graceful exit of one host // would also delete the sibling host's sequence file before it could be published). + // + // We base the sequence file on the *resolved* user pattern (after substituting {asm}/{tfm}/ + // {time}) so the sequence file name stays in sync with the dump file name; only the + // runtime placeholders that we deferred to "createdump" (e.g. {pid} -> %p, {pname} -> %e) + // are stripped here. string uniqueToken = Guid.NewGuid().ToString("N").Substring(0, 8); - string sequenceFileName = dumpFileName is not null - ? $"{StripRuntimePlaceholders(dumpFileName[0])}_{uniqueToken}.sequence.log" + string sequenceFileName = resolvedUserPattern is not null + ? $"{StripRuntimePlaceholders(resolvedUserPattern)}_{uniqueToken}.sequence.log" : $"{testAppName}_{uniqueToken}_crash.sequence.log"; _sequenceFileValue = Path.Combine(_configuration.GetTestResultDirectory(), sequenceFileName); _crashDumpGeneratorConfiguration.SequenceFileName = _sequenceFileValue; @@ -190,6 +202,25 @@ private bool IsSequenceLoggingEnabled() return !CommandLineOptionArgumentValidator.IsOffValue(arguments[0]); } + private string ResolveUserDumpFileNameTemplate(string userTemplate) + { + // CrashDump's filename is consumed by the .NET runtime's "createdump" - not by our code - + // so any user-facing token whose value is only knowable at crash time (process id and + // executable name) must map to a runtime placeholder ("%p" / "%e") rather than a literal + // value resolved here. The remaining standard tokens (asm/tfm/time) are deterministic at + // configuration time and substituted straight away. + // + // Implementation note: GetStandardReplacements seeds the dictionary with pname=processName + // and pid=processId, so passing "%e" / "%p" as those arguments yields a dictionary whose + // {pname}/{pid} entries are the runtime placeholders themselves. This keeps the resolver + // generic and avoids a second post-processing pass. + Dictionary replacements = ArtifactNamingHelper.GetStandardReplacements( + processName: "%e", + processId: "%p", + _clock.UtcNow); + return ArtifactNamingHelper.ResolveTemplate(userTemplate, replacements); + } + private static string StripRuntimePlaceholders(string pattern) { // Drop the .NET runtime's "createdump" placeholders (%p, %e, %h, %t, ...) from a dump filename diff --git a/src/Platform/Microsoft.Testing.Extensions.CrashDump/CrashDumpExtensions.cs b/src/Platform/Microsoft.Testing.Extensions.CrashDump/CrashDumpExtensions.cs index c1b7dc50ec..b92e7b6f6a 100644 --- a/src/Platform/Microsoft.Testing.Extensions.CrashDump/CrashDumpExtensions.cs +++ b/src/Platform/Microsoft.Testing.Extensions.CrashDump/CrashDumpExtensions.cs @@ -34,7 +34,8 @@ public static void AddCrashDumpProvider(this ITestApplicationBuilder builder, bo serviceProvider.GetConfiguration(), serviceProvider.GetCommandLineOptions(), crashDumpGeneratorConfiguration, - serviceProvider.GetLoggerFactory())); + serviceProvider.GetLoggerFactory(), + serviceProvider.GetClock())); builder.TestHostControllers.AddProcessLifetimeHandler(serviceProvider => new CrashDumpProcessLifetimeHandler( diff --git a/src/Platform/Microsoft.Testing.Extensions.CrashDump/CrashDumpProcessLifetimeHandler.cs b/src/Platform/Microsoft.Testing.Extensions.CrashDump/CrashDumpProcessLifetimeHandler.cs index e35bf29e79..332389ba3f 100644 --- a/src/Platform/Microsoft.Testing.Extensions.CrashDump/CrashDumpProcessLifetimeHandler.cs +++ b/src/Platform/Microsoft.Testing.Extensions.CrashDump/CrashDumpProcessLifetimeHandler.cs @@ -274,7 +274,35 @@ public async Task OnTestHostProcessExitedAsync(ITestHostProcessInformation testH // 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); + + // The crash report file is written as ".crashreport.json" beside the dump. + // The dump file name pattern can contain runtime placeholders besides "%p" (e.g. "%e" when + // the user picks the {pname} token, "%h" or "%t" when configured directly). A plain + // File.Exists check on `expectedCrashReportFile` would miss those reports, so we apply the + // same testhost-dump-name regex to the prefix of each "*.crashreport.json" file in the dump + // directory: this preserves the literal-`%p`-baked PID match while expanding any remaining + // placeholders as wildcards, mirroring the dump-publication logic above. + string? matchedCrashReportFile = null; + if (generateCrashReport && Directory.Exists(dumpDirectory)) + { + foreach (string crashReportFile in Directory.EnumerateFiles(dumpDirectory, CrashReportFileSearchPattern)) + { + string crashReportFileName = Path.GetFileName(crashReportFile); + if (!crashReportFileName.EndsWith(CrashReportFileExtension, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + string dumpFileNamePart = crashReportFileName.Substring(0, crashReportFileName.Length - CrashReportFileExtension.Length); + if (testhostDumpRegex.IsMatch(dumpFileNamePart)) + { + matchedCrashReportFile = crashReportFile; + break; + } + } + } + + bool crashReportArtifactProduced = matchedCrashReportFile is not null; // Inspect the disk before emitting the crash banner so the message reflects // what was actually produced, not what was requested. The runtime may fail @@ -314,9 +342,9 @@ public async Task OnTestHostProcessExitedAsync(ITestHostProcessInformation testH if (generateCrashReport) { - if (crashReportArtifactProduced) + if (matchedCrashReportFile is not null) { - await _messageBus.PublishAsync(this, new FileArtifact(new FileInfo(expectedCrashReportFile), CrashDumpResources.CrashReportArtifactDisplayName, CrashDumpResources.CrashReportArtifactDescription)).ConfigureAwait(false); + await _messageBus.PublishAsync(this, new FileArtifact(new FileInfo(matchedCrashReportFile), CrashDumpResources.CrashReportArtifactDisplayName, CrashDumpResources.CrashReportArtifactDescription)).ConfigureAwait(false); } else { diff --git a/src/Platform/Microsoft.Testing.Extensions.CrashDump/Microsoft.Testing.Extensions.CrashDump.csproj b/src/Platform/Microsoft.Testing.Extensions.CrashDump/Microsoft.Testing.Extensions.CrashDump.csproj index 6b734a548b..61dd9ae8aa 100644 --- a/src/Platform/Microsoft.Testing.Extensions.CrashDump/Microsoft.Testing.Extensions.CrashDump.csproj +++ b/src/Platform/Microsoft.Testing.Extensions.CrashDump/Microsoft.Testing.Extensions.CrashDump.csproj @@ -12,6 +12,8 @@ + + diff --git a/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/CrashDumpResources.resx b/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/CrashDumpResources.resx index e4cc8fbef1..f889afc556 100644 --- a/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/CrashDumpResources.resx +++ b/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/CrashDumpResources.resx @@ -136,7 +136,8 @@ Crash dump - Specify the name of the dump file + Specify the name of the dump file. +Supports the following placeholders: {pname} (process executable name, deferred to the .NET runtime as "%e"), {pid} (process ID, deferred to the .NET runtime as "%p"), {asm} (entry assembly name), {tfm} (target framework moniker), {time} (timestamp when the crashdump environment is configured). The legacy %p / %e / %h / %t tokens consumed directly by the .NET runtime's createdump are also supported. Environment variable '{0}' should have been set to '{1}' but value is '{2}' diff --git a/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.cs.xlf b/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.cs.xlf index 6f14fbaf78..d548c7638d 100644 --- a/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.cs.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.cs.xlf @@ -33,8 +33,9 @@ - Specify the name of the dump file - Zadejte název souboru výpisu paměti. + Specify the name of the dump file. +Supports the following placeholders: {pname} (process executable name, deferred to the .NET runtime as "%e"), {pid} (process ID, deferred to the .NET runtime as "%p"), {asm} (entry assembly name), {tfm} (target framework moniker), {time} (timestamp when the crashdump environment is configured). The legacy %p / %e / %h / %t tokens consumed directly by the .NET runtime's createdump are also supported. + Zadejte název souboru výpisu paměti. diff --git a/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.de.xlf b/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.de.xlf index fd4892bc5a..e624e8545e 100644 --- a/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.de.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.de.xlf @@ -33,8 +33,9 @@ - Specify the name of the dump file - Namen der Speicherabbilddatei angeben + Specify the name of the dump file. +Supports the following placeholders: {pname} (process executable name, deferred to the .NET runtime as "%e"), {pid} (process ID, deferred to the .NET runtime as "%p"), {asm} (entry assembly name), {tfm} (target framework moniker), {time} (timestamp when the crashdump environment is configured). The legacy %p / %e / %h / %t tokens consumed directly by the .NET runtime's createdump are also supported. + Namen der Speicherabbilddatei angeben diff --git a/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.es.xlf b/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.es.xlf index bacad05dbf..38a284a436 100644 --- a/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.es.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.es.xlf @@ -33,8 +33,9 @@ - Specify the name of the dump file - Especificar el nombre del archivo de volcado + Specify the name of the dump file. +Supports the following placeholders: {pname} (process executable name, deferred to the .NET runtime as "%e"), {pid} (process ID, deferred to the .NET runtime as "%p"), {asm} (entry assembly name), {tfm} (target framework moniker), {time} (timestamp when the crashdump environment is configured). The legacy %p / %e / %h / %t tokens consumed directly by the .NET runtime's createdump are also supported. + Especificar el nombre del archivo de volcado diff --git a/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.fr.xlf b/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.fr.xlf index f1fcde6c74..9e733cb717 100644 --- a/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.fr.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.fr.xlf @@ -33,8 +33,9 @@ - Specify the name of the dump file - Spécifier le nom du fichier de vidage + Specify the name of the dump file. +Supports the following placeholders: {pname} (process executable name, deferred to the .NET runtime as "%e"), {pid} (process ID, deferred to the .NET runtime as "%p"), {asm} (entry assembly name), {tfm} (target framework moniker), {time} (timestamp when the crashdump environment is configured). The legacy %p / %e / %h / %t tokens consumed directly by the .NET runtime's createdump are also supported. + Spécifier le nom du fichier de vidage diff --git a/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.it.xlf b/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.it.xlf index 27505d8d0f..124de436aa 100644 --- a/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.it.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.it.xlf @@ -33,8 +33,9 @@ - Specify the name of the dump file - Specificare il nome del file di dump + Specify the name of the dump file. +Supports the following placeholders: {pname} (process executable name, deferred to the .NET runtime as "%e"), {pid} (process ID, deferred to the .NET runtime as "%p"), {asm} (entry assembly name), {tfm} (target framework moniker), {time} (timestamp when the crashdump environment is configured). The legacy %p / %e / %h / %t tokens consumed directly by the .NET runtime's createdump are also supported. + Specificare il nome del file di dump diff --git a/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.ja.xlf b/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.ja.xlf index 47cdeedbc3..3f4bfeff6d 100644 --- a/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.ja.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.ja.xlf @@ -33,8 +33,9 @@ - Specify the name of the dump file - ダンプ ファイルの名前を指定する + Specify the name of the dump file. +Supports the following placeholders: {pname} (process executable name, deferred to the .NET runtime as "%e"), {pid} (process ID, deferred to the .NET runtime as "%p"), {asm} (entry assembly name), {tfm} (target framework moniker), {time} (timestamp when the crashdump environment is configured). The legacy %p / %e / %h / %t tokens consumed directly by the .NET runtime's createdump are also supported. + ダンプ ファイルの名前を指定する diff --git a/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.ko.xlf b/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.ko.xlf index 512a4dd712..07441bb264 100644 --- a/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.ko.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.ko.xlf @@ -33,8 +33,9 @@ - Specify the name of the dump file - 덤프 파일의 이름 지정 + Specify the name of the dump file. +Supports the following placeholders: {pname} (process executable name, deferred to the .NET runtime as "%e"), {pid} (process ID, deferred to the .NET runtime as "%p"), {asm} (entry assembly name), {tfm} (target framework moniker), {time} (timestamp when the crashdump environment is configured). The legacy %p / %e / %h / %t tokens consumed directly by the .NET runtime's createdump are also supported. + 덤프 파일의 이름 지정 diff --git a/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.pl.xlf b/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.pl.xlf index 100d24b8b3..8c5a04160d 100644 --- a/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.pl.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.pl.xlf @@ -33,8 +33,9 @@ - Specify the name of the dump file - Określ nazwę pliku zrzutu + Specify the name of the dump file. +Supports the following placeholders: {pname} (process executable name, deferred to the .NET runtime as "%e"), {pid} (process ID, deferred to the .NET runtime as "%p"), {asm} (entry assembly name), {tfm} (target framework moniker), {time} (timestamp when the crashdump environment is configured). The legacy %p / %e / %h / %t tokens consumed directly by the .NET runtime's createdump are also supported. + Określ nazwę pliku zrzutu diff --git a/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.pt-BR.xlf b/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.pt-BR.xlf index 31ca8e1585..ea99487068 100644 --- a/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.pt-BR.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.pt-BR.xlf @@ -33,8 +33,9 @@ - Specify the name of the dump file - Especifique o nome do arquivo de despejo + Specify the name of the dump file. +Supports the following placeholders: {pname} (process executable name, deferred to the .NET runtime as "%e"), {pid} (process ID, deferred to the .NET runtime as "%p"), {asm} (entry assembly name), {tfm} (target framework moniker), {time} (timestamp when the crashdump environment is configured). The legacy %p / %e / %h / %t tokens consumed directly by the .NET runtime's createdump are also supported. + Especifique o nome do arquivo de despejo diff --git a/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.ru.xlf b/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.ru.xlf index 60efbeb7f0..8bd64191de 100644 --- a/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.ru.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.ru.xlf @@ -33,8 +33,9 @@ - Specify the name of the dump file - Укажите имя файла дампа зависания + Specify the name of the dump file. +Supports the following placeholders: {pname} (process executable name, deferred to the .NET runtime as "%e"), {pid} (process ID, deferred to the .NET runtime as "%p"), {asm} (entry assembly name), {tfm} (target framework moniker), {time} (timestamp when the crashdump environment is configured). The legacy %p / %e / %h / %t tokens consumed directly by the .NET runtime's createdump are also supported. + Укажите имя файла дампа зависания diff --git a/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.tr.xlf b/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.tr.xlf index 8c200f832e..29555b61e1 100644 --- a/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.tr.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.tr.xlf @@ -33,8 +33,9 @@ - Specify the name of the dump file - Döküm dosyasının adını belirtin + Specify the name of the dump file. +Supports the following placeholders: {pname} (process executable name, deferred to the .NET runtime as "%e"), {pid} (process ID, deferred to the .NET runtime as "%p"), {asm} (entry assembly name), {tfm} (target framework moniker), {time} (timestamp when the crashdump environment is configured). The legacy %p / %e / %h / %t tokens consumed directly by the .NET runtime's createdump are also supported. + Döküm dosyasının adını belirtin diff --git a/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.zh-Hans.xlf b/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.zh-Hans.xlf index 00704d47d5..e7f3f7575c 100644 --- a/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.zh-Hans.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.zh-Hans.xlf @@ -33,8 +33,9 @@ - Specify the name of the dump file - 指定转储文件的名称 + Specify the name of the dump file. +Supports the following placeholders: {pname} (process executable name, deferred to the .NET runtime as "%e"), {pid} (process ID, deferred to the .NET runtime as "%p"), {asm} (entry assembly name), {tfm} (target framework moniker), {time} (timestamp when the crashdump environment is configured). The legacy %p / %e / %h / %t tokens consumed directly by the .NET runtime's createdump are also supported. + 指定转储文件的名称 diff --git a/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.zh-Hant.xlf b/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.zh-Hant.xlf index fd5ea0674c..d61d56c25c 100644 --- a/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.zh-Hant.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.zh-Hant.xlf @@ -33,8 +33,9 @@ - Specify the name of the dump file - 指定傾印檔案的名稱 + Specify the name of the dump file. +Supports the following placeholders: {pname} (process executable name, deferred to the .NET runtime as "%e"), {pid} (process ID, deferred to the .NET runtime as "%p"), {asm} (entry assembly name), {tfm} (target framework moniker), {time} (timestamp when the crashdump environment is configured). The legacy %p / %e / %h / %t tokens consumed directly by the .NET runtime's createdump are also supported. + 指定傾印檔案的名稱 diff --git a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/HelpInfoAllExtensionsTests.cs b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/HelpInfoAllExtensionsTests.cs index d0b71aabf1..ed4f1aeec5 100644 --- a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/HelpInfoAllExtensionsTests.cs +++ b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/HelpInfoAllExtensionsTests.cs @@ -92,7 +92,8 @@ The file makes it possible to identify the tests that were running at the time o --crashdump [net6.0+ only] Generate a dump file if the test process crashes --crashdump-filename - Specify the name of the dump file + Specify the name of the dump file. + Supports the following placeholders: {pname} (process executable name, deferred to the .NET runtime as "%e"), {pid} (process ID, deferred to the .NET runtime as "%p"), {asm} (entry assembly name), {tfm} (target framework moniker), {time} (timestamp when the crashdump environment is configured). The legacy %p / %e / %h / %t tokens consumed directly by the .NET runtime's createdump are also supported. --crashdump-type Specify the type of the dump. Valid values are 'Mini', 'Heap', 'Triage' or 'Full'. Default type is 'Full'. @@ -448,7 +449,8 @@ The file makes it possible to identify the tests that were running at the time o --crashdump-filename Arity: 1 Hidden: False - Description: Specify the name of the dump file + Description: Specify the name of the dump file. + Supports the following placeholders: {pname} (process executable name, deferred to the .NET runtime as "%e"), {pid} (process ID, deferred to the .NET runtime as "%p"), {asm} (entry assembly name), {tfm} (target framework moniker), {time} (timestamp when the crashdump environment is configured). The legacy %p / %e / %h / %t tokens consumed directly by the .NET runtime's createdump are also supported. --crashdump-type Arity: 1 Hidden: False diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/CrashDumpTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/CrashDumpTests.cs index 3b06be9cf5..2f3ec3be8c 100644 --- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/CrashDumpTests.cs +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/CrashDumpTests.cs @@ -619,6 +619,67 @@ public async Task OnTestHostProcessExitedAsync_TesthostAndChildBothCrashWithMult } } + [TestMethod] + public async Task OnTestHostProcessExitedAsync_CrashReport_PatternWithRuntimePlaceholders_PublishesMatchedReport() + { + // When the configured dump pattern relies on runtime placeholders other than `%p` + // (here `%e`, which the `{pname}` token maps to), the literal-`%p`-substituted crash + // report path never exists on disk. The handler must therefore identify the crash + // report via the testhost-dump regex applied to the report's "" + // prefix, and publish the actual file found on disk (not the unresolved 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, [] }, + { CrashDumpCommandLineOptions.CrashReportOptionName, [] }, + }); + 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); + + // Simulate the runtime writing the testhost dump and its companion crash report with + // `%e` expanded to "testhost". A naive File.Exists check on the literal-%p-substituted + // crash report path (`Dump_%e_123.dmp.crashreport.json`) would miss the actual file. + string testhostDump = Path.Combine(dumpDirectory, "Dump_testhost_123.dmp"); + string testhostCrashReport = testhostDump + ".crashreport.json"; + File.WriteAllText(testhostDump, "fresh"); + File.WriteAllText(testhostCrashReport, "{}"); + + await handler.OnTestHostProcessExitedAsync(new TestHostProcessInformation(pid: 123, exitCode: 1, hasExitedGracefully: false), CancellationToken.None); + + string[] publishedFiles = messageBus.Published + .OfType() + .Select(static a => a.FileInfo.FullName) + .OrderBy(static p => p, StringComparer.Ordinal) + .ToArray(); + string[] expected = new[] { testhostDump, testhostCrashReport }.OrderBy(static p => p, StringComparer.Ordinal).ToArray(); + Assert.AreSequenceEqual(expected, publishedFiles); + + // The "expected crash report not found" warning must NOT be emitted: the report was + // matched by the regex even though `expectedCrashReportFile` (literal `%p` substitution) + // would be `Dump_%e_123.dmp.crashreport.json`, which does not exist on disk. + string captured = string.Join(" | ", outputDevice.Displayed); + Assert.DoesNotContain("Dump_%e_123.dmp.crashreport.json", captured, "CannotFindExpectedCrashReportFile must not be displayed when the report 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; } = []; From ee04d8af0af89fdca6caa09ae5e651bb269f4247 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Thu, 11 Jun 2026 10:11:47 +0200 Subject: [PATCH 2/2] Address crashdump artifact naming review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/glossary.md | 2 +- .../CrashDumpProcessLifetimeHandler.cs | 23 +++++--- .../CrashDumpTests.cs | 54 ++++++++++++++++++- 3 files changed, 70 insertions(+), 9 deletions(-) diff --git a/docs/glossary.md b/docs/glossary.md index 877f149ccb..0c9946e90a 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -14,7 +14,7 @@ An MTP struct (`ArgumentArity.cs`) that defines the minimum and maximum number o ### ArtifactNamingHelper -A shared static helper compiled into MTP extensions via file linking (no NuGet service registration or InternalsVisibleTo required) that provides template-based naming for test artifact files (dump files, report files, etc.). Templates are strings containing `{placeholder}` tokens (case-sensitive, lowercase): `{pname}` (process name), `{pid}` (process ID), `{asm}` (entry-assembly name), `{tfm}` (target framework moniker, best-effort runtime detection), and `{time}` (high-precision UTC timestamp). Legacy `%p` patterns from earlier hang-dump versions continue to work. Custom per-call overrides can replace default placeholder values via a `Dictionary`. Used directly by the [HangDump](#hangdump) and [CrashDump](#crashdump) extensions, and indirectly by the report extensions ([HtmlReport](#htmlreport), [JUnitReport](#junitreport), and TrxReport) via the shared `ReportFileNameHelper`. The CrashDump consumer maps `{pid}` to the .NET runtime's `%p` and `{pname}` to `%e` so they expand at crash-write time (the testhost PID is not known when the environment variables are configured). +A shared static helper compiled into MTP extensions via file linking (no NuGet service registration or InternalsVisibleTo required) that provides template-based naming for test artifact files (dump files, report files, etc.). Templates are strings containing `{placeholder}` tokens (case-sensitive, lowercase): `{pname}` (process name), `{pid}` (process ID), `{asm}` (entry-assembly name), `{tfm}` (target framework moniker, best-effort runtime detection), and `{time}` (high-precision UTC timestamp). Custom per-call overrides can replace default placeholder values via a `Dictionary`. Used directly by the [HangDump](#hangdump) and [CrashDump](#crashdump) extensions, and indirectly by the report extensions ([HtmlReport](#htmlreport), [JUnitReport](#junitreport), and TrxReport) via the shared `ReportFileNameHelper`. The CrashDump consumer maps `{pid}` to the .NET runtime's `%p` and `{pname}` to `%e` so they expand at crash-write time (the testhost PID is not known when the environment variables are configured). HangDump preserves its legacy `%p` compatibility in its own option handling. ### AzureDevOpsReport diff --git a/src/Platform/Microsoft.Testing.Extensions.CrashDump/CrashDumpProcessLifetimeHandler.cs b/src/Platform/Microsoft.Testing.Extensions.CrashDump/CrashDumpProcessLifetimeHandler.cs index 332389ba3f..4003fd04ac 100644 --- a/src/Platform/Microsoft.Testing.Extensions.CrashDump/CrashDumpProcessLifetimeHandler.cs +++ b/src/Platform/Microsoft.Testing.Extensions.CrashDump/CrashDumpProcessLifetimeHandler.cs @@ -282,7 +282,8 @@ public async Task OnTestHostProcessExitedAsync(ITestHostProcessInformation testH // same testhost-dump-name regex to the prefix of each "*.crashreport.json" file in the dump // directory: this preserves the literal-`%p`-baked PID match while expanding any remaining // placeholders as wildcards, mirroring the dump-publication logic above. - string? matchedCrashReportFile = null; + List? crashReportFiles = null; + bool matchedCrashReportFile = false; if (generateCrashReport && Directory.Exists(dumpDirectory)) { foreach (string crashReportFile in Directory.EnumerateFiles(dumpDirectory, CrashReportFileSearchPattern)) @@ -293,16 +294,19 @@ public async Task OnTestHostProcessExitedAsync(ITestHostProcessInformation testH continue; } + crashReportFiles ??= []; + crashReportFiles.Add(crashReportFile); + string dumpFileNamePart = crashReportFileName.Substring(0, crashReportFileName.Length - CrashReportFileExtension.Length); if (testhostDumpRegex.IsMatch(dumpFileNamePart)) { - matchedCrashReportFile = crashReportFile; - break; + matchedCrashReportFile = true; } } } - bool crashReportArtifactProduced = matchedCrashReportFile is not null; + bool expectedCrashReportFileExists = File.Exists(expectedCrashReportFile); + bool crashReportArtifactProduced = expectedCrashReportFileExists || matchedCrashReportFile; // Inspect the disk before emitting the crash banner so the message reflects // what was actually produced, not what was requested. The runtime may fail @@ -342,9 +346,16 @@ public async Task OnTestHostProcessExitedAsync(ITestHostProcessInformation testH if (generateCrashReport) { - if (matchedCrashReportFile is not null) + if (expectedCrashReportFileExists) + { + await _messageBus.PublishAsync(this, new FileArtifact(new FileInfo(expectedCrashReportFile), CrashDumpResources.CrashReportArtifactDisplayName, CrashDumpResources.CrashReportArtifactDescription)).ConfigureAwait(false); + } + else if (matchedCrashReportFile) { - await _messageBus.PublishAsync(this, new FileArtifact(new FileInfo(matchedCrashReportFile), CrashDumpResources.CrashReportArtifactDisplayName, CrashDumpResources.CrashReportArtifactDescription)).ConfigureAwait(false); + foreach (string crashReportFile in crashReportFiles!) + { + await _messageBus.PublishAsync(this, new FileArtifact(new FileInfo(crashReportFile), CrashDumpResources.CrashReportArtifactDisplayName, CrashDumpResources.CrashReportArtifactDescription)).ConfigureAwait(false); + } } else { diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/CrashDumpTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/CrashDumpTests.cs index 2f3ec3be8c..a445e9db9e 100644 --- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/CrashDumpTests.cs +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/CrashDumpTests.cs @@ -4,13 +4,18 @@ using Microsoft.Testing.Extensions.Diagnostics; using Microsoft.Testing.Extensions.Diagnostics.Resources; using Microsoft.Testing.Extensions.UnitTests.Helpers; +using Microsoft.Testing.Platform.Configurations; 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.Helpers; +using Microsoft.Testing.Platform.Logging; using Microsoft.Testing.Platform.Messages; using Microsoft.Testing.Platform.OutputDevice; +using Moq; + namespace Microsoft.Testing.Extensions.UnitTests; [TestClass] @@ -406,6 +411,47 @@ public void GetDumpDirectory_WhenPatternHasDirectoryComponent_ReturnsDirectory() Assert.AreEqual(directory, actual); } + [TestMethod] + [DataRow("{pname}_{pid}_crash.dmp", "%e_%p_crash.dmp")] + [DataRow("{asm}_{pid}.dmp", null)] + [DataRow("literal.dmp", "literal.dmp")] + public async Task UpdateAsync_UserFilenameTemplate_SetsCorrectDumpFileNamePattern(string userTemplate, string? expectedSuffix) + { + var commandLineOptions = new TestCommandLineOptions(new Dictionary + { + { CrashDumpCommandLineOptions.CrashDumpOptionName, [] }, + { CrashDumpCommandLineOptions.CrashDumpFileNameOptionName, [userTemplate] }, + }); + string resultsDirectory = Path.Combine(Path.GetTempPath(), "crashdump-tests-" + Guid.NewGuid().ToString("N")); + var configuration = new Mock(); + configuration.Setup(x => x[PlatformConfigurationConstants.PlatformResultDirectory]).Returns(resultsDirectory); + var crashDumpConfiguration = new CrashDumpConfiguration(); + var loggerFactory = new Mock(); + loggerFactory.Setup(x => x.CreateLogger(It.IsAny())).Returns(Mock.Of()); + var clock = new Mock(); + clock.Setup(x => x.UtcNow).Returns(new DateTimeOffset(2026, 6, 11, 7, 0, 0, TimeSpan.Zero)); + var environmentVariables = new Mock(); + var provider = new CrashDumpEnvironmentVariableProvider( + configuration.Object, + commandLineOptions, + crashDumpConfiguration, + loggerFactory.Object, + clock.Object); + + await provider.UpdateAsync(environmentVariables.Object); + + Assert.IsNotNull(crashDumpConfiguration.DumpFileNamePattern); + if (expectedSuffix is not null) + { + Assert.IsTrue(crashDumpConfiguration.DumpFileNamePattern.EndsWith(expectedSuffix, StringComparison.Ordinal), $"Expected pattern ending '{expectedSuffix}', got '{crashDumpConfiguration.DumpFileNamePattern}'."); + } + else + { + Assert.DoesNotContain("{", crashDumpConfiguration.DumpFileNamePattern, "Unresolved placeholder left in pattern."); + Assert.IsTrue(crashDumpConfiguration.DumpFileNamePattern.EndsWith("_%p.dmp", StringComparison.Ordinal), $"Expected pattern ending '_%p.dmp', got '{crashDumpConfiguration.DumpFileNamePattern}'."); + } + } + [TestMethod] public async Task OnTestHostProcessExitedAsync_OnlyPublishesDumpsThatAppearedDuringTheRun() { @@ -648,8 +694,12 @@ public async Task OnTestHostProcessExitedAsync_CrashReport_PatternWithRuntimePla // crash report path (`Dump_%e_123.dmp.crashreport.json`) would miss the actual file. string testhostDump = Path.Combine(dumpDirectory, "Dump_testhost_123.dmp"); string testhostCrashReport = testhostDump + ".crashreport.json"; + string childDump = Path.Combine(dumpDirectory, "Dump_child_456.dmp"); + string childCrashReport = childDump + ".crashreport.json"; File.WriteAllText(testhostDump, "fresh"); File.WriteAllText(testhostCrashReport, "{}"); + File.WriteAllText(childDump, "fresh"); + File.WriteAllText(childCrashReport, "{}"); await handler.OnTestHostProcessExitedAsync(new TestHostProcessInformation(pid: 123, exitCode: 1, hasExitedGracefully: false), CancellationToken.None); @@ -658,7 +708,7 @@ public async Task OnTestHostProcessExitedAsync_CrashReport_PatternWithRuntimePla .Select(static a => a.FileInfo.FullName) .OrderBy(static p => p, StringComparer.Ordinal) .ToArray(); - string[] expected = new[] { testhostDump, testhostCrashReport }.OrderBy(static p => p, StringComparer.Ordinal).ToArray(); + string[] expected = new[] { testhostDump, testhostCrashReport, childDump, childCrashReport }.OrderBy(static p => p, StringComparer.Ordinal).ToArray(); Assert.AreSequenceEqual(expected, publishedFiles); // The "expected crash report not found" warning must NOT be emitted: the report was @@ -673,7 +723,7 @@ public async Task OnTestHostProcessExitedAsync_CrashReport_PatternWithRuntimePla { Directory.Delete(dumpDirectory, recursive: true); } - catch + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { // Best effort cleanup. }