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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,5 @@ internal static class CrashDumpCommandLineOptions
public const string CrashReportOptionName = "crash-report";
public const string CrashDumpFileNameOptionName = "crashdump-filename";
public const string CrashDumpTypeOptionName = "crashdump-type";
public const string CrashSequenceOptionName = "crash-sequence";
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ internal sealed class CrashDumpCommandLineProvider : ICommandLineOptionsProvider
[
new(CrashDumpCommandLineOptions.CrashDumpOptionName, CrashDumpResources.CrashDumpOptionDescription, ArgumentArity.Zero, false),
new(CrashDumpCommandLineOptions.CrashReportOptionName, CrashDumpResources.CrashReportOptionDescription, ArgumentArity.Zero, false),
new(CrashDumpCommandLineOptions.CrashSequenceOptionName, CrashDumpResources.CrashSequenceOptionDescription, ArgumentArity.ExactlyOne, false),
new(CrashDumpCommandLineOptions.CrashDumpFileNameOptionName, CrashDumpResources.CrashDumpFileNameOptionDescription, ArgumentArity.ExactlyOne, false),
new(CrashDumpCommandLineOptions.CrashDumpTypeOptionName, CrashDumpResources.CrashDumpTypeOptionDescription, ArgumentArity.ExactlyOne, false)
];
Expand All @@ -40,6 +41,13 @@ public Task<ValidationResult> ValidateOptionArgumentsAsync(CommandLineOption com
return ValidationResult.InvalidTask(string.Format(CultureInfo.InvariantCulture, CrashDumpResources.CrashDumpTypeOptionInvalidType, arguments[0]));
}
}
else if (commandOption.Name == CrashDumpCommandLineOptions.CrashSequenceOptionName)
{
if (!CommandLineOptionArgumentValidator.IsValidBooleanArgument(arguments[0]))
{
return ValidationResult.InvalidTask(CrashDumpResources.CrashSequenceOptionInvalidArgument);
}
}
Comment on lines +44 to +50

// TODO: Validate that the file name ends with '.dmp'?
return ValidationResult.ValidTask;
Expand All @@ -59,7 +67,8 @@ private static bool IsCrashReportUnsupportedOnCurrentPlatform(ICommandLineOption
private static bool IsCrashDumpMainOptionMissing(ICommandLineOptions commandLineOptions)
{
bool hasCrashDumpSubOption = commandLineOptions.IsOptionSet(CrashDumpCommandLineOptions.CrashDumpFileNameOptionName) ||
commandLineOptions.IsOptionSet(CrashDumpCommandLineOptions.CrashDumpTypeOptionName);
commandLineOptions.IsOptionSet(CrashDumpCommandLineOptions.CrashDumpTypeOptionName) ||
commandLineOptions.IsOptionSet(CrashDumpCommandLineOptions.CrashSequenceOptionName);
bool hasCrashDumpMainOption = commandLineOptions.IsOptionSet(CrashDumpCommandLineOptions.CrashDumpOptionName) ||
commandLineOptions.IsOptionSet(CrashDumpCommandLineOptions.CrashReportOptionName);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,7 @@ internal sealed class CrashDumpConfiguration
{
public string? DumpFileNamePattern { get; set; }

public string? SequenceFileName { get; set; }

public bool Enable { get; set; } = true;
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ namespace Microsoft.Testing.Extensions.Diagnostics;

internal sealed class CrashDumpEnvironmentVariableProvider : ITestHostEnvironmentVariableProvider
{
public const string SequenceFileEnvironmentVariableName = "TESTINGPLATFORM_CRASHDUMP_SEQUENCE_FILE";

private const string EnableMiniDumpVariable = "DbgEnableMiniDump";
private const string MiniDumpTypeVariable = "DbgMiniDumpType";
private const string MiniDumpNameVariable = "DbgMiniDumpName";
Expand All @@ -27,6 +29,7 @@ internal sealed class CrashDumpEnvironmentVariableProvider : ITestHostEnvironmen
private readonly ILogger<CrashDumpEnvironmentVariableProvider> _logger;

private string? _miniDumpNameValue;
private string? _sequenceFileValue;

public CrashDumpEnvironmentVariableProvider(
IConfiguration configuration,
Expand Down Expand Up @@ -135,15 +138,79 @@ public Task UpdateAsync(IEnvironmentVariables environmentVariables)
environmentVariables.SetVariable(new($"{prefix}{MiniDumpNameVariable}", _miniDumpNameValue, false, true));
}

if (IsSequenceLoggingEnabled())
{
// The sequence file is written directly by the testhost (not by the .NET runtime), so it
// does not need any of the runtime "createdump" placeholders (%p, %e, %h, %t). We compose
// a deterministic path next to the (eventual) dump file so the testhost and the controller
// agree on the exact path to write to / read from without any per-side expansion.
//
// The path includes a per-controller-instance unique token so that parallel testhost
// 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).
string uniqueToken = Guid.NewGuid().ToString("N").Substring(0, 8);
string sequenceFileName = dumpFileName is not null
? $"{StripRuntimePlaceholders(dumpFileName[0])}_{uniqueToken}.sequence.log"
: $"{testAppName}_{uniqueToken}_crash.sequence.log";
_sequenceFileValue = Path.Combine(_configuration.GetTestResultDirectory(), sequenceFileName);
_crashDumpGeneratorConfiguration.SequenceFileName = _sequenceFileValue;
environmentVariables.SetVariable(new(SequenceFileEnvironmentVariableName, _sequenceFileValue, isSecret: false, isLocked: true));
}

if (_logger.IsEnabled(LogLevel.Trace))
{
_logger.LogTrace($"{MiniDumpNameVariable}: {_miniDumpNameValue}");
_logger.LogTrace($"{MiniDumpTypeVariable}: {miniDumpTypeValue}");
if (_sequenceFileValue is not null)
{
_logger.LogTrace($"{SequenceFileEnvironmentVariableName}: {_sequenceFileValue}");
}
}

return Task.CompletedTask;
}

private bool IsSequenceLoggingEnabled()
{
if (!_commandLineOptions.TryGetOptionArgumentList(CrashDumpCommandLineOptions.CrashSequenceOptionName, out string[]? arguments))
{
// Default: on whenever --crashdump or --crash-report is set (IsEnabledAsync gates this method).
return true;
}

return !CommandLineOptionArgumentValidator.IsOffValue(arguments[0]);
}

private static string StripRuntimePlaceholders(string pattern)
{
// Drop the .NET runtime's "createdump" placeholders (%p, %e, %h, %t, ...) from a dump filename
// pattern so we obtain a concrete, deterministic file path that the testhost extension and the
// controller can agree on without any per-side expansion. "%%" remains a literal "%" per the
// runtime's escaping convention.
var sb = new StringBuilder(pattern.Length);
for (int i = 0; i < pattern.Length; i++)
{
if (pattern[i] == '%' && i + 1 < pattern.Length)
{
if (pattern[i + 1] == '%')
{
sb.Append('%');
i++;
continue;
}

// Drop the single-character placeholder (e.g. %p, %e, %h, %t).
i++;
continue;
}

sb.Append(pattern[i]);
}

return sb.ToString();
}

public Task<ValidationResult> ValidateTestHostEnvironmentVariablesAsync(IReadOnlyEnvironmentVariables environmentVariables)
{
#if !NETCOREAPP
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

using Microsoft.Testing.Extensions.Diagnostics;
using Microsoft.Testing.Platform.Builder;
using Microsoft.Testing.Platform.Extensions;
using Microsoft.Testing.Platform.Services;

namespace Microsoft.Testing.Extensions;
Expand Down Expand Up @@ -43,5 +44,18 @@ public static void AddCrashDumpProvider(this ITestApplicationBuilder builder, bo
crashDumpGeneratorConfiguration));

builder.CommandLine.AddProvider(() => new CrashDumpCommandLineProvider());

// Testhost-side extension that journals test state transitions to a sequence file so the
// controller can list "tests still running at the time of the crash" without needing a dump.
// The same instance plays both an IDataConsumer and an ITestSessionLifetimeHandler role, so
// we register a composite factory like HangDumpExtensions does for HangDumpActivityIndicator.
var sequenceLoggerComposite = new CompositeExtensionFactory<CrashDumpSequenceLogger>(serviceProvider
=> new CrashDumpSequenceLogger(
serviceProvider.GetEnvironment(),
serviceProvider.GetClock(),
serviceProvider.GetLoggerFactory()));

builder.TestHost.AddDataConsumer(sequenceLoggerComposite);
builder.TestHost.AddTestSessionLifetimeHandler(sequenceLoggerComposite);
}
}
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 @@ -120,6 +121,11 @@ public async Task OnTestHostProcessExitedAsync(ITestHostProcessInformation testH
if (testHostProcessInformation.HasExitedGracefully
|| (AppDomain.CurrentDomain.GetData("ProcessKilledByHangDump") is string processKilledByHangDump && processKilledByHangDump == "true"))
{
// No crash → the sequence file (if any) has no diagnostic value. Delete it so the user's
// results directory is not polluted with stale "tests still running" logs after a clean
// run. Hang-dump kills are handled identically because HangDump already produces its own
// .log of in-progress tests via IPC.
TryDeleteSequenceFile();
return;
}

Expand Down Expand Up @@ -265,6 +271,117 @@ public async Task OnTestHostProcessExitedAsync(ITestHostProcessInformation testH
}
}
}

await TryPublishSequenceFileAsync(cancellationToken).ConfigureAwait(false);
}

private async Task TryPublishSequenceFileAsync(CancellationToken cancellationToken)
{
string? sequenceFilePath = _netCoreCrashDumpGeneratorConfiguration.SequenceFileName;
if (RoslynString.IsNullOrEmpty(sequenceFilePath) || !File.Exists(sequenceFilePath))
{
return;
}

// Parse the journal to compute the set of tests that started but never ended. We can tolerate
// a partially-written final line because the testhost flushes after each whole record; any
// half-written tail line would simply fail to parse and be ignored.
var inFlight = new Dictionary<string, (string DisplayName, DateTimeOffset StartedAt)>(StringComparer.Ordinal);
DateTimeOffset latestSeen = DateTimeOffset.MinValue;
try
{
foreach (string line in File.ReadLines(sequenceFilePath))
{
if (line.Length == 0 || line[0] == '#')
{
continue;
}

// Format: <event>\t<isoTimestamp>\t<uid>\t<displayName-or-state>
string[] parts = line.Split('\t');
if (parts.Length < 4)
{
continue;
}

if (!DateTimeOffset.TryParse(parts[1], CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out DateTimeOffset timestamp))
{
continue;
}

if (timestamp > latestSeen)
{
latestSeen = timestamp;
}

string uid = parts[2];
// Any field originally containing tabs was sanitized to spaces by the testhost, so
// a 4-element split is sufficient. Defensively re-join any extras to tolerate future
// schema changes.
string lastField = parts.Length == 4 ? parts[3] : string.Join("\t", parts, 3, parts.Length - 3);

if (parts[0].Equals(CrashDumpSequenceLogger.StartedEvent, StringComparison.Ordinal))
{
inFlight[uid] = (lastField, timestamp);
}
else if (parts[0].Equals(CrashDumpSequenceLogger.EndedEvent, StringComparison.Ordinal))
{
inFlight.Remove(uid);
}
}
}
catch (IOException ex)
{
// Best-effort diagnostic. If we cannot read the sequence file we still publish it so the
// user can inspect it manually, but skip the friendly summary.
await _outputDisplay.DisplayAsync(this, new ErrorMessageOutputDeviceData(string.Format(CultureInfo.InvariantCulture, CrashDumpResources.CrashDumpSequenceFileReadError, sequenceFilePath, ex.Message)), cancellationToken).ConfigureAwait(false);
await _messageBus.PublishAsync(this, new FileArtifact(new FileInfo(sequenceFilePath), CrashDumpResources.CrashDumpSequenceArtifactDisplayName, CrashDumpResources.CrashDumpSequenceArtifactDescription)).ConfigureAwait(false);
return;
Comment on lines +333 to +339
}

if (inFlight.Count > 0)
{
await _outputDisplay.DisplayAsync(this, new ErrorMessageOutputDeviceData(CrashDumpResources.CrashDumpTestsRunningAtCrash), cancellationToken).ConfigureAwait(false);
DateTimeOffset anchor = latestSeen == DateTimeOffset.MinValue ? DateTimeOffset.UtcNow : latestSeen;
foreach (KeyValuePair<string, (string DisplayName, DateTimeOffset StartedAt)> entry in inFlight.OrderBy(static x => x.Value.StartedAt))
{
TimeSpan elapsed = anchor - entry.Value.StartedAt;
if (elapsed < TimeSpan.Zero)
{
elapsed = TimeSpan.Zero;
}

await _outputDisplay.DisplayAsync(this, new ErrorMessageOutputDeviceData($"[{elapsed}] {entry.Value.DisplayName}"), cancellationToken).ConfigureAwait(false);
}
}

await _messageBus.PublishAsync(this, new FileArtifact(new FileInfo(sequenceFilePath), CrashDumpResources.CrashDumpSequenceArtifactDisplayName, CrashDumpResources.CrashDumpSequenceArtifactDescription)).ConfigureAwait(false);
}

private void TryDeleteSequenceFile()
{
string? sequenceFilePath = _netCoreCrashDumpGeneratorConfiguration.SequenceFileName;
if (RoslynString.IsNullOrEmpty(sequenceFilePath))
{
return;
}

try
{
if (File.Exists(sequenceFilePath))
{
File.Delete(sequenceFilePath);
}
}
catch (IOException)
{
// Best-effort cleanup; a leftover sequence file is harmless beyond cluttering the results
// directory. Avoid surfacing this as a user-visible error because the run itself succeeded.
}
catch (UnauthorizedAccessException)
{
// Same rationale as IOException.
}
}

internal static string GetDumpDirectory(string dumpFileNamePattern)
Expand Down
Loading
Loading