diff --git a/src/Adapter/MSTest.TestAdapter/InternalAPI/InternalAPI.Unshipped.txt b/src/Adapter/MSTest.TestAdapter/InternalAPI/InternalAPI.Unshipped.txt index b0696895a5..1dcf65510a 100644 --- a/src/Adapter/MSTest.TestAdapter/InternalAPI/InternalAPI.Unshipped.txt +++ b/src/Adapter/MSTest.TestAdapter/InternalAPI/InternalAPI.Unshipped.txt @@ -1,4 +1,9 @@ #nullable enable +Microsoft.VisualStudio.TestTools.UnitTesting.MSTestGracefulStopTestExecutionCapability.TryStopTestExecutionAsync(System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +Microsoft.VisualStudio.TestTools.UnitTesting.MSTestGracefulStopTestExecutionCapability.NotifyTestExecutionCompleted() -> void +Microsoft.VisualStudio.TestTools.UnitTesting.MSTestGracefulStopTestExecutionCapability.NotifyTestExecutionPending() -> void +Microsoft.VisualStudio.TestTools.UnitTesting.MSTestGracefulStopTestExecutionCapability.NotifyTestExecutionStarting() -> void +static Microsoft.VisualStudio.TestTools.UnitTesting.MSTestGracefulStopTestExecutionCapability.Create() -> Microsoft.VisualStudio.TestTools.UnitTesting.MSTestGracefulStopTestExecutionCapability! abstract Microsoft.Testing.Extensions.RunSettingsCommandLineOptionsProviderBase.EnvironmentVariablesNotSupportedOnBrowserError.get -> string! abstract Microsoft.Testing.Extensions.RunSettingsConfigurationProviderBase.DisplayName.get -> string! abstract Microsoft.Testing.Extensions.RunSettingsConfigurationProviderBase.ReadRunSettings(Microsoft.Testing.Platform.CommandLine.CommandLineParseResult! commandLineParseResult) -> string? diff --git a/src/Adapter/MSTest.TestAdapter/TestingPlatformAdapter/MSTestGracefulStopTestExecutionCapability.cs b/src/Adapter/MSTest.TestAdapter/TestingPlatformAdapter/MSTestGracefulStopTestExecutionCapability.cs index 01bce0f496..7031faaffa 100644 --- a/src/Adapter/MSTest.TestAdapter/TestingPlatformAdapter/MSTestGracefulStopTestExecutionCapability.cs +++ b/src/Adapter/MSTest.TestAdapter/TestingPlatformAdapter/MSTestGracefulStopTestExecutionCapability.cs @@ -8,18 +8,126 @@ namespace Microsoft.VisualStudio.TestTools.UnitTesting; [SuppressMessage("ApiDesign", "RS0030:Do not use banned APIs", Justification = "We can use MTP from this folder")] -internal sealed class MSTestGracefulStopTestExecutionCapability : IGracefulStopTestExecutionCapability +internal sealed class MSTestGracefulStopTestExecutionCapability : IGracefulStopTestExecutionResultCapability { +#pragma warning disable IDE0330 // Use 'System.Threading.Lock' - not available on all target frameworks of this project. + private static readonly object Sync = new(); +#pragma warning restore IDE0330 + private static int s_activeExecutionCount; + private static int s_pendingStopOwnerCount; + private ExecutionState _executionState; + private bool _isStopRequested; + private MSTestGracefulStopTestExecutionCapability() { } public static MSTestGracefulStopTestExecutionCapability Instance { get; } = new(); + internal static MSTestGracefulStopTestExecutionCapability Create() => new(); + public Task StopTestExecutionAsync(CancellationToken cancellationToken) { - PlatformServiceProvider.Instance.IsGracefulStopRequested = true; + _ = TryRequestGracefulStop(); + return Task.CompletedTask; } + + public Task TryStopTestExecutionAsync(CancellationToken cancellationToken) + => Task.FromResult(TryRequestGracefulStop()); + + internal void NotifyTestExecutionPending() + { + lock (Sync) + { + UnregisterPendingStopOwner(_executionState == ExecutionState.Pending && _isStopRequested); + _isStopRequested = false; + _executionState = ExecutionState.Pending; + } + } + + internal void NotifyTestExecutionStarting() + { + lock (Sync) + { + // Preserve a stop accepted while the run was pending. Otherwise this is a new run, so clear + // the process-wide engine flag left by a previous request, but only when no overlapping run + // still owns that flag. Discovery never reaches this path. + if (_isStopRequested) + { + UnregisterPendingStopOwner(_executionState == ExecutionState.Pending && _isStopRequested); + PlatformServiceProvider.Instance.IsGracefulStopRequested = true; + } + else if (s_activeExecutionCount == 0 && s_pendingStopOwnerCount == 0) + { + PlatformServiceProvider.Instance.IsGracefulStopRequested = false; + } + + RegisterActiveExecution(); + _executionState = ExecutionState.Active; + } + } + + internal void NotifyTestExecutionCompleted() + { + lock (Sync) + { + if (_executionState == ExecutionState.Active) + { + UnregisterActiveExecution(); + } + else + { + UnregisterPendingStopOwner(_executionState == ExecutionState.Pending && _isStopRequested); + } + + _executionState = ExecutionState.Completed; + } + } + + private bool TryRequestGracefulStop() + { + lock (Sync) + { + if (_executionState == ExecutionState.Completed || _isStopRequested) + { + return false; + } + + RegisterPendingStopOwner(_executionState == ExecutionState.Pending && !_isStopRequested); + _isStopRequested = true; + PlatformServiceProvider.Instance.IsGracefulStopRequested = true; + return true; + } + } + + private static void RegisterActiveExecution() + => s_activeExecutionCount++; + + private static void UnregisterActiveExecution() + => s_activeExecutionCount--; + + private static void RegisterPendingStopOwner(bool shouldRegister) + { + if (shouldRegister) + { + s_pendingStopOwnerCount++; + } + } + + private static void UnregisterPendingStopOwner(bool shouldUnregister) + { + if (shouldUnregister) + { + s_pendingStopOwnerCount--; + } + } + + private enum ExecutionState + { + Pending, + Active, + Completed, + } } #endif diff --git a/src/Adapter/MSTest.TestAdapter/TestingPlatformAdapter/MSTestTestFramework.cs b/src/Adapter/MSTest.TestAdapter/TestingPlatformAdapter/MSTestTestFramework.cs index 7633bcf5b2..432e1c0cc3 100644 --- a/src/Adapter/MSTest.TestAdapter/TestingPlatformAdapter/MSTestTestFramework.cs +++ b/src/Adapter/MSTest.TestAdapter/TestingPlatformAdapter/MSTestTestFramework.cs @@ -40,6 +40,7 @@ internal sealed class MSTestTestFramework : ITestFramework, IDataProducer, IDisp private readonly ITrxReportCapability? _trxReportCapability; private readonly PlatformServicesConfigurationAdapter _configuration; private readonly ILoggerFactory _loggerFactory; + private readonly MSTestGracefulStopTestExecutionCapability _gracefulStopCapability; private readonly CountdownEvent _incomingRequestCounter = new(1); private bool? _isTrxEnabled; private bool _isDisposed; @@ -54,7 +55,9 @@ public MSTestTestFramework(MSTestExtension extension, Func _trxReportCapability = capabilities.GetCapability(); _configuration = new(serviceProvider.GetConfiguration()); _loggerFactory = serviceProvider.GetRequiredService(); + _gracefulStopCapability = (MSTestGracefulStopTestExecutionCapability)capabilities.GetCapability()!; PlatformServiceProvider.Instance.AdapterTraceLogger = new MTPTraceLogger(_loggerFactory.CreateLogger("mstest-trace")); + _gracefulStopCapability.NotifyTestExecutionPending(); // Let the engine emit fixture/test-method spans that nest under the platform's test-case spans. This is a // no-op unless the OpenTelemetry extension is registered. @@ -176,18 +179,26 @@ private async Task RunTestsAsync(RunTestExecutionRequest request, IMessageBus me // through the VSTest MSTestExecutor class. Results are published natively via MtpTestResultRecorder and the // MTP-specific filter provider evaluates the filter from the neutral UnitTestElement model so this path // never materializes a vstest TestCase (see #9769). - await new MSTestEngine(cancellationToken, CreateTelemetrySender()) - .RunFromSourcesAsync( - assemblyPaths, - runSettings.SettingsXml, - runContext.TestRunDirectory, - handle.ToAdapterMessageLogger(), - settings => new MtpTestResultRecorder(messageBus, this, sessionUid, IsTrxEnabled, settings), - new MtpTestElementFilterProvider(runContext), - _configuration, - new TestSourceHandler(), - isMTP: true) - .ConfigureAwait(false); + _gracefulStopCapability.NotifyTestExecutionStarting(); + try + { + await new MSTestEngine(cancellationToken, CreateTelemetrySender()) + .RunFromSourcesAsync( + assemblyPaths, + runSettings.SettingsXml, + runContext.TestRunDirectory, + handle.ToAdapterMessageLogger(), + settings => new MtpTestResultRecorder(messageBus, this, sessionUid, IsTrxEnabled, settings), + new MtpTestElementFilterProvider(runContext), + _configuration, + new TestSourceHandler(), + isMTP: true) + .ConfigureAwait(false); + } + finally + { + _gracefulStopCapability.NotifyTestExecutionCompleted(); + } } private MSTestRunSettings CreateRunSettings(MSTestFrameworkHandle handle) @@ -232,6 +243,7 @@ public void Dispose() { if (!_isDisposed) { + _gracefulStopCapability.NotifyTestExecutionCompleted(); _incomingRequestCounter.Dispose(); _isDisposed = true; } diff --git a/src/Adapter/MSTest.TestAdapter/TestingPlatformAdapter/TestApplicationBuilderExtensions.cs b/src/Adapter/MSTest.TestAdapter/TestingPlatformAdapter/TestApplicationBuilderExtensions.cs index c0b608a5a9..8ecda90429 100644 --- a/src/Adapter/MSTest.TestAdapter/TestingPlatformAdapter/TestApplicationBuilderExtensions.cs +++ b/src/Adapter/MSTest.TestAdapter/TestingPlatformAdapter/TestApplicationBuilderExtensions.cs @@ -72,7 +72,7 @@ public static void AddMSTest(this ITestApplicationBuilder testApplicationBuilder serviceProvider => new TestFrameworkCapabilities( new MSTestCapabilities(), new MSTestBannerCapability(serviceProvider.GetRequiredService()), - MSTestGracefulStopTestExecutionCapability.Instance), + MSTestGracefulStopTestExecutionCapability.Create()), (capabilities, serviceProvider) => new MSTestTestFramework(extension, getTestAssemblies, serviceProvider, capabilities)); } } diff --git a/src/Adapter/MSTestAdapter.PlatformServices/PlatformServiceProvider.cs b/src/Adapter/MSTestAdapter.PlatformServices/PlatformServiceProvider.cs index aa460a8231..af69a7e0f1 100644 --- a/src/Adapter/MSTestAdapter.PlatformServices/PlatformServiceProvider.cs +++ b/src/Adapter/MSTestAdapter.PlatformServices/PlatformServiceProvider.cs @@ -14,6 +14,8 @@ namespace Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter; /// internal sealed class PlatformServiceProvider : IPlatformServiceProvider { + private int _isGracefulStopRequested; + /// /// Initializes a new instance of the class - a singleton. /// @@ -109,7 +111,11 @@ public IReflectionOperations ReflectionOperations /// public TestRunCancellationToken? TestRunCancellationToken { get; set; } - public bool IsGracefulStopRequested { get; set; } + public bool IsGracefulStopRequested + { + get => Volatile.Read(ref _isGracefulStopRequested) != 0; + set => Volatile.Write(ref _isGracefulStopRequested, value ? 1 : 0); + } /// /// Gets or sets the instance for the platform service. diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/InternalAPI/InternalAPI.Unshipped.txt b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/InternalAPI/InternalAPI.Unshipped.txt index 522fa6b172..aba49f8f62 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/InternalAPI/InternalAPI.Unshipped.txt +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/InternalAPI/InternalAPI.Unshipped.txt @@ -14,6 +14,10 @@ static Microsoft.Testing.Extensions.AzureDevOpsReport.AzureDevOpsSummaryReporter *REMOVED*Microsoft.Testing.Extensions.AzureDevOpsReport.AzureDevOpsSummaryReporter.AzureDevOpsSummaryReporter(Microsoft.Testing.Platform.CommandLine.ICommandLineOptions! commandLineOptions, Microsoft.Testing.Platform.Configurations.IConfiguration! configuration, Microsoft.Testing.Platform.Helpers.IEnvironment! environment, Microsoft.Testing.Platform.Helpers.IFileSystem! fileSystem, Microsoft.Testing.Platform.OutputDevice.IOutputDevice! outputDevice, Microsoft.Testing.Platform.Services.ITestApplicationModuleInfo! testApplicationModuleInfo, Microsoft.Testing.Platform.Logging.ILoggerFactory! loggerFactory) -> void *REMOVED*abstract Microsoft.Testing.Extensions.SlowTestReporterBase.EmitSlowTestAsync(string! testName, System.TimeSpan elapsed, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! Microsoft.Testing.Platform.Helpers.ExitCode.CoverageThresholdFailed = 14 -> Microsoft.Testing.Platform.Helpers.ExitCode +Microsoft.Testing.Platform.Helpers.ExitCode.TestExecutionStoppedAtDeadline = 15 -> Microsoft.Testing.Platform.Helpers.ExitCode +const Microsoft.Testing.Platform.Helpers.EnvironmentVariableConstants.TESTINGPLATFORM_DEADLINE = "TESTINGPLATFORM_DEADLINE" -> string! +const Microsoft.Testing.Platform.Helpers.EnvironmentVariableConstants.TESTINGPLATFORM_DEADLINE_STOP_MARGIN = "TESTINGPLATFORM_DEADLINE_STOP_MARGIN" -> string! +const Microsoft.Testing.Platform.Helpers.EnvironmentVariableConstants.TESTINGPLATFORM_DEADLINE_DUMP_MARGIN = "TESTINGPLATFORM_DEADLINE_DUMP_MARGIN" -> string! static Microsoft.Testing.Extensions.AzureDevOpsReport.AzureDevOpsTestResultsClient.CreateHttpClientHandler() -> System.Net.Http.HttpClientHandler! static Microsoft.Testing.Extensions.AzureDevOpsReport.AzureDevOpsTestResultsClient.ShouldOptInToAutomaticDecompression(System.Net.Http.HttpClientHandler! handler) -> bool Microsoft.Testing.Extensions.AzureDevOpsReport.AzureDevOpsTestResultsPublisher.AzureDevOpsTestResultsPublisher(Microsoft.Testing.Platform.CommandLine.ICommandLineOptions! commandLineOptions, Microsoft.Testing.Platform.Configurations.IConfiguration! configuration, Microsoft.Testing.Platform.Helpers.IEnvironment! environment, Microsoft.Testing.Platform.Helpers.IFileSystem! fileSystem, Microsoft.Testing.Platform.OutputDevice.IOutputDevice! outputDevice, Microsoft.Testing.Platform.Services.ITestApplicationModuleInfo! testApplicationModuleInfo, Microsoft.Testing.Platform.Services.ITestApplicationProcessExitCode! testApplicationProcessExitCode, Microsoft.Testing.Extensions.AzureDevOpsReport.IAzureDevOpsTestResultsClient! client, Microsoft.Testing.Platform.Helpers.ITask! task, Microsoft.Testing.Platform.Helpers.IClock! clock, Microsoft.Testing.Platform.Logging.ILogger! logger, Microsoft.Testing.Extensions.AzureDevOpsReport.AzureDevOpsTestResultsPublisherOptions! options) -> void diff --git a/src/Platform/Microsoft.Testing.Extensions.CtrfReport/InternalAPI/InternalAPI.Unshipped.txt b/src/Platform/Microsoft.Testing.Extensions.CtrfReport/InternalAPI/InternalAPI.Unshipped.txt index 949324c46e..136adb206c 100644 --- a/src/Platform/Microsoft.Testing.Extensions.CtrfReport/InternalAPI/InternalAPI.Unshipped.txt +++ b/src/Platform/Microsoft.Testing.Extensions.CtrfReport/InternalAPI/InternalAPI.Unshipped.txt @@ -27,6 +27,10 @@ static Microsoft.Testing.Extensions.CtrfReport.CtrfReportMerger.MergeToFileAsync static Microsoft.Testing.Platform.Resources.PlatformResources.NamedPipeDirectoryNotWritableErrorMessage.get -> string! static Microsoft.Testing.Platform.Resources.PlatformResources.NamedPipePathTooLongErrorMessage.get -> string! Microsoft.Testing.Platform.Helpers.ExitCode.CoverageThresholdFailed = 14 -> Microsoft.Testing.Platform.Helpers.ExitCode +Microsoft.Testing.Platform.Helpers.ExitCode.TestExecutionStoppedAtDeadline = 15 -> Microsoft.Testing.Platform.Helpers.ExitCode +const Microsoft.Testing.Platform.Helpers.EnvironmentVariableConstants.TESTINGPLATFORM_DEADLINE = "TESTINGPLATFORM_DEADLINE" -> string! +const Microsoft.Testing.Platform.Helpers.EnvironmentVariableConstants.TESTINGPLATFORM_DEADLINE_STOP_MARGIN = "TESTINGPLATFORM_DEADLINE_STOP_MARGIN" -> string! +const Microsoft.Testing.Platform.Helpers.EnvironmentVariableConstants.TESTINGPLATFORM_DEADLINE_DUMP_MARGIN = "TESTINGPLATFORM_DEADLINE_DUMP_MARGIN" -> string! static Microsoft.Testing.Platform.Services.ArtifactNamingHelper.ResolveAndSanitize(string! template, string! processName, string! processId, System.DateTimeOffset timestamp, System.Func! sanitizeLeafFileName) -> string! Microsoft.Testing.Extensions.MergeOutputFileHelper static Microsoft.Testing.Extensions.MergeOutputFileHelper.BuildCaseFoldedProbePath(string! directory, string! probeFileName) -> string! diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsExitCode.cs b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsExitCode.cs index fac35b631b..fa10973db0 100644 --- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsExitCode.cs +++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsExitCode.cs @@ -60,6 +60,7 @@ public static string GetReason(int exitCode) (int)ExitCode.IncompatibleProtocolVersion => GitHubActionsResources.ExitCodeReasonIncompatibleProtocolVersion, (int)ExitCode.TestExecutionStoppedForMaxFailedTests => GitHubActionsResources.ExitCodeReasonTestExecutionStoppedForMaxFailedTests, (int)ExitCode.CoverageThresholdFailed => GitHubActionsResources.ExitCodeReasonCoverageThresholdFailed, + (int)ExitCode.TestExecutionStoppedAtDeadline => GitHubActionsResources.ExitCodeReasonTestExecutionStoppedAtDeadline, _ => GitHubActionsResources.ExitCodeReasonUnknown, }; } diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/InternalAPI/InternalAPI.Unshipped.txt b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/InternalAPI/InternalAPI.Unshipped.txt index 16a4b1056c..ac95f6fa8d 100644 --- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/InternalAPI/InternalAPI.Unshipped.txt +++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/InternalAPI/InternalAPI.Unshipped.txt @@ -99,6 +99,10 @@ Microsoft.Testing.Extensions.TestFailureDetails.TestFailureDetails(string? messa Microsoft.Testing.Extensions.TestRecord.Failure.get -> Microsoft.Testing.Extensions.TestFailureDetails? Microsoft.Testing.Extensions.TestRecord.TestRecord(string! displayName, string! fullyQualifiedName, Microsoft.Testing.Extensions.TerminalKind kind, System.TimeSpan duration, Microsoft.Testing.Extensions.TestFailureDetails? failure) -> void Microsoft.Testing.Platform.Helpers.ExitCode.CoverageThresholdFailed = 14 -> Microsoft.Testing.Platform.Helpers.ExitCode +Microsoft.Testing.Platform.Helpers.ExitCode.TestExecutionStoppedAtDeadline = 15 -> Microsoft.Testing.Platform.Helpers.ExitCode +const Microsoft.Testing.Platform.Helpers.EnvironmentVariableConstants.TESTINGPLATFORM_DEADLINE = "TESTINGPLATFORM_DEADLINE" -> string! +const Microsoft.Testing.Platform.Helpers.EnvironmentVariableConstants.TESTINGPLATFORM_DEADLINE_STOP_MARGIN = "TESTINGPLATFORM_DEADLINE_STOP_MARGIN" -> string! +const Microsoft.Testing.Platform.Helpers.EnvironmentVariableConstants.TESTINGPLATFORM_DEADLINE_DUMP_MARGIN = "TESTINGPLATFORM_DEADLINE_DUMP_MARGIN" -> string! Microsoft.Testing.Platform.Helpers.RuntimeFeatureHelper override Microsoft.Testing.Extensions.GitHubActionsReport.GitHubActionsCommandLineProvider.ValidateCommandLineOptionsAsync(Microsoft.Testing.Platform.CommandLine.ICommandLineOptions! commandLineOptions) -> System.Threading.Tasks.Task! static Microsoft.Testing.Extensions.GitHubActionsReport.GitHubActionsAnnotationReporter.GetErrorAnnotation(string! testName, string? explanation, System.Exception? exception, string? repoRoot, Microsoft.Testing.Platform.Helpers.IFileSystem! fileSystem, Microsoft.Testing.Platform.Logging.ILogger! logger, bool skipAssertionFrames, Microsoft.Testing.Extensions.GitHubActionsReport.GitHubActionsSourceLocation? declaredLocation = null) -> string! diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/PACKAGE.md b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/PACKAGE.md index 5d85d7aaee..f7b434ac70 100644 --- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/PACKAGE.md +++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/PACKAGE.md @@ -15,12 +15,12 @@ dotnet add package Microsoft.Testing.Extensions.GitHubActionsReport This package extends Microsoft.Testing.Platform with: - **Per-assembly log groups**: emits `::group::` / `::endgroup::` workflow commands so each test assembly's output is collapsed by default in the runner UI -- **Failure annotations**: emits an `::error` workflow command for each failing test so failures appear in the workflow Annotations tab and, when the source location can be resolved, on the pull request's "Files changed" diff gutter. Skipped tests are surfaced as `::warning` annotations so they are visible in the Annotations tab too. When the test session completes with a non-test-result failure — a `--minimum-expected-tests` violation, a run that discovered zero tests, a `--maximum-failed-tests` stop, or a test-adapter session failure — a single run-level `::error` is emitted describing the [Microsoft.Testing.Platform exit code](https://learn.microsoft.com/dotnet/core/testing/microsoft-testing-platform-troubleshooting#exit-codes) +- **Failure annotations**: emits an `::error` workflow command for each failing test so failures appear in the workflow Annotations tab and, when the source location can be resolved, on the pull request's "Files changed" diff gutter. Skipped tests are surfaced as `::warning` annotations so they are visible in the Annotations tab too. When the test session completes with a non-test-result failure — a `--minimum-expected-tests` violation, a run that discovered zero tests, a `--maximum-failed-tests` stop, a deadline-triggered early stop, or a test-adapter session failure — a single run-level `::error` is emitted describing the [Microsoft.Testing.Platform exit code](https://learn.microsoft.com/dotnet/core/testing/microsoft-testing-platform-troubleshooting#exit-codes) - **Job summary**: writes one markdown roll-up (totals, failures, coverage, slowest tests) to the file pointed to by `GITHUB_STEP_SUMMARY`, which GitHub renders on the workflow run summary page. Use `--report-gh-step-summary-sections` to select `test-results`, `coverage`, `slow-tests`, or any combination; `all` keeps every currently supported section and is the default. With an SDK that supports required artifact post-processing, multi-module `dotnet test` runs produce one authoritative overall section using the SDK's outer duration and exit verdict, with deterministic per-assembly details underneath. Older SDKs preserve the per-assembly sections. A non-test-result failure exit code is called out so a failure is not hidden behind a green ✅ - **Slow-test notices**: emits a `::notice` workflow command for any test still running past a threshold (default 60 seconds) > [!NOTE] -> The exit-code callout and run-level annotation only cover outcomes the extension can observe once the in-process test session has finished. Those are: `ZeroTests` (8), `MinimumExpectedTestsPolicyViolation` (9), `TestAdapterTestSessionFailure` (10), and `TestExecutionStoppedForMaxFailedTests` (13). `AtLeastOneTestFailed` (2) is already conveyed by the per-test failures, so it gets no separate callout. A hard abort/cancellation (`TestSessionAborted`, 3) short-circuits end-of-session reporting, and codes raised before or after the session — e.g. `InvalidCommandLine` (5) or `TestHostProcessExitedNonGracefully` (7) — occur outside the extension's reach, so none of those are surfaced here. +> The exit-code callout and run-level annotation only cover outcomes the extension can observe once the in-process test session has finished. Those are: `ZeroTests` (8), `MinimumExpectedTestsPolicyViolation` (9), `TestAdapterTestSessionFailure` (10), `TestExecutionStoppedForMaxFailedTests` (13), and `TestExecutionStoppedAtDeadline` (15). `AtLeastOneTestFailed` (2) is already conveyed by the per-test failures, so it gets no separate callout. A hard abort/cancellation (`TestSessionAborted`, 3) short-circuits end-of-session reporting, and codes raised before or after the session — e.g. `InvalidCommandLine` (5) or `TestHostProcessExitedNonGracefully` (7) — occur outside the extension's reach, so none of those are surfaced here. > > Cross-module aggregation is negotiated with `dotnet test`. If the SDK does not provide the authoritative run-summary context, the extension keeps its standalone behavior; a manually invoked post-processor labels totals as observed and leaves overall duration and exit verdict unavailable rather than reconstructing them. diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/GitHubActionsResources.resx b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/GitHubActionsResources.resx index c576c214f6..e0303c4e06 100644 --- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/GitHubActionsResources.resx +++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/GitHubActionsResources.resx @@ -197,6 +197,9 @@ One or more code coverage thresholds were not met. + + Test execution was stopped early because a CI-imposed deadline was approaching. + The test run reported a non-success exit code. diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.cs.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.cs.xlf index b5017a08ab..db0304c52c 100644 --- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.cs.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.cs.xlf @@ -87,6 +87,11 @@ Testovací adaptér oznámil selhání testovací relace. + + Test execution was stopped early because a CI-imposed deadline was approaching. + Test execution was stopped early because a CI-imposed deadline was approaching. + + Test execution stopped after reaching the limit set by --maximum-failed-tests. Spouštění testů se zastavilo po dosažení limitu nastaveného parametrem --maximum-failed-tests. diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.de.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.de.xlf index fa861559d0..965c23d6e0 100644 --- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.de.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.de.xlf @@ -87,6 +87,11 @@ Der Testadapter hat einen Fehler in der Testsitzung gemeldet. + + Test execution was stopped early because a CI-imposed deadline was approaching. + Test execution was stopped early because a CI-imposed deadline was approaching. + + Test execution stopped after reaching the limit set by --maximum-failed-tests. Die Testausführung wurde beendet, nachdem das von --maximum-failed-tests festgelegte Limit erreicht wurde. diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.es.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.es.xlf index 212f67c004..4b1fb69c3b 100644 --- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.es.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.es.xlf @@ -87,6 +87,11 @@ El adaptador de prueba notificó un error de sesión de prueba. + + Test execution was stopped early because a CI-imposed deadline was approaching. + Test execution was stopped early because a CI-imposed deadline was approaching. + + Test execution stopped after reaching the limit set by --maximum-failed-tests. La ejecución de pruebas se detuvo al alcanzar el límite establecido por --maximum-failed-tests. diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.fr.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.fr.xlf index b37c24b717..dcf0fb1646 100644 --- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.fr.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.fr.xlf @@ -87,6 +87,11 @@ L’adaptateur de test a signalé l’échec d’une session de test. + + Test execution was stopped early because a CI-imposed deadline was approaching. + Test execution was stopped early because a CI-imposed deadline was approaching. + + Test execution stopped after reaching the limit set by --maximum-failed-tests. L’exécution de tests s’est arrêtée après avoir atteint la limite définie par --maximum-failed-tests. diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.it.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.it.xlf index 4267360b4c..7847246b20 100644 --- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.it.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.it.xlf @@ -87,6 +87,11 @@ L'adattatore di test ha segnalato un errore della sessione di test. + + Test execution was stopped early because a CI-imposed deadline was approaching. + Test execution was stopped early because a CI-imposed deadline was approaching. + + Test execution stopped after reaching the limit set by --maximum-failed-tests. L'esecuzione dei test è stata interrotta dopo aver raggiunto il limite impostato da --maximum-failed-tests. diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ja.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ja.xlf index c8a49dcbe7..6f1ea3a1f8 100644 --- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ja.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ja.xlf @@ -87,6 +87,11 @@ テスト アダプターから、テスト セッションの失敗が報告されました。 + + Test execution was stopped early because a CI-imposed deadline was approaching. + Test execution was stopped early because a CI-imposed deadline was approaching. + + Test execution stopped after reaching the limit set by --maximum-failed-tests. --maximum-failed-tests で設定された上限に達したため、テストの実行が停止しました。 diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ko.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ko.xlf index c5471ddda6..b467a20a94 100644 --- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ko.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ko.xlf @@ -87,6 +87,11 @@ 테스트 어댑터에서 테스트 세션 실패를 보고했습니다. + + Test execution was stopped early because a CI-imposed deadline was approaching. + Test execution was stopped early because a CI-imposed deadline was approaching. + + Test execution stopped after reaching the limit set by --maximum-failed-tests. --maximum-failed-tests에서 설정한 한도에 도달해 테스트 실행이 중지되었습니다. diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.pl.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.pl.xlf index 10bb015e4d..fc45d230df 100644 --- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.pl.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.pl.xlf @@ -87,6 +87,11 @@ Adapter testowy zgłosił błąd sesji testowej. + + Test execution was stopped early because a CI-imposed deadline was approaching. + Test execution was stopped early because a CI-imposed deadline was approaching. + + Test execution stopped after reaching the limit set by --maximum-failed-tests. Wykonanie testów zostało zatrzymane po osiągnięciu limitu ustawionego za pomocą opcji --maximum-failed-tests. diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.pt-BR.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.pt-BR.xlf index c64d27bf47..36f5af2c10 100644 --- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.pt-BR.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.pt-BR.xlf @@ -87,6 +87,11 @@ O adaptador de teste relatou uma falha na sessão de teste. + + Test execution was stopped early because a CI-imposed deadline was approaching. + Test execution was stopped early because a CI-imposed deadline was approaching. + + Test execution stopped after reaching the limit set by --maximum-failed-tests. A execução de teste foi interrompida após atingir o limite definido por --maximum-failed-tests. diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ru.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ru.xlf index 6e09880dd5..4f9f7884ce 100644 --- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ru.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ru.xlf @@ -87,6 +87,11 @@ Адаптер тестирования сообщил о сбое тестового сеанса. + + Test execution was stopped early because a CI-imposed deadline was approaching. + Test execution was stopped early because a CI-imposed deadline was approaching. + + Test execution stopped after reaching the limit set by --maximum-failed-tests. Выполнение тестов остановлено после достижения предела, заданного параметром --maximum-failed-tests. diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.tr.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.tr.xlf index d6692d06f5..65620f6855 100644 --- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.tr.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.tr.xlf @@ -87,6 +87,11 @@ Test bağdaştırıcısı bir test oturumu hatası bildirdi. + + Test execution was stopped early because a CI-imposed deadline was approaching. + Test execution was stopped early because a CI-imposed deadline was approaching. + + Test execution stopped after reaching the limit set by --maximum-failed-tests. --maximum-failed-tests tarafından belirlenen sınıra ulaşıldıktan sonra test yürütme durduruldu. diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.zh-Hans.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.zh-Hans.xlf index d2c5ea9308..00687cd557 100644 --- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.zh-Hans.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.zh-Hans.xlf @@ -87,6 +87,11 @@ 测试适配器报告了测试会话失败。 + + Test execution was stopped early because a CI-imposed deadline was approaching. + Test execution was stopped early because a CI-imposed deadline was approaching. + + Test execution stopped after reaching the limit set by --maximum-failed-tests. 测试执行在达到 --maximum-failed-tests 设置的限制后停止。 diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.zh-Hant.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.zh-Hant.xlf index 967f0d73ea..4e2c98ce79 100644 --- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.zh-Hant.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.zh-Hant.xlf @@ -87,6 +87,11 @@ 測試配接器報告測試工作階段失敗。 + + Test execution was stopped early because a CI-imposed deadline was approaching. + Test execution was stopped early because a CI-imposed deadline was approaching. + + Test execution stopped after reaching the limit set by --maximum-failed-tests. 測試執行在達到 --maximum-failed-tests 所設定的限制之後停止。 diff --git a/src/Platform/Microsoft.Testing.Extensions.HangDump/HangDumpProcessLifetimeHandler.cs b/src/Platform/Microsoft.Testing.Extensions.HangDump/HangDumpProcessLifetimeHandler.cs index d632969bff..2cd50b839a 100644 --- a/src/Platform/Microsoft.Testing.Extensions.HangDump/HangDumpProcessLifetimeHandler.cs +++ b/src/Platform/Microsoft.Testing.Extensions.HangDump/HangDumpProcessLifetimeHandler.cs @@ -46,10 +46,41 @@ internal sealed class HangDumpProcessLifetimeHandler : ITestHostProcessLifetimeH private readonly bool _traceEnabled; private readonly ILogger _logger; private readonly ManualResetEventSlim _waitConsumerPipeName = new(false); - private readonly List _dumpFiles = []; + private readonly ConcurrentQueue _dumpFiles = []; + + // Guards the "take the dump only once" gate (_dumpTaken) together with publishing the running + // dump task (_activityIndicatorTask), so disposal always observes and awaits the winning dump. +#if NET9_0_OR_GREATER + private readonly Lock _dumpLock = new(); +#else + private readonly object _dumpLock = new(); +#endif private TimeSpan? _activityTimerValue; private Timer? _activityTimer; + private DateTimeOffset? _deadlineDumpAt; + private Timer? _deadlineTimer; + private bool _hostExited; + + /// + /// throws for due times above ~49.7 days (its internal limit is + /// milliseconds). + /// + private static readonly TimeSpan MaxTimerDueTime = TimeSpan.FromMilliseconds(uint.MaxValue - 1); + + /// + /// Upper bound for the optional in-progress-test query before taking a dump. A connected but + /// wedged host never answers the request/reply, and the application token is not cancelled while + /// the run is still "in progress" (which is exactly when the deadline dump fires), so an unbounded + /// query would block the dump and kill indefinitely and consume the whole dump margin. The query + /// is issued once per dump of the tree, not once per process, so this is the total worst case for + /// the whole tree and stays a small slice of the default 30s dump margin however many processes + /// are dumped; the healthy path answers in milliseconds. + /// + private static readonly TimeSpan InProgressTestsQueryTimeout = TimeSpan.FromSeconds(5); + private static readonly TimeSpan BestEffortDiagnosticsTimeout = TimeSpan.FromSeconds(1); + + private int _dumpTaken; private Task? _waitConnectionTask; private Task? _activityIndicatorTask; private NamedPipeServer? _singleConnectionNamedPipeServer; @@ -58,6 +89,42 @@ internal sealed class HangDumpProcessLifetimeHandler : ITestHostProcessLifetimeH private ITestHostProcessInformation? _testHostProcessInformation; private NamedPipeClient? _namedPipeClient; + // Cancels the pipe handshake in OnTestHostProcessStartedAsync when a dump wins the race. Read and + // written under _dumpLock, and null whenever no handshake is in flight. Cancelled outside the lock, + // because cancellation runs the waiters' continuations inline and those continue into the handshake, + // which takes _dumpLock again on its way out. + private CancellationTokenSource? _handshakeCancellationTokenSource; + + internal static TimeSpan GetTimerDueTime(DateTimeOffset deadline, DateTimeOffset now) + { + TimeSpan remaining = deadline - now; + return remaining <= TimeSpan.Zero + ? TimeSpan.Zero + : remaining > MaxTimerDueTime + ? MaxTimerDueTime + : remaining; + } + + private void OnDeadlineTimerElapsed(CancellationToken cancellationToken) + { + TimeSpan dueTime = GetTimerDueTime(_deadlineDumpAt!.Value, _clock.UtcNow); + if (dueTime > TimeSpan.Zero) + { + try + { + _deadlineTimer!.Change(dueTime, Timeout.InfiniteTimeSpan); + } + catch (ObjectDisposedException) + { + // Teardown won the race with this timer callback. + } + + return; + } + + TriggerDumpOnce(cancellationToken, triggeredByDeadline: true); + } + public HangDumpProcessLifetimeHandler( PipeNameDescription pipeNameDescription, IMessageBus messageBus, @@ -129,6 +196,15 @@ await _outputDisplay.DisplayAsync( await _logger.LogInformationAsync($"Hang dump timeout setup {_activityTimerValue}.").ConfigureAwait(false); + // In addition to the inactivity timeout above, honor an absolute CI deadline (if provided). + // We compute the wall-clock instant at which we should start taking the dump so that the dump + // has a chance to complete before the CI runner hard-kills the process. + if (DeadlineHelper.TryGetDeadline(_environment, out DateTimeOffset deadline)) + { + _deadlineDumpAt = DeadlineHelper.SubtractSaturating(deadline, DeadlineHelper.GetDumpMargin(_environment)); + await _logger.LogInformationAsync($"Hang dump deadline setup {_deadlineDumpAt:o}.").ConfigureAwait(false); + } + _singleConnectionNamedPipeServer = new(_pipeNameDescription, CallbackAsync, _environment, _logger, _task, cancellationToken); _singleConnectionNamedPipeServer.RegisterSerializer(new VoidResponseSerializer(), typeof(VoidResponse)); _singleConnectionNamedPipeServer.RegisterSerializer(new ConsumerPipeNameRequestSerializer(), typeof(ConsumerPipeNameRequest)); @@ -147,7 +223,15 @@ private async Task CallbackAsync(IRequest request) if (request is ConsumerPipeNameRequest consumerPipeNameRequest) { await _logger.LogDebugAsync($"Consumer pipe name received '{consumerPipeNameRequest.PipeName}'").ConfigureAwait(false); - _namedPipeClient = new NamedPipeClient(consumerPipeNameRequest.PipeName, _environment); + + // exitProcessOnConnectionLoss: false, because this is an auxiliary channel. It carries nothing but + // the best-effort in-progress-test query used to annotate a dump, and the peer is a test host we + // are often about to dump and kill -- so a disconnect here is expected rather than fatal. With the + // default (true) a host that drops while the query is in flight would call IEnvironment.Exit on + // this controller, killing the very process that still has to take and publish the dump, and the + // catch in QueryInProgressTestsWithTimeoutAsync would never run. Surfacing it as an exception lets + // the query fall back to an empty list and the dump continue. + _namedPipeClient = new NamedPipeClient(consumerPipeNameRequest.PipeName, _environment, exitProcessOnConnectionLoss: false); _namedPipeClient.RegisterSerializer(new GetInProgressTestsResponseSerializer(), typeof(GetInProgressTestsResponse)); _namedPipeClient.RegisterSerializer(new GetInProgressTestsRequestSerializer(), typeof(GetInProgressTestsRequest)); _namedPipeClient.RegisterSerializer(new VoidResponseSerializer(), typeof(VoidResponse)); @@ -175,22 +259,89 @@ public async Task OnTestHostProcessStartedAsync(ITestHostProcessInformation test ApplicationStateGuard.Ensure(_waitConnectionTask is not null); ApplicationStateGuard.Ensure(_singleConnectionNamedPipeServer is not null); + // Read the pipe server once, here, where the guard above proves it is set. The dereference is in a + // nested try several awaits down, and a field can in principle change under those awaits, so a local + // is what makes the null-safety contract hold at the point it is actually used. + NamedPipeServer singleConnectionNamedPipeServer = _singleConnectionNamedPipeServer; + _testHostProcessInformation = testHostProcessInformation; - await _logger.LogDebugAsync($"Wait for test host connection to the server pipe '{_singleConnectionNamedPipeServer.PipeName.Name}'").ConfigureAwait(false); - await _waitConnectionTask.TimeoutAfterAsync(TimeoutHelper.DefaultHangTimeSpanTimeout).ConfigureAwait(false); - using CancellationTokenSource timeout = new(TimeoutHelper.DefaultHangTimeSpanTimeout); - using var linkedCancellationToken = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeout.Token); - _waitConsumerPipeName.Wait(linkedCancellationToken.Token); - ApplicationStateGuard.Ensure(_namedPipeClient is not null); - await _namedPipeClient.ConnectAsync(cancellationToken).TimeoutAfterAsync(TimeoutHelper.DefaultHangTimeSpanTimeout).ConfigureAwait(false); - await _logger.LogDebugAsync($"Connected to the test host server pipe '{_namedPipeClient.PipeName}'").ConfigureAwait(false); - - _activityTimer = new Timer( - _ => _activityIndicatorTask = TakeDumpOfTreeAsync(cancellationToken), - null, - _activityTimerValue!.Value, - TimeSpan.FromMilliseconds(-1)); + // The pipe handshake below must be interruptible by a dump. Killing the test host does not + // complete this process's own WaitConnectionAsync -- that pipe is waiting for a client that will + // now never connect -- so without this token a host that wedged before connecting keeps Started + // blocked for DefaultHangTimeSpanTimeout (five minutes), far past the default 30s dump margin and + // the CI hard deadline, and OnTestHostProcessExitedAsync never runs to publish the dump that was + // taken. Published before the deadline timer is armed so a timer that fires immediately (a + // deadline already in the past) still finds it. + var handshakeCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + lock (_dumpLock) + { + _handshakeCancellationTokenSource = handshakeCancellation; + } + + CancellationToken handshakeToken = handshakeCancellation.Token; + + try + { + // Arm the absolute CI deadline timer as early as possible, before we block on the pipe + // handshake below. If the test host wedges during startup (never connects back over the + // pipe), those waits would otherwise block well past the deadline and the deadline dump/kill + // would never be armed, which defeats the purpose of the deadline. The dump path only needs + // the test host PID, which we already have here; the in-progress-test list (which needs the + // consumer pipe) is best-effort and skipped when the pipe never connected. + if (_deadlineDumpAt is { } deadlineDumpAt) + { + _deadlineTimer = new Timer( + _ => OnDeadlineTimerElapsed(cancellationToken), + null, + Timeout.InfiniteTimeSpan, + TimeSpan.FromMilliseconds(-1)); + _deadlineTimer.Change(GetTimerDueTime(deadlineDumpAt, _clock.UtcNow), Timeout.InfiniteTimeSpan); + } + + // Once a dump has started, the test host is being dumped and killed out from under this + // handshake, so the pipe waits below will throw (cancellation, timeout, or a torn-down pipe). + // Let Started return normally in that case so the lifetime handler still receives + // OnTestHostProcessExitedAsync, which is where the dump files are published; otherwise a + // deadline dump would be taken but never surfaced as an artifact. + try + { + await _logger.LogDebugAsync($"Wait for test host connection to the server pipe '{singleConnectionNamedPipeServer.PipeName.Name}'").ConfigureAwait(false); + await _waitConnectionTask.TimeoutAfterAsync(TimeoutHelper.DefaultHangTimeSpanTimeout, handshakeToken).ConfigureAwait(false); + using CancellationTokenSource timeout = new(TimeoutHelper.DefaultHangTimeSpanTimeout); + using var linkedCancellationToken = CancellationTokenSource.CreateLinkedTokenSource(handshakeToken, timeout.Token); + _waitConsumerPipeName.Wait(linkedCancellationToken.Token); + ApplicationStateGuard.Ensure(_namedPipeClient is not null); + await _namedPipeClient.ConnectAsync(handshakeToken).TimeoutAfterAsync(TimeoutHelper.DefaultHangTimeSpanTimeout, handshakeToken).ConfigureAwait(false); + await _logger.LogDebugAsync($"Connected to the test host server pipe '{_namedPipeClient.PipeName}'").ConfigureAwait(false); + + // The inactivity timer only makes sense once the host has connected and can send activity + // signals; before that there is nothing to reset it. The deadline timer above is independent. + _activityTimer = new Timer( + _ => TriggerDumpOnce(cancellationToken, triggeredByDeadline: false), + null, + _activityTimerValue!.Value, + TimeSpan.FromMilliseconds(-1)); + } + catch (Exception ex) when (Volatile.Read(ref _dumpTaken) != 0) + { + // A dump is already in progress; the failed handshake is expected. Return normally so + // OnTestHostProcessExitedAsync runs and publishes the dump that is being taken. + await RunBestEffortDiagnosticAsync( + () => _logger.LogDebugAsync($"Test host handshake failed after the dump started; continuing so the dump can be published. {ex}"), + BestEffortDiagnosticsTimeout).ConfigureAwait(false); + } + } + finally + { + // Unpublish before disposing, so a later dump reads null instead of a disposed source. + lock (_dumpLock) + { + _handshakeCancellationTokenSource = null; + } + + handshakeCancellation.Dispose(); + } } private static string GetDiskInfo() @@ -225,6 +376,44 @@ public async Task OnTestHostProcessExitedAsync(ITestHostProcessInformation testH #endif } + if (_deadlineTimer is not null) + { +#if NETCOREAPP + await _deadlineTimer.DisposeAsync().ConfigureAwait(false); +#else + _deadlineTimer.Dispose(); +#endif + } + + Task? activityIndicatorTask; + lock (_dumpLock) + { + // Timer.DisposeAsync waits for the timer callback, but TriggerDumpOnce returns as soon as it + // publishes the actual dump task. Capture and await that task before enumerating its artifacts. + _hostExited = true; + _dumpTaken = 1; + activityIndicatorTask = _activityIndicatorTask; + } + + if (activityIndicatorTask is not null) + { + try + { + await activityIndicatorTask.TimeoutAfterAsync(TimeoutHelper.DefaultHangTimeSpanTimeout).ConfigureAwait(false); + } + catch (Exception ex) + { + await RunBestEffortDiagnosticAsync( + () => _logger.LogErrorAsync("The hang dump operation failed while the test host was exiting.", ex), + BestEffortDiagnosticsTimeout).ConfigureAwait(false); + await RunBestEffortDiagnosticAsync( + () => _outputDisplay.DisplayAsync( + new ErrorMessageOutputDeviceData(string.Format(CultureInfo.InvariantCulture, ExtensionResources.HangDumpFailed, ex, GetDiskInfo())), + CancellationToken.None), + BestEffortDiagnosticsTimeout).ConfigureAwait(false); + } + } + if (!testHostProcessInformation.HasExitedGracefully) { _logger.LogDebug($"Testhost didn't exit gracefully '{testHostProcessInformation.ExitCode}')"); @@ -240,54 +429,153 @@ public async Task OnTestHostProcessExitedAsync(ITestHostProcessInformation testH [UnsupportedOSPlatform("ios")] [UnsupportedOSPlatform("tvos")] [UnsupportedOSPlatform("wasi")] - private async Task TakeDumpOfTreeAsync(CancellationToken cancellationToken) + private void TriggerDumpOnce(CancellationToken cancellationToken, bool triggeredByDeadline) { - ApplicationStateGuard.Ensure(_testHostProcessInformation is not null); + // The inactivity timer and the deadline timer can both fire, and disposal can run + // concurrently. Claim the gate and publish the running dump task under the same lock, so both + // disposal paths (which take the lock, claim the gate, and capture _activityIndicatorTask) + // always observe and await the winning dump instead of tearing down the pipes underneath it. + CancellationTokenSource? handshakeCancellation; + lock (_dumpLock) + { + if (_dumpTaken != 0) + { + return; + } + + _dumpTaken = 1; + _activityIndicatorTask = TakeDumpOfTreeAsync(cancellationToken, triggeredByDeadline); + handshakeCancellation = _handshakeCancellationTokenSource; + } + + // Interrupt the pipe handshake, if one is still in flight. We are about to dump and kill the test + // host, and a host that wedged before connecting leaves that handshake waiting for a connection + // that will never arrive; killing it does not complete our own wait, so nothing else would end it + // before DefaultHangTimeSpanTimeout and the dump would never reach OnTestHostProcessExitedAsync + // to be published. Cancelled outside _dumpLock on purpose: the waiters' continuations can run + // inline here, and they continue into a handshake that takes _dumpLock again on its way out. + try + { + handshakeCancellation?.Cancel(); + } + catch (ObjectDisposedException) + { + // The handshake finished and disposed the source between the read above and this call, so + // there is nothing left to interrupt. + } + } - await _logger.LogInformationAsync($"Hang dump timeout({_activityTimerValue}) expired.").ConfigureAwait(false); - await _outputDisplay.DisplayAsync(new ErrorMessageOutputDeviceData(string.Format(CultureInfo.InvariantCulture, ExtensionResources.HangDumpTimeoutExpired, _activityTimerValue)), cancellationToken).ConfigureAwait(false); + private async Task TakeDumpOfTreeAsync(CancellationToken cancellationToken, bool triggeredByDeadline) + { + // This method is started synchronously inside the _dumpLock (see TriggerDumpOnce), which also + // publishes the returned task into _activityIndicatorTask. Yield immediately so none of the + // dump work runs while the lock is held: control returns to the caller, the task field is + // observed, and the lock is released before the (potentially slow) dump proceeds. HangDump runs + // out-of-process on a full runtime, so yielding to the thread pool here is safe. + await Task.Yield(); + + lock (_dumpLock) + { + if (_hostExited) + { + return; + } + } + + ITestHostProcessInformation testHostProcessInformation = + _testHostProcessInformation ?? throw ApplicationStateGuard.Unreachable(); + + string dumpReason = triggeredByDeadline + ? $"CI deadline approaching (dump scheduled at {_deadlineDumpAt:o})" + : $"Hang dump timeout({_activityTimerValue}) expired"; + + // Announcing the dump is diagnostics only, and it runs before the try/finally that kills the + // process tree. Loggers and output devices propagate exceptions, so letting one escape here would + // fault the dump task and leave the wedged host alive with no dump at all -- the exact situation + // this handler exists to resolve. Report the failure and take the dump anyway. + await RunBestEffortDiagnosticAsync( + () => _logger.LogInformationAsync($"{dumpReason}. Taking hang dump."), + BestEffortDiagnosticsTimeout).ConfigureAwait(false); + await RunBestEffortDiagnosticAsync( + () => _outputDisplay.DisplayAsync( + new ErrorMessageOutputDeviceData(triggeredByDeadline + ? ExtensionResources.HangDumpDeadlineApproaching + : string.Format(CultureInfo.InvariantCulture, ExtensionResources.HangDumpTimeoutExpired, _activityTimerValue)), + cancellationToken), + BestEffortDiagnosticsTimeout).ConfigureAwait(false); + + using IProcess? process = TryGetProcessById(_processHandler, testHostProcessInformation.PID); + if (process is null) + { + await RunBestEffortDiagnosticAsync( + () => _logger.LogDebugAsync("The test host exited before the hang dump could start."), + BestEffortDiagnosticsTimeout).ConfigureAwait(false); + return; + } + + // Walking the tree writes diagnostics through the same logger and output device, so deadline-driven + // enumeration gets a short bound. Fall back to the root test host process: dumping and killing at least + // that one is what unblocks the run. + TimeSpan processTreeTimeout = triggeredByDeadline + ? BestEffortDiagnosticsTimeout + : TimeoutHelper.DefaultHangTimeSpanTimeout; + List processTree = await GetProcessTreeWithTimeoutAsync( + token => process.GetProcessTreeAsync(_logger, _outputDisplay, token), + processTreeTimeout, + ex => _logger.LogErrorAsync("Could not enumerate the test host process tree. Falling back to the root test host process.", ex), + process, + cancellationToken).ConfigureAwait(false); + processTree = processTree.Where(p => p.Process?.Name is not null and not "conhost" and not "WerFault").ToList(); - using IProcess process = _processHandler.GetProcessById(_testHostProcessInformation.PID); - var processTree = (await process.GetProcessTreeAsync(_logger, _outputDisplay, cancellationToken).ConfigureAwait(false)).Where(p => p.Process?.Name is not null and not "conhost" and not "WerFault").ToList(); IEnumerable bottomUpTree = processTree.OrderByDescending(t => t.Level).Select(t => t.Process).OfType(); try { if (processTree.Count > 1) { - await _outputDisplay.DisplayAsync(new ErrorMessageOutputDeviceData(ExtensionResources.DumpingProcessTree), cancellationToken).ConfigureAwait(false); - - foreach (ProcessTreeNode? p in processTree.OrderBy(t => t.Level)) - { - await _outputDisplay.DisplayAsync(new ErrorMessageOutputDeviceData($"{(p.Level != 0 ? " + " : " > ")}{new string('-', p.Level)} {p.Process!.Id} - {p.Process.Name}"), cancellationToken).ConfigureAwait(false); - } + string processTreeDisplay = string.Join( + Environment.NewLine, + processTree + .OrderBy(t => t.Level) + .Select(p => $"{(p.Level != 0 ? " + " : " > ")}{new string('-', p.Level)} {p.Process!.Id} - {p.Process.Name}")); + await RunBestEffortDiagnosticAsync( + () => _outputDisplay.DisplayAsync( + new ErrorMessageOutputDeviceData($"{ExtensionResources.DumpingProcessTree}{Environment.NewLine}{processTreeDisplay}"), + cancellationToken), + BestEffortDiagnosticsTimeout).ConfigureAwait(false); } else { - await _outputDisplay.DisplayAsync(new ErrorMessageOutputDeviceData(string.Format(CultureInfo.InvariantCulture, ExtensionResources.DumpingProcess, process.Id, process.Name)), cancellationToken).ConfigureAwait(false); + await RunBestEffortDiagnosticAsync( + () => _outputDisplay.DisplayAsync(new ErrorMessageOutputDeviceData(string.Format(CultureInfo.InvariantCulture, ExtensionResources.DumpingProcess, process.Id, process.Name)), cancellationToken), + BestEffortDiagnosticsTimeout).ConfigureAwait(false); } - await _logger.LogInformationAsync($"Hang dump timeout({_activityTimerValue}) expired.").ConfigureAwait(false); + await RunBestEffortDiagnosticAsync( + () => _logger.LogInformationAsync($"{dumpReason}."), + BestEffortDiagnosticsTimeout).ConfigureAwait(false); - // Do not suspend processes with NetClient dumper it stops the diagnostic thread running in - // them and hang dump request will get stuck forever, because the process is not co-operating. - // Instead we start one task per dump asynchronously, and hope that the parent process will start dumping - // before the child process is done dumping. This way if the parent is waiting for the children to exit, - // we will be dumping it before it observes the child exiting and we get a more accurate results. If we did not - // do this, then parent that is awaiting child might exit before we get to dumping it. - foreach (IProcess p in bottomUpTree) - { - try + await QueryOnceAndDumpTreeAsync( + bottomUpTree, + _task, + GetInProgressTestsAsync, + async (p, inProgressTests, ct) => { - await TakeDumpAsync(p, cancellationToken).ConfigureAwait(false); - } - catch (Exception e) - { - // exceptions.Add(new InvalidOperationException($"Error while taking dump of process {p.Name} {p.Id}", e)); - await _logger.LogErrorAsync($"Error while taking dump of process {p.Id} - {p.Name}", e).ConfigureAwait(false); - await _outputDisplay.DisplayAsync(new ErrorMessageOutputDeviceData(string.Format(CultureInfo.InvariantCulture, ExtensionResources.ErrorWhileDumpingProcess, p.Id, p.Name, e)), cancellationToken).ConfigureAwait(false); - } - } + try + { + await TakeDumpAsync(p, inProgressTests, ct).ConfigureAwait(false); + } + catch (Exception e) + { + await RunBestEffortDiagnosticAsync( + () => _logger.LogErrorAsync($"Error while taking dump of process {p.Id} - {p.Name}", e), + BestEffortDiagnosticsTimeout).ConfigureAwait(false); + await RunBestEffortDiagnosticAsync( + () => _outputDisplay.DisplayAsync(new ErrorMessageOutputDeviceData(string.Format(CultureInfo.InvariantCulture, ExtensionResources.ErrorWhileDumpingProcess, p.Id, p.Name, e)), ct), + BestEffortDiagnosticsTimeout).ConfigureAwait(false); + } + }, + cancellationToken).ConfigureAwait(false); } finally { @@ -314,22 +602,208 @@ private async Task TakeDumpOfTreeAsync(CancellationToken cancellationToken) } catch (Exception e) { - await _logger.LogErrorAsync($"Problem killing {p.Id} - {p.Name}", e).ConfigureAwait(false); - await _outputDisplay.DisplayAsync(new ErrorMessageOutputDeviceData(string.Format(CultureInfo.InvariantCulture, ExtensionResources.ErrorKillingProcess, p.Id, p.Name, e)), cancellationToken).ConfigureAwait(false); + await RunBestEffortDiagnosticAsync( + () => _logger.LogErrorAsync($"Problem killing {p.Id} - {p.Name}", e), + BestEffortDiagnosticsTimeout).ConfigureAwait(false); + await RunBestEffortDiagnosticAsync( + () => _outputDisplay.DisplayAsync(new ErrorMessageOutputDeviceData(string.Format(CultureInfo.InvariantCulture, ExtensionResources.ErrorKillingProcess, p.Id, p.Name, e)), cancellationToken), + BestEffortDiagnosticsTimeout).ConfigureAwait(false); } } } } - private async Task TakeDumpAsync(IProcess process, CancellationToken cancellationToken) + internal static async Task RunBestEffortDiagnosticAsync(Func diagnosticAsync, TimeSpan timeout) + { + try + { + await diagnosticAsync().TimeoutAfterAsync(timeout).ConfigureAwait(false); + } + catch (Exception) + { + // Diagnostics must never prevent the dump or the process-tree kill. + } + } + + internal static async Task> GetProcessTreeWithTimeoutAsync( + Func>> getProcessTreeAsync, + TimeSpan timeout, + Func logFailureAsync, + IProcess rootProcess, + CancellationToken cancellationToken) + { + using var timeoutCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCancellationTokenSource.CancelAfter(timeout); + + try + { + Task> processTreeTask = getProcessTreeAsync(timeoutCancellationTokenSource.Token); + await processTreeTask.TimeoutAfterAsync(timeout, cancellationToken).ConfigureAwait(false); + return await processTreeTask.ConfigureAwait(false); + } + catch (Exception ex) + { + await RunBestEffortDiagnosticAsync( + () => logFailureAsync(ex), + BestEffortDiagnosticsTimeout).ConfigureAwait(false); + return [new ProcessTreeNode { Process = rootProcess, Level = 0 }]; + } + } + + /// + /// Asks once and then dumps every process in + /// , annotating each dump with that single answer. + /// + /// + /// The in-progress-test list describes the test host, so it is the same for every process in the tree, + /// while the query is bounded by . Asking per process would + /// multiply that bound by the size of the tree, and with a wedged consumer pipe a six-process tree would + /// spend the entire default 30s dump margin waiting before a single dump is written. + /// This is a separate method so that guarantee can be exercised with a fake tree and a stalled query: + /// itself dumps and then kills every process it walks, so a test + /// cannot drive it against a real process tree. + /// + internal static async Task QueryOnceAndDumpTreeAsync( + IEnumerable bottomUpTree, + ITask task, + Func> queryInProgressTestsAsync, + Func dumpProcessAsync, + CancellationToken cancellationToken) { - ApplicationStateGuard.Ensure(_testHostProcessInformation is not null); + (string, int)[] inProgressTests = await queryInProgressTestsAsync(cancellationToken).ConfigureAwait(false); + + // Do not suspend processes with NetClient dumper it stops the diagnostic thread running in + // them and hang dump request will get stuck forever, because the process is not co-operating. + // Instead we start one task per dump asynchronously, and hope that the parent process will start dumping + // before the child process is done dumping. This way if the parent is waiting for the children to exit, + // we will be dumping it before it observes the child exiting and we get a more accurate results. If we did not + // do this, then parent that is awaiting child might exit before we get to dumping it. + List dumpTasks = []; + foreach (IProcess p in bottomUpTree) + { + dumpTasks.Add(task.Run(() => dumpProcessAsync(p, inProgressTests, cancellationToken), CancellationToken.None)); + } + + await task.WhenAll([.. dumpTasks]).ConfigureAwait(false); + } + + internal static string EnsureProcessIdPlaceholder(string pattern) + { + if (pattern.Contains("%p", StringComparison.Ordinal) || pattern.Contains("{pid}", StringComparison.Ordinal)) + { + return pattern; + } + + string? directory = Path.GetDirectoryName(pattern); + string fileName = Path.GetFileNameWithoutExtension(pattern); + string extension = Path.GetExtension(pattern); + string uniqueFileName = $"{fileName}_%p{extension}"; + return directory is null or "" + ? uniqueFileName + : Path.Combine(directory, uniqueFileName); + } + + internal static string GetDumpFileNamePattern(string? configuredPattern, string processName, int processId, int rootProcessId) + => configuredPattern is null + ? $"{processName}_%p_hang.dmp" + : processId == rootProcessId + ? configuredPattern + : EnsureProcessIdPlaceholder(configuredPattern); + + internal static IProcess? TryGetProcessById(IProcessHandler processHandler, int processId) + { + try + { + return processHandler.GetProcessById(processId); + } + catch (ArgumentException) + { + return null; + } + } + + /// + /// Asks the test host which tests are still running, so the dump can be annotated with them. + /// + /// + /// Called once per dump operation, not once per process: the answer describes the test host and the query + /// is bounded by , so repeating it for every process in the tree + /// would multiply that wait by the tree size and eat the dump margin. + /// The consumer pipe is only usable once the test host connected back over it. A non-null client is not + /// enough: it is created when the host sends its pipe name but only connected later, so a deadline dump + /// firing in that window (or a host that wedged during startup) would hit an unconnected pipe. The list is + /// therefore best-effort -- a connected-but-wedged host never replies and the app token is not cancelled + /// mid-run, so any failure is logged and swallowed and an empty list is returned, and it can never block + /// taking the dump and killing the tree. + /// + private Task<(string, int)[]> GetInProgressTestsAsync(CancellationToken cancellationToken) + { + NamedPipeClient? namedPipeClient = _namedPipeClient; + return namedPipeClient is null + ? Task.FromResult<(string, int)[]>([]) + : QueryInProgressTestsWithTimeoutAsync( + async queryCancellationToken => + { + GetInProgressTestsResponse tests = await namedPipeClient.RequestReplyAsync(new GetInProgressTestsRequest(), queryCancellationToken).ConfigureAwait(false); + return tests.Tests; + }, + InProgressTestsQueryTimeout, + ex => _logger.LogDebugAsync($"Could not collect the in-progress tests before dumping (the consumer pipe may not be connected, or the host did not reply within {InProgressTestsQueryTimeout}). Continuing with the dump. {ex}"), + cancellationToken); + } + + /// + /// Runs under a bound of , and + /// returns an empty list if it does not answer in time or fails -- including when reporting that failure + /// itself fails. + /// + /// + /// The bound lives here rather than in the caller's delegate so it is the product, not the caller, that + /// gives up on a connected-but-wedged host: the application token is not cancelled while the run is still + /// "in progress", which is exactly when the deadline dump fires, so an unbounded request/reply would block + /// the dump and the kill indefinitely and consume the whole dump margin. + /// This is a separate method so that bound can be exercised with a reply that never arrives: the real + /// request/reply goes over a named pipe to another process, which a unit test cannot stand up. + /// + internal static async Task<(string, int)[]> QueryInProgressTestsWithTimeoutAsync( + Func> requestInProgressTestsAsync, + TimeSpan timeout, + Func logFailureAsync, + CancellationToken cancellationToken) + { + try + { + using var queryCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + queryCts.CancelAfter(timeout); + Task<(string, int)[]> queryTask = requestInProgressTestsAsync(queryCts.Token); + await queryTask.TimeoutAfterAsync(timeout, cancellationToken).ConfigureAwait(false); + return await queryTask.ConfigureAwait(false); + } + catch (Exception ex) + { + // The empty-list fallback is the whole point of this method, so it must survive a failing + // diagnostic too. logFailureAsync is a logger call and logger providers can fail; letting that + // throw would escape the caller, which is explicitly best-effort, and skip the dump entirely. + await RunBestEffortDiagnosticAsync(() => logFailureAsync(ex), BestEffortDiagnosticsTimeout).ConfigureAwait(false); + + return []; + } + } + + private async Task TakeDumpAsync(IProcess process, (string, int)[] inProgressTests, CancellationToken cancellationToken) + { + ITestHostProcessInformation testHostProcessInformation = + _testHostProcessInformation ?? throw ApplicationStateGuard.Unreachable(); ApplicationStateGuard.Ensure(_dumpType is not null); string processId = process.Id.ToString(CultureInfo.InvariantCulture); Dictionary replacements = ArtifactNamingHelper.GetStandardReplacements(process.Name, processId, _clock.UtcNow); - string pattern = _dumpFileNamePattern ?? $"{process.Name}_%p_hang.dmp"; + string pattern = GetDumpFileNamePattern( + _dumpFileNamePattern, + process.Name, + process.Id, + testHostProcessInformation.PID); // First resolve {placeholder} templates, then handle legacy %p pattern for backward compatibility. string finalDumpFileName = ArtifactNamingHelper.ResolveTemplate(pattern, replacements) @@ -355,28 +829,48 @@ private async Task TakeDumpAsync(IProcess process, CancellationToken cancellatio // Ensure the destination directory exists (templates may include directory separators, e.g. {asm}/{pname}). Directory.CreateDirectory(Path.GetDirectoryName(finalDumpFileName)!); - ApplicationStateGuard.Ensure(_namedPipeClient is not null); - GetInProgressTestsResponse tests = await _namedPipeClient.RequestReplyAsync(new GetInProgressTestsRequest(), cancellationToken).ConfigureAwait(false); - if (tests.Tests.Length > 0) + // The in-progress tests were queried once for the whole dump operation (see GetInProgressTestsAsync); + // write them next to this dump so the dump can be read together with what was running. + if (inProgressTests.Length > 0) { - string hangTestsFileName = Path.ChangeExtension(finalDumpFileName, ".log"); - using (FileStream fs = File.OpenWrite(hangTestsFileName)) - using (StreamWriter sw = new(fs)) + try { - await _outputDisplay.DisplayAsync(new ErrorMessageOutputDeviceData(ExtensionResources.RunningTestsWhileDumping), cancellationToken).ConfigureAwait(false); - foreach ((string testName, int seconds) in tests.Tests) + string hangTestsFileName = Path.ChangeExtension(finalDumpFileName, ".log"); + using (FileStream fs = File.OpenWrite(hangTestsFileName)) + using (StreamWriter sw = new(fs)) { - await sw.WriteLineAsync($"[{TimeSpan.FromSeconds(seconds)}] {testName}").ConfigureAwait(false); - await _outputDisplay.DisplayAsync(new ErrorMessageOutputDeviceData($"[{TimeSpan.FromSeconds(seconds)}] {testName}"), cancellationToken).ConfigureAwait(false); + string inProgressTestsDisplay = string.Join( + Environment.NewLine, + inProgressTests.Select(test => $"[{TimeSpan.FromSeconds(test.Item2)}] {test.Item1}")); + await RunBestEffortDiagnosticAsync( + () => _outputDisplay.DisplayAsync( + new ErrorMessageOutputDeviceData($"{ExtensionResources.RunningTestsWhileDumping}{Environment.NewLine}{inProgressTestsDisplay}"), + cancellationToken), + BestEffortDiagnosticsTimeout).ConfigureAwait(false); + foreach ((string testName, int seconds) in inProgressTests) + { + await sw.WriteLineAsync($"[{TimeSpan.FromSeconds(seconds)}] {testName}").ConfigureAwait(false); + } } - } - await _messageBus.PublishAsync(this, new FileArtifact(new FileInfo(hangTestsFileName), ExtensionResources.HangTestListArtifactDisplayName, ExtensionResources.HangTestListArtifactDescription)).ConfigureAwait(false); + await _messageBus.PublishAsync(this, new FileArtifact(new FileInfo(hangTestsFileName), ExtensionResources.HangTestListArtifactDisplayName, ExtensionResources.HangTestListArtifactDescription)).ConfigureAwait(false); + } + catch (Exception ex) + { + // Writing the list is a convenience; it must never block taking the dump and killing the tree. + await RunBestEffortDiagnosticAsync( + () => _logger.LogDebugAsync($"Could not write the in-progress tests next to the dump of process {process.Id}. Continuing with the dump. {ex}"), + BestEffortDiagnosticsTimeout).ConfigureAwait(false); + } } - await _logger.LogInformationAsync($"Creating dump filename {finalDumpFileName}").ConfigureAwait(false); + await RunBestEffortDiagnosticAsync( + () => _logger.LogInformationAsync($"Creating dump filename {finalDumpFileName}"), + BestEffortDiagnosticsTimeout).ConfigureAwait(false); - await _outputDisplay.DisplayAsync(new ErrorMessageOutputDeviceData(string.Format(CultureInfo.InvariantCulture, ExtensionResources.CreatingDumpFile, finalDumpFileName)), cancellationToken).ConfigureAwait(false); + await RunBestEffortDiagnosticAsync( + () => _outputDisplay.DisplayAsync(new ErrorMessageOutputDeviceData(string.Format(CultureInfo.InvariantCulture, ExtensionResources.CreatingDumpFile, finalDumpFileName)), cancellationToken), + BestEffortDiagnosticsTimeout).ConfigureAwait(false); #if NETCOREAPP DiagnosticsClient diagnosticsClient = new(process.Id); @@ -398,7 +892,7 @@ private async Task TakeDumpAsync(IProcess process, CancellationToken cancellatio if (dumpType.HasValue) { diagnosticsClient.WriteDump(dumpType.Value, dumpFileNames.WriteDumpFileName, logDumpGeneration: false); - _dumpFiles.Add(dumpFileNames.ArtifactDumpFileName); + _dumpFiles.Enqueue(dumpFileNames.ArtifactDumpFileName); } } catch (Exception e) @@ -423,7 +917,7 @@ private async Task TakeDumpAsync(IProcess process, CancellationToken cancellatio if (miniDumpTypeOption.HasValue) { MiniDumpWriteDump.CollectDumpUsingMiniDumpWriteDump(process.Id, finalDumpFileName, miniDumpTypeOption.Value); - _dumpFiles.Add(finalDumpFileName); + _dumpFiles.Enqueue(finalDumpFileName); } } catch (Exception e) @@ -450,12 +944,28 @@ private static void NotifyCrashDumpServiceIfEnabled() public void Dispose() { - if (_activityIndicatorTask is not null) + // Stop the deadline and inactivity timers so no callback can start a new dump while we tear + // down the pipes. The happy path disposes them in OnTestHostProcessExitedAsync, but that runs + // only on a clean exit; Ctrl+C or an exception skips it, so dispose here too (Timer.Dispose is + // idempotent, so disposing twice is safe). + _deadlineTimer?.Dispose(); + _activityTimer?.Dispose(); + + Task? activityIndicatorTask; + lock (_dumpLock) + { + // Claim the gate so no timer callback can start a new dump once we begin tearing down the + // pipes, and capture any dump already in flight so we wait for it below. + _dumpTaken = 1; + activityIndicatorTask = _activityIndicatorTask; + } + + if (activityIndicatorTask is not null) { bool waitResult; try { - waitResult = _activityIndicatorTask.Wait(TimeoutHelper.DefaultHangTimeSpanTimeout); + waitResult = activityIndicatorTask.Wait(TimeoutHelper.DefaultHangTimeSpanTimeout); } catch (Exception e) { @@ -477,11 +987,27 @@ public void Dispose() #if NETCOREAPP public async ValueTask DisposeAsync() { - if (_activityIndicatorTask is not null) + // Stop the deadline and inactivity timers so no callback can start a new dump while we tear + // down the pipes. The happy path disposes them in OnTestHostProcessExitedAsync, but that runs + // only on a clean exit; Ctrl+C or an exception skips it, so dispose here too (Timer.Dispose is + // idempotent, so disposing twice is safe). + _deadlineTimer?.Dispose(); + _activityTimer?.Dispose(); + + Task? activityIndicatorTask; + lock (_dumpLock) + { + // Claim the gate so no timer callback can start a new dump once we begin tearing down the + // pipes, and capture any dump already in flight so we await it below. + _dumpTaken = 1; + activityIndicatorTask = _activityIndicatorTask; + } + + if (activityIndicatorTask is not null) { try { - await _activityIndicatorTask.TimeoutAfterAsync(TimeoutHelper.DefaultHangTimeSpanTimeout).ConfigureAwait(false); + await activityIndicatorTask.TimeoutAfterAsync(TimeoutHelper.DefaultHangTimeSpanTimeout).ConfigureAwait(false); } catch (Exception e) { diff --git a/src/Platform/Microsoft.Testing.Extensions.HangDump/InternalAPI/InternalAPI.Unshipped.txt b/src/Platform/Microsoft.Testing.Extensions.HangDump/InternalAPI/InternalAPI.Unshipped.txt index a2cd220733..5ef61f90b0 100644 --- a/src/Platform/Microsoft.Testing.Extensions.HangDump/InternalAPI/InternalAPI.Unshipped.txt +++ b/src/Platform/Microsoft.Testing.Extensions.HangDump/InternalAPI/InternalAPI.Unshipped.txt @@ -10,6 +10,7 @@ static Microsoft.Testing.Platform.IPC.NamedPipeServer.ResolvePipeDirectory(strin static Microsoft.Testing.Platform.Resources.PlatformResources.NamedPipeDirectoryNotWritableErrorMessage.get -> string! static Microsoft.Testing.Platform.Resources.PlatformResources.NamedPipePathTooLongErrorMessage.get -> string! Microsoft.Testing.Platform.Helpers.ExitCode.CoverageThresholdFailed = 14 -> Microsoft.Testing.Platform.Helpers.ExitCode +Microsoft.Testing.Platform.Helpers.ExitCode.TestExecutionStoppedAtDeadline = 15 -> Microsoft.Testing.Platform.Helpers.ExitCode static Microsoft.Testing.Platform.Services.ArtifactNamingHelper.ResolveAndSanitize(string! template, string! processName, string! processId, System.DateTimeOffset timestamp, System.Func! sanitizeLeafFileName) -> string! static Microsoft.Testing.Platform.IPC.Serializers.BaseSerializer.ReadFields(System.IO.Stream! stream, System.Func! tryReadField) -> void static Microsoft.Testing.Platform.IPC.Serializers.BaseSerializer.TryReadExecutionScopedField(System.IO.Stream! stream, ushort fieldId, int fieldSize, ref string? executionId, ref string? instanceId) -> bool @@ -27,3 +28,19 @@ Microsoft.Testing.Platform.IPC.NamedPipeConnectionBase.WriteMessageAsync(System. *REMOVED*Microsoft.Testing.Platform.IPC.NamedPipeConnectionBase.WriteMessageAsync(System.IO.Pipes.PipeStream! stream, Microsoft.Testing.Platform.IPC.INamedPipeSerializer! serializer, object! message, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! Microsoft.Testing.Platform.IPC.NamedPipeServer.NamedPipeServer(Microsoft.Testing.Platform.IPC.PipeNameDescription! pipeNameDescription, System.Func!>! callback, Microsoft.Testing.Platform.Helpers.IEnvironment! environment, Microsoft.Testing.Platform.Logging.ILogger! logger, Microsoft.Testing.Platform.Helpers.ITask! task, int maxNumberOfServerInstances, System.Collections.Generic.IReadOnlyList? authorizedSecurityIdentities, System.Threading.CancellationToken cancellationToken) -> void Microsoft.Testing.Platform.IPC.NamedPipeServer.NamedPipeServer(string! name, System.Func!>! callback, Microsoft.Testing.Platform.Helpers.IEnvironment! environment, Microsoft.Testing.Platform.Logging.ILogger! logger, Microsoft.Testing.Platform.Helpers.ITask! task, System.Collections.Generic.IReadOnlyList? authorizedSecurityIdentities, System.Threading.CancellationToken cancellationToken) -> void +const Microsoft.Testing.Platform.Helpers.EnvironmentVariableConstants.TESTINGPLATFORM_DEADLINE = "TESTINGPLATFORM_DEADLINE" -> string! +const Microsoft.Testing.Platform.Helpers.EnvironmentVariableConstants.TESTINGPLATFORM_DEADLINE_STOP_MARGIN = "TESTINGPLATFORM_DEADLINE_STOP_MARGIN" -> string! +const Microsoft.Testing.Platform.Helpers.EnvironmentVariableConstants.TESTINGPLATFORM_DEADLINE_DUMP_MARGIN = "TESTINGPLATFORM_DEADLINE_DUMP_MARGIN" -> string! +Microsoft.Testing.Platform.Helpers.DeadlineHelper +static Microsoft.Testing.Platform.Helpers.DeadlineHelper.TryGetDeadline(Microsoft.Testing.Platform.Helpers.IEnvironment! environment, out System.DateTimeOffset deadlineUtc) -> bool +static Microsoft.Testing.Platform.Helpers.DeadlineHelper.GetStopMargin(Microsoft.Testing.Platform.Helpers.IEnvironment! environment) -> System.TimeSpan +static Microsoft.Testing.Platform.Helpers.DeadlineHelper.GetDumpMargin(Microsoft.Testing.Platform.Helpers.IEnvironment! environment) -> System.TimeSpan +static Microsoft.Testing.Platform.Helpers.DeadlineHelper.SubtractSaturating(System.DateTimeOffset instant, System.TimeSpan margin) -> System.DateTimeOffset +static Microsoft.Testing.Extensions.Diagnostics.HangDumpProcessLifetimeHandler.GetProcessTreeWithTimeoutAsync(System.Func!>!>! getProcessTreeAsync, System.TimeSpan timeout, System.Func! logFailureAsync, Microsoft.Testing.Platform.Helpers.IProcess! rootProcess, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task!>! +static Microsoft.Testing.Extensions.Diagnostics.HangDumpProcessLifetimeHandler.GetTimerDueTime(System.DateTimeOffset deadline, System.DateTimeOffset now) -> System.TimeSpan +static Microsoft.Testing.Extensions.Diagnostics.HangDumpProcessLifetimeHandler.EnsureProcessIdPlaceholder(string! pattern) -> string! +static Microsoft.Testing.Extensions.Diagnostics.HangDumpProcessLifetimeHandler.GetDumpFileNamePattern(string? configuredPattern, string! processName, int processId, int rootProcessId) -> string! +static Microsoft.Testing.Extensions.Diagnostics.HangDumpProcessLifetimeHandler.TryGetProcessById(Microsoft.Testing.Platform.Helpers.IProcessHandler! processHandler, int processId) -> Microsoft.Testing.Platform.Helpers.IProcess? +static Microsoft.Testing.Extensions.Diagnostics.HangDumpProcessLifetimeHandler.QueryOnceAndDumpTreeAsync(System.Collections.Generic.IEnumerable! bottomUpTree, Microsoft.Testing.Platform.Helpers.ITask! task, System.Func!>! queryInProgressTestsAsync, System.Func! dumpProcessAsync, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +static Microsoft.Testing.Extensions.Diagnostics.HangDumpProcessLifetimeHandler.QueryInProgressTestsWithTimeoutAsync(System.Func!>! requestInProgressTestsAsync, System.TimeSpan timeout, System.Func! logFailureAsync, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task<(string!, int)[]!>! +static Microsoft.Testing.Extensions.Diagnostics.HangDumpProcessLifetimeHandler.RunBestEffortDiagnosticAsync(System.Func! diagnosticAsync, System.TimeSpan timeout) -> System.Threading.Tasks.Task! diff --git a/src/Platform/Microsoft.Testing.Extensions.HangDump/Microsoft.Testing.Extensions.HangDump.csproj b/src/Platform/Microsoft.Testing.Extensions.HangDump/Microsoft.Testing.Extensions.HangDump.csproj index fed93f4f2c..b486d73161 100644 --- a/src/Platform/Microsoft.Testing.Extensions.HangDump/Microsoft.Testing.Extensions.HangDump.csproj +++ b/src/Platform/Microsoft.Testing.Extensions.HangDump/Microsoft.Testing.Extensions.HangDump.csproj @@ -40,6 +40,7 @@ $(CommonProductDescription)]]> + diff --git a/src/Platform/Microsoft.Testing.Extensions.HangDump/Resources/ExtensionResources.resx b/src/Platform/Microsoft.Testing.Extensions.HangDump/Resources/ExtensionResources.resx index 8755a8f89e..c1469e2bb4 100644 --- a/src/Platform/Microsoft.Testing.Extensions.HangDump/Resources/ExtensionResources.resx +++ b/src/Platform/Microsoft.Testing.Extensions.HangDump/Resources/ExtensionResources.resx @@ -146,6 +146,9 @@ Hang dump file + + CI deadline approaching: taking a hang dump before the run is hard-cancelled. + Environment variable '{0}' is set to '{1}' instead of '{2}' {0} is the environment variable name. {1} is the actual value. {2} is the expected value. diff --git a/src/Platform/Microsoft.Testing.Extensions.HangDump/Resources/xlf/ExtensionResources.cs.xlf b/src/Platform/Microsoft.Testing.Extensions.HangDump/Resources/xlf/ExtensionResources.cs.xlf index 2b2e67d45a..eabed350f7 100644 --- a/src/Platform/Microsoft.Testing.Extensions.HangDump/Resources/xlf/ExtensionResources.cs.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.HangDump/Resources/xlf/ExtensionResources.cs.xlf @@ -42,6 +42,11 @@ Soubor výpisu paměti při zablokování + + CI deadline approaching: taking a hang dump before the run is hard-cancelled. + CI deadline approaching: taking a hang dump before the run is hard-cancelled. + + Environment variable '{0}' is set to '{1}' instead of '{2}' Proměnná prostředí {0} je nastavená na {1} místo na {2}. diff --git a/src/Platform/Microsoft.Testing.Extensions.HangDump/Resources/xlf/ExtensionResources.de.xlf b/src/Platform/Microsoft.Testing.Extensions.HangDump/Resources/xlf/ExtensionResources.de.xlf index a5021909c9..de96d1b15e 100644 --- a/src/Platform/Microsoft.Testing.Extensions.HangDump/Resources/xlf/ExtensionResources.de.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.HangDump/Resources/xlf/ExtensionResources.de.xlf @@ -42,6 +42,11 @@ Absturzspeicherabbilddatei + + CI deadline approaching: taking a hang dump before the run is hard-cancelled. + CI deadline approaching: taking a hang dump before the run is hard-cancelled. + + Environment variable '{0}' is set to '{1}' instead of '{2}' Umgebungsvariable "{0}" ist auf "{1}" anstatt auf "{2}" festgelegt. diff --git a/src/Platform/Microsoft.Testing.Extensions.HangDump/Resources/xlf/ExtensionResources.es.xlf b/src/Platform/Microsoft.Testing.Extensions.HangDump/Resources/xlf/ExtensionResources.es.xlf index d32ebd52dd..afb7227ff2 100644 --- a/src/Platform/Microsoft.Testing.Extensions.HangDump/Resources/xlf/ExtensionResources.es.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.HangDump/Resources/xlf/ExtensionResources.es.xlf @@ -42,6 +42,11 @@ Archivo de volcado de bloqueo + + CI deadline approaching: taking a hang dump before the run is hard-cancelled. + CI deadline approaching: taking a hang dump before the run is hard-cancelled. + + Environment variable '{0}' is set to '{1}' instead of '{2}' La variable de entorno '{0}' se establece en '{1}' en lugar de '{2}' diff --git a/src/Platform/Microsoft.Testing.Extensions.HangDump/Resources/xlf/ExtensionResources.fr.xlf b/src/Platform/Microsoft.Testing.Extensions.HangDump/Resources/xlf/ExtensionResources.fr.xlf index 5ee680b2f6..4d395a2184 100644 --- a/src/Platform/Microsoft.Testing.Extensions.HangDump/Resources/xlf/ExtensionResources.fr.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.HangDump/Resources/xlf/ExtensionResources.fr.xlf @@ -42,6 +42,11 @@ Bloquer le fichier de vidage + + CI deadline approaching: taking a hang dump before the run is hard-cancelled. + CI deadline approaching: taking a hang dump before the run is hard-cancelled. + + Environment variable '{0}' is set to '{1}' instead of '{2}' La variable d’environnement «{0}» a la valeur «{1}» au lieu de «{2}» diff --git a/src/Platform/Microsoft.Testing.Extensions.HangDump/Resources/xlf/ExtensionResources.it.xlf b/src/Platform/Microsoft.Testing.Extensions.HangDump/Resources/xlf/ExtensionResources.it.xlf index f2b9c99c3c..1a69030180 100644 --- a/src/Platform/Microsoft.Testing.Extensions.HangDump/Resources/xlf/ExtensionResources.it.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.HangDump/Resources/xlf/ExtensionResources.it.xlf @@ -42,6 +42,11 @@ File dump di blocco + + CI deadline approaching: taking a hang dump before the run is hard-cancelled. + CI deadline approaching: taking a hang dump before the run is hard-cancelled. + + Environment variable '{0}' is set to '{1}' instead of '{2}' La variabile di ambiente '{0}' è impostata su '{1}' anziché su '{2}' diff --git a/src/Platform/Microsoft.Testing.Extensions.HangDump/Resources/xlf/ExtensionResources.ja.xlf b/src/Platform/Microsoft.Testing.Extensions.HangDump/Resources/xlf/ExtensionResources.ja.xlf index f368e7cc8a..f7fb26f225 100644 --- a/src/Platform/Microsoft.Testing.Extensions.HangDump/Resources/xlf/ExtensionResources.ja.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.HangDump/Resources/xlf/ExtensionResources.ja.xlf @@ -42,6 +42,11 @@ ハング ダンプ ファイル + + CI deadline approaching: taking a hang dump before the run is hard-cancelled. + CI deadline approaching: taking a hang dump before the run is hard-cancelled. + + Environment variable '{0}' is set to '{1}' instead of '{2}' 環境変数 '{0}' は '{2}' ではなく '{1}' に設定されています diff --git a/src/Platform/Microsoft.Testing.Extensions.HangDump/Resources/xlf/ExtensionResources.ko.xlf b/src/Platform/Microsoft.Testing.Extensions.HangDump/Resources/xlf/ExtensionResources.ko.xlf index 8fe827a212..c12cc6f2b0 100644 --- a/src/Platform/Microsoft.Testing.Extensions.HangDump/Resources/xlf/ExtensionResources.ko.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.HangDump/Resources/xlf/ExtensionResources.ko.xlf @@ -42,6 +42,11 @@ 중단 덤프 파일 + + CI deadline approaching: taking a hang dump before the run is hard-cancelled. + CI deadline approaching: taking a hang dump before the run is hard-cancelled. + + Environment variable '{0}' is set to '{1}' instead of '{2}' 환경 변수 '{0}'이(가) '{2}' 대신 '{1}'로 설정되어 있습니다. diff --git a/src/Platform/Microsoft.Testing.Extensions.HangDump/Resources/xlf/ExtensionResources.pl.xlf b/src/Platform/Microsoft.Testing.Extensions.HangDump/Resources/xlf/ExtensionResources.pl.xlf index a2462135d7..fd885fe2f6 100644 --- a/src/Platform/Microsoft.Testing.Extensions.HangDump/Resources/xlf/ExtensionResources.pl.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.HangDump/Resources/xlf/ExtensionResources.pl.xlf @@ -42,6 +42,11 @@ Zawieszanie pliku zrzutu + + CI deadline approaching: taking a hang dump before the run is hard-cancelled. + CI deadline approaching: taking a hang dump before the run is hard-cancelled. + + Environment variable '{0}' is set to '{1}' instead of '{2}' Zmienna środowiskowa „{0}” jest ustawiona na wartość „{1}” zamiast „{2}” diff --git a/src/Platform/Microsoft.Testing.Extensions.HangDump/Resources/xlf/ExtensionResources.pt-BR.xlf b/src/Platform/Microsoft.Testing.Extensions.HangDump/Resources/xlf/ExtensionResources.pt-BR.xlf index 5c59ed1787..dfdb927e25 100644 --- a/src/Platform/Microsoft.Testing.Extensions.HangDump/Resources/xlf/ExtensionResources.pt-BR.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.HangDump/Resources/xlf/ExtensionResources.pt-BR.xlf @@ -42,6 +42,11 @@ Arquivo de despejo de travamento + + CI deadline approaching: taking a hang dump before the run is hard-cancelled. + CI deadline approaching: taking a hang dump before the run is hard-cancelled. + + Environment variable '{0}' is set to '{1}' instead of '{2}' A variável de ambiente ''{0}'' ambiente está definida como ''{1}'' em vez de ''{2}'' diff --git a/src/Platform/Microsoft.Testing.Extensions.HangDump/Resources/xlf/ExtensionResources.ru.xlf b/src/Platform/Microsoft.Testing.Extensions.HangDump/Resources/xlf/ExtensionResources.ru.xlf index 0b18240ba9..79073b631d 100644 --- a/src/Platform/Microsoft.Testing.Extensions.HangDump/Resources/xlf/ExtensionResources.ru.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.HangDump/Resources/xlf/ExtensionResources.ru.xlf @@ -42,6 +42,11 @@ Файл дампа зависания + + CI deadline approaching: taking a hang dump before the run is hard-cancelled. + CI deadline approaching: taking a hang dump before the run is hard-cancelled. + + Environment variable '{0}' is set to '{1}' instead of '{2}' Переменной среды "{0}" присвоено значение "{1}" вместо "{2}" diff --git a/src/Platform/Microsoft.Testing.Extensions.HangDump/Resources/xlf/ExtensionResources.tr.xlf b/src/Platform/Microsoft.Testing.Extensions.HangDump/Resources/xlf/ExtensionResources.tr.xlf index cfaae77a47..aad4d2f83a 100644 --- a/src/Platform/Microsoft.Testing.Extensions.HangDump/Resources/xlf/ExtensionResources.tr.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.HangDump/Resources/xlf/ExtensionResources.tr.xlf @@ -42,6 +42,11 @@ Döküm dosyasını as + + CI deadline approaching: taking a hang dump before the run is hard-cancelled. + CI deadline approaching: taking a hang dump before the run is hard-cancelled. + + Environment variable '{0}' is set to '{1}' instead of '{2}' '{0}' ortam değişkeni '{1}' yerine '{2}' olarak ayarlandı diff --git a/src/Platform/Microsoft.Testing.Extensions.HangDump/Resources/xlf/ExtensionResources.zh-Hans.xlf b/src/Platform/Microsoft.Testing.Extensions.HangDump/Resources/xlf/ExtensionResources.zh-Hans.xlf index 9c46c6dadf..7f029cd8cd 100644 --- a/src/Platform/Microsoft.Testing.Extensions.HangDump/Resources/xlf/ExtensionResources.zh-Hans.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.HangDump/Resources/xlf/ExtensionResources.zh-Hans.xlf @@ -42,6 +42,11 @@ 挂起转储文件 + + CI deadline approaching: taking a hang dump before the run is hard-cancelled. + CI deadline approaching: taking a hang dump before the run is hard-cancelled. + + Environment variable '{0}' is set to '{1}' instead of '{2}' 环境变量“{0}”设置为“{1}”,而不是“{2}” diff --git a/src/Platform/Microsoft.Testing.Extensions.HangDump/Resources/xlf/ExtensionResources.zh-Hant.xlf b/src/Platform/Microsoft.Testing.Extensions.HangDump/Resources/xlf/ExtensionResources.zh-Hant.xlf index 428db223fd..07457c5a17 100644 --- a/src/Platform/Microsoft.Testing.Extensions.HangDump/Resources/xlf/ExtensionResources.zh-Hant.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.HangDump/Resources/xlf/ExtensionResources.zh-Hant.xlf @@ -42,6 +42,11 @@ 擱置傾印檔案 + + CI deadline approaching: taking a hang dump before the run is hard-cancelled. + CI deadline approaching: taking a hang dump before the run is hard-cancelled. + + Environment variable '{0}' is set to '{1}' instead of '{2}' 環境變數 '{0}' 已設定為 '{1}' 而不是 '{2}' diff --git a/src/Platform/Microsoft.Testing.Extensions.HotReload/HotReloadHandler.cs b/src/Platform/Microsoft.Testing.Extensions.HotReload/HotReloadHandler.cs index 7821028351..fdbf10dea7 100644 --- a/src/Platform/Microsoft.Testing.Extensions.HotReload/HotReloadHandler.cs +++ b/src/Platform/Microsoft.Testing.Extensions.HotReload/HotReloadHandler.cs @@ -23,6 +23,9 @@ namespace Microsoft.Testing.Extensions.Hosting; internal sealed class HotReloadHandler { private static readonly SemaphoreSlim SemaphoreSlim = new(1, 1); +#pragma warning disable IDE0330 // Use 'System.Threading.Lock' - HotReload targets netstandard2.0. + private static readonly object Sync = new(); +#pragma warning restore IDE0330 private static bool s_shutdownProcess; private readonly IConsole _console; private readonly IOutputDevice _outputDevice; @@ -36,14 +39,7 @@ public HotReloadHandler(IConsole console, IOutputDevice outputDevice, IOutputDev if (!IsCancelKeyPressNotSupported()) { - _console.CancelKeyPress += (_, _) => - { - if (!s_shutdownProcess) - { - s_shutdownProcess = true; - SemaphoreSlim.Release(); - } - }; + _console.CancelKeyPress += (_, _) => RequestShutdown(); } } @@ -65,7 +61,32 @@ public static void ClearCache(Type[]? _) } // Called automatically by the runtime through the MetadataUpdateHandlerAttribute - public static void UpdateApplication(Type[]? _) => SemaphoreSlim.Release(); + public static void UpdateApplication(Type[]? _) => SignalWaiter(); + + internal static void RequestShutdown() + { + lock (Sync) + { + if (s_shutdownProcess) + { + return; + } + + s_shutdownProcess = true; + SignalWaiter(); + } + } + + private static void SignalWaiter() + { + lock (Sync) + { + if (SemaphoreSlim.CurrentCount == 0) + { + SemaphoreSlim.Release(); + } + } + } #if !NET6_0_OR_GREATER public Task ShouldRunAsync(Task? waitExecutionCompletion, CancellationToken cancellationToken) @@ -102,7 +123,7 @@ public async Task ShouldRunAsync(Task? waitExecutionCompletion, Cancellati // We're closing } - if (!IsClearNotSupported()) + if (!_console.IsOutputRedirected && !IsClearNotSupported()) { _console.Clear(); } diff --git a/src/Platform/Microsoft.Testing.Extensions.HotReload/HotReloadTestHostTestFrameworkInvoker.cs b/src/Platform/Microsoft.Testing.Extensions.HotReload/HotReloadTestHostTestFrameworkInvoker.cs index e5c7c9e533..2e1ce6dbce 100644 --- a/src/Platform/Microsoft.Testing.Extensions.HotReload/HotReloadTestHostTestFrameworkInvoker.cs +++ b/src/Platform/Microsoft.Testing.Extensions.HotReload/HotReloadTestHostTestFrameworkInvoker.cs @@ -14,14 +14,21 @@ namespace Microsoft.Testing.Extensions.Hosting; internal sealed class HotReloadTestHostTestFrameworkInvoker : TestHostTestFrameworkInvoker { private readonly bool _isHotReloadEnabled; + private readonly IStopPoliciesService _stopPoliciesService; public HotReloadTestHostTestFrameworkInvoker(IServiceProvider serviceProvider) : base(serviceProvider) { _isHotReloadEnabled = IsHotReloadEnabled(serviceProvider.GetEnvironment()); + _stopPoliciesService = serviceProvider.GetRequiredService(); if (_isHotReloadEnabled) { ((SystemRuntimeFeature)serviceProvider.GetRuntimeFeature()).EnableHotReload(); + _stopPoliciesService.RegisterDeadlineStopFallback(() => + { + HotReloadHandler.RequestShutdown(); + return Task.FromResult(true); + }); } } @@ -41,8 +48,14 @@ public override async Task ExecuteRequestAsync(ITestFramework testFramework, Tes // Using the output device here rather than Console WriteLine ensures that we don't break live logger output. IOutputDevice outputDevice = ServiceProvider.GetOutputDevice(); var hotReloadHandler = new HotReloadHandler(ServiceProvider.GetConsole(), outputDevice, this); + await _stopPoliciesService.RegisterOnDeadlineCallbackAsync(() => + { + HotReloadHandler.RequestShutdown(); + return Task.CompletedTask; + }).ConfigureAwait(false); TaskCompletionSource? executionCompleted = null; - while (await hotReloadHandler.ShouldRunAsync(executionCompleted?.Task, cancellationToken).ConfigureAwait(false)) + while (!_stopPoliciesService.IsDeadlineTriggered + && await hotReloadHandler.ShouldRunAsync(executionCompleted?.Task, cancellationToken).ConfigureAwait(false)) { executionCompleted = new(); diff --git a/src/Platform/Microsoft.Testing.Extensions.HotReload/InternalAPI/InternalAPI.Unshipped.txt b/src/Platform/Microsoft.Testing.Extensions.HotReload/InternalAPI/InternalAPI.Unshipped.txt index 53cce0d029..05d83a45d0 100644 --- a/src/Platform/Microsoft.Testing.Extensions.HotReload/InternalAPI/InternalAPI.Unshipped.txt +++ b/src/Platform/Microsoft.Testing.Extensions.HotReload/InternalAPI/InternalAPI.Unshipped.txt @@ -1,3 +1,7 @@ #nullable enable const Microsoft.Testing.Platform.Helpers.EnvironmentVariableConstants.TESTINGPLATFORM_DOTNETTEST_ATTEMPTNUMBER = "TESTINGPLATFORM_DOTNETTEST_ATTEMPTNUMBER" -> string! const Microsoft.Testing.Platform.Helpers.EnvironmentVariableConstants.TESTINGPLATFORM_PIPE_DIRECTORY = "TESTINGPLATFORM_PIPE_DIRECTORY" -> string! +const Microsoft.Testing.Platform.Helpers.EnvironmentVariableConstants.TESTINGPLATFORM_DEADLINE = "TESTINGPLATFORM_DEADLINE" -> string! +const Microsoft.Testing.Platform.Helpers.EnvironmentVariableConstants.TESTINGPLATFORM_DEADLINE_STOP_MARGIN = "TESTINGPLATFORM_DEADLINE_STOP_MARGIN" -> string! +const Microsoft.Testing.Platform.Helpers.EnvironmentVariableConstants.TESTINGPLATFORM_DEADLINE_DUMP_MARGIN = "TESTINGPLATFORM_DEADLINE_DUMP_MARGIN" -> string! +static Microsoft.Testing.Extensions.Hosting.HotReloadHandler.RequestShutdown() -> void diff --git a/src/Platform/Microsoft.Testing.Extensions.HtmlReport/InternalAPI/InternalAPI.Unshipped.txt b/src/Platform/Microsoft.Testing.Extensions.HtmlReport/InternalAPI/InternalAPI.Unshipped.txt index cd81ae4e2b..cc35e166d4 100644 --- a/src/Platform/Microsoft.Testing.Extensions.HtmlReport/InternalAPI/InternalAPI.Unshipped.txt +++ b/src/Platform/Microsoft.Testing.Extensions.HtmlReport/InternalAPI/InternalAPI.Unshipped.txt @@ -37,4 +37,5 @@ virtual Microsoft.Testing.Extensions.ReportGeneratorBase string! static Microsoft.Testing.Platform.Resources.PlatformResources.NamedPipePathTooLongErrorMessage.get -> string! Microsoft.Testing.Platform.Helpers.ExitCode.CoverageThresholdFailed = 14 -> Microsoft.Testing.Platform.Helpers.ExitCode +Microsoft.Testing.Platform.Helpers.ExitCode.TestExecutionStoppedAtDeadline = 15 -> Microsoft.Testing.Platform.Helpers.ExitCode static Microsoft.Testing.Platform.Services.ArtifactNamingHelper.ResolveAndSanitize(string! template, string! processName, string! processId, System.DateTimeOffset timestamp, System.Func! sanitizeLeafFileName) -> string! diff --git a/src/Platform/Microsoft.Testing.Extensions.JUnitReport/InternalAPI/InternalAPI.Unshipped.txt b/src/Platform/Microsoft.Testing.Extensions.JUnitReport/InternalAPI/InternalAPI.Unshipped.txt index 7cb905ea93..d48d8bd49c 100644 --- a/src/Platform/Microsoft.Testing.Extensions.JUnitReport/InternalAPI/InternalAPI.Unshipped.txt +++ b/src/Platform/Microsoft.Testing.Extensions.JUnitReport/InternalAPI/InternalAPI.Unshipped.txt @@ -25,6 +25,7 @@ static Microsoft.Testing.Extensions.JUnitReport.JUnitReportMerger.MergeToFileAsy static Microsoft.Testing.Platform.Resources.PlatformResources.NamedPipeDirectoryNotWritableErrorMessage.get -> string! static Microsoft.Testing.Platform.Resources.PlatformResources.NamedPipePathTooLongErrorMessage.get -> string! Microsoft.Testing.Platform.Helpers.ExitCode.CoverageThresholdFailed = 14 -> Microsoft.Testing.Platform.Helpers.ExitCode +Microsoft.Testing.Platform.Helpers.ExitCode.TestExecutionStoppedAtDeadline = 15 -> Microsoft.Testing.Platform.Helpers.ExitCode static Microsoft.Testing.Platform.Services.ArtifactNamingHelper.ResolveAndSanitize(string! template, string! processName, string! processId, System.DateTimeOffset timestamp, System.Func! sanitizeLeafFileName) -> string! Microsoft.Testing.Extensions.MergeOutputFileHelper static Microsoft.Testing.Extensions.MergeOutputFileHelper.BuildCaseFoldedProbePath(string! directory, string! probeFileName) -> string! diff --git a/src/Platform/Microsoft.Testing.Extensions.MSBuild/InternalAPI.Unshipped.txt b/src/Platform/Microsoft.Testing.Extensions.MSBuild/InternalAPI.Unshipped.txt index 3f863c495b..963521c88d 100644 --- a/src/Platform/Microsoft.Testing.Extensions.MSBuild/InternalAPI.Unshipped.txt +++ b/src/Platform/Microsoft.Testing.Extensions.MSBuild/InternalAPI.Unshipped.txt @@ -10,6 +10,7 @@ static Microsoft.Testing.Platform.IPC.NamedPipeServer.ResolvePipeDirectory(strin static Microsoft.Testing.Platform.Resources.PlatformResources.NamedPipeDirectoryNotWritableErrorMessage.get -> string! static Microsoft.Testing.Platform.Resources.PlatformResources.NamedPipePathTooLongErrorMessage.get -> string! Microsoft.Testing.Platform.Helpers.ExitCode.CoverageThresholdFailed = 14 -> Microsoft.Testing.Platform.Helpers.ExitCode +Microsoft.Testing.Platform.Helpers.ExitCode.TestExecutionStoppedAtDeadline = 15 -> Microsoft.Testing.Platform.Helpers.ExitCode static Microsoft.Testing.Platform.IPC.Serializers.BaseSerializer.ReadFields(System.IO.Stream! stream, System.Func! tryReadField) -> void static Microsoft.Testing.Platform.IPC.Serializers.BaseSerializer.TryReadExecutionScopedField(System.IO.Stream! stream, ushort fieldId, int fieldSize, ref string? executionId, ref string? instanceId) -> bool static Microsoft.Testing.Platform.IPC.Serializers.BaseSerializer.WriteExecutionScopedHeader(System.IO.Stream! stream, string? executionId, string? instanceId, ushort payloadFieldCount) -> void @@ -26,3 +27,6 @@ Microsoft.Testing.Platform.IPC.NamedPipeConnectionBase.WriteMessageAsync(System. *REMOVED*Microsoft.Testing.Platform.IPC.NamedPipeConnectionBase.WriteMessageAsync(System.IO.Pipes.PipeStream! stream, Microsoft.Testing.Platform.IPC.INamedPipeSerializer! serializer, object! message, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! Microsoft.Testing.Platform.IPC.NamedPipeServer.NamedPipeServer(Microsoft.Testing.Platform.IPC.PipeNameDescription! pipeNameDescription, System.Func!>! callback, Microsoft.Testing.Platform.Helpers.IEnvironment! environment, Microsoft.Testing.Platform.Logging.ILogger! logger, Microsoft.Testing.Platform.Helpers.ITask! task, int maxNumberOfServerInstances, System.Collections.Generic.IReadOnlyList? authorizedSecurityIdentities, System.Threading.CancellationToken cancellationToken) -> void Microsoft.Testing.Platform.IPC.NamedPipeServer.NamedPipeServer(string! name, System.Func!>! callback, Microsoft.Testing.Platform.Helpers.IEnvironment! environment, Microsoft.Testing.Platform.Logging.ILogger! logger, Microsoft.Testing.Platform.Helpers.ITask! task, System.Collections.Generic.IReadOnlyList? authorizedSecurityIdentities, System.Threading.CancellationToken cancellationToken) -> void +const Microsoft.Testing.Platform.Helpers.EnvironmentVariableConstants.TESTINGPLATFORM_DEADLINE = "TESTINGPLATFORM_DEADLINE" -> string! +const Microsoft.Testing.Platform.Helpers.EnvironmentVariableConstants.TESTINGPLATFORM_DEADLINE_STOP_MARGIN = "TESTINGPLATFORM_DEADLINE_STOP_MARGIN" -> string! +const Microsoft.Testing.Platform.Helpers.EnvironmentVariableConstants.TESTINGPLATFORM_DEADLINE_DUMP_MARGIN = "TESTINGPLATFORM_DEADLINE_DUMP_MARGIN" -> string! diff --git a/src/Platform/Microsoft.Testing.Extensions.Retry/InternalAPI/InternalAPI.Unshipped.txt b/src/Platform/Microsoft.Testing.Extensions.Retry/InternalAPI/InternalAPI.Unshipped.txt index 9f7f7aa7ff..b932d48f4c 100644 --- a/src/Platform/Microsoft.Testing.Extensions.Retry/InternalAPI/InternalAPI.Unshipped.txt +++ b/src/Platform/Microsoft.Testing.Extensions.Retry/InternalAPI/InternalAPI.Unshipped.txt @@ -1,4 +1,4 @@ -#nullable enable +#nullable enable const Microsoft.Testing.Platform.Helpers.EnvironmentVariableConstants.TESTINGPLATFORM_DOTNETTEST_ATTEMPTNUMBER = "TESTINGPLATFORM_DOTNETTEST_ATTEMPTNUMBER" -> string! const Microsoft.Testing.Platform.IPC.FileArtifactMessageFieldsId.InputArtifactPaths = 8 -> ushort const Microsoft.Testing.Platform.IPC.FileArtifactMessageFieldsId.Kind = 7 -> ushort @@ -14,6 +14,7 @@ static Microsoft.Testing.Platform.IPC.NamedPipeServer.ResolvePipeDirectory(strin static Microsoft.Testing.Platform.Resources.PlatformResources.NamedPipeDirectoryNotWritableErrorMessage.get -> string! static Microsoft.Testing.Platform.Resources.PlatformResources.NamedPipePathTooLongErrorMessage.get -> string! Microsoft.Testing.Platform.Helpers.ExitCode.CoverageThresholdFailed = 14 -> Microsoft.Testing.Platform.Helpers.ExitCode +Microsoft.Testing.Platform.Helpers.ExitCode.TestExecutionStoppedAtDeadline = 15 -> Microsoft.Testing.Platform.Helpers.ExitCode static Microsoft.Testing.Platform.IPC.Serializers.BaseSerializer.ReadFields(System.IO.Stream! stream, System.Func! tryReadField) -> void static Microsoft.Testing.Platform.IPC.Serializers.BaseSerializer.TryReadExecutionScopedField(System.IO.Stream! stream, ushort fieldId, int fieldSize, ref string? executionId, ref string? instanceId) -> bool static Microsoft.Testing.Platform.IPC.Serializers.BaseSerializer.WriteExecutionScopedHeader(System.IO.Stream! stream, string? executionId, string? instanceId, ushort payloadFieldCount) -> void @@ -93,3 +94,6 @@ Microsoft.Testing.Extensions.Policy.RetryAttemptArtifact.DestinationPath.get -> Microsoft.Testing.Extensions.Policy.RetryAttemptArtifact.Kind.get -> string? Microsoft.Testing.Extensions.Policy.RetryAttemptArtifact.Path.get -> string! static Microsoft.Testing.Extensions.Policy.RetrySummaryReporter.MoveArtifactsAsync(Microsoft.Testing.Platform.Extensions.OutputDevice.IOutputDeviceDataProducer! producer, Microsoft.Testing.Platform.OutputDevice.IOutputDevice! outputDevice, Microsoft.Testing.Platform.Helpers.IFileSystem! fileSystem, Microsoft.Testing.Platform.Logging.ILogger! logger, string! currentTryResultFolder, string! resultDirectory, System.Collections.Generic.IReadOnlyDictionary! replacements, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +const Microsoft.Testing.Platform.Helpers.EnvironmentVariableConstants.TESTINGPLATFORM_DEADLINE = "TESTINGPLATFORM_DEADLINE" -> string! +const Microsoft.Testing.Platform.Helpers.EnvironmentVariableConstants.TESTINGPLATFORM_DEADLINE_STOP_MARGIN = "TESTINGPLATFORM_DEADLINE_STOP_MARGIN" -> string! +const Microsoft.Testing.Platform.Helpers.EnvironmentVariableConstants.TESTINGPLATFORM_DEADLINE_DUMP_MARGIN = "TESTINGPLATFORM_DEADLINE_DUMP_MARGIN" -> string! diff --git a/src/Platform/Microsoft.Testing.Extensions.TrxReport/InternalAPI/InternalAPI.Unshipped.txt b/src/Platform/Microsoft.Testing.Extensions.TrxReport/InternalAPI/InternalAPI.Unshipped.txt index 7cd93fa1d6..bdac54e837 100644 --- a/src/Platform/Microsoft.Testing.Extensions.TrxReport/InternalAPI/InternalAPI.Unshipped.txt +++ b/src/Platform/Microsoft.Testing.Extensions.TrxReport/InternalAPI/InternalAPI.Unshipped.txt @@ -63,6 +63,7 @@ static Microsoft.Testing.Platform.IPC.NamedPipeServer.ResolvePipeDirectory(strin static Microsoft.Testing.Platform.Resources.PlatformResources.NamedPipeDirectoryNotWritableErrorMessage.get -> string! static Microsoft.Testing.Platform.Resources.PlatformResources.NamedPipePathTooLongErrorMessage.get -> string! Microsoft.Testing.Platform.Helpers.ExitCode.CoverageThresholdFailed = 14 -> Microsoft.Testing.Platform.Helpers.ExitCode +Microsoft.Testing.Platform.Helpers.ExitCode.TestExecutionStoppedAtDeadline = 15 -> Microsoft.Testing.Platform.Helpers.ExitCode static Microsoft.Testing.Platform.Services.ArtifactNamingHelper.ResolveAndSanitize(string! template, string! processName, string! processId, System.DateTimeOffset timestamp, System.Func! sanitizeLeafFileName) -> string! static Microsoft.Testing.Platform.IPC.Serializers.BaseSerializer.ReadFields(System.IO.Stream! stream, System.Func! tryReadField) -> void static Microsoft.Testing.Platform.IPC.Serializers.BaseSerializer.TryReadExecutionScopedField(System.IO.Stream! stream, ushort fieldId, int fieldSize, ref string? executionId, ref string? instanceId) -> bool @@ -70,6 +71,9 @@ static Microsoft.Testing.Platform.IPC.Serializers.BaseSerializer.WriteExecutionS static Microsoft.Testing.Platform.IPC.Serializers.BaseSerializer.WriteListPayload(System.IO.Stream! stream, ushort fieldId, T[]? list, System.Action! writeItem) -> void const Microsoft.Testing.Platform.IPC.FailedTestResultMessageFieldsId.Expected = 10 -> ushort const Microsoft.Testing.Platform.IPC.FailedTestResultMessageFieldsId.Actual = 11 -> ushort +const Microsoft.Testing.Platform.Helpers.EnvironmentVariableConstants.TESTINGPLATFORM_DEADLINE = "TESTINGPLATFORM_DEADLINE" -> string! +const Microsoft.Testing.Platform.Helpers.EnvironmentVariableConstants.TESTINGPLATFORM_DEADLINE_STOP_MARGIN = "TESTINGPLATFORM_DEADLINE_STOP_MARGIN" -> string! +const Microsoft.Testing.Platform.Helpers.EnvironmentVariableConstants.TESTINGPLATFORM_DEADLINE_DUMP_MARGIN = "TESTINGPLATFORM_DEADLINE_DUMP_MARGIN" -> string! const Microsoft.Testing.Platform.IPC.FailedTestResultMessageFieldsId.RetryAttemptNumber = 12 -> ushort const Microsoft.Testing.Platform.IPC.FailedTestResultMessageFieldsId.IsSuperseded = 13 -> ushort const Microsoft.Testing.Platform.IPC.SuccessfulTestResultMessageFieldsId.RetryAttemptNumber = 9 -> ushort diff --git a/src/Platform/Microsoft.Testing.Platform/Capabilities/TestFramework/IGracefulStopTestExecutionCapability.cs b/src/Platform/Microsoft.Testing.Platform/Capabilities/TestFramework/IGracefulStopTestExecutionCapability.cs index bb1f233203..6e60a16121 100644 --- a/src/Platform/Microsoft.Testing.Platform/Capabilities/TestFramework/IGracefulStopTestExecutionCapability.cs +++ b/src/Platform/Microsoft.Testing.Platform/Capabilities/TestFramework/IGracefulStopTestExecutionCapability.cs @@ -17,5 +17,27 @@ public interface IGracefulStopTestExecutionCapability : ITestFrameworkCapability /// Stops the test execution gracefully. /// /// The cancellation token. + /// A task representing the asynchronous operation. Task StopTestExecutionAsync(CancellationToken cancellationToken); } + +/// +/// A graceful-stop capability that reports whether a stop request was accepted. +/// +/// +/// Test frameworks should implement this capability when a successful stop request can be a no-op because +/// execution has already completed. +/// +[Experimental("TPEXP", UrlFormat = "https://aka.ms/testingplatform/diagnostics#{0}")] +public interface IGracefulStopTestExecutionResultCapability : IGracefulStopTestExecutionCapability +{ + /// + /// Attempts to stop the test execution gracefully. + /// + /// The cancellation token. + /// + /// A task whose result is when a new stop request was accepted; otherwise, + /// when execution had already completed or a stop had already been requested. + /// + Task TryStopTestExecutionAsync(CancellationToken cancellationToken); +} diff --git a/src/Platform/Microsoft.Testing.Platform/Extensions/AbortAtDeadlineExtension.cs b/src/Platform/Microsoft.Testing.Platform/Extensions/AbortAtDeadlineExtension.cs new file mode 100644 index 0000000000..3f22959f51 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Platform/Extensions/AbortAtDeadlineExtension.cs @@ -0,0 +1,616 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.Testing.Platform.Capabilities.TestFramework; +using Microsoft.Testing.Platform.Extensions.Messages; +using Microsoft.Testing.Platform.Extensions.OutputDevice; +using Microsoft.Testing.Platform.Extensions.TestHost; +using Microsoft.Testing.Platform.Helpers; +using Microsoft.Testing.Platform.Logging; +using Microsoft.Testing.Platform.OutputDevice; +using Microsoft.Testing.Platform.Resources; +using Microsoft.Testing.Platform.Services; + +namespace Microsoft.Testing.Platform.Extensions; + +/// +/// Reacts to a CI-imposed hard-cancel deadline (see ): a short margin +/// before the deadline it asks the test framework to gracefully stop scheduling new tests, so the +/// in-flight session can end normally and all reporters (TRX/HTML/AzDO live run) get to finalize +/// before the CI runner hard-kills the process. +/// +/// +/// This is a prototype. It is timer-driven (the deadline is an absolute instant, so the timer is +/// armed at construction). It implements so the message bus keeps a live +/// reference to it for the duration of the run (which also keeps its timer alive); it consumes no +/// message types, so the bus keeps that reference without routing any message to it. It also implements +/// as a backstop and is registered as a service so the host can call +/// the moment the test framework invoker returns. That early signal +/// prevents a timer firing while reporters finalize an already-finished run from marking it deadline-truncated. +/// +internal sealed class AbortAtDeadlineExtension : IDataConsumer, ITestSessionLifetimeHandler, IOutputDeviceDataProducer, IDisposable +#if NETCOREAPP +#pragma warning disable SA1001 // Commas should be spaced correctly + , IAsyncDisposable +#pragma warning restore SA1001 // Commas should be spaced correctly +#endif +{ + private readonly IGracefulStopTestExecutionCapability? _capability; + private readonly IStopPoliciesService _policiesService; + private readonly ITestApplicationCancellationTokenSource _cancellationTokenSource; + private readonly IOutputDevice _outputDevice; + private readonly ILogger _logger; + private readonly IClock _clock; + private readonly List _startupWarnings = []; + private readonly DateTimeOffset? _stopAt; + private readonly Timer? _timer; + + // How long a single best-effort diagnostic may take before it is abandoned. Injectable only so a test + // can exercise the bound without waiting DefaultReportTimeout for it; production always uses the default. + private readonly TimeSpan _reportTimeout; + + // Serializes publishing _handleDeadlineTask against Dispose reading it, so the timer callback and + // disposal cannot interleave in a way that starts the handler after Dispose has already returned. +#if NET9_0_OR_GREATER + private readonly Lock _lock = new(); +#else + private readonly object _lock = new(); +#endif + private int _handled; + private int _startupWarningsDisplayed; + private volatile bool _disposed; + + // Which of "test execution finished" and "the deadline took the run" happened first. Both transitions are + // made under _lock and only out of Running, so they are mutually exclusive: whichever takes the lock first + // wins and the other becomes a no-op. Read without the lock on the timer callback's fast-path, so it is + // volatile. + private volatile RunState _state; + private Task? _handleDeadlineTask; + + /// + /// Bounded wait applied on disposal to let an in-flight deadline handler finish reporting before + /// the host tears down, without letting a wedged stop hang disposal forever. + /// + private static readonly TimeSpan DisposeDrainTimeout = TimeSpan.FromSeconds(30); + + /// + /// Bounded wait applied to each best-effort diagnostic on the deadline path, so a logger or output + /// device that never completes cannot hold up the graceful stop it precedes. + /// + /// + /// Generous enough that a healthy provider never hits it, and short relative to the margin the + /// deadline leaves for the stop to take effect. + /// + private static readonly TimeSpan DefaultReportTimeout = TimeSpan.FromSeconds(10); + + /// + /// throws for due times above ~49.7 days (its internal limit is + /// milliseconds). Longer delays are scheduled in chunks. + /// + private static readonly TimeSpan MaxTimerDueTime = TimeSpan.FromMilliseconds(uint.MaxValue - 1); + + public AbortAtDeadlineExtension( + IEnvironment environment, + IClock clock, + IGracefulStopTestExecutionCapability? capability, + IStopPoliciesService policiesService, + ITestApplicationCancellationTokenSource cancellationTokenSource, + IOutputDevice outputDevice, + ILoggerFactory loggerFactory, + TimeSpan? reportTimeout = null, + bool isHangDumpEnabled = false) + { + _capability = capability; + _policiesService = policiesService; + _cancellationTokenSource = cancellationTokenSource; + _outputDevice = outputDevice; + _logger = loggerFactory.CreateLogger(nameof(AbortAtDeadlineExtension)); + _clock = clock; + _reportTimeout = reportTimeout ?? DefaultReportTimeout; + + if (!DeadlineHelper.TryGetDeadline(environment, out DateTimeOffset deadline)) + { + // Distinguish "opt-in is off" (variable unset) from "set but malformed". The former is the + // normal case and stays silent; the latter is a configuration mistake worth a warning. + string? raw = environment.GetEnvironmentVariable(EnvironmentVariableConstants.TESTINGPLATFORM_DEADLINE); + if (!RoslynString.IsNullOrWhiteSpace(raw)) + { + TryLog(() => _logger.LogWarning($"Environment variable '{EnvironmentVariableConstants.TESTINGPLATFORM_DEADLINE}' is set to '{raw}' but could not be parsed as an absolute ISO 8601 instant. Deadline-aware cancellation is disabled.")); + _startupWarnings.Add(string.Format( + CultureInfo.InvariantCulture, + PlatformResources.AbortAtDeadlineInvalidDeadlineWarning, + EnvironmentVariableConstants.TESTINGPLATFORM_DEADLINE, + raw)); + } + + return; + } + + TimeSpan stopMargin = DeadlineHelper.GetStopMargin(environment); + TimeSpan dumpMargin = DeadlineHelper.GetDumpMargin(environment); + DateTimeOffset stopAt = DeadlineHelper.SubtractSaturating(deadline, stopMargin); + bool areMarginsInverted = isHangDumpEnabled && dumpMargin >= stopMargin; + if (areMarginsInverted) + { + _startupWarnings.Add(string.Format( + CultureInfo.InvariantCulture, + PlatformResources.AbortAtDeadlineInvalidMarginOrderWarning, + dumpMargin, + stopMargin)); + } + + // Log the resolved instants and margins so any misconfiguration is visible. + TryLog(() => + { + _logger.LogInformation($"Deadline-aware cancellation: deadline={deadline:o}, stopMargin={stopMargin}, dumpMargin={dumpMargin}, graceful stop scheduled at {stopAt:o} (UTC)."); + + // stopMargin is meant to be larger than dumpMargin so the graceful stop is attempted before + // the hang dump. Warn when the ordering is inverted rather than silently misbehaving. + if (areMarginsInverted) + { + _logger.LogWarning($"Deadline dump margin ({dumpMargin}) is greater than or equal to the stop margin ({stopMargin}). The graceful stop is meant to run before the hang dump; with these margins the hang dump may fire first."); + } + }); + + if (capability is null) + { + // A deadline is configured but this framework cannot stop gracefully, so nothing is armed. + // Surface it rather than silently doing nothing. + TryLog(() => _logger.LogWarning($"Environment variable '{EnvironmentVariableConstants.TESTINGPLATFORM_DEADLINE}' is set but the test framework does not support graceful stop ('{nameof(IGracefulStopTestExecutionCapability)}'); the platform cannot stop early at the deadline.")); + _startupWarnings.Add(string.Format( + CultureInfo.InvariantCulture, + PlatformResources.AbortAtDeadlineCapabilityUnavailableWarning, + EnvironmentVariableConstants.TESTINGPLATFORM_DEADLINE, + nameof(IGracefulStopTestExecutionCapability))); + return; + } + + _stopAt = stopAt; + + // Timer cannot represent delays above ~49.7 days. Arm it in bounded chunks and re-check the + // absolute instant on every callback so a far-future deadline never fires early. + _timer = new Timer(static state => ((AbortAtDeadlineExtension)state!).OnTimerElapsed(), this, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); + _timer.Change(GetTimerDueTime(stopAt, clock.UtcNow), Timeout.InfiniteTimeSpan); + } + + private static void TryLog(Action logAction) + { + try + { + logAction(); + } + catch (Exception) + { + // Construction-time diagnostics are best-effort: a logger failure must never break test + // framework construction. + } + } + + internal static TimeSpan GetTimerDueTime(DateTimeOffset deadline, DateTimeOffset now) + { + TimeSpan remaining = deadline - now; + return remaining <= TimeSpan.Zero + ? TimeSpan.Zero + : remaining > MaxTimerDueTime + ? MaxTimerDueTime + : remaining; + } + + private void OnTimerElapsed() + { + TimeSpan dueTime = GetTimerDueTime(_stopAt!.Value, _clock.UtcNow); + if (dueTime > TimeSpan.Zero) + { + lock (_lock) + { + if (!_disposed && _state == RunState.Running) + { + _timer!.Change(dueTime, Timeout.InfiniteTimeSpan); + } + } + + return; + } + + OnDeadlineReached(); + } + + // No message types are consumed: this extension implements IDataConsumer only to keep a live + // reference on the message bus (see the remark on the class). Returning an empty list avoids the + // bus routing every test result to a no-op ConsumeAsync, which would be O(test-count) overhead. + public Type[] DataTypesConsumed { get; } = []; + + /// + public string Uid => nameof(AbortAtDeadlineExtension); + + /// + public string Version => PlatformVersion.Version; + + /// + public string DisplayName => nameof(AbortAtDeadlineExtension); + + /// + public string Description { get; } = PlatformResources.AbortAtDeadlineDescription; + + /// + public async Task IsEnabledAsync() + { + if (Interlocked.Exchange(ref _startupWarningsDisplayed, 1) == 0) + { + foreach (string warning in _startupWarnings) + { + await TryReportAsync( + () => _outputDevice.DisplayAsync( + this, + new WarningMessageOutputDeviceData(warning), + _cancellationTokenSource.CancellationToken), + "Failed to display a deadline configuration warning.").ConfigureAwait(false); + } + } + + return _stopAt.HasValue && _capability is not null; + } + + /// + public Task ConsumeAsync(IDataProducer dataProducer, IData value, CancellationToken cancellationToken) + => Task.CompletedTask; + + /// + public Task OnTestSessionStartingAsync(ITestSessionContext testSessionContext) + => Task.CompletedTask; + + /// + public Task OnTestSessionFinishingAsync(ITestSessionContext testSessionContext) + { + NotifyTestExecutionCompleted(); + return Task.CompletedTask; + } + + /// + /// Disarms the deadline because test execution has finished. + /// + /// + /// The host calls this as soon as the test framework invoker returns, before end-of-session draining and + /// reporting begin. That instant matters: from here on the deadline is moot, because every test that was + /// going to run has run. Doing it through instead would be far + /// too late -- session-end notification first drains the message bus, then runs the non-consumer handlers, + /// then the consumer handlers in registration order, and this extension is a consumer appended after the + /// reporters. A deadline reached anywhere in that window would still mark a fully-executed run as + /// truncated (exit code 15). + /// The transition is made under the same lock that claims the deadline, and only out of + /// , so it is atomic against claiming the stop. Completion after the deadline + /// claim is recorded separately so a rejected stop can restore the completed state without invoking + /// framework code while holding the lock. + /// The timer itself is left to be disposed at host teardown; the state is what makes any late fire a no-op. + /// + public void NotifyTestExecutionCompleted() + { + lock (_lock) + { + if (_state == RunState.Running) + { + _state = RunState.Completed; + } + else if (_state == RunState.DeadlineClaimed) + { + _state = RunState.DeadlineClaimedAndCompleted; + } + } + } + + /// + /// Waits until a deadline stop request that raced test-execution completion has resolved its verdict. + /// + internal Task WaitForDeadlineHandlingAsync() + { + lock (_lock) + { + return _handleDeadlineTask ?? Task.CompletedTask; + } + } + + /// + /// Atomically claims the deadline while test execution is still running. + /// + /// + /// Framework code is invoked only after releasing the lock. Completion that occurs after the claim is + /// tracked by and reconciled if the stop is rejected. + /// + private bool TryClaimDeadline() + { + lock (_lock) + { + if (_disposed || _state != RunState.Running || _policiesService.IsTestExecutionCompleted) + { + return false; + } + + _state = RunState.DeadlineClaimed; + return true; + } + } + + private static async Task RequestGracefulStopAsync( + IGracefulStopTestExecutionCapability capability, + CancellationToken cancellationToken) + { + await capability.StopTestExecutionAsync(cancellationToken).ConfigureAwait(false); + return true; + } + + private void OnDeadlineReached() + { + // Do not start deadline handling once we are tearing down, or once test execution has already + // completed (cheap fast-path; both are re-checked under the lock below to actually close the race + // with Dispose and NotifyTestExecutionCompleted). + if (_disposed || _state != RunState.Running || _policiesService.IsTestExecutionCompleted) + { + return; + } + + // Ensure we react only once. + if (Interlocked.Exchange(ref _handled, 1) != 0) + { + return; + } + + // Publish the handler task under the same lock Dispose uses so the two cannot interleave: + // either we set _handleDeadlineTask before Dispose captures it (Dispose then drains it), or + // Dispose sets _disposed first and we observe it here and never start the handler against + // torn-down services. Without this, the timer callback could pass the _disposed check above, + // Dispose could run to completion (seeing _handleDeadlineTask still null, so draining + // nothing), and only then would the handler start -- after disposal already returned. + lock (_lock) + { + // Bail if disposal started, or if test execution finished between the fast-path check above and + // acquiring the lock (the timer fired while the reporters are finalizing a fully-completed run). In + // either case there is nothing left to stop, and marking the run deadline-truncated would wrongly + // force exit code 15 on a run that actually completed. This is only an early-out: the handler + // claims the deadline under this same lock before invoking the graceful-stop capability. + if (_disposed || _state != RunState.Running || _policiesService.IsTestExecutionCompleted) + { + return; + } + + // Start the handler and capture its task while holding the lock so it cannot interleave with + // Dispose. HandleDeadlineAsync yields immediately (await Task.Yield()), so it returns an incomplete + // task here and the lock is released before the handler body starts. + _handleDeadlineTask = HandleDeadlineAsync(); + } + } + + private async Task HandleDeadlineAsync() + { + // This method is started synchronously inside _lock (see OnDeadlineReached), which also publishes + // the returned task into _handleDeadlineTask. An async method does NOT necessarily yield at its + // first await: the logger and output device can return already-completed tasks, which would otherwise + // run the handler synchronously before _handleDeadlineTask is published. Yield first so control returns + // to the caller, _handleDeadlineTask is observed, and _lock is released before the handler starts. + // The deadline is later claimed under _lock, but framework code is invoked after releasing the lock. + // Task.Yield is cooperative and safe even on a single-threaded runtime (browser/WASI). + await Task.Yield(); + + if (_capability is not { } capability) + { + return; + } + + if (!TryClaimDeadline()) + { + await TryReportAsync( + () => _logger.LogDebugAsync("Test execution completed while the approaching deadline was being reported; abandoning the graceful stop."), + "Failed to report the abandoned deadline stop.").ConfigureAwait(false); + return; + } + + Task stopTask; + try + { + stopTask = capability is IGracefulStopTestExecutionResultCapability resultCapability + ? resultCapability.TryStopTestExecutionAsync(_cancellationTokenSource.CancellationToken) + : RequestGracefulStopAsync(capability, _cancellationTokenSource.CancellationToken); + } + catch (Exception ex) + { + ReleaseDeadlineClaim(); + await TryReportAsync( + () => _logger.LogErrorAsync("Failed to request graceful stop at deadline.", ex), + "Failed to report the graceful-stop failure.").ConfigureAwait(false); + return; + } + + // Diagnostics are best-effort and run only after the framework has received the stop request. A wedged + // logger therefore cannot consume the remaining deadline margin before graceful shutdown starts. + await TryReportAsync( + () => _logger.LogInformationAsync($"Deadline approaching (stop scheduled at {_stopAt:o}). Requesting graceful stop of test execution."), + "Failed to report the approaching deadline.").ConfigureAwait(false); + + bool stopAccepted = false; + try + { + await stopTask.TimeoutAfterAsync(DisposeDrainTimeout).ConfigureAwait(false); + stopAccepted = await stopTask.ConfigureAwait(false); + if (!stopAccepted) + { + if (!await _policiesService.TryExecuteDeadlineStopFallbackAsync().ConfigureAwait(false)) + { + return; + } + + stopAccepted = true; + } + + // Commit only after the framework accepted the stop. The host awaits this handler after the + // invoker returns and before reporters run, so committing here cannot be missed by exit-code + // consumers, while an asynchronously rejected stop is resolved before they inspect the verdict. + await _policiesService.ExecuteDeadlineCallbacksAsync().ConfigureAwait(false); + + // Only now tell the user. It is written after the stop is accepted rather than before it for the + // window above, and it is reached only when the deadline actually won the race, so the message is + // never printed for a run that finished on its own or for a stop the framework rejected. The + // framework has been asked to stop but in-flight tests are still finishing, so this still lands + // before the end-of-run summary. + await TryReportAsync( + () => _outputDevice.DisplayAsync( + this, + new SessionMessageOutputDeviceData(PlatformResources.AbortAtDeadlineMessage), + _cancellationTokenSource.CancellationToken), + "Failed to report the approaching deadline.").ConfigureAwait(false); + } + catch (Exception ex) + { + // An asynchronous stop failure leaves the verdict unset and releases the claim below. If a + // deadline callback failed instead, ExecuteDeadlineCallbacksAsync already set the verdict + // synchronously, so the accepted stop remains correctly classified. + string message = stopAccepted + ? "The deadline stop was accepted, but a deadline callback failed." + : "Failed to request graceful stop at deadline."; + await TryReportAsync( + () => _logger.LogErrorAsync(message, ex), + "Failed to report the graceful-stop failure.").ConfigureAwait(false); + } + finally + { + if (!stopAccepted) + { + ReleaseDeadlineClaim(); + } + } + } + + private void ReleaseDeadlineClaim() + { + lock (_lock) + { + if (_state == RunState.DeadlineClaimed) + { + _state = RunState.Running; + } + else if (_state == RunState.DeadlineClaimedAndCompleted) + { + _state = RunState.Completed; + } + } + } + + /// + /// Runs a best-effort diagnostic, swallowing anything it throws and giving up on it if it does not + /// complete promptly, so it can never skip or delay the graceful stop. + /// + private async Task TryReportAsync(Func report, string failureMessage) + { + try + { + // Swallowing faults is not enough on its own. A wedged logger or output device does not throw, + // it hands back a task that never completes. Before the claim that would stop the handler ever + // reaching the graceful stop, so the deadline would pass with nothing done; after the stop it + // would keep the handler task alive until disposal gave up on draining it. Bound the wait: + // TimeoutAfterAsync abandons the task and keeps observing it, so a fault arriving later cannot + // resurface as an unobserved task exception. + await report().TimeoutAfterAsync(_reportTimeout).ConfigureAwait(false); + } + catch (Exception ex) + { + // Even this failure log is best-effort, and bounded for the same reason: the logger may be + // exactly what threw or wedged, so reporting the failure must not re-throw or block the stop. + try + { + await _logger.LogErrorAsync(failureMessage, ex).TimeoutAfterAsync(_reportTimeout).ConfigureAwait(false); + } + catch (Exception) + { + // Ignore: the graceful stop is the only thing that must happen. + } + } + } + + public void Dispose() + { + // Capture the in-flight handler task under the lock so we either observe the task the timer + // callback published (and drain it below) or set _disposed first (so the callback never + // starts the handler). See OnDeadlineReached. + Task? handleDeadlineTask; + lock (_lock) + { + _disposed = true; + handleDeadlineTask = _handleDeadlineTask; + } + + _timer?.Dispose(); +#if !NETCOREAPP + // netstandard2.0 has no ValueTask/IAsyncDisposable, so there is no async drain path (see + // DisposeAsync below, which is netcoreapp-only). Fall back to a bounded blocking drain so an + // in-flight deadline handler can finish reporting before teardown, without letting a wedged + // graceful stop hang disposal. HandleDeadlineAsync swallows its own failures, so this wait + // never observes a fault. + try + { + handleDeadlineTask?.Wait(DisposeDrainTimeout); + } + catch (Exception) + { + // Best-effort drain: disposal must never throw. + } +#endif + } + +#if NETCOREAPP + public async ValueTask DisposeAsync() + { + // Capture the in-flight handler task under the lock so we either observe the task the timer + // callback published (and drain it below) or set _disposed first (so the callback never + // starts the handler). See OnDeadlineReached. + Task? handleTask; + lock (_lock) + { + _disposed = true; + handleTask = _handleDeadlineTask; + } + + _timer?.Dispose(); + + // Drain an in-flight deadline handler so its reporting can finish before the host tears down, + // but bound the wait so a wedged graceful stop cannot hang disposal. HandleDeadlineAsync + // swallows its own failures, so awaiting the completed task here never throws. + if (handleTask is not null) + { + Task completed = await Task.WhenAny(handleTask, Task.Delay(DisposeDrainTimeout)).ConfigureAwait(false); + if (completed == handleTask) + { + await handleTask.ConfigureAwait(false); + } + } + } +#endif + + /// + /// Which of test execution finishing and the deadline firing took the run. The winner is claimed only out + /// of and under the extension's lock; completion during a deadline claim is recorded + /// separately so a rejected stop can restore the completed state. + /// + private enum RunState + { + /// + /// Test execution is in progress, so the deadline still applies. + /// + Running, + + /// + /// The test framework invoker returned: every test that was going to run has run, so a deadline + /// reached from here on must not mark the run as truncated. + /// + Completed, + + /// + /// The deadline fired while tests were still running and owns the verdict. Test execution completing + /// afterwards is the stop taking effect, so it must not take the verdict back. + /// + DeadlineClaimed, + + /// + /// Test execution completed after the deadline claim but before the framework accepted the stop. If the + /// framework rejects the stop, the run returns to rather than . + /// + DeadlineClaimedAndCompleted, + } +} diff --git a/src/Platform/Microsoft.Testing.Platform/Helpers/DeadlineHelper.cs b/src/Platform/Microsoft.Testing.Platform/Helpers/DeadlineHelper.cs new file mode 100644 index 0000000000..f4a95096ad --- /dev/null +++ b/src/Platform/Microsoft.Testing.Platform/Helpers/DeadlineHelper.cs @@ -0,0 +1,103 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.CodeAnalysis; + +namespace Microsoft.Testing.Platform.Helpers; + +/// +/// Reads the CI-imposed hard-cancel deadline and its associated margins from the environment. +/// The deadline is exported by the CI system (or Arcade) as an absolute wall-clock instant so +/// that both the in-process test host and the out-of-process test host controller can schedule +/// their reactions (graceful stop, hang dump) backwards from the same instant. +/// +[Embedded] +internal static class DeadlineHelper +{ + private static readonly string[] SupportedDeadlineFormats = + [ + "yyyy-MM-dd'T'HH:mm:ssK", + "yyyy-MM-dd'T'HH:mm:ss.FFFFFFFK", + ]; + + // Prototype defaults. stopMargin > dumpMargin so the graceful stop is attempted first and the + // hang dump is the fallback for a host that did not stop in time. + private static readonly TimeSpan DefaultStopMargin = TimeSpan.FromSeconds(60); + private static readonly TimeSpan DefaultDumpMargin = TimeSpan.FromSeconds(30); + + /// + /// Attempts to read and parse + /// it as an absolute instant in UTC. + /// + public static bool TryGetDeadline(IEnvironment environment, out DateTimeOffset deadlineUtc) + { + deadlineUtc = default; + string? raw = environment.GetEnvironmentVariable(EnvironmentVariableConstants.TESTINGPLATFORM_DEADLINE); + if (RoslynString.IsNullOrWhiteSpace(raw)) + { + return false; + } + + bool hasExplicitOffset = raw.EndsWith("Z", StringComparison.OrdinalIgnoreCase) + || (raw.Length >= 6 + && raw[^6] is '+' or '-' + && raw[^3] == ':'); + if (!hasExplicitOffset) + { + return false; + } + + if (!DateTimeOffset.TryParseExact( + raw, + SupportedDeadlineFormats, + CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, + out DateTimeOffset parsed)) + { + return false; + } + + deadlineUtc = parsed.ToUniversalTime(); + return true; + } + + /// + /// Gets the lead time before the deadline at which the platform should gracefully stop scheduling + /// new tests. Reads + /// (bare numbers are seconds); falls back to a default when unset or unparsable. + /// + public static TimeSpan GetStopMargin(IEnvironment environment) + => GetMargin(environment, EnvironmentVariableConstants.TESTINGPLATFORM_DEADLINE_STOP_MARGIN, DefaultStopMargin); + + /// + /// Gets the lead time before the deadline at which the platform should take a hang dump. Reads + /// (bare numbers + /// are seconds); falls back to a default when unset or unparsable. + /// + public static TimeSpan GetDumpMargin(IEnvironment environment) + => GetMargin(environment, EnvironmentVariableConstants.TESTINGPLATFORM_DEADLINE_DUMP_MARGIN, DefaultDumpMargin); + + /// + /// Subtracts from , clamping the result at + /// instead of throwing when the subtraction would underflow. + /// A very old (but valid) deadline, or a large margin, could otherwise overflow while computing + /// the stop/dump instant. Saturating means "this instant is already in the past", which for both + /// callers translates to "act immediately". + /// + public static DateTimeOffset SubtractSaturating(DateTimeOffset instant, TimeSpan margin) + => margin > instant - DateTimeOffset.MinValue + ? DateTimeOffset.MinValue + : instant - margin; + + private static TimeSpan GetMargin(IEnvironment environment, string variableName, TimeSpan defaultValue) + { + string? raw = environment.GetEnvironmentVariable(variableName); + + // TimeSpanParser only matches non-negative numbers (its regex has no sign), so a parsed + // value is always >= zero; no extra sign check is needed here. + return !RoslynString.IsNullOrWhiteSpace(raw) + && TimeSpanParser.TryParse(raw, TimeSpanDefaultUnit.Seconds, out TimeSpan parsed) + ? parsed + : defaultValue; + } +} diff --git a/src/Platform/Microsoft.Testing.Platform/Helpers/EnvironmentVariableConstants.cs b/src/Platform/Microsoft.Testing.Platform/Helpers/EnvironmentVariableConstants.cs index d42f5e3f41..409a008bc8 100644 --- a/src/Platform/Microsoft.Testing.Platform/Helpers/EnvironmentVariableConstants.cs +++ b/src/Platform/Microsoft.Testing.Platform/Helpers/EnvironmentVariableConstants.cs @@ -90,4 +90,12 @@ internal static class EnvironmentVariableConstants // Trx public const string TESTINGPLATFORM_TRX_TESTRUN_ID = nameof(TESTINGPLATFORM_TRX_TESTRUN_ID); + + // Deadline-aware cancellation. TESTINGPLATFORM_DEADLINE is an absolute wall-clock instant + // (ISO 8601 round-trip, parsed to UTC) that the CI runner will hard-cancel the process at. + // The margins are the lead time before the deadline at which the platform reacts: graceful + // stop (stop scheduling new tests, let reporters finalize) and hang dump (out-of-proc dump). + public const string TESTINGPLATFORM_DEADLINE = nameof(TESTINGPLATFORM_DEADLINE); + public const string TESTINGPLATFORM_DEADLINE_STOP_MARGIN = nameof(TESTINGPLATFORM_DEADLINE_STOP_MARGIN); + public const string TESTINGPLATFORM_DEADLINE_DUMP_MARGIN = nameof(TESTINGPLATFORM_DEADLINE_DUMP_MARGIN); } diff --git a/src/Platform/Microsoft.Testing.Platform/Helpers/ExitCodes.cs b/src/Platform/Microsoft.Testing.Platform/Helpers/ExitCodes.cs index 521fa77e34..119c885b41 100644 --- a/src/Platform/Microsoft.Testing.Platform/Helpers/ExitCodes.cs +++ b/src/Platform/Microsoft.Testing.Platform/Helpers/ExitCodes.cs @@ -84,4 +84,9 @@ internal enum ExitCode /// One or more code-coverage thresholds were not met. /// CoverageThresholdFailed = 14, + + /// + /// Test execution stopped early because the configured deadline was approaching, so not every test ran. + /// + TestExecutionStoppedAtDeadline = 15, } diff --git a/src/Platform/Microsoft.Testing.Platform/Hosts/CommonTestHost.MessageBus.cs b/src/Platform/Microsoft.Testing.Platform/Hosts/CommonTestHost.MessageBus.cs index 4ab4dca444..a35a3947fc 100644 --- a/src/Platform/Microsoft.Testing.Platform/Hosts/CommonTestHost.MessageBus.cs +++ b/src/Platform/Microsoft.Testing.Platform/Hosts/CommonTestHost.MessageBus.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. +using Microsoft.Testing.Platform.Extensions; using Microsoft.Testing.Platform.Extensions.TestFramework; using Microsoft.Testing.Platform.Messages; using Microsoft.Testing.Platform.OutputDevice; @@ -12,7 +13,8 @@ namespace Microsoft.Testing.Platform.Hosts; internal abstract partial class CommonHost { protected static async Task ExecuteRequestAsync(ProxyOutputDevice outputDevice, ITestSessionContext testSessionInfo, - ServiceProvider serviceProvider, BaseMessageBus baseMessageBus, ITestFramework testFramework, TestHost.ClientInfo client) + ServiceProvider serviceProvider, BaseMessageBus baseMessageBus, ITestFramework testFramework, TestHost.ClientInfo client, + bool isDiscoveryRequest) { // Reset the shared, application-scoped coverage accumulator at the start of every request here, in the // common host/request lifecycle, so it happens for all output modes (terminal, pipe, server, custom) @@ -20,10 +22,37 @@ protected static async Task ExecuteRequestAsync(ProxyOutputDevice outputDevice, // thresholds would be reprinted and its threshold-failure verdict could poison a later session. serviceProvider.GetRequiredService().Reset(); - await DisplayBeforeSessionStartAsync(outputDevice, testSessionInfo).ConfigureAwait(false); CancellationToken cancellationToken = testSessionInfo.CancellationToken; + bool executionCompletedNotified = false; + + async Task NotifyTestExecutionCompletedAsync() + { + if (executionCompletedNotified) + { + return; + } + + AbortAtDeadlineExtension? abortAtDeadlineExtension = serviceProvider.GetService(); + abortAtDeadlineExtension?.NotifyTestExecutionCompleted(); + if (!isDiscoveryRequest) + { + serviceProvider.GetRequiredService().NotifyTestExecutionCompleted(); + } + + executionCompletedNotified = true; + if (abortAtDeadlineExtension is not null) + { + // A successful stop can make the invoker return before the deadline handler records + // its verdict, while an asynchronously rejected stop must release its claim. Resolve + // either outcome before reporters and exit-code consumers inspect the run. + await abortAtDeadlineExtension.WaitForDeadlineHandlingAsync().ConfigureAwait(false); + } + } + try { + await DisplayBeforeSessionStartAsync(outputDevice, testSessionInfo).ConfigureAwait(false); + try { IPlatformOpenTelemetryService? otelService = serviceProvider.GetPlatformOTelService(); @@ -34,7 +63,21 @@ protected static async Task ExecuteRequestAsync(ProxyOutputDevice outputDevice, using (otelService?.StartActivity("TestFrameworkInvoker")) { - await serviceProvider.GetTestFrameworkInvoker().ExecuteAsync(testFramework, client, cancellationToken).ConfigureAwait(false); + try + { + await serviceProvider.GetTestFrameworkInvoker().ExecuteAsync(testFramework, client, cancellationToken).ConfigureAwait(false); + } + finally + { + // Test execution is over -- normally, or because it failed or was canceled. Disarm the + // deadline here, before end-of-session draining and reporting begin, so a deadline + // reached while the reporters finalize an already-executed run cannot mark it as + // truncated. The extension cannot do this from ITestSessionLifetimeHandler: it is an + // IDataConsumer, and consumer handlers run last in NotifyTestSessionEndAsync, after the + // drains and after the reporters. Absent for discovery requests, where the extension is + // not registered. + await NotifyTestExecutionCompletedAsync().ConfigureAwait(false); + } } using (otelService?.StartActivity("OnTestSessionEnding")) @@ -53,6 +96,10 @@ protected static async Task ExecuteRequestAsync(ProxyOutputDevice outputDevice, } finally { + // Session startup can fail before the invoker is entered. Complete the run registration on that + // path too, otherwise one failed server request leaves the application-scoped active count stuck. + await NotifyTestExecutionCompletedAsync().ConfigureAwait(false); + // The message bus shutdown handshake must complete before the services - and with them every // IDataConsumer - get disposed, otherwise a consumer can still be inside ConsumeAsync while it is // being disposed. NotifyTestSessionEndAsync does it on the happy path, but it is skipped whenever the diff --git a/src/Platform/Microsoft.Testing.Platform/Hosts/CommonTestHost.cs b/src/Platform/Microsoft.Testing.Platform/Hosts/CommonTestHost.cs index 68dffba0c3..acc8a2d5d5 100644 --- a/src/Platform/Microsoft.Testing.Platform/Hosts/CommonTestHost.cs +++ b/src/Platform/Microsoft.Testing.Platform/Hosts/CommonTestHost.cs @@ -20,6 +20,15 @@ namespace Microsoft.Testing.Platform.Hosts; [StackTraceHidden] internal abstract partial class CommonHost(ServiceProvider serviceProvider) : IHost { +#if NET9_0_OR_GREATER + private readonly Lock _activeGracefulStopCapabilitiesSync = new(); +#else + private readonly object _activeGracefulStopCapabilitiesSync = new(); +#endif + private readonly List _activeGracefulStopCapabilities = []; + private CancellationToken _gracefulSessionStopCancellationToken; + private bool _isGracefulSessionStopRequested; + public ServiceProvider ServiceProvider => serviceProvider; protected IPushOnlyProtocol? PushOnlyProtocol => ServiceProvider.GetService(); @@ -204,14 +213,52 @@ private string GetHostType() // stop so the framework stops scheduling new tests but still emits trx/logs/artifacts for whatever completed // (mirroring the local '--maximum-failed-tests' behavior). Fall back to hard cancellation when the running // framework has no graceful-stop capability (e.g. the test host controller), which is the only lever left. - private async Task RequestGracefulSessionStopAsync(CancellationToken cancellationToken) + protected Task RegisterActiveGracefulStopCapabilityAsync(IGracefulStopTestExecutionCapability capability) + { + CancellationToken cancellationToken; + bool stopCapability; + lock (_activeGracefulStopCapabilitiesSync) + { + stopCapability = _isGracefulSessionStopRequested && !_activeGracefulStopCapabilities.Contains(capability); + cancellationToken = _gracefulSessionStopCancellationToken; + _activeGracefulStopCapabilities.Add(capability); + } + + return stopCapability + ? capability.StopTestExecutionAsync(cancellationToken) + : Task.CompletedTask; + } + + protected void UnregisterActiveGracefulStopCapability(IGracefulStopTestExecutionCapability capability) + { + lock (_activeGracefulStopCapabilitiesSync) + { + _activeGracefulStopCapabilities.Remove(capability); + } + } + + protected async Task RequestGracefulSessionStopAsync(CancellationToken cancellationToken) { - IGracefulStopTestExecutionCapability? capability = + IGracefulStopTestExecutionCapability[] capabilities; + lock (_activeGracefulStopCapabilitiesSync) + { + _isGracefulSessionStopRequested = true; + _gracefulSessionStopCancellationToken = cancellationToken; + capabilities = [.. _activeGracefulStopCapabilities.Distinct()]; + } + + if (capabilities.Length > 0) + { + await Task.WhenAll(capabilities.Select(capability => capability.StopTestExecutionAsync(cancellationToken))).ConfigureAwait(false); + return; + } + + IGracefulStopTestExecutionCapability? applicationCapability = ServiceProvider.GetService()?.GetCapability(); - if (capability is not null) + if (applicationCapability is not null) { - await capability.StopTestExecutionAsync(cancellationToken).ConfigureAwait(false); + await applicationCapability.StopTestExecutionAsync(cancellationToken).ConfigureAwait(false); } else { diff --git a/src/Platform/Microsoft.Testing.Platform/Hosts/ConsoleTestHost.cs b/src/Platform/Microsoft.Testing.Platform/Hosts/ConsoleTestHost.cs index 5f0af5cd75..5cc4a88731 100644 --- a/src/Platform/Microsoft.Testing.Platform/Hosts/ConsoleTestHost.cs +++ b/src/Platform/Microsoft.Testing.Platform/Hosts/ConsoleTestHost.cs @@ -93,7 +93,8 @@ await ExecuteRequestAsync( ServiceProvider, ServiceProvider.GetBaseMessageBus(), testFramework, - ClientInfoHost).ConfigureAwait(false); + ClientInfoHost, + ServiceProvider.GetCommandLineOptions().IsOptionSet(PlatformCommandLineProvider.DiscoverTestsOptionKey)).ConfigureAwait(false); requestExecuteStop = _clock.UtcNow; // Get the exit code service to be able to set the exit code diff --git a/src/Platform/Microsoft.Testing.Platform/Hosts/ServerTestHost.RequestExecution.cs b/src/Platform/Microsoft.Testing.Platform/Hosts/ServerTestHost.RequestExecution.cs index 93c06c1336..395c28fec7 100644 --- a/src/Platform/Microsoft.Testing.Platform/Hosts/ServerTestHost.RequestExecution.cs +++ b/src/Platform/Microsoft.Testing.Platform/Hosts/ServerTestHost.RequestExecution.cs @@ -68,75 +68,103 @@ private async Task HandleRequestCoreAsync(RequestMessage message, RpcInv private async Task ExecuteRequestAsync(RequestArgsBase args, string method, ServiceProvider perRequestServiceProvider, CancellationToken cancellationToken) { DateTimeOffset requestStart = _clock.UtcNow; - ITestSessionContext perRequestTestSessionContext = perRequestServiceProvider.GetTestSessionContext(); - // Verify request cancellation, above the chain the exception will be - // catch and propagated as correct json rpc error + // Avoid allocating request-scoped services that register cancellation callbacks when the request + // was already cancelled before execution started. cancellationToken.ThrowIfCancellationRequested(); - // The JSON-RPC payload owns server request selection. Providers receive a server-origin - // context so they can explicitly opt out; non-empty contributions are rejected below. - ServerTestExecutionRequestFactory requestFactory = new(async (session, requestCancellationToken) => + ITestSessionContext perRequestTestSessionContext = perRequestServiceProvider.GetTestSessionContext(); + StopPoliciesService? requestPoliciesService = null; + PerRequestServerDataConsumer? testNodeUpdateProcessor = null; + DateTimeOffset adapterLoadStart = default; + DateTimeOffset adapterLoadStop = default; + DateTimeOffset requestExecuteStart = default; + DateTimeOffset? requestExecuteStop = null; + IGracefulStopTestExecutionCapability? perRequestGracefulStopCapability = null; + try { - ICollection? testNodes = args.TestNodes; - string? filter = args.GraphFilter; - ITestExecutionFilter executionFilter = testNodes is not null - ? new TestNodeUidListFilter(testNodes.Select(node => node.Uid).ToArray()) - : filter is not null - ? new TreeNodeFilter(filter) - : new NopFilter(); - - TestExecutionRequestKind requestKind = method switch + StopPoliciesService applicationPoliciesService = perRequestServiceProvider.GetRequiredService(); + requestPoliciesService = new(perRequestServiceProvider.GetTestApplicationCancellationTokenSource()) { - JsonRpcMethods.TestingRunTests => TestExecutionRequestKind.Run, - JsonRpcMethods.TestingDiscoverTests => TestExecutionRequestKind.Discovery, - _ => throw new NotImplementedException($"Request not implemented '{method}'"), + ProcessRole = applicationPoliciesService.ProcessRole, }; + perRequestServiceProvider.ReplaceService(requestPoliciesService); + perRequestServiceProvider.ReplaceService(new TestApplicationResult( + perRequestServiceProvider.GetOutputDevice(), + perRequestServiceProvider.GetCommandLineOptions(), + perRequestServiceProvider.GetEnvironment(), + requestPoliciesService, + perRequestServiceProvider.GetPlatformOTelService(), + perRequestServiceProvider.GetRequiredService())); + + // The JSON-RPC payload owns server request selection. Providers receive a server-origin + // context so they can explicitly opt out; non-empty contributions are rejected below. + ServerTestExecutionRequestFactory requestFactory = new(async (session, requestCancellationToken) => + { + ICollection? testNodes = args.TestNodes; + string? filter = args.GraphFilter; + ITestExecutionFilter executionFilter = testNodes is not null + ? new TestNodeUidListFilter(testNodes.Select(node => node.Uid).ToArray()) + : filter is not null + ? new TreeNodeFilter(filter) + : new NopFilter(); + + TestExecutionRequestKind requestKind = method switch + { + JsonRpcMethods.TestingRunTests => TestExecutionRequestKind.Run, + JsonRpcMethods.TestingDiscoverTests => TestExecutionRequestKind.Discovery, + _ => throw new NotImplementedException($"Request not implemented '{method}'"), + }; + + executionFilter = await TestExecutionFilterComposer.ComposeAsync( + executionFilter, + [.. perRequestServiceProvider.Services.OfType()], + new TestExecutionFilterContext(requestKind, TestExecutionRequestOrigin.Server), + allowProviderContributions: false, + requestCancellationToken).ConfigureAwait(false); + + return requestKind == TestExecutionRequestKind.Run + ? new RunTestExecutionRequest(session, executionFilter) + : new DiscoverTestExecutionRequest(session, executionFilter); + }); + + // Build the per request objects + ServerTestExecutionFilterFactory filterFactory = new(); + TestHostTestFrameworkInvoker invoker = new(perRequestServiceProvider); + testNodeUpdateProcessor = new(perRequestServiceProvider, this, args.RunId, perRequestServiceProvider.GetTask()); + + adapterLoadStart = _clock.UtcNow; + + // Add the client info service to the per request service provider + RoslynDebug.Assert(_clientInfoService is not null, "Request should only have been called after initialization"); + perRequestServiceProvider.TryAddService(_clientInfoService); + + ProxyOutputDevice outputDevice = ServiceProvider.GetRequiredService(); + await outputDevice.InitializeAsync(this).ConfigureAwait(false); + + // Build the per request adapter + ITestFramework perRequestTestFramework = await _buildTestFrameworkAsync(new TestFrameworkBuilderData( + perRequestServiceProvider, + requestFactory, + invoker, + filterFactory, + outputDevice.OriginalOutputDevice, + [testNodeUpdateProcessor], + _testFrameworkManager, + _testSessionManager, + new MessageBusProxy(), + method == JsonRpcMethods.TestingDiscoverTests, + isServerRequest: true)).ConfigureAwait(false); + perRequestGracefulStopCapability = + perRequestServiceProvider.GetTestFrameworkCapabilities().GetCapability(); + if (perRequestGracefulStopCapability is not null) + { + await RegisterActiveGracefulStopCapabilityAsync(perRequestGracefulStopCapability).ConfigureAwait(false); + } + + adapterLoadStop = _clock.UtcNow; + requestExecuteStart = _clock.UtcNow; - executionFilter = await TestExecutionFilterComposer.ComposeAsync( - executionFilter, - [.. perRequestServiceProvider.Services.OfType()], - new TestExecutionFilterContext(requestKind, TestExecutionRequestOrigin.Server), - allowProviderContributions: false, - requestCancellationToken).ConfigureAwait(false); - - return requestKind == TestExecutionRequestKind.Run - ? new RunTestExecutionRequest(session, executionFilter) - : new DiscoverTestExecutionRequest(session, executionFilter); - }); - - // Build the per request objects - ServerTestExecutionFilterFactory filterFactory = new(); - TestHostTestFrameworkInvoker invoker = new(perRequestServiceProvider); - PerRequestServerDataConsumer testNodeUpdateProcessor = new(perRequestServiceProvider, this, args.RunId, perRequestServiceProvider.GetTask()); - - // Add the client info service to the per request service provider - RoslynDebug.Assert(_clientInfoService is not null, "Request should only have been called after initialization"); - perRequestServiceProvider.TryAddService(_clientInfoService); - - DateTimeOffset adapterLoadStart = _clock.UtcNow; - - ProxyOutputDevice outputDevice = ServiceProvider.GetRequiredService(); - await outputDevice.InitializeAsync(this).ConfigureAwait(false); - - // Build the per request adapter - ITestFramework perRequestTestFramework = await _buildTestFrameworkAsync(new TestFrameworkBuilderData( - perRequestServiceProvider, - requestFactory, - invoker, - filterFactory, - outputDevice.OriginalOutputDevice, - [testNodeUpdateProcessor], - _testFrameworkManager, - _testSessionManager, - new MessageBusProxy(), - method == JsonRpcMethods.TestingDiscoverTests)).ConfigureAwait(false); - - DateTimeOffset adapterLoadStop = _clock.UtcNow; - DateTimeOffset requestExecuteStart = _clock.UtcNow; - DateTimeOffset? requestExecuteStop = null; - try - { RoslynDebug.Assert(_client is not null, "Request should only have been called after initialization"); // Execute the request @@ -146,7 +174,8 @@ await ExecuteRequestAsync( perRequestServiceProvider, perRequestServiceProvider.GetBaseMessageBus(), perRequestTestFramework, - _client).ConfigureAwait(false); + _client, + method == JsonRpcMethods.TestingDiscoverTests).ConfigureAwait(false); // Check if there was a test adapter testSession failure ITestApplicationProcessExitCode testApplicationResult = perRequestServiceProvider.GetTestApplicationProcessExitCode(); @@ -166,10 +195,23 @@ await ExecuteRequestAsync( { requestExecuteStop ??= _clock.UtcNow; + if (perRequestGracefulStopCapability is not null) + { + UnregisterActiveGracefulStopCapability(perRequestGracefulStopCapability); + } + + bool requestPoliciesServiceOwnedByProvider = + requestPoliciesService is not null && perRequestServiceProvider.Services.Contains(requestPoliciesService); + // Cleanup all services // We skip all services that are "cloned" per call because are reused and will be disposed on shutdown. await DisposeServiceProviderAsync(perRequestServiceProvider, obj => !ServiceProvider.Services.Contains(obj)).ConfigureAwait(false); + if (!requestPoliciesServiceOwnedByProvider) + { + requestPoliciesService?.Dispose(); + } + // We need to dispose this service manually because the shared DisposeServiceProviderAsync skip some special service like the ITestApplicationCooperativeLifetimeService // that needs to be disposed at process exits. // Here we have one crafted for per-call and we won't invoke the stopping events on it in the same way as the global one. @@ -178,6 +220,7 @@ await ExecuteRequestAsync( DateTimeOffset requestStop = _clock.UtcNow; RoslynDebug.Assert(requestExecuteStop != null); + RoslynDebug.Assert(testNodeUpdateProcessor is not null); bool isRunRequest = method switch { diff --git a/src/Platform/Microsoft.Testing.Platform/Hosts/TestFrameworkBuilderData.cs b/src/Platform/Microsoft.Testing.Platform/Hosts/TestFrameworkBuilderData.cs index eaeab6e89f..0294df1e9f 100644 --- a/src/Platform/Microsoft.Testing.Platform/Hosts/TestFrameworkBuilderData.cs +++ b/src/Platform/Microsoft.Testing.Platform/Hosts/TestFrameworkBuilderData.cs @@ -17,6 +17,33 @@ internal sealed class TestFrameworkBuilderData(ServiceProvider serviceProvider, TestFrameworkManager testFrameworkManager, TestHostManager testSessionManager, MessageBusProxy messageBusProxy, bool isForDiscoveryRequest) { + internal TestFrameworkBuilderData( + ServiceProvider serviceProvider, + ITestExecutionRequestFactory testExecutionRequestFactory, + ITestFrameworkInvoker testExecutionRequestInvoker, + ITestExecutionFilterFactory testExecutionFilterFactory, + IPlatformOutputDevice platformOutputDisplayService, + IEnumerable serverPerCallConsumers, + TestFrameworkManager testFrameworkManager, + TestHostManager testSessionManager, + MessageBusProxy messageBusProxy, + bool isForDiscoveryRequest, + bool isServerRequest) + : this( + serviceProvider, + testExecutionRequestFactory, + testExecutionRequestInvoker, + testExecutionFilterFactory, + platformOutputDisplayService, + serverPerCallConsumers, + testFrameworkManager, + testSessionManager, + messageBusProxy, + isForDiscoveryRequest) + { + IsServerRequest = isServerRequest; + } + public ServiceProvider ServiceProvider { get; } = serviceProvider; public ITestExecutionRequestFactory TestExecutionRequestFactory { get; } = testExecutionRequestFactory; @@ -36,4 +63,6 @@ internal sealed class TestFrameworkBuilderData(ServiceProvider serviceProvider, public MessageBusProxy MessageBusProxy { get; } = messageBusProxy; public bool IsForDiscoveryRequest { get; } = isForDiscoveryRequest; + + public bool IsServerRequest { get; } } diff --git a/src/Platform/Microsoft.Testing.Platform/Hosts/TestHostBuilder.Framework.cs b/src/Platform/Microsoft.Testing.Platform/Hosts/TestHostBuilder.Framework.cs index a2f5e36eb5..97f0b88620 100644 --- a/src/Platform/Microsoft.Testing.Platform/Hosts/TestHostBuilder.Framework.cs +++ b/src/Platform/Microsoft.Testing.Platform/Hosts/TestHostBuilder.Framework.cs @@ -16,7 +16,30 @@ namespace Microsoft.Testing.Platform.Hosts; internal sealed partial class TestHostBuilder { + private const string HangDumpOptionName = "hangdump"; + private static async Task BuildTestFrameworkAsync(TestFrameworkBuilderData testFrameworkBuilderData) + { + if (!testFrameworkBuilderData.IsForDiscoveryRequest) + { + // Register the run before its deadline timer can observe the execution-completion state. + IStopPoliciesService stopPoliciesService = testFrameworkBuilderData.ServiceProvider.GetRequiredService(); + stopPoliciesService.NotifyTestExecutionStarting(); + try + { + return await BuildTestFrameworkCoreAsync(testFrameworkBuilderData).ConfigureAwait(false); + } + catch + { + stopPoliciesService.NotifyTestExecutionCompleted(); + throw; + } + } + + return await BuildTestFrameworkCoreAsync(testFrameworkBuilderData).ConfigureAwait(false); + } + + private static async Task BuildTestFrameworkCoreAsync(TestFrameworkBuilderData testFrameworkBuilderData) { ServiceProvider serviceProvider = testFrameworkBuilderData.ServiceProvider; serviceProvider.AddService(testFrameworkBuilderData.MessageBusProxy); @@ -36,6 +59,20 @@ private static async Task BuildTestFrameworkAsync(TestFrameworkB await RegisterAsServiceOrConsumerOrBothAsync(testFrameworkBuilderData.TestExecutionFilterFactory, serviceProvider, dataConsumersBuilder).ConfigureAwait(false); ITestFrameworkCapabilities testFrameworkCapabilities = serviceProvider.GetTestFrameworkCapabilities(); + if (testFrameworkBuilderData.IsServerRequest) + { + // Capabilities can contain request lifecycle state (for example graceful-stop pending/active/completed). + // Replace the application capability copied into a server request's cloned provider. The framework and + // its per-request extensions then share one fresh capability without affecting the console host. + testFrameworkCapabilities = testFrameworkBuilderData.TestFrameworkManager.TestFrameworkCapabilitiesFactory(serviceProvider); + if (testFrameworkCapabilities is IAsyncInitializableExtension testFrameworkCapabilitiesAsyncInitializable) + { + await testFrameworkCapabilitiesAsyncInitializable.InitializeAsync().ConfigureAwait(false); + } + + serviceProvider.ReplaceService(testFrameworkCapabilities); + } + ITestFramework testFramework = testFrameworkBuilderData.TestFrameworkManager.TestFrameworkFactory(testFrameworkCapabilities, serviceProvider); await testFramework.TryInitializeAsync().ConfigureAwait(false); if (testFramework is IDataProducer dataProducer) @@ -90,8 +127,6 @@ private static async Task BuildTestFrameworkAsync(TestFrameworkB testSessionLifetimeHandlers.Add(pushOnlyProtocolDataConsumer); } - serviceProvider.AddService(new TestSessionLifetimeHandlersContainer(testSessionLifetimeHandlers)); - ITestApplicationProcessExitCode testApplicationResult = serviceProvider.GetRequiredService(); await RegisterAsServiceOrConsumerOrBothAsync(testApplicationResult, serviceProvider, dataConsumersBuilder).ConfigureAwait(false); @@ -114,6 +149,42 @@ private static async Task BuildTestFrameworkAsync(TestFrameworkB dataConsumersBuilder.Add(abortForMaxFailedTestsExtension); } + // Build one deadline extension for the active run request. In server mode the per-request service + // provider and message bus own and dispose it when that request ends; discovery requests never arm it. + if (!testFrameworkBuilderData.IsForDiscoveryRequest) + { + var abortAtDeadlineExtension = new AbortAtDeadlineExtension( + serviceProvider.GetEnvironment(), + serviceProvider.GetSystemClock(), + serviceProvider.GetTestFrameworkCapabilities().GetCapability(), + serviceProvider.GetRequiredService(), + serviceProvider.GetTestApplicationCancellationTokenSource(), + serviceProvider.GetOutputDevice(), + serviceProvider.GetLoggerFactory(), + isHangDumpEnabled: serviceProvider.GetCommandLineOptions().IsOptionSet(HangDumpOptionName)); + + if (await abortAtDeadlineExtension.IsEnabledAsync().ConfigureAwait(false)) + { + dataConsumersBuilder.Add(abortAtDeadlineExtension); + + // Also register it as a service so the host can tell it, the moment the test framework invoker + // returns, that test execution is over. On that signal it disarms the deadline, so a timer + // firing while the reporters finalize an already-finished run cannot wrongly mark the run as + // deadline-truncated (exit code 15). A session-lifetime handler would be too late: this is an + // IDataConsumer, and consumer handlers run at the very end of NotifyTestSessionEndAsync. + serviceProvider.AddService(abortAtDeadlineExtension); + + // Keep the lifetime-handler registration as a backstop for host paths that do not execute the + // invoker path above. It runs too late to protect reporting on its own. + testSessionLifetimeHandlers.Add(abortAtDeadlineExtension); + } + } + + // The container captures the list by reference (so a later Add would still be observed), but populating + // it fully before registering keeps this order-independent and free of that subtlety. Lifetime handlers + // are enumerated later, during NotifyTestSessionEndAsync. + serviceProvider.AddService(new TestSessionLifetimeHandlersContainer(testSessionLifetimeHandlers)); + AsynchronousMessageBus concreteMessageBusService = new( [.. dataConsumersBuilder], serviceProvider.GetTestApplicationCancellationTokenSource(), diff --git a/src/Platform/Microsoft.Testing.Platform/InternalAPI/InternalAPI.Unshipped.txt b/src/Platform/Microsoft.Testing.Platform/InternalAPI/InternalAPI.Unshipped.txt index 040bfeec25..b956bf4021 100644 --- a/src/Platform/Microsoft.Testing.Platform/InternalAPI/InternalAPI.Unshipped.txt +++ b/src/Platform/Microsoft.Testing.Platform/InternalAPI/InternalAPI.Unshipped.txt @@ -170,6 +170,24 @@ Microsoft.Testing.Platform.OutputDevice.Terminal.TestProgressState.GetOrCreateTe Microsoft.Testing.Platform.OutputDevice.Terminal.TestProgressState.NotifyHandshake(string! instanceId, int attemptNumber) -> void Microsoft.Testing.Platform.OutputDevice.Terminal.TestProgressState.ReportDiscoveredTest(string? displayName) -> void Microsoft.Testing.Platform.Helpers.ExitCode.CoverageThresholdFailed = 14 -> Microsoft.Testing.Platform.Helpers.ExitCode +Microsoft.Testing.Platform.Helpers.ExitCode.TestExecutionStoppedAtDeadline = 15 -> Microsoft.Testing.Platform.Helpers.ExitCode +Microsoft.Testing.Platform.Services.IStopPoliciesService.IsDeadlineTriggered.get -> bool +Microsoft.Testing.Platform.Services.IStopPoliciesService.RegisterDeadlineStopFallback(System.Func!>! callback) -> void +Microsoft.Testing.Platform.Services.IStopPoliciesService.RegisterOnDeadlineCallbackAsync(System.Func! callback) -> System.Threading.Tasks.Task! +Microsoft.Testing.Platform.Services.IStopPoliciesService.ExecuteDeadlineCallbacksAsync() -> System.Threading.Tasks.Task! +Microsoft.Testing.Platform.Services.IStopPoliciesService.IsTestExecutionCompleted.get -> bool +Microsoft.Testing.Platform.Services.IStopPoliciesService.NotifyTestExecutionStarting() -> void +Microsoft.Testing.Platform.Services.IStopPoliciesService.NotifyTestExecutionCompleted() -> void +Microsoft.Testing.Platform.Services.IStopPoliciesService.TryExecuteDeadlineStopFallbackAsync() -> System.Threading.Tasks.Task! +Microsoft.Testing.Platform.Services.StopPoliciesService.IsTestExecutionCompleted.get -> bool +Microsoft.Testing.Platform.Services.StopPoliciesService.NotifyTestExecutionStarting() -> void +Microsoft.Testing.Platform.Services.StopPoliciesService.NotifyTestExecutionCompleted() -> void +Microsoft.Testing.Platform.Services.StopPoliciesService.IsDeadlineTriggered.get -> bool +Microsoft.Testing.Platform.Services.StopPoliciesService.RegisterDeadlineStopFallback(System.Func!>! callback) -> void +Microsoft.Testing.Platform.Services.StopPoliciesService.Dispose() -> void +Microsoft.Testing.Platform.Services.StopPoliciesService.RegisterOnDeadlineCallbackAsync(System.Func! callback) -> System.Threading.Tasks.Task! +Microsoft.Testing.Platform.Services.StopPoliciesService.ExecuteDeadlineCallbacksAsync() -> System.Threading.Tasks.Task! +Microsoft.Testing.Platform.Services.StopPoliciesService.TryExecuteDeadlineStopFallbackAsync() -> System.Threading.Tasks.Task! Microsoft.Testing.Platform.OutputDevice.Terminal.TerminalTestReporter.AppendCoverageSummary(System.Collections.Generic.IReadOnlyList! scopes, System.Collections.Generic.IReadOnlyList! thresholds) -> void Microsoft.Testing.Platform.OutputDevice.TerminalOutputDevice.TerminalOutputDevice(Microsoft.Testing.Platform.Helpers.IConsole! console, Microsoft.Testing.Platform.Services.ITestApplicationModuleInfo! testApplicationModuleInfo, Microsoft.Testing.Platform.TestHostControllers.ITestHostControllerInfo! testHostControllerInfo, Microsoft.Testing.Platform.Helpers.IAsyncMonitor! asyncMonitor, Microsoft.Testing.Platform.Helpers.IRuntimeFeature! runtimeFeature, Microsoft.Testing.Platform.Helpers.IEnvironment! environment, Microsoft.Testing.Platform.Services.IPlatformInformation! platformInformation, Microsoft.Testing.Platform.CommandLine.ICommandLineOptions! commandLineOptions, Microsoft.Testing.Platform.Logging.IFileLoggerInformation? fileLoggerInformation, Microsoft.Testing.Platform.Logging.ILoggerFactory! loggerFactory, Microsoft.Testing.Platform.Helpers.IClock! clock, Microsoft.Testing.Platform.Services.IStopPoliciesService! policiesService, Microsoft.Testing.Platform.Services.ITestApplicationCancellationTokenSource! testApplicationCancellationTokenSource, Microsoft.Testing.Platform.Services.ITestCoverageResult! testCoverageResult) -> void *REMOVED*Microsoft.Testing.Platform.OutputDevice.TerminalOutputDevice.TerminalOutputDevice(Microsoft.Testing.Platform.Helpers.IConsole! console, Microsoft.Testing.Platform.Services.ITestApplicationModuleInfo! testApplicationModuleInfo, Microsoft.Testing.Platform.TestHostControllers.ITestHostControllerInfo! testHostControllerInfo, Microsoft.Testing.Platform.Helpers.IAsyncMonitor! asyncMonitor, Microsoft.Testing.Platform.Helpers.IRuntimeFeature! runtimeFeature, Microsoft.Testing.Platform.Helpers.IEnvironment! environment, Microsoft.Testing.Platform.Services.IPlatformInformation! platformInformation, Microsoft.Testing.Platform.CommandLine.ICommandLineOptions! commandLineOptions, Microsoft.Testing.Platform.Logging.IFileLoggerInformation? fileLoggerInformation, Microsoft.Testing.Platform.Logging.ILoggerFactory! loggerFactory, Microsoft.Testing.Platform.Helpers.IClock! clock, Microsoft.Testing.Platform.Services.IStopPoliciesService! policiesService, Microsoft.Testing.Platform.Services.ITestApplicationCancellationTokenSource! testApplicationCancellationTokenSource) -> void @@ -351,6 +369,36 @@ static Microsoft.Testing.Platform.OutputDevice.TerminalOutputDevice.GetSlowestTe *REMOVED*static Microsoft.Testing.Platform.Services.ServiceProviderExtensions.GetSystemClock(this System.IServiceProvider! serviceProvider) -> Microsoft.Testing.Platform.Helpers.IClock! *REMOVED*virtual Microsoft.Testing.Platform.CommandLine.CommandLineOptionsProviderBase.ValidateCommandLineOptionsAsync(Microsoft.Testing.Platform.CommandLine.ICommandLineOptions! commandLineOptions) -> System.Threading.Tasks.Task! *REMOVED*virtual Microsoft.Testing.Platform.CommandLine.CommandLineOptionsProviderBase.ValidateOptionArgumentsAsync(Microsoft.Testing.Platform.Extensions.CommandLine.CommandLineOption! commandOption, string![]! arguments) -> System.Threading.Tasks.Task! +const Microsoft.Testing.Platform.Helpers.EnvironmentVariableConstants.TESTINGPLATFORM_DEADLINE = "TESTINGPLATFORM_DEADLINE" -> string! +const Microsoft.Testing.Platform.Helpers.EnvironmentVariableConstants.TESTINGPLATFORM_DEADLINE_STOP_MARGIN = "TESTINGPLATFORM_DEADLINE_STOP_MARGIN" -> string! +const Microsoft.Testing.Platform.Helpers.EnvironmentVariableConstants.TESTINGPLATFORM_DEADLINE_DUMP_MARGIN = "TESTINGPLATFORM_DEADLINE_DUMP_MARGIN" -> string! +Microsoft.Testing.Platform.Helpers.DeadlineHelper +static Microsoft.Testing.Platform.Helpers.DeadlineHelper.TryGetDeadline(Microsoft.Testing.Platform.Helpers.IEnvironment! environment, out System.DateTimeOffset deadlineUtc) -> bool +static Microsoft.Testing.Platform.Helpers.DeadlineHelper.GetStopMargin(Microsoft.Testing.Platform.Helpers.IEnvironment! environment) -> System.TimeSpan +static Microsoft.Testing.Platform.Helpers.DeadlineHelper.GetDumpMargin(Microsoft.Testing.Platform.Helpers.IEnvironment! environment) -> System.TimeSpan +static Microsoft.Testing.Platform.Helpers.DeadlineHelper.SubtractSaturating(System.DateTimeOffset instant, System.TimeSpan margin) -> System.DateTimeOffset +Microsoft.Testing.Platform.Extensions.AbortAtDeadlineExtension +Microsoft.Testing.Platform.Extensions.AbortAtDeadlineExtension.AbortAtDeadlineExtension(Microsoft.Testing.Platform.Helpers.IEnvironment! environment, Microsoft.Testing.Platform.Helpers.IClock! clock, Microsoft.Testing.Platform.Capabilities.TestFramework.IGracefulStopTestExecutionCapability? capability, Microsoft.Testing.Platform.Services.IStopPoliciesService! policiesService, Microsoft.Testing.Platform.Services.ITestApplicationCancellationTokenSource! cancellationTokenSource, Microsoft.Testing.Platform.OutputDevice.IOutputDevice! outputDevice, Microsoft.Testing.Platform.Logging.ILoggerFactory! loggerFactory, System.TimeSpan? reportTimeout = null, bool isHangDumpEnabled = false) -> void +static Microsoft.Testing.Platform.Extensions.AbortAtDeadlineExtension.GetTimerDueTime(System.DateTimeOffset deadline, System.DateTimeOffset now) -> System.TimeSpan +Microsoft.Testing.Platform.Extensions.AbortAtDeadlineExtension.WaitForDeadlineHandlingAsync() -> System.Threading.Tasks.Task! +Microsoft.Testing.Platform.Extensions.AbortAtDeadlineExtension.ConsumeAsync(Microsoft.Testing.Platform.Extensions.Messages.IDataProducer! dataProducer, Microsoft.Testing.Platform.Extensions.Messages.IData! value, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +Microsoft.Testing.Platform.Extensions.AbortAtDeadlineExtension.DataTypesConsumed.get -> System.Type![]! +Microsoft.Testing.Platform.Extensions.AbortAtDeadlineExtension.Description.get -> string! +Microsoft.Testing.Platform.Extensions.AbortAtDeadlineExtension.DisplayName.get -> string! +Microsoft.Testing.Platform.Extensions.AbortAtDeadlineExtension.Dispose() -> void +Microsoft.Testing.Platform.Extensions.AbortAtDeadlineExtension.IsEnabledAsync() -> System.Threading.Tasks.Task! +Microsoft.Testing.Platform.Extensions.AbortAtDeadlineExtension.NotifyTestExecutionCompleted() -> void +Microsoft.Testing.Platform.Extensions.AbortAtDeadlineExtension.OnTestSessionFinishingAsync(Microsoft.Testing.Platform.Services.ITestSessionContext! testSessionContext) -> System.Threading.Tasks.Task! +Microsoft.Testing.Platform.Extensions.AbortAtDeadlineExtension.OnTestSessionStartingAsync(Microsoft.Testing.Platform.Services.ITestSessionContext! testSessionContext) -> System.Threading.Tasks.Task! +Microsoft.Testing.Platform.Extensions.AbortAtDeadlineExtension.Uid.get -> string! +Microsoft.Testing.Platform.Extensions.AbortAtDeadlineExtension.Version.get -> string! +Microsoft.Testing.Platform.Hosts.CommonHost.RegisterActiveGracefulStopCapabilityAsync(Microsoft.Testing.Platform.Capabilities.TestFramework.IGracefulStopTestExecutionCapability! capability) -> System.Threading.Tasks.Task! +Microsoft.Testing.Platform.Hosts.CommonHost.RequestGracefulSessionStopAsync(System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +static Microsoft.Testing.Platform.Hosts.CommonHost.ExecuteRequestAsync(Microsoft.Testing.Platform.OutputDevice.ProxyOutputDevice! outputDevice, Microsoft.Testing.Platform.Services.ITestSessionContext! testSessionInfo, Microsoft.Testing.Platform.Services.ServiceProvider! serviceProvider, Microsoft.Testing.Platform.Messages.BaseMessageBus! baseMessageBus, Microsoft.Testing.Platform.Extensions.TestFramework.ITestFramework! testFramework, Microsoft.Testing.Platform.TestHost.ClientInfo! client, bool isDiscoveryRequest) -> System.Threading.Tasks.Task! +*REMOVED*static Microsoft.Testing.Platform.Hosts.CommonHost.ExecuteRequestAsync(Microsoft.Testing.Platform.OutputDevice.ProxyOutputDevice! outputDevice, Microsoft.Testing.Platform.Services.ITestSessionContext! testSessionInfo, Microsoft.Testing.Platform.Services.ServiceProvider! serviceProvider, Microsoft.Testing.Platform.Messages.BaseMessageBus! baseMessageBus, Microsoft.Testing.Platform.Extensions.TestFramework.ITestFramework! testFramework, Microsoft.Testing.Platform.TestHost.ClientInfo! client) -> System.Threading.Tasks.Task! +Microsoft.Testing.Platform.Hosts.CommonHost.UnregisterActiveGracefulStopCapability(Microsoft.Testing.Platform.Capabilities.TestFramework.IGracefulStopTestExecutionCapability! capability) -> void +Microsoft.Testing.Platform.Hosts.TestFrameworkBuilderData.IsServerRequest.get -> bool +Microsoft.Testing.Platform.Hosts.TestFrameworkBuilderData.TestFrameworkBuilderData(Microsoft.Testing.Platform.Services.ServiceProvider! serviceProvider, Microsoft.Testing.Platform.Requests.ITestExecutionRequestFactory! testExecutionRequestFactory, Microsoft.Testing.Platform.Requests.ITestFrameworkInvoker! testExecutionRequestInvoker, Microsoft.Testing.Platform.Requests.ITestExecutionFilterFactory! testExecutionFilterFactory, Microsoft.Testing.Platform.OutputDevice.IPlatformOutputDevice! platformOutputDisplayService, System.Collections.Generic.IEnumerable! serverPerCallConsumers, Microsoft.Testing.Internal.Framework.TestFrameworkManager! testFrameworkManager, Microsoft.Testing.Platform.TestHost.TestHostManager! testSessionManager, Microsoft.Testing.Platform.Messages.MessageBusProxy! messageBusProxy, bool isForDiscoveryRequest, bool isServerRequest) -> void Microsoft.Testing.Platform.DotnetTestConnection.DotnetTestConnection(Microsoft.Testing.Platform.CommandLine.CommandLineHandler! commandLineHandler, Microsoft.Testing.Platform.Helpers.IEnvironment! environment, Microsoft.Testing.Platform.Services.ITestApplicationModuleInfo! testApplicationModuleInfo, Microsoft.Testing.Platform.Services.ITestApplicationCancellationTokenSource! cancellationTokenSource, Microsoft.Testing.Platform.Logging.ILogger! logger) -> void Microsoft.Testing.Platform.OutputDevice.Terminal.TerminalTestReporter.TerminalTestReporter(Microsoft.Testing.Platform.Helpers.IConsole! console, System.Func! isCancellationRequested, Microsoft.Testing.Platform.OutputDevice.Terminal.TerminalTestReporterOptions! options, Microsoft.Testing.Platform.Logging.ILogger! logger) -> void Microsoft.Testing.Platform.OutputDevice.Terminal.TestProgressStateAwareTerminal.TestProgressStateAwareTerminal(Microsoft.Testing.Platform.OutputDevice.Terminal.ITerminal! terminal, System.Func! showProgress, Microsoft.Testing.Platform.OutputDevice.Terminal.IProgressRenderer! renderer, Microsoft.Testing.Platform.Logging.ILogger! logger) -> void diff --git a/src/Platform/Microsoft.Testing.Platform/InternalAPI/net/InternalAPI.Unshipped.txt b/src/Platform/Microsoft.Testing.Platform/InternalAPI/net/InternalAPI.Unshipped.txt index 0fee2424cc..3740e04415 100644 --- a/src/Platform/Microsoft.Testing.Platform/InternalAPI/net/InternalAPI.Unshipped.txt +++ b/src/Platform/Microsoft.Testing.Platform/InternalAPI/net/InternalAPI.Unshipped.txt @@ -1,4 +1,5 @@ #nullable enable +Microsoft.Testing.Platform.Extensions.AbortAtDeadlineExtension.DisposeAsync() -> System.Threading.Tasks.ValueTask Microsoft.Testing.Platform.Messages.AsyncConsumerDataProcessor.AsyncConsumerDataProcessor(Microsoft.Testing.Platform.Extensions.IDataConsumer! consumer, Microsoft.Testing.Platform.Helpers.ITask! task, System.Threading.CancellationToken cancellationToken, System.TimeSpan canceledShutdownTimeout) -> void *REMOVED*Microsoft.Testing.Platform.ServerMode.FormatterUtilities.MessageFormatter.Deserialize(System.ReadOnlyMemory serializedUtf8Content) -> T *REMOVED*Microsoft.Testing.Platform.ServerMode.IMessageFormatter.Deserialize(System.ReadOnlyMemory serializedUtf8Content) -> T diff --git a/src/Platform/Microsoft.Testing.Platform/PublicAPI/PublicAPI.Unshipped.txt b/src/Platform/Microsoft.Testing.Platform/PublicAPI/PublicAPI.Unshipped.txt index 44201bf631..656397a1b2 100644 --- a/src/Platform/Microsoft.Testing.Platform/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/Platform/Microsoft.Testing.Platform/PublicAPI/PublicAPI.Unshipped.txt @@ -1,4 +1,6 @@ #nullable enable +[TPEXP]Microsoft.Testing.Platform.Capabilities.TestFramework.IGracefulStopTestExecutionResultCapability +[TPEXP]Microsoft.Testing.Platform.Capabilities.TestFramework.IGracefulStopTestExecutionResultCapability.TryStopTestExecutionAsync(System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! Microsoft.Testing.Platform.Configurations.IConfigurationRoot Microsoft.Testing.Platform.Configurations.IConfigurationRoot.GetChildren() -> System.Collections.Generic.IEnumerable! Microsoft.Testing.Platform.Configurations.IConfigurationRoot.GetSection(string! key) -> Microsoft.Testing.Platform.Configurations.IConfigurationSection! diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/PlatformResources.resx b/src/Platform/Microsoft.Testing.Platform/Resources/PlatformResources.resx index 07ae9a40f0..212298c0cb 100644 --- a/src/Platform/Microsoft.Testing.Platform/Resources/PlatformResources.resx +++ b/src/Platform/Microsoft.Testing.Platform/Resources/PlatformResources.resx @@ -950,6 +950,24 @@ Valid values are 'allow-skipped' (the default) which counts skipped tests as run Extension used to support '--maximum-failed-tests'. When a given failures threshold is reached, the test run will be aborted. {Locked="--maximum-failed-tests"} + + Extension that gracefully stops the test run shortly before a CI-imposed deadline so reports can be finalized. + + + Deadline approaching: gracefully stopping the test run so reports can be finalized before the CI hard-cancel. + + + Environment variable '{0}' is set to '{1}' but is not a valid absolute ISO 8601 instant. Deadline-aware cancellation is disabled. + {0} is the environment variable name. {1} is the invalid value. {Locked="ISO 8601"} + + + Deadline dump margin ({0}) is greater than or equal to the stop margin ({1}). The hang dump may start before the graceful stop. + {0} is the dump margin. {1} is the stop margin. + + + Environment variable '{0}' is set, but the test framework does not support '{1}'. The platform cannot stop the test run before the deadline. + {0} is the environment variable name. {1} is the capability interface name. + Test session is aborting due to reaching failures ('{0}') specified by the '--maximum-failed-tests' option. {0} is the number of max failed tests. {Locked="--maximum-failed-tests"} diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.cs.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.cs.xlf index 6636449f9b..26d2586786 100644 --- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.cs.xlf +++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.cs.xlf @@ -2,6 +2,31 @@ + + Environment variable '{0}' is set, but the test framework does not support '{1}'. The platform cannot stop the test run before the deadline. + Environment variable '{0}' is set, but the test framework does not support '{1}'. The platform cannot stop the test run before the deadline. + {0} is the environment variable name. {1} is the capability interface name. + + + Extension that gracefully stops the test run shortly before a CI-imposed deadline so reports can be finalized. + Extension that gracefully stops the test run shortly before a CI-imposed deadline so reports can be finalized. + + + + Environment variable '{0}' is set to '{1}' but is not a valid absolute ISO 8601 instant. Deadline-aware cancellation is disabled. + Environment variable '{0}' is set to '{1}' but is not a valid absolute ISO 8601 instant. Deadline-aware cancellation is disabled. + {0} is the environment variable name. {1} is the invalid value. {Locked="ISO 8601"} + + + Deadline dump margin ({0}) is greater than or equal to the stop margin ({1}). The hang dump may start before the graceful stop. + Deadline dump margin ({0}) is greater than or equal to the stop margin ({1}). The hang dump may start before the graceful stop. + {0} is the dump margin. {1} is the stop margin. + + + Deadline approaching: gracefully stopping the test run so reports can be finalized before the CI hard-cancel. + Deadline approaching: gracefully stopping the test run so reports can be finalized before the CI hard-cancel. + + The current test framework does not implement 'IGracefulStopTestExecutionCapability' which is required for '--maximum-failed-tests' feature. Aktuální testovací architektura neimplementuje rozhraní IGracefulStopTestExecutionCapability, které je vyžadováno pro funkci --maximum-failed-tests. diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.de.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.de.xlf index e8be502a3c..8975165444 100644 --- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.de.xlf +++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.de.xlf @@ -2,6 +2,31 @@ + + Environment variable '{0}' is set, but the test framework does not support '{1}'. The platform cannot stop the test run before the deadline. + Environment variable '{0}' is set, but the test framework does not support '{1}'. The platform cannot stop the test run before the deadline. + {0} is the environment variable name. {1} is the capability interface name. + + + Extension that gracefully stops the test run shortly before a CI-imposed deadline so reports can be finalized. + Extension that gracefully stops the test run shortly before a CI-imposed deadline so reports can be finalized. + + + + Environment variable '{0}' is set to '{1}' but is not a valid absolute ISO 8601 instant. Deadline-aware cancellation is disabled. + Environment variable '{0}' is set to '{1}' but is not a valid absolute ISO 8601 instant. Deadline-aware cancellation is disabled. + {0} is the environment variable name. {1} is the invalid value. {Locked="ISO 8601"} + + + Deadline dump margin ({0}) is greater than or equal to the stop margin ({1}). The hang dump may start before the graceful stop. + Deadline dump margin ({0}) is greater than or equal to the stop margin ({1}). The hang dump may start before the graceful stop. + {0} is the dump margin. {1} is the stop margin. + + + Deadline approaching: gracefully stopping the test run so reports can be finalized before the CI hard-cancel. + Deadline approaching: gracefully stopping the test run so reports can be finalized before the CI hard-cancel. + + The current test framework does not implement 'IGracefulStopTestExecutionCapability' which is required for '--maximum-failed-tests' feature. Das aktuelle Testframework implementiert nicht "IGracefulStopTestExecutionCapability", das für das Feature "--maximum-failed-tests" erforderlich ist. diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.es.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.es.xlf index 3971a91d0f..a5345aeaa8 100644 --- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.es.xlf +++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.es.xlf @@ -2,6 +2,31 @@ + + Environment variable '{0}' is set, but the test framework does not support '{1}'. The platform cannot stop the test run before the deadline. + Environment variable '{0}' is set, but the test framework does not support '{1}'. The platform cannot stop the test run before the deadline. + {0} is the environment variable name. {1} is the capability interface name. + + + Extension that gracefully stops the test run shortly before a CI-imposed deadline so reports can be finalized. + Extension that gracefully stops the test run shortly before a CI-imposed deadline so reports can be finalized. + + + + Environment variable '{0}' is set to '{1}' but is not a valid absolute ISO 8601 instant. Deadline-aware cancellation is disabled. + Environment variable '{0}' is set to '{1}' but is not a valid absolute ISO 8601 instant. Deadline-aware cancellation is disabled. + {0} is the environment variable name. {1} is the invalid value. {Locked="ISO 8601"} + + + Deadline dump margin ({0}) is greater than or equal to the stop margin ({1}). The hang dump may start before the graceful stop. + Deadline dump margin ({0}) is greater than or equal to the stop margin ({1}). The hang dump may start before the graceful stop. + {0} is the dump margin. {1} is the stop margin. + + + Deadline approaching: gracefully stopping the test run so reports can be finalized before the CI hard-cancel. + Deadline approaching: gracefully stopping the test run so reports can be finalized before the CI hard-cancel. + + The current test framework does not implement 'IGracefulStopTestExecutionCapability' which is required for '--maximum-failed-tests' feature. El marco de pruebas actual no implementa "IGracefulStopTestExecutionCapability", que es necesario para la característica "--maximum-failed-tests". diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.fr.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.fr.xlf index ac7c028a60..9303a45225 100644 --- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.fr.xlf +++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.fr.xlf @@ -2,6 +2,31 @@ + + Environment variable '{0}' is set, but the test framework does not support '{1}'. The platform cannot stop the test run before the deadline. + Environment variable '{0}' is set, but the test framework does not support '{1}'. The platform cannot stop the test run before the deadline. + {0} is the environment variable name. {1} is the capability interface name. + + + Extension that gracefully stops the test run shortly before a CI-imposed deadline so reports can be finalized. + Extension that gracefully stops the test run shortly before a CI-imposed deadline so reports can be finalized. + + + + Environment variable '{0}' is set to '{1}' but is not a valid absolute ISO 8601 instant. Deadline-aware cancellation is disabled. + Environment variable '{0}' is set to '{1}' but is not a valid absolute ISO 8601 instant. Deadline-aware cancellation is disabled. + {0} is the environment variable name. {1} is the invalid value. {Locked="ISO 8601"} + + + Deadline dump margin ({0}) is greater than or equal to the stop margin ({1}). The hang dump may start before the graceful stop. + Deadline dump margin ({0}) is greater than or equal to the stop margin ({1}). The hang dump may start before the graceful stop. + {0} is the dump margin. {1} is the stop margin. + + + Deadline approaching: gracefully stopping the test run so reports can be finalized before the CI hard-cancel. + Deadline approaching: gracefully stopping the test run so reports can be finalized before the CI hard-cancel. + + The current test framework does not implement 'IGracefulStopTestExecutionCapability' which is required for '--maximum-failed-tests' feature. Le framework de tests actuel n’implémente pas 'IGracefulStopTestExecutionCapability', qui est requis pour la fonctionnalité '--maximum-failed-tests'. diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.it.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.it.xlf index a23af453c2..dd420ff9d3 100644 --- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.it.xlf +++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.it.xlf @@ -2,6 +2,31 @@ + + Environment variable '{0}' is set, but the test framework does not support '{1}'. The platform cannot stop the test run before the deadline. + Environment variable '{0}' is set, but the test framework does not support '{1}'. The platform cannot stop the test run before the deadline. + {0} is the environment variable name. {1} is the capability interface name. + + + Extension that gracefully stops the test run shortly before a CI-imposed deadline so reports can be finalized. + Extension that gracefully stops the test run shortly before a CI-imposed deadline so reports can be finalized. + + + + Environment variable '{0}' is set to '{1}' but is not a valid absolute ISO 8601 instant. Deadline-aware cancellation is disabled. + Environment variable '{0}' is set to '{1}' but is not a valid absolute ISO 8601 instant. Deadline-aware cancellation is disabled. + {0} is the environment variable name. {1} is the invalid value. {Locked="ISO 8601"} + + + Deadline dump margin ({0}) is greater than or equal to the stop margin ({1}). The hang dump may start before the graceful stop. + Deadline dump margin ({0}) is greater than or equal to the stop margin ({1}). The hang dump may start before the graceful stop. + {0} is the dump margin. {1} is the stop margin. + + + Deadline approaching: gracefully stopping the test run so reports can be finalized before the CI hard-cancel. + Deadline approaching: gracefully stopping the test run so reports can be finalized before the CI hard-cancel. + + The current test framework does not implement 'IGracefulStopTestExecutionCapability' which is required for '--maximum-failed-tests' feature. Il framework di test corrente non implementa 'IGracefulStopTestExecutionCapability', necessario per la funzionalità '--maximum-failed-tests'. diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.ja.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.ja.xlf index 1320536e21..a4957ffe19 100644 --- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.ja.xlf +++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.ja.xlf @@ -2,6 +2,31 @@ + + Environment variable '{0}' is set, but the test framework does not support '{1}'. The platform cannot stop the test run before the deadline. + Environment variable '{0}' is set, but the test framework does not support '{1}'. The platform cannot stop the test run before the deadline. + {0} is the environment variable name. {1} is the capability interface name. + + + Extension that gracefully stops the test run shortly before a CI-imposed deadline so reports can be finalized. + Extension that gracefully stops the test run shortly before a CI-imposed deadline so reports can be finalized. + + + + Environment variable '{0}' is set to '{1}' but is not a valid absolute ISO 8601 instant. Deadline-aware cancellation is disabled. + Environment variable '{0}' is set to '{1}' but is not a valid absolute ISO 8601 instant. Deadline-aware cancellation is disabled. + {0} is the environment variable name. {1} is the invalid value. {Locked="ISO 8601"} + + + Deadline dump margin ({0}) is greater than or equal to the stop margin ({1}). The hang dump may start before the graceful stop. + Deadline dump margin ({0}) is greater than or equal to the stop margin ({1}). The hang dump may start before the graceful stop. + {0} is the dump margin. {1} is the stop margin. + + + Deadline approaching: gracefully stopping the test run so reports can be finalized before the CI hard-cancel. + Deadline approaching: gracefully stopping the test run so reports can be finalized before the CI hard-cancel. + + The current test framework does not implement 'IGracefulStopTestExecutionCapability' which is required for '--maximum-failed-tests' feature. 現在のテスト フレームワークは、'--maximum-failed-tests' 機能に必要な 'IGracefulStopTestExecutionCapability' を実装していません。 diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.ko.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.ko.xlf index 7f408e5631..ac8a2fb4c9 100644 --- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.ko.xlf +++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.ko.xlf @@ -2,6 +2,31 @@ + + Environment variable '{0}' is set, but the test framework does not support '{1}'. The platform cannot stop the test run before the deadline. + Environment variable '{0}' is set, but the test framework does not support '{1}'. The platform cannot stop the test run before the deadline. + {0} is the environment variable name. {1} is the capability interface name. + + + Extension that gracefully stops the test run shortly before a CI-imposed deadline so reports can be finalized. + Extension that gracefully stops the test run shortly before a CI-imposed deadline so reports can be finalized. + + + + Environment variable '{0}' is set to '{1}' but is not a valid absolute ISO 8601 instant. Deadline-aware cancellation is disabled. + Environment variable '{0}' is set to '{1}' but is not a valid absolute ISO 8601 instant. Deadline-aware cancellation is disabled. + {0} is the environment variable name. {1} is the invalid value. {Locked="ISO 8601"} + + + Deadline dump margin ({0}) is greater than or equal to the stop margin ({1}). The hang dump may start before the graceful stop. + Deadline dump margin ({0}) is greater than or equal to the stop margin ({1}). The hang dump may start before the graceful stop. + {0} is the dump margin. {1} is the stop margin. + + + Deadline approaching: gracefully stopping the test run so reports can be finalized before the CI hard-cancel. + Deadline approaching: gracefully stopping the test run so reports can be finalized before the CI hard-cancel. + + The current test framework does not implement 'IGracefulStopTestExecutionCapability' which is required for '--maximum-failed-tests' feature. 현재 테스트 프레임워크는 '--maximum-failed-tests' 기능에 필요한 'IGracefulStopTestExecutionCapability'를 구현하지 않습니다. diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.pl.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.pl.xlf index 27e29bac8a..c37b98a6f8 100644 --- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.pl.xlf +++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.pl.xlf @@ -2,6 +2,31 @@ + + Environment variable '{0}' is set, but the test framework does not support '{1}'. The platform cannot stop the test run before the deadline. + Environment variable '{0}' is set, but the test framework does not support '{1}'. The platform cannot stop the test run before the deadline. + {0} is the environment variable name. {1} is the capability interface name. + + + Extension that gracefully stops the test run shortly before a CI-imposed deadline so reports can be finalized. + Extension that gracefully stops the test run shortly before a CI-imposed deadline so reports can be finalized. + + + + Environment variable '{0}' is set to '{1}' but is not a valid absolute ISO 8601 instant. Deadline-aware cancellation is disabled. + Environment variable '{0}' is set to '{1}' but is not a valid absolute ISO 8601 instant. Deadline-aware cancellation is disabled. + {0} is the environment variable name. {1} is the invalid value. {Locked="ISO 8601"} + + + Deadline dump margin ({0}) is greater than or equal to the stop margin ({1}). The hang dump may start before the graceful stop. + Deadline dump margin ({0}) is greater than or equal to the stop margin ({1}). The hang dump may start before the graceful stop. + {0} is the dump margin. {1} is the stop margin. + + + Deadline approaching: gracefully stopping the test run so reports can be finalized before the CI hard-cancel. + Deadline approaching: gracefully stopping the test run so reports can be finalized before the CI hard-cancel. + + The current test framework does not implement 'IGracefulStopTestExecutionCapability' which is required for '--maximum-failed-tests' feature. Bieżąca platforma testowa nie implementuje interfejsu "IGracefulStopTestExecutionCapability", który jest wymagany dla funkcji "--maximum-failed-tests". diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.pt-BR.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.pt-BR.xlf index 09a4849fcc..93fb1fb37f 100644 --- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.pt-BR.xlf +++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.pt-BR.xlf @@ -2,6 +2,31 @@ + + Environment variable '{0}' is set, but the test framework does not support '{1}'. The platform cannot stop the test run before the deadline. + Environment variable '{0}' is set, but the test framework does not support '{1}'. The platform cannot stop the test run before the deadline. + {0} is the environment variable name. {1} is the capability interface name. + + + Extension that gracefully stops the test run shortly before a CI-imposed deadline so reports can be finalized. + Extension that gracefully stops the test run shortly before a CI-imposed deadline so reports can be finalized. + + + + Environment variable '{0}' is set to '{1}' but is not a valid absolute ISO 8601 instant. Deadline-aware cancellation is disabled. + Environment variable '{0}' is set to '{1}' but is not a valid absolute ISO 8601 instant. Deadline-aware cancellation is disabled. + {0} is the environment variable name. {1} is the invalid value. {Locked="ISO 8601"} + + + Deadline dump margin ({0}) is greater than or equal to the stop margin ({1}). The hang dump may start before the graceful stop. + Deadline dump margin ({0}) is greater than or equal to the stop margin ({1}). The hang dump may start before the graceful stop. + {0} is the dump margin. {1} is the stop margin. + + + Deadline approaching: gracefully stopping the test run so reports can be finalized before the CI hard-cancel. + Deadline approaching: gracefully stopping the test run so reports can be finalized before the CI hard-cancel. + + The current test framework does not implement 'IGracefulStopTestExecutionCapability' which is required for '--maximum-failed-tests' feature. A estrutura de teste atual não implementa 'IGracefulStopTestExecutionCapability', que é necessário para o recurso '--maximum-failed-tests'. diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.ru.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.ru.xlf index d7ccdc4db9..87d55ba425 100644 --- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.ru.xlf +++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.ru.xlf @@ -2,6 +2,31 @@ + + Environment variable '{0}' is set, but the test framework does not support '{1}'. The platform cannot stop the test run before the deadline. + Environment variable '{0}' is set, but the test framework does not support '{1}'. The platform cannot stop the test run before the deadline. + {0} is the environment variable name. {1} is the capability interface name. + + + Extension that gracefully stops the test run shortly before a CI-imposed deadline so reports can be finalized. + Extension that gracefully stops the test run shortly before a CI-imposed deadline so reports can be finalized. + + + + Environment variable '{0}' is set to '{1}' but is not a valid absolute ISO 8601 instant. Deadline-aware cancellation is disabled. + Environment variable '{0}' is set to '{1}' but is not a valid absolute ISO 8601 instant. Deadline-aware cancellation is disabled. + {0} is the environment variable name. {1} is the invalid value. {Locked="ISO 8601"} + + + Deadline dump margin ({0}) is greater than or equal to the stop margin ({1}). The hang dump may start before the graceful stop. + Deadline dump margin ({0}) is greater than or equal to the stop margin ({1}). The hang dump may start before the graceful stop. + {0} is the dump margin. {1} is the stop margin. + + + Deadline approaching: gracefully stopping the test run so reports can be finalized before the CI hard-cancel. + Deadline approaching: gracefully stopping the test run so reports can be finalized before the CI hard-cancel. + + The current test framework does not implement 'IGracefulStopTestExecutionCapability' which is required for '--maximum-failed-tests' feature. Текущая платформа тестирования не реализует параметр "IGracefulStopTestExecutionCapability", необходимый для функции "--maximum-failed-tests". diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.tr.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.tr.xlf index 4ddfc53677..7df178a882 100644 --- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.tr.xlf +++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.tr.xlf @@ -2,6 +2,31 @@ + + Environment variable '{0}' is set, but the test framework does not support '{1}'. The platform cannot stop the test run before the deadline. + Environment variable '{0}' is set, but the test framework does not support '{1}'. The platform cannot stop the test run before the deadline. + {0} is the environment variable name. {1} is the capability interface name. + + + Extension that gracefully stops the test run shortly before a CI-imposed deadline so reports can be finalized. + Extension that gracefully stops the test run shortly before a CI-imposed deadline so reports can be finalized. + + + + Environment variable '{0}' is set to '{1}' but is not a valid absolute ISO 8601 instant. Deadline-aware cancellation is disabled. + Environment variable '{0}' is set to '{1}' but is not a valid absolute ISO 8601 instant. Deadline-aware cancellation is disabled. + {0} is the environment variable name. {1} is the invalid value. {Locked="ISO 8601"} + + + Deadline dump margin ({0}) is greater than or equal to the stop margin ({1}). The hang dump may start before the graceful stop. + Deadline dump margin ({0}) is greater than or equal to the stop margin ({1}). The hang dump may start before the graceful stop. + {0} is the dump margin. {1} is the stop margin. + + + Deadline approaching: gracefully stopping the test run so reports can be finalized before the CI hard-cancel. + Deadline approaching: gracefully stopping the test run so reports can be finalized before the CI hard-cancel. + + The current test framework does not implement 'IGracefulStopTestExecutionCapability' which is required for '--maximum-failed-tests' feature. Geçerli test çerçevesi, '--maximum-failed-tests' özelliği için gerekli olan 'IGracefulStopTestExecutionCapability' gerçekleştiremiyor. diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.zh-Hans.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.zh-Hans.xlf index c7139927cb..97280cc876 100644 --- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.zh-Hans.xlf +++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.zh-Hans.xlf @@ -2,6 +2,31 @@ + + Environment variable '{0}' is set, but the test framework does not support '{1}'. The platform cannot stop the test run before the deadline. + Environment variable '{0}' is set, but the test framework does not support '{1}'. The platform cannot stop the test run before the deadline. + {0} is the environment variable name. {1} is the capability interface name. + + + Extension that gracefully stops the test run shortly before a CI-imposed deadline so reports can be finalized. + Extension that gracefully stops the test run shortly before a CI-imposed deadline so reports can be finalized. + + + + Environment variable '{0}' is set to '{1}' but is not a valid absolute ISO 8601 instant. Deadline-aware cancellation is disabled. + Environment variable '{0}' is set to '{1}' but is not a valid absolute ISO 8601 instant. Deadline-aware cancellation is disabled. + {0} is the environment variable name. {1} is the invalid value. {Locked="ISO 8601"} + + + Deadline dump margin ({0}) is greater than or equal to the stop margin ({1}). The hang dump may start before the graceful stop. + Deadline dump margin ({0}) is greater than or equal to the stop margin ({1}). The hang dump may start before the graceful stop. + {0} is the dump margin. {1} is the stop margin. + + + Deadline approaching: gracefully stopping the test run so reports can be finalized before the CI hard-cancel. + Deadline approaching: gracefully stopping the test run so reports can be finalized before the CI hard-cancel. + + The current test framework does not implement 'IGracefulStopTestExecutionCapability' which is required for '--maximum-failed-tests' feature. 当前测试框架未实现 “--maximum-failed-tests” 功能所需的 “IGracefulStopTestExecutionCapability”。 diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.zh-Hant.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.zh-Hant.xlf index c112d6f143..b25cb62f65 100644 --- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.zh-Hant.xlf +++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.zh-Hant.xlf @@ -2,6 +2,31 @@ + + Environment variable '{0}' is set, but the test framework does not support '{1}'. The platform cannot stop the test run before the deadline. + Environment variable '{0}' is set, but the test framework does not support '{1}'. The platform cannot stop the test run before the deadline. + {0} is the environment variable name. {1} is the capability interface name. + + + Extension that gracefully stops the test run shortly before a CI-imposed deadline so reports can be finalized. + Extension that gracefully stops the test run shortly before a CI-imposed deadline so reports can be finalized. + + + + Environment variable '{0}' is set to '{1}' but is not a valid absolute ISO 8601 instant. Deadline-aware cancellation is disabled. + Environment variable '{0}' is set to '{1}' but is not a valid absolute ISO 8601 instant. Deadline-aware cancellation is disabled. + {0} is the environment variable name. {1} is the invalid value. {Locked="ISO 8601"} + + + Deadline dump margin ({0}) is greater than or equal to the stop margin ({1}). The hang dump may start before the graceful stop. + Deadline dump margin ({0}) is greater than or equal to the stop margin ({1}). The hang dump may start before the graceful stop. + {0} is the dump margin. {1} is the stop margin. + + + Deadline approaching: gracefully stopping the test run so reports can be finalized before the CI hard-cancel. + Deadline approaching: gracefully stopping the test run so reports can be finalized before the CI hard-cancel. + + The current test framework does not implement 'IGracefulStopTestExecutionCapability' which is required for '--maximum-failed-tests' feature. 目前的測試架構未實作 '--maximum-failed-tests' 功能所需的 'IGracefulStopTestExecutionCapability'。 diff --git a/src/Platform/Microsoft.Testing.Platform/Services/IStopPoliciesService.cs b/src/Platform/Microsoft.Testing.Platform/Services/IStopPoliciesService.cs index d82def8818..1928996e74 100644 --- a/src/Platform/Microsoft.Testing.Platform/Services/IStopPoliciesService.cs +++ b/src/Platform/Microsoft.Testing.Platform/Services/IStopPoliciesService.cs @@ -9,11 +9,45 @@ internal interface IStopPoliciesService bool IsAbortTriggered { get; } + bool IsDeadlineTriggered { get; } + + /// + /// Gets a value indicating whether test execution has finished, meaning the test framework invoker has + /// returned and only reporting and teardown remain. + /// + /// + /// This is the gate that stops a deadline elapsing during reporting from marking an already-finished run as + /// deadline-truncated. Server mode creates a separate policy service for each request. + /// + bool IsTestExecutionCompleted { get; } + + /// + /// Records that a test execution request is starting. + /// + /// + /// A new execution must clear the completion gate before arming its deadline. + /// + void NotifyTestExecutionStarting(); + + /// + /// Records that test execution has finished. Called by the host the moment the test framework invoker + /// returns, before any reporting or message-bus draining starts. + /// + void NotifyTestExecutionCompleted(); + Task RegisterOnMaxFailedTestsCallbackAsync(Func callback); Task RegisterOnAbortCallbackAsync(Func callback); + Task RegisterOnDeadlineCallbackAsync(Func callback); + + void RegisterDeadlineStopFallback(Func> callback); + Task ExecuteMaxFailedTestsCallbacksAsync(int maxFailedTests, CancellationToken cancellationToken); Task ExecuteAbortCallbacksAsync(); + + Task ExecuteDeadlineCallbacksAsync(); + + Task TryExecuteDeadlineStopFallbackAsync(); } diff --git a/src/Platform/Microsoft.Testing.Platform/Services/StopPoliciesService.cs b/src/Platform/Microsoft.Testing.Platform/Services/StopPoliciesService.cs index be2f89dfd7..fcee580a4d 100644 --- a/src/Platform/Microsoft.Testing.Platform/Services/StopPoliciesService.cs +++ b/src/Platform/Microsoft.Testing.Platform/Services/StopPoliciesService.cs @@ -5,21 +5,50 @@ namespace Microsoft.Testing.Platform.Services; -internal sealed class StopPoliciesService : IStopPoliciesService +internal sealed class StopPoliciesService : IStopPoliciesService, IDisposable { private readonly ITestApplicationCancellationTokenSource _testApplicationCancellationTokenSource; + private readonly CancellationTokenRegistration _abortRegistration; private readonly ConcurrentQueue> _maxFailedTestsCallbacks = new(); private readonly ConcurrentQueue> _abortCallbacks = new(); + + // Guards the deadline state together with its callback list, so registration and the one-shot trigger + // cannot interleave and drop a callback. A flag plus a concurrent queue is not enough: the registering + // thread can read the flag as false, the trigger can then set it and snapshot a still-empty queue, and only + // afterwards does the callback land in the queue -- where nothing will ever invoke it, because the deadline + // fires once. Under this lock a callback is invoked exactly once, either by the trigger (it was in the list + // when the snapshot was taken) or by the registering thread itself (the trigger had already happened). +#if NET9_0_OR_GREATER + private readonly Lock _deadlineLock = new(); +#else + private readonly object _deadlineLock = new(); +#endif + private readonly List> _deadlineCallbacks = []; + private Func>? _deadlineStopFallback; + + // Whether the callbacks have run. This is the one-shot gate and it is never cleared: once the callbacks + // have run, a second trigger must not run them again and a late registration must be invoked on the spot. + private bool _areDeadlineCallbacksExecuted; + + // Whether the run is to be reported as stopped at the deadline. +#pragma warning disable IDE0032 // Use auto property - synchronized access requires a backing field. + private bool _isDeadlineTriggered; +#pragma warning restore IDE0032 private int _lastMaxFailedTests; + // One policy service can observe nested execution starts, so count active executions rather than using + // a Boolean completion flag. + private int _activeTestExecutions; + private volatile bool _hasTestExecutionStarted; + public StopPoliciesService(ITestApplicationCancellationTokenSource testApplicationCancellationTokenSource) { _testApplicationCancellationTokenSource = testApplicationCancellationTokenSource; #pragma warning disable VSTHRD101 // Avoid unsupported async delegates // Note: If cancellation already requested, Register will still invoke the callback. - testApplicationCancellationTokenSource.CancellationToken.Register(async () => await ExecuteAbortCallbacksAsync().ConfigureAwait(false)); + _abortRegistration = testApplicationCancellationTokenSource.CancellationToken.Register(async () => await ExecuteAbortCallbacksAsync().ConfigureAwait(false)); #pragma warning restore VSTHRD101 // Avoid unsupported async delegates } @@ -29,6 +58,45 @@ public StopPoliciesService(ITestApplicationCancellationTokenSource testApplicati public bool IsAbortTriggered { get; private set; } + public bool IsDeadlineTriggered + { + get + { + lock (_deadlineLock) + { + return _isDeadlineTriggered; + } + } + } + + public bool IsTestExecutionCompleted + => _hasTestExecutionStarted && Volatile.Read(ref _activeTestExecutions) == 0; + + public void NotifyTestExecutionStarting() + { + Interlocked.Increment(ref _activeTestExecutions); + _hasTestExecutionStarted = true; + } + + public void NotifyTestExecutionCompleted() + { + _hasTestExecutionStarted = true; + + int activeExecutions; + do + { + activeExecutions = Volatile.Read(ref _activeTestExecutions); + if (activeExecutions == 0) + { + return; + } + } + while (Interlocked.CompareExchange(ref _activeTestExecutions, activeExecutions - 1, activeExecutions) != activeExecutions); + } + + public void Dispose() + => _abortRegistration.Dispose(); + public async Task ExecuteMaxFailedTestsCallbacksAsync(int maxFailedTests, CancellationToken cancellationToken) { _lastMaxFailedTests = maxFailedTests; @@ -63,6 +131,53 @@ public async Task ExecuteAbortCallbacksAsync() } } + public async Task ExecuteDeadlineCallbacksAsync() + { + Func[] callbacks; + lock (_deadlineLock) + { + if (_areDeadlineCallbacksExecuted) + { + // The deadline is one-shot; a second trigger must not run the callbacks again. + return; + } + + _areDeadlineCallbacksExecuted = true; + _isDeadlineTriggered = true; + + // Take the callbacks under the lock and clear the list, so a callback registered from now on is + // invoked by RegisterOnDeadlineCallbackAsync instead of being silently dropped here. + callbacks = [.. _deadlineCallbacks]; + _deadlineCallbacks.Clear(); + } + + foreach (Func callback in callbacks) + { + // For now, we are fine if the callback crashed us. It shouldn't happen for our + // current usage anyway and the APIs around this are all internal for now. + await callback.Invoke().ConfigureAwait(false); + } + } + + public void RegisterDeadlineStopFallback(Func> callback) + { + lock (_deadlineLock) + { + _deadlineStopFallback = callback; + } + } + + public Task TryExecuteDeadlineStopFallbackAsync() + { + Func>? deadlineStopFallback; + lock (_deadlineLock) + { + deadlineStopFallback = _deadlineStopFallback; + } + + return deadlineStopFallback?.Invoke() ?? Task.FromResult(false); + } + public async Task RegisterOnMaxFailedTestsCallbackAsync(Func callback) { if (ProcessRole != TestProcessRole.TestHost) @@ -87,4 +202,21 @@ public async Task RegisterOnAbortCallbackAsync(Func callback) _abortCallbacks.Enqueue(callback); } + + public async Task RegisterOnDeadlineCallbackAsync(Func callback) + { + lock (_deadlineLock) + { + if (!_areDeadlineCallbacksExecuted) + { + _deadlineCallbacks.Add(callback); + return; + } + } + + // The callbacks already ran, so this registration came too late for the snapshot in + // ExecuteDeadlineCallbacksAsync. Invoke the callback here instead, outside the lock: it is + // arbitrary code and must not run while the deadline transition is held. + await callback().ConfigureAwait(false); + } } diff --git a/src/Platform/Microsoft.Testing.Platform/Services/TestApplicationResult.cs b/src/Platform/Microsoft.Testing.Platform/Services/TestApplicationResult.cs index 13a59de88a..e2240baa57 100644 --- a/src/Platform/Microsoft.Testing.Platform/Services/TestApplicationResult.cs +++ b/src/Platform/Microsoft.Testing.Platform/Services/TestApplicationResult.cs @@ -175,6 +175,13 @@ internal int GetProcessExitCodeWithoutIgnore() exitCode = exitCode == ExitCode.Success && _failedTestsCount > 0 ? ExitCode.AtLeastOneTestFailed : exitCode; exitCode = exitCode == ExitCode.Success && _policiesService.IsAbortTriggered ? ExitCode.TestSessionAborted : exitCode; + // A deadline-driven graceful stop (see AbortAtDeadlineExtension) truncates the run before the CI + // hard-cancel. Such a run may otherwise look successful (it did not fail or abort), but it did not + // execute every test, so it must not report success. Real failures/abort above keep precedence; a + // clean-but-truncated run becomes non-zero here and takes precedence over the zero-tests/coverage + // verdicts below (a truncated run legitimately may not have run the expected number of tests). + exitCode = exitCode == ExitCode.Success && _policiesService.IsDeadlineTriggered ? ExitCode.TestExecutionStoppedAtDeadline : exitCode; + // An explicitly-provided `--minimum-expected-tests` governs the count-based verdict and // supersedes the ZeroTests (8) verdict below: a run of fewer than N tests yields // ExitCode.MinimumExpectedTestsPolicyViolation (9), even when zero tests ran. This lets callers diff --git a/src/Platform/Microsoft.Testing.Platform/TestHostControllers/EnvironmentVariables.cs b/src/Platform/Microsoft.Testing.Platform/TestHostControllers/EnvironmentVariables.cs index 4ca6397932..3c74198c54 100644 --- a/src/Platform/Microsoft.Testing.Platform/TestHostControllers/EnvironmentVariables.cs +++ b/src/Platform/Microsoft.Testing.Platform/TestHostControllers/EnvironmentVariables.cs @@ -10,7 +10,12 @@ namespace Microsoft.Testing.Platform.TestHostControllers; internal sealed class EnvironmentVariables(ILoggerFactory loggerFactory) : IEnvironmentVariables { private const string StrippedSecretValue = "*****"; - private readonly Dictionary _environmentVariables = []; + private readonly Dictionary _environmentVariables = new( + 0, + RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? StringComparer.OrdinalIgnoreCase + : StringComparer.Ordinal); + private readonly ILogger _logger = loggerFactory.CreateLogger(); public ITestHostEnvironmentVariableProvider? CurrentProvider { get; set; } diff --git a/src/Platform/Microsoft.Testing.Platform/TestHostControllers/SystemEnvironmentVariableProvider.cs b/src/Platform/Microsoft.Testing.Platform/TestHostControllers/SystemEnvironmentVariableProvider.cs index 00bed2b5a6..25721da418 100644 --- a/src/Platform/Microsoft.Testing.Platform/TestHostControllers/SystemEnvironmentVariableProvider.cs +++ b/src/Platform/Microsoft.Testing.Platform/TestHostControllers/SystemEnvironmentVariableProvider.cs @@ -9,6 +9,13 @@ namespace Microsoft.Testing.Platform.TestHostControllers; internal sealed class SystemEnvironmentVariableProvider(IEnvironment environment) : ITestHostEnvironmentVariableProvider { + private static readonly string[] ReservedDeadlineVariables = + [ + EnvironmentVariableConstants.TESTINGPLATFORM_DEADLINE, + EnvironmentVariableConstants.TESTINGPLATFORM_DEADLINE_STOP_MARGIN, + EnvironmentVariableConstants.TESTINGPLATFORM_DEADLINE_DUMP_MARGIN, + ]; + private readonly SystemExtension _systemExtension = new(); private readonly IEnvironment _environment = environment; @@ -24,9 +31,22 @@ internal sealed class SystemEnvironmentVariableProvider(IEnvironment environment public Task UpdateAsync(IEnvironmentVariables environmentVariables) { + StringComparer environmentVariableComparer = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? StringComparer.OrdinalIgnoreCase + : StringComparer.Ordinal; + var reservedDeadlineVariables = new HashSet(ReservedDeadlineVariables, environmentVariableComparer); foreach (DictionaryEntry entry in _environment.GetEnvironmentVariables()) { - environmentVariables.SetVariable(new(entry.Key.ToString()!, entry.Value!.ToString(), false, false)); + string variable = entry.Key.ToString()!; + bool isReservedDeadlineVariable = reservedDeadlineVariables.Remove(variable); + environmentVariables.SetVariable(new(variable, entry.Value!.ToString(), false, isReservedDeadlineVariable)); + } + + // A child-only provider must not activate deadline handling when the controller did not. + // Reserve absent values as empty and locked so every child observes the controller snapshot. + foreach (string variable in reservedDeadlineVariables) + { + environmentVariables.SetVariable(new(variable, string.Empty, false, true)); } return Task.CompletedTask; diff --git a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/AbortAtDeadlineTests.cs b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/AbortAtDeadlineTests.cs new file mode 100644 index 0000000000..f81d57d60c --- /dev/null +++ b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/AbortAtDeadlineTests.cs @@ -0,0 +1,358 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Microsoft.Testing.Platform.Acceptance.IntegrationTests; + +[TestClass] +public sealed class AbortAtDeadlineTests : AcceptanceTestBase +{ + private const string AssetName = nameof(AbortAtDeadlineTests); + + private const string StopMessage = "gracefully stopping the test run so reports can be finalized"; + + [TestMethod] + public async Task WhenDeadlineIsInThePast_GracefullyStopsImmediately() + { + var testHost = TestInfrastructure.TestHost.LocateFrom(AssetFixture.TargetAssetPath, AssetName, TargetFrameworks.NetCurrent); + TestHostResult testHostResult = await testHost.ExecuteAsync( + environmentVariables: new() + { + // A deadline already in the past means the stop instant is also in the past, so the + // graceful stop fires as soon as the extension arms its timer. + ["TESTINGPLATFORM_DEADLINE"] = DateTimeOffset.UtcNow.AddMinutes(-5).ToString("o"), + ["TESTINGPLATFORM_DEADLINE_STOP_MARGIN"] = "0", + ["WAIT_FOR_STOP"] = "1", + }, + cancellationToken: TestContext.CancellationToken); + + // A deadline-truncated run reports its own exit code so CI/tooling can tell it apart from a + // clean pass, even though the in-flight test finished and the summary shows it as passed. + testHostResult.AssertExitCodeIs(ExitCode.TestExecutionStoppedAtDeadline); + testHostResult.AssertOutputContains(StopMessage); + testHostResult.AssertOutputContainsSummary(failed: 0, passed: 1, skipped: 0); + } + + [TestMethod] + public async Task WhenDeadlineIsInThePast_WithDotnetTest_GracefullyStopsImmediately() + { + DotnetMuxerResult testResult = await DotnetCli.RunAsync( + $"test --project \"{AssetFixture.TargetAssetPath}\" --no-build -c Release -f {TargetFrameworks.NetCurrent}", + environmentVariables: new() + { + ["TESTINGPLATFORM_DEADLINE"] = DateTimeOffset.UtcNow.AddMinutes(-5).ToString("o"), + ["TESTINGPLATFORM_DEADLINE_STOP_MARGIN"] = "0", + ["WAIT_FOR_STOP"] = "1", + }, + workingDirectory: AssetFixture.TargetAssetPath, + failIfReturnValueIsNotZero: false, + cancellationToken: TestContext.CancellationToken); + + testResult.AssertExitCodeIs(ExitCode.TestExecutionStoppedAtDeadline); + testResult.AssertOutputContains(StopMessage); + testResult.AssertOutputContains("Test run summary: Failed!"); + testResult.AssertOutputContains("error: 1"); + testResult.AssertOutputContains("total: 1"); + testResult.AssertOutputContains("failed: 0"); + testResult.AssertOutputContains("succeeded: 1"); + testResult.AssertOutputContains("skipped: 0"); + } + + [TestMethod] + public async Task WhenDeadlineStopsAHotReloadRun_ReportersFinalize() + { + string reportFileName = $"{Guid.NewGuid():N}.html"; + var testHost = TestInfrastructure.TestHost.LocateFrom(AssetFixture.TargetAssetPath, AssetName, TargetFrameworks.NetCurrent); + TestHostResult testHostResult = await testHost.ExecuteAsync( + $"--report-html --report-html-filename {reportFileName}", + environmentVariables: new() + { + ["TESTINGPLATFORM_DEADLINE_STOP_MARGIN"] = "0", + ["TESTINGPLATFORM_HOTRELOAD_ENABLED"] = "1", + ["TESTINGPLATFORM_TEST_SET_DEADLINE_ON_START"] = "1", + ["WAIT_FOR_STOP"] = "1", + }, + cancellationToken: TestContext.CancellationToken); + + testHostResult.AssertExitCodeIs(ExitCode.TestExecutionStoppedAtDeadline); + testHostResult.AssertOutputContains(StopMessage); + testHostResult.AssertOutputContainsSummary(failed: 0, passed: 1, skipped: 0); + + string reportPath = Path.Combine(testHost.DirectoryName, "TestResults", reportFileName); + Assert.IsTrue(File.Exists(reportPath), $"HTML report should be generated at: {reportPath}"); + string reportContent = File.ReadAllText(reportPath); + Assert.Contains("", reportContent); + Assert.Contains("id=\"mtp-data\"", reportContent); + } + + [TestMethod] + public async Task WhenDeadlineStopsIdleHotReload_ReportersFinalize() + { + var testHost = TestInfrastructure.TestHost.LocateFrom(AssetFixture.TargetAssetPath, AssetName, TargetFrameworks.NetCurrent); + TestHostResult testHostResult = await testHost.ExecuteAsync( + environmentVariables: new() + { + ["TESTINGPLATFORM_DEADLINE_STOP_MARGIN"] = "0", + ["TESTINGPLATFORM_HOTRELOAD_ENABLED"] = "1", + ["TESTINGPLATFORM_TEST_SET_DEADLINE_ON_START"] = "1", + ["REJECT_STOP_AFTER_EXECUTION"] = "1", + }, + cancellationToken: TestContext.CancellationToken); + + testHostResult.AssertExitCodeIs(ExitCode.TestExecutionStoppedAtDeadline); + testHostResult.AssertOutputContains(StopMessage); + testHostResult.AssertOutputContainsSummary(failed: 0, passed: 1, skipped: 0); + } + + [TestMethod] + public async Task WhenPastDeadlineStopIsRejectedBeforeHotReloadStarts_FallbackStopsHost() + { + var testHost = TestInfrastructure.TestHost.LocateFrom(AssetFixture.TargetAssetPath, AssetName, TargetFrameworks.NetCurrent); + TestHostResult testHostResult = await testHost.ExecuteAsync( + environmentVariables: new() + { + ["TESTINGPLATFORM_DEADLINE"] = DateTimeOffset.UtcNow.AddMinutes(-5).ToString("o"), + ["TESTINGPLATFORM_DEADLINE_STOP_MARGIN"] = "0", + ["TESTINGPLATFORM_HOTRELOAD_ENABLED"] = "1", + ["REJECT_STOP"] = "1", + }, + cancellationToken: TestContext.CancellationToken); + + testHostResult.AssertExitCodeIs(ExitCode.TestExecutionStoppedAtDeadline); + testHostResult.AssertOutputContains(StopMessage); + } + + [TestMethod] + public async Task WhenDeadlineIsInTheFuture_GracefullyStopsWhenReached() + { + var testHost = TestInfrastructure.TestHost.LocateFrom(AssetFixture.TargetAssetPath, AssetName, TargetFrameworks.NetCurrent); + TestHostResult testHostResult = await testHost.ExecuteAsync( + environmentVariables: new() + { + // Stop margin 0 means the graceful stop is scheduled for the deadline itself, a few + // seconds out. The framework blocks until the stop is requested, so this proves the + // timer fires on schedule (not only when the deadline is already past). + ["TESTINGPLATFORM_DEADLINE"] = DateTimeOffset.UtcNow.AddSeconds(6).ToString("o"), + ["TESTINGPLATFORM_DEADLINE_STOP_MARGIN"] = "0", + ["WAIT_FOR_STOP"] = "1", + }, + cancellationToken: TestContext.CancellationToken); + + testHostResult.AssertExitCodeIs(ExitCode.TestExecutionStoppedAtDeadline); + testHostResult.AssertOutputContains(StopMessage); + testHostResult.AssertOutputContainsSummary(failed: 0, passed: 1, skipped: 0); + } + + [TestMethod] + public async Task WhenStopMarginIsSubtracted_GracefullyStopsBeforeDeadline() + { + var testHost = TestInfrastructure.TestHost.LocateFrom(AssetFixture.TargetAssetPath, AssetName, TargetFrameworks.NetCurrent); + TestHostResult testHostResult = await testHost.ExecuteAsync( + environmentVariables: new() + { + // Deadline is a minute out, but a 60s stop margin pulls the stop instant back to + // roughly now, exercising the margin subtraction against the absolute deadline. + ["TESTINGPLATFORM_DEADLINE"] = DateTimeOffset.UtcNow.AddSeconds(60).ToString("o"), + ["TESTINGPLATFORM_DEADLINE_STOP_MARGIN"] = "60", + ["WAIT_FOR_STOP"] = "1", + }, + cancellationToken: TestContext.CancellationToken); + + testHostResult.AssertExitCodeIs(ExitCode.TestExecutionStoppedAtDeadline); + testHostResult.AssertOutputContains(StopMessage); + testHostResult.AssertOutputContainsSummary(failed: 0, passed: 1, skipped: 0); + } + + [TestMethod] + public async Task WhenNoDeadlineIsSet_DoesNotStop() + { + var testHost = TestInfrastructure.TestHost.LocateFrom(AssetFixture.TargetAssetPath, AssetName, TargetFrameworks.NetCurrent); + + // No deadline environment variable, and the framework does not wait for a stop, so the run + // completes normally and the extension stays silent (it is strictly opt-in). + TestHostResult testHostResult = await testHost.ExecuteAsync( + environmentVariables: new() + { + ["TESTINGPLATFORM_DEADLINE"] = null, + }, + cancellationToken: TestContext.CancellationToken); + + testHostResult.AssertExitCodeIs(ExitCode.Success); + testHostResult.AssertOutputDoesNotContain(StopMessage); + testHostResult.AssertOutputContainsSummary(failed: 0, passed: 1, skipped: 0); + } + + [TestMethod] + public async Task WhenGracefulStopCapabilityIsMissing_DoesNotStopAndRunsToCompletion() + { + var testHost = TestInfrastructure.TestHost.LocateFrom(AssetFixture.TargetAssetPath, AssetName, TargetFrameworks.NetCurrent); + + // A deadline is set, but the framework does not expose IGracefulStopTestExecutionCapability, + // so the extension degrades to a no-op instead of failing the command line. + TestHostResult testHostResult = await testHost.ExecuteAsync( + environmentVariables: new() + { + ["TESTINGPLATFORM_DEADLINE"] = DateTimeOffset.UtcNow.AddMinutes(-5).ToString("o"), + ["DO_NOT_ADD_CAPABILITY"] = "1", + }, + cancellationToken: TestContext.CancellationToken); + + testHostResult.AssertExitCodeIs(ExitCode.Success); + testHostResult.AssertOutputDoesNotContain(StopMessage); + testHostResult.AssertOutputContainsSummary(failed: 0, passed: 1, skipped: 0); + } + + public sealed class TestAssetFixture() : TestAssetFixtureBase() + { + private const string Sources = """ +#file AbortAtDeadlineTests.csproj + + + $TargetFrameworks$ + Exe + true + enable + preview + + + + + + + + +#file Program.cs +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Testing.Extensions; +using Microsoft.Testing.Platform.Builder; +using Microsoft.Testing.Platform.Capabilities; +using Microsoft.Testing.Platform.Capabilities.TestFramework; +using Microsoft.Testing.Platform.Extensions.Messages; +using Microsoft.Testing.Platform.Extensions.TestFramework; + +internal sealed class Program +{ + public static async Task Main(string[] args) + { + if (Environment.GetEnvironmentVariable("TESTINGPLATFORM_TEST_SET_DEADLINE_ON_START") == "1") + { + Environment.SetEnvironmentVariable( + "TESTINGPLATFORM_DEADLINE", + DateTimeOffset.UtcNow.AddSeconds(15).ToString("o")); + } + + ITestApplicationBuilder builder = await TestApplication.CreateBuilderAsync(args); + builder.RegisterTestFramework(_ => new Capabilities(), (_, __) => new DummyTestFramework()); + builder.AddHotReloadProvider(); + builder.AddHtmlReportProvider(); + using ITestApplication app = await builder.BuildAsync(); + return await app.RunAsync(); + } +} + +internal class DummyTestFramework : ITestFramework, IDataProducer +{ + public string Uid => nameof(DummyTestFramework); + + public string Version => string.Empty; + + public string DisplayName => string.Empty; + + public string Description => string.Empty; + + public Type[] DataTypesProduced => new[] { typeof(TestNodeUpdateMessage) }; + + public Task CloseTestSessionAsync(CloseTestSessionContext context) => Task.FromResult(new CloseTestSessionResult() { IsSuccess = true }); + + public Task CreateTestSessionAsync(CreateTestSessionContext context) => Task.FromResult(new CreateTestSessionResult() { IsSuccess = true }); + + public async Task ExecuteRequestAsync(ExecuteRequestContext context) + { + await context.MessageBus.PublishAsync(this, new TestNodeUpdateMessage(context.Request.Session.SessionUid, + new TestNode() { Uid = "1", DisplayName = "Test1", Properties = new(PassedTestNodeStateProperty.CachedInstance) })); + + // When asked to, block until the deadline-driven graceful stop is requested. This mimics a + // long-running suite whose remaining tests are cut short by the approaching CI deadline. Cap + // the wait so a broken stop path fails the assertions fast instead of hanging until the harness + // times out; the StopMessage and exit-code assertions still catch a stop that never happened. + if (Environment.GetEnvironmentVariable("WAIT_FOR_STOP") == "1") + { + Task completed = await Task.WhenAny(GracefulStop.Instance.TCS.Task, Task.Delay(TimeSpan.FromMinutes(2))); + if (completed != GracefulStop.Instance.TCS.Task) + { + throw new TimeoutException("Timed out waiting for graceful stop."); + } + } + + GracefulStop.Instance.NotifyExecutionCompleted(); + context.Complete(); + } + + public Task IsEnabledAsync() => Task.FromResult(true); +} + +internal class Capabilities : ITestFrameworkCapabilities +{ + IReadOnlyCollection ICapabilities.Capabilities + { + get + { + if (Environment.GetEnvironmentVariable("DO_NOT_ADD_CAPABILITY") == "1") + { + return []; + } + + return [GracefulStop.Instance]; + } + } +} + +#pragma warning disable TPEXP // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. +internal sealed class GracefulStop : IGracefulStopTestExecutionResultCapability +#pragma warning restore TPEXP // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. +{ + private GracefulStop() + { + } + + public static GracefulStop Instance { get; } = new(); + + public TaskCompletionSource TCS { get; } = new(); + + private bool _executionCompleted; + + public void NotifyExecutionCompleted() + => _executionCompleted = true; + + public Task StopTestExecutionAsync(CancellationToken cancellationToken) + => TryStopTestExecutionAsync(cancellationToken); + + public Task TryStopTestExecutionAsync(CancellationToken cancellationToken) + { + if (Environment.GetEnvironmentVariable("REJECT_STOP") == "1") + { + return Task.FromResult(false); + } + + return Task.FromResult( + Environment.GetEnvironmentVariable("REJECT_STOP_AFTER_EXECUTION") != "1" + || !_executionCompleted + ? TCS.TrySetResult() + : false); + } +} + +"""; + + public string TargetAssetPath => GetAssetPath(AssetName); + + public override (string ID, string Name, string Code) GetAssetsToGenerate() => (AssetName, AssetName, + Sources + .PatchTargetFrameworks(TargetFrameworks.NetCurrent) + .PatchCodeWithReplace("$MicrosoftTestingPlatformVersion$", MicrosoftTestingPlatformVersion)); + } + + public TestContext TestContext { get; set; } +} diff --git a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/HangDumpTests.cs b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/HangDumpTests.cs index 427fbaceae..09ef30714b 100644 --- a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/HangDumpTests.cs +++ b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/HangDumpTests.cs @@ -25,6 +25,31 @@ public async Task HangDump_DefaultSetting_CreateDump(string tfm) Assert.ContainsSingle(dumpFiles, $"Expected single dump file. Found: {Environment.NewLine}{string.Join(Environment.NewLine, dumpFiles)}{Environment.NewLine}{testHostResult}"); } + [DynamicData(nameof(TargetFrameworks.AllForDynamicData), typeof(TargetFrameworks))] + [TestMethod] + public async Task HangDump_AbsoluteDeadline_CreateDump(string tfm) + { + string resultDirectory = Path.Combine(AssetFixture.TargetAssetPath, Guid.NewGuid().ToString("N"), tfm); + var testHost = TestInfrastructure.TestHost.LocateFrom(AssetFixture.TargetAssetPath, "HangDump", tfm); + + // Inactivity timeout is huge so the classic hang path never fires. The dump is driven purely + // by the absolute CI deadline: a few seconds out, with a zero dump margin so it triggers at + // the deadline itself. The test hangs (SLEEPTIMEMS2), so the deadline is what takes the dump. + TestHostResult testHostResult = await testHost.ExecuteAsync( + $"--hangdump --hangdump-timeout 30m --results-directory {resultDirectory}", + new Dictionary + { + { "SLEEPTIMEMS1", "1000" }, + { "SLEEPTIMEMS2", "600000" }, + { "TESTINGPLATFORM_DEADLINE", DateTimeOffset.UtcNow.AddSeconds(8).ToString("o") }, + { "TESTINGPLATFORM_DEADLINE_DUMP_MARGIN", "0" }, + }, + cancellationToken: TestContext.CancellationToken); + testHostResult.AssertExitCodeIs(ExitCode.TestHostProcessExitedNonGracefully); + string[] dumpFiles = Directory.GetFiles(resultDirectory, "HangDump*.dmp", SearchOption.AllDirectories); + Assert.ContainsSingle(dumpFiles, $"Expected single dump file. Found: {Environment.NewLine}{string.Join(Environment.NewLine, dumpFiles)}{Environment.NewLine}{testHostResult}"); + } + [TestMethod] public async Task HangDump_WithDotnetTest_CreateDump() { diff --git a/test/UnitTests/MSTestAdapter.UnitTests/MSTestGracefulStopTestExecutionCapabilityTests.cs b/test/UnitTests/MSTestAdapter.UnitTests/MSTestGracefulStopTestExecutionCapabilityTests.cs new file mode 100644 index 0000000000..d869ee81e1 --- /dev/null +++ b/test/UnitTests/MSTestAdapter.UnitTests/MSTestGracefulStopTestExecutionCapabilityTests.cs @@ -0,0 +1,188 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using AwesomeAssertions; + +using Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using TestFramework.ForTestingMSTest; + +namespace Microsoft.VisualStudio.TestPlatform.MSTestAdapter.UnitTests; + +public sealed class MSTestGracefulStopTestExecutionCapabilityTests : TestContainer +{ + public async Task TryStopTestExecutionAsync_DistinguishesPendingActiveAndCompletedExecution() + { + var capability = MSTestGracefulStopTestExecutionCapability.Create(); + try + { + capability.NotifyTestExecutionPending(); + + bool pendingStopAccepted = await capability.TryStopTestExecutionAsync(CancellationToken.None); + + pendingStopAccepted.Should().BeTrue(); + PlatformServiceProvider.Instance.IsGracefulStopRequested.Should().BeTrue(); + + capability.NotifyTestExecutionCompleted(); + capability = MSTestGracefulStopTestExecutionCapability.Create(); + capability.NotifyTestExecutionPending(); + capability.NotifyTestExecutionStarting(); + + bool activeStopAccepted = await capability.TryStopTestExecutionAsync(CancellationToken.None); + + activeStopAccepted.Should().BeTrue(); + + capability.NotifyTestExecutionCompleted(); + + bool completedStopAccepted = await capability.TryStopTestExecutionAsync(CancellationToken.None); + + completedStopAccepted.Should().BeFalse(); + } + finally + { + capability.NotifyTestExecutionCompleted(); + PlatformServiceProvider.Instance.IsGracefulStopRequested = false; + } + } + + public async Task DiscoveryCapabilityCannotClearAnActiveRunsStopRequest() + { + var runCapability = MSTestGracefulStopTestExecutionCapability.Create(); + var discoveryCapability = MSTestGracefulStopTestExecutionCapability.Create(); + + try + { + runCapability.NotifyTestExecutionPending(); + runCapability.NotifyTestExecutionStarting(); + (await runCapability.TryStopTestExecutionAsync(CancellationToken.None)).Should().BeTrue(); + + discoveryCapability.NotifyTestExecutionPending(); + + PlatformServiceProvider.Instance.IsGracefulStopRequested.Should().BeTrue(); + discoveryCapability.NotifyTestExecutionCompleted(); + PlatformServiceProvider.Instance.IsGracefulStopRequested.Should().BeTrue(); + } + finally + { + runCapability.NotifyTestExecutionCompleted(); + discoveryCapability.NotifyTestExecutionCompleted(); + PlatformServiceProvider.Instance.IsGracefulStopRequested = false; + } + } + + public async Task LegacyStopTestExecutionAsync_DoesNotReassertStopAfterExecutionCompleted() + { + var capability = MSTestGracefulStopTestExecutionCapability.Create(); + try + { + capability.NotifyTestExecutionPending(); + capability.NotifyTestExecutionStarting(); + capability.NotifyTestExecutionCompleted(); + PlatformServiceProvider.Instance.IsGracefulStopRequested = false; + + await capability.StopTestExecutionAsync(CancellationToken.None); + + PlatformServiceProvider.Instance.IsGracefulStopRequested.Should().BeFalse(); + } + finally + { + capability.NotifyTestExecutionCompleted(); + PlatformServiceProvider.Instance.IsGracefulStopRequested = false; + } + } + + public async Task OverlappingRunCannotClearAnActiveRunsStopRequest() + { + var firstRun = MSTestGracefulStopTestExecutionCapability.Create(); + var overlappingRun = MSTestGracefulStopTestExecutionCapability.Create(); + var nextRun = MSTestGracefulStopTestExecutionCapability.Create(); + + try + { + firstRun.NotifyTestExecutionPending(); + firstRun.NotifyTestExecutionStarting(); + (await firstRun.TryStopTestExecutionAsync(CancellationToken.None)).Should().BeTrue(); + + overlappingRun.NotifyTestExecutionPending(); + overlappingRun.NotifyTestExecutionStarting(); + + PlatformServiceProvider.Instance.IsGracefulStopRequested.Should().BeTrue(); + + firstRun.NotifyTestExecutionCompleted(); + PlatformServiceProvider.Instance.IsGracefulStopRequested.Should().BeTrue(); + + overlappingRun.NotifyTestExecutionCompleted(); + nextRun.NotifyTestExecutionPending(); + nextRun.NotifyTestExecutionStarting(); + + PlatformServiceProvider.Instance.IsGracefulStopRequested.Should().BeFalse(); + } + finally + { + firstRun.NotifyTestExecutionCompleted(); + overlappingRun.NotifyTestExecutionCompleted(); + nextRun.NotifyTestExecutionCompleted(); + PlatformServiceProvider.Instance.IsGracefulStopRequested = false; + } + } + + public async Task OverlappingRunCannotClearAPendingRunsStopRequest() + { + var pendingRun = MSTestGracefulStopTestExecutionCapability.Create(); + var overlappingRun = MSTestGracefulStopTestExecutionCapability.Create(); + var nextRun = MSTestGracefulStopTestExecutionCapability.Create(); + + try + { + pendingRun.NotifyTestExecutionPending(); + (await pendingRun.TryStopTestExecutionAsync(CancellationToken.None)).Should().BeTrue(); + + overlappingRun.NotifyTestExecutionPending(); + overlappingRun.NotifyTestExecutionStarting(); + + PlatformServiceProvider.Instance.IsGracefulStopRequested.Should().BeTrue(); + + pendingRun.NotifyTestExecutionStarting(); + PlatformServiceProvider.Instance.IsGracefulStopRequested.Should().BeTrue(); + + pendingRun.NotifyTestExecutionCompleted(); + overlappingRun.NotifyTestExecutionCompleted(); + nextRun.NotifyTestExecutionPending(); + nextRun.NotifyTestExecutionStarting(); + + PlatformServiceProvider.Instance.IsGracefulStopRequested.Should().BeFalse(); + } + finally + { + pendingRun.NotifyTestExecutionCompleted(); + overlappingRun.NotifyTestExecutionCompleted(); + nextRun.NotifyTestExecutionCompleted(); + PlatformServiceProvider.Instance.IsGracefulStopRequested = false; + } + } + + public async Task CompletedDiscoveryReleasesPendingStopOwnershipBeforeNextRun() + { + var discovery = MSTestGracefulStopTestExecutionCapability.Create(); + var nextRun = MSTestGracefulStopTestExecutionCapability.Create(); + + try + { + discovery.NotifyTestExecutionPending(); + (await discovery.TryStopTestExecutionAsync(CancellationToken.None)).Should().BeTrue(); + + discovery.NotifyTestExecutionCompleted(); + nextRun.NotifyTestExecutionPending(); + nextRun.NotifyTestExecutionStarting(); + + PlatformServiceProvider.Instance.IsGracefulStopRequested.Should().BeFalse(); + } + finally + { + discovery.NotifyTestExecutionCompleted(); + nextRun.NotifyTestExecutionCompleted(); + PlatformServiceProvider.Instance.IsGracefulStopRequested = false; + } + } +} diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsExitCodeTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsExitCodeTests.cs index 208ec58f35..4bddedf198 100644 --- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsExitCodeTests.cs +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsExitCodeTests.cs @@ -56,6 +56,21 @@ public void GetReason_ForKnownCode_MentionsRelevantOption() Assert.Contains("--minimum-expected-tests", GitHubActionsExitCode.GetReason(9)); Assert.Contains("--maximum-failed-tests", GitHubActionsExitCode.GetReason(13)); Assert.Contains("coverage threshold", GitHubActionsExitCode.GetReason(14)); + Assert.Contains("deadline", GitHubActionsExitCode.GetReason(15)); + } + + [TestMethod] + public void GetReason_ForDeadlineStop_IsSpecificAndNotTheUnknownFallback() + { + // 15 is the newest arm, so it is the one most likely to be dropped and silently answered by the + // unknown fallback. Pin it to its own text so that regression is visible. + string deadlineReason = GitHubActionsExitCode.GetReason(15); + + Assert.AreNotEqual(GitHubActionsExitCode.GetReason(255), deadlineReason); + Assert.AreNotEqual(GitHubActionsExitCode.GetReason(13), deadlineReason); + Assert.Contains("deadline", deadlineReason); + Assert.IsFalse(string.IsNullOrWhiteSpace(deadlineReason)); + Assert.AreEqual("TestExecutionStoppedAtDeadline", GitHubActionsExitCode.GetName(15)); } [TestMethod] diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/HangDumpTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/HangDumpTests.cs index 2cf0ceaab0..d0fb2d4a7a 100644 --- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/HangDumpTests.cs +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/HangDumpTests.cs @@ -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; +using Microsoft.Testing.Extensions.Diagnostics.Helpers; using Microsoft.Testing.Extensions.Diagnostics.Resources; using Microsoft.Testing.Extensions.UnitTests.Helpers; using Microsoft.Testing.Platform.Extensions.CommandLine; @@ -18,6 +19,8 @@ namespace Microsoft.Testing.Extensions.UnitTests; [TestClass] public sealed class HangDumpTests { + public TestContext TestContext { get; set; } = null!; + private HangDumpCommandLineProvider GetProvider() { var testApplicationModuleInfo = new Mock(); @@ -93,6 +96,59 @@ public void HangDumpTypeOptionDescription_ListsValidValues() option.Description); } + [TestMethod] + public void FarFutureDeadlineDumpIsScheduledInMultipleTimerIntervals() + { + DateTimeOffset now = new(2030, 1, 1, 12, 0, 0, TimeSpan.Zero); + DateTimeOffset deadline = now + TimeSpan.FromDays(60); + var maxTimerDueTime = TimeSpan.FromMilliseconds(uint.MaxValue - 1); + + TimeSpan firstInterval = HangDumpProcessLifetimeHandler.GetTimerDueTime(deadline, now); + DateTimeOffset afterFirstInterval = now + firstInterval; + TimeSpan secondInterval = HangDumpProcessLifetimeHandler.GetTimerDueTime(deadline, afterFirstInterval); + + Assert.AreEqual(maxTimerDueTime, firstInterval); + Assert.IsGreaterThan(TimeSpan.Zero, secondInterval); + Assert.IsGreaterThan(secondInterval, firstInterval); + Assert.AreEqual(TimeSpan.Zero, HangDumpProcessLifetimeHandler.GetTimerDueTime(deadline, afterFirstInterval + secondInterval)); + Assert.AreEqual(TimeSpan.Zero, HangDumpProcessLifetimeHandler.GetTimerDueTime(deadline, deadline)); + } + + [TestMethod] + [DataRow("hang.dmp", "hang_%p.dmp")] + [DataRow("hang", "hang_%p")] + [DataRow("subdirectory/hang.dmp", "subdirectory/hang_%p.dmp")] + [DataRow("hang_%p.dmp", "hang_%p.dmp")] + [DataRow("hang_{pid}.dmp", "hang_{pid}.dmp")] + public void EnsureProcessIdPlaceholder_MakesCustomDumpPathUnique(string pattern, string expected) + => Assert.AreEqual( + expected.Replace('/', Path.DirectorySeparatorChar), + HangDumpProcessLifetimeHandler.EnsureProcessIdPlaceholder(pattern)); + + [TestMethod] + [DataRow(null, "testhost", 123, 123, "testhost_%p_hang.dmp")] + [DataRow("hang.dmp", "testhost", 123, 123, "hang.dmp")] + [DataRow("hang.dmp", "child", 456, 123, "hang_%p.dmp")] + [DataRow("hang_%p.dmp", "child", 456, 123, "hang_%p.dmp")] + public void GetDumpFileNamePattern_PreservesRootNameAndMakesChildNamesUnique( + string? configuredPattern, + string processName, + int processId, + int rootProcessId, + string expected) + => Assert.AreEqual( + expected, + HangDumpProcessLifetimeHandler.GetDumpFileNamePattern(configuredPattern, processName, processId, rootProcessId)); + + [TestMethod] + public void TryGetProcessById_WhenProcessHasExited_ReturnsNull() + { + Mock processHandler = new(); + processHandler.Setup(x => x.GetProcessById(123)).Throws(); + + Assert.IsNull(HangDumpProcessLifetimeHandler.TryGetProcessById(processHandler.Object, 123)); + } + [TestMethod] [DataRow(HangDumpCommandLineProvider.HangDumpFileNameOptionName)] [DataRow(HangDumpCommandLineProvider.HangDumpTimeoutOptionName)] @@ -140,6 +196,238 @@ public void GetDumpFileNames_WindowsPathWithSpaces_QuotesOnlyWriteDumpArgument() Assert.AreEqual(dumpFileName, dumpFileNames.ArtifactDumpFileName); } + [TestMethod] + public async Task QueryOnceAndDumpTree_WithStalledQuery_QueriesOncePerDumpAndStillDumpsWholeTree() + { + // A wedged test host never answers the in-progress-test query, so the query costs a full + // InProgressTestsQueryTimeout. Issuing it per process would multiply that bound by the size of + // the tree, so a six-process tree must still pay it exactly once and then dump every process. + int queryCount = 0; + IProcess[] bottomUpTree = [.. Enumerable.Range(0, 6).Select(_ => Mock.Of())]; + ConcurrentQueue dumped = []; + ConcurrentQueue<(string, int)[]> annotations = []; + (string, int)[] expectedAnnotations = []; + + await HangDumpProcessLifetimeHandler.QueryOnceAndDumpTreeAsync( + bottomUpTree, + new SystemTask(), + cancellationToken => + { + Interlocked.Increment(ref queryCount); + + // The real bounded query, against a reply that never arrives: the product's own bound + // cancels the wait and the dump proceeds with an empty list. + return HangDumpProcessLifetimeHandler.QueryInProgressTestsWithTimeoutAsync( + async queryCancellationToken => + { + await Task.Delay(Timeout.Infinite, queryCancellationToken); + return expectedAnnotations; + }, + TimeSpan.FromMilliseconds(50), + _ => Task.CompletedTask, + cancellationToken); + }, + (process, inProgressTests, _) => + { + dumped.Enqueue(process); + annotations.Enqueue(inProgressTests); + return Task.CompletedTask; + }, + CancellationToken.None); + + Assert.AreEqual(1, queryCount); + Assert.HasCount(bottomUpTree.Length, dumped); + foreach (IProcess process in bottomUpTree) + { + Assert.Contains(process, dumped); + } + + // Every dump is annotated with the answer from that one query, so no process triggers another. + Assert.HasCount(bottomUpTree.Length, annotations); + foreach ((string, int)[] annotation in annotations) + { + Assert.AreSame(expectedAnnotations, annotation); + } + } + + [TestMethod] + public async Task QueryOnceAndDumpTree_StartsEveryDumpBeforeAwaitingCompletion() + { + IProcess[] bottomUpTree = [.. Enumerable.Range(0, 6).Select(_ => Mock.Of())]; + TaskCompletionSource allDumpsStarted = new(TaskCreationOptions.RunContinuationsAsynchronously); + using ManualResetEventSlim releaseDumps = new(); + int startedDumpCount = 0; + + Task dumpTreeTask = HangDumpProcessLifetimeHandler.QueryOnceAndDumpTreeAsync( + bottomUpTree, + new SystemTask(), + _ => Task.FromResult<(string, int)[]>([]), + (_, _, _) => + { + if (Interlocked.Increment(ref startedDumpCount) == bottomUpTree.Length) + { + allDumpsStarted.TrySetResult(true); + } + + releaseDumps.Wait(TestContext.CancellationToken); + return Task.CompletedTask; + }, + CancellationToken.None); + + try + { + Task completed = await Task.WhenAny(allDumpsStarted.Task, Task.Delay(TimeSpan.FromSeconds(30), TestContext.CancellationToken)); + Assert.AreSame(allDumpsStarted.Task, completed, "A dump was awaited before the remaining process dumps were started."); + Assert.IsFalse(dumpTreeTask.IsCompleted); + } + finally + { + releaseDumps.Set(); + } + + await dumpTreeTask; + + Assert.AreEqual(bottomUpTree.Length, startedDumpCount); + } + + [TestMethod] + public async Task QueryInProgressTestsWithTimeout_WhenTheReplyNeverArrives_ReturnsEmptyList() + { + // A connected-but-wedged host accepts the request and never replies, and the application token is + // not cancelled while the run is still in progress -- which is exactly when the deadline dump + // fires. So the bound inside the product is the only thing that can end this wait: the request + // here honors the token it is handed and nothing else, and never times out on its own. + Exception? loggedFailure = null; + + Task<(string, int)[]> query = HangDumpProcessLifetimeHandler.QueryInProgressTestsWithTimeoutAsync( + async queryCancellationToken => + { + await Task.Delay(Timeout.Infinite, queryCancellationToken); + return []; + }, + TimeSpan.FromMilliseconds(200), + ex => + { + loggedFailure = ex; + return Task.CompletedTask; + }, + CancellationToken.None); + + // Fail with a message rather than hanging the run if the bound is ever removed. + Task completed = await Task.WhenAny(query, Task.Delay(TimeSpan.FromSeconds(30), TestContext.CancellationToken)); + Assert.AreSame(query, completed, "The query did not give up on a reply that never arrives, so a wedged host would block the dump."); + + Assert.IsEmpty(await query); + + // The give-up is reported, so a missing in-progress-test list in a dump can be explained. + Assert.IsNotNull(loggedFailure); + } + + [TestMethod] + public async Task QueryInProgressTestsWithTimeout_WhenTheReplyIgnoresCancellation_ReturnsEmptyList() + { + TaskCompletionSource neverCompletes = new(TaskCreationOptions.RunContinuationsAsynchronously); + Exception? loggedFailure = null; + + try + { + Task<(string, int)[]> query = HangDumpProcessLifetimeHandler.QueryInProgressTestsWithTimeoutAsync( + async _ => + { + await neverCompletes.Task; + return []; + }, + TimeSpan.FromMilliseconds(50), + ex => + { + loggedFailure = ex; + return Task.CompletedTask; + }, + CancellationToken.None); + + Task completed = await Task.WhenAny(query, Task.Delay(TimeSpan.FromSeconds(30), TestContext.CancellationToken)); + Assert.AreSame(query, completed, "A query that ignores cancellation blocked the dump."); + Assert.IsEmpty(await query); + Assert.IsNotNull(loggedFailure); + } + finally + { + neverCompletes.TrySetResult(true); + } + } + + [TestMethod] + public async Task QueryInProgressTestsWithTimeout_WhenTheHostReplies_ReturnsTheAnswer() + { + // The bound must not get in the way of the healthy path, which answers in milliseconds. + (string, int)[] expected = [("Test1", 3), ("Test2", 7)]; + + (string, int)[] inProgressTests = await HangDumpProcessLifetimeHandler.QueryInProgressTestsWithTimeoutAsync( + _ => Task.FromResult(expected), + TimeSpan.FromSeconds(30), + _ => Task.CompletedTask, + CancellationToken.None); + + Assert.AreSequenceEqual(expected, inProgressTests); + } + + [TestMethod] + public async Task QueryInProgressTestsWithTimeout_WhenReportingTheFailureThrows_StillReturnsEmptyList() + { + // The empty list is what lets the dump go ahead after a failed query, and the delegate that reports + // the failure is a logger call -- logger providers can fail. If that throw escaped, a query failure + // would take the dump down with it, even though the query is explicitly best-effort. + (string, int)[] inProgressTests = await HangDumpProcessLifetimeHandler.QueryInProgressTestsWithTimeoutAsync( + _ => throw new InvalidOperationException("The consumer pipe is not connected."), + TimeSpan.FromSeconds(30), + _ => throw new InvalidOperationException("This logger provider is broken too."), + CancellationToken.None); + + Assert.IsEmpty(inProgressTests); + } + + [TestMethod] + public async Task RunBestEffortDiagnostic_WhenDiagnosticNeverCompletes_ReturnsAfterTimeout() + { + TaskCompletionSource neverCompletes = new(TaskCreationOptions.RunContinuationsAsynchronously); + Task diagnostic = HangDumpProcessLifetimeHandler.RunBestEffortDiagnosticAsync( + () => neverCompletes.Task, + TimeSpan.FromMilliseconds(50)); + + Task completed = await Task.WhenAny(diagnostic, Task.Delay(TimeSpan.FromSeconds(30), TestContext.CancellationToken)); + + Assert.AreSame(diagnostic, completed); + await diagnostic; + } + + [TestMethod] + public async Task GetProcessTreeWithTimeout_WhenEnumerationNeverCompletes_FallsBackToRootProcess() + { + TaskCompletionSource neverCompletes = new(TaskCreationOptions.RunContinuationsAsynchronously); + IProcess rootProcess = Mock.Of(); + + try + { + List processTree = await HangDumpProcessLifetimeHandler.GetProcessTreeWithTimeoutAsync( + async _ => + { + await neverCompletes.Task; + return []; + }, + TimeSpan.FromMilliseconds(50), + _ => Task.CompletedTask, + rootProcess, + TestContext.CancellationToken); + + Assert.HasCount(1, processTree); + Assert.AreSame(rootProcess, processTree[0].Process); + } + finally + { + neverCompletes.TrySetResult(true); + } + } + [TestMethod] [DataRow("Mini")] [DataRow("Heap")] diff --git a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/Extensions/AbortAtDeadlineExtensionTests.cs b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/Extensions/AbortAtDeadlineExtensionTests.cs new file mode 100644 index 0000000000..7602495205 --- /dev/null +++ b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/Extensions/AbortAtDeadlineExtensionTests.cs @@ -0,0 +1,536 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.Testing.Platform.Capabilities.TestFramework; +using Microsoft.Testing.Platform.Extensions; +using Microsoft.Testing.Platform.Extensions.OutputDevice; +using Microsoft.Testing.Platform.Helpers; +using Microsoft.Testing.Platform.Logging; +using Microsoft.Testing.Platform.OutputDevice; +using Microsoft.Testing.Platform.Services; + +using Moq; + +namespace Microsoft.Testing.Platform.UnitTests; + +#pragma warning disable TPEXP // IGracefulStopTestExecutionCapability is for evaluation purposes only. + +[TestClass] +public sealed class AbortAtDeadlineExtensionTests : IDisposable +{ + private static readonly DateTimeOffset Now = new(2030, 1, 1, 12, 0, 0, TimeSpan.Zero); + + private readonly CancellationTokenSource _cts = new(); + private readonly Mock _policiesService = new(); + private readonly Mock _capability = new(); + + public AbortAtDeadlineExtensionTests() + => _capability + .Setup(x => x.TryStopTestExecutionAsync(It.IsAny())) + .ReturnsAsync(true); + + public TestContext TestContext { get; set; } = null!; + + public void Dispose() => _cts.Dispose(); + + [TestMethod] + public async Task WhenGracefulStopFails_TheDeadlineVerdictIsNeverCommitted() + { + TaskCompletionSource stopping = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource release = new(TaskCreationOptions.RunContinuationsAsynchronously); + _capability + .Setup(x => x.TryStopTestExecutionAsync(It.IsAny())) + .Returns(async () => + { + stopping.TrySetResult(true); + await release.Task; + throw new InvalidOperationException("This framework refuses to stop."); + }); + + using AbortAtDeadlineExtension extension = CreateExtension(deadlineIn: TimeSpan.Zero); + + await WaitForAsync(stopping.Task); + Task resolution = extension.WaitForDeadlineHandlingAsync(); + Assert.IsFalse(resolution.IsCompleted, "The deadline outcome resolved before the asynchronous stop request completed."); + + release.SetResult(true); + await WaitForAsync(resolution); + + // The host awaits this resolution before reporters run. A rejected stop therefore leaves no transient + // deadline verdict for an exit-code consumer to observe. + _policiesService.Verify(x => x.ExecuteDeadlineCallbacksAsync(), Times.Never); + } + + [TestMethod] + public async Task WhenGracefulStopSucceeds_TheDeadlineVerdictIsKept() + { + TaskCompletionSource stopped = new(TaskCreationOptions.RunContinuationsAsynchronously); + IOutputDeviceData? displayedData = null; + _capability + .Setup(x => x.TryStopTestExecutionAsync(It.IsAny())) + .Returns(() => + { + stopped.TrySetResult(true); + return Task.FromResult(true); + }); + + using AbortAtDeadlineExtension extension = CreateExtension( + deadlineIn: TimeSpan.Zero, + onDisplayData: data => displayedData = data); + + await WaitForAsync(stopped.Task); + await WaitForAsync(extension.WaitForDeadlineHandlingAsync()); + _policiesService.Verify(x => x.ExecuteDeadlineCallbacksAsync(), Times.Once); + Assert.IsInstanceOfType(displayedData); + } + + [TestMethod] + public async Task WhenGracefulStopReportsExecutionAlreadyCompleted_TheDeadlineVerdictIsNotCommitted() + { + _capability + .Setup(x => x.TryStopTestExecutionAsync(It.IsAny())) + .ReturnsAsync(false); + + using AbortAtDeadlineExtension extension = CreateExtension(deadlineIn: TimeSpan.Zero); + + await WaitForAsync(extension.WaitForDeadlineHandlingAsync()); + + _policiesService.Verify(x => x.ExecuteDeadlineCallbacksAsync(), Times.Never); + } + + [TestMethod] + public async Task WhenFrameworkUsesLegacyGracefulStopCapability_TheDeadlineVerdictIsKept() + { + TaskCompletionSource stopped = new(TaskCreationOptions.RunContinuationsAsynchronously); + Mock legacyCapability = new(); + legacyCapability + .Setup(x => x.StopTestExecutionAsync(It.IsAny())) + .Callback(() => stopped.TrySetResult(true)) + .Returns(Task.CompletedTask); + + using AbortAtDeadlineExtension extension = CreateExtension( + deadlineIn: TimeSpan.Zero, + capability: legacyCapability.Object); + + await WaitForAsync(stopped.Task); + await WaitForAsync(extension.WaitForDeadlineHandlingAsync()); + + legacyCapability.Verify(x => x.StopTestExecutionAsync(It.IsAny()), Times.Once); + _policiesService.Verify(x => x.ExecuteDeadlineCallbacksAsync(), Times.Once); + } + + [TestMethod] + public async Task WhenFrameworkStopIsRejected_FallbackStopCommitsDeadlineVerdict() + { + _capability + .Setup(x => x.TryStopTestExecutionAsync(It.IsAny())) + .ReturnsAsync(false); + TaskCompletionSource fallbackStopped = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource deadlineCommitted = new(TaskCreationOptions.RunContinuationsAsynchronously); + _policiesService + .Setup(x => x.ExecuteDeadlineCallbacksAsync()) + .Callback(() => deadlineCommitted.TrySetResult(true)) + .Returns(Task.CompletedTask); + + _policiesService + .Setup(x => x.TryExecuteDeadlineStopFallbackAsync()) + .Returns(() => + { + fallbackStopped.TrySetResult(true); + return Task.FromResult(true); + }); + using AbortAtDeadlineExtension extension = CreateExtension(deadlineIn: TimeSpan.FromMilliseconds(300)); + + await WaitForAsync(deadlineCommitted.Task); + + Assert.IsTrue(fallbackStopped.Task.IsCompleted); + _policiesService.Verify(x => x.ExecuteDeadlineCallbacksAsync(), Times.Once); + } + + [TestMethod] + public async Task WhenTestExecutionCompleted_TheDeadlineDoesNotTrigger() + { + TaskCompletionSource triggered = new(TaskCreationOptions.RunContinuationsAsynchronously); + _policiesService.Setup(x => x.ExecuteDeadlineCallbacksAsync()).Callback(() => triggered.TrySetResult(true)).Returns(Task.CompletedTask); + + using AbortAtDeadlineExtension extension = CreateExtension(deadlineIn: TimeSpan.FromMilliseconds(300)); + + // The host signals this the moment the test framework invoker returns, which is well before the timer + // below fires. Everything after it -- draining the message bus, the reporters -- happens on a run that + // already executed every test, so a deadline reached during it must not truncate the verdict. + extension.NotifyTestExecutionCompleted(); + + Task completed = await Task.WhenAny(triggered.Task, Task.Delay(TimeSpan.FromSeconds(2), TestContext.CancellationToken)); + Assert.AreNotSame(triggered.Task, completed, "The deadline fired even though test execution had already completed."); + _policiesService.Verify(x => x.ExecuteDeadlineCallbacksAsync(), Times.Never); + _capability.Verify(x => x.TryStopTestExecutionAsync(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task WhenPoliciesReportTestExecutionCompleted_TheDeadlineDoesNotTrigger() + { + TaskCompletionSource triggered = new(TaskCreationOptions.RunContinuationsAsynchronously); + _policiesService.SetupGet(x => x.IsTestExecutionCompleted).Returns(true); + _policiesService.Setup(x => x.ExecuteDeadlineCallbacksAsync()).Callback(() => triggered.TrySetResult(true)).Returns(Task.CompletedTask); + + using AbortAtDeadlineExtension extension = CreateExtension(deadlineIn: TimeSpan.Zero); + + Task completed = await Task.WhenAny(triggered.Task, Task.Delay(TimeSpan.FromSeconds(2), TestContext.CancellationToken)); + Assert.AreNotSame(triggered.Task, completed, "The deadline fired even though the shared policy state reported completed execution."); + _policiesService.Verify(x => x.ExecuteDeadlineCallbacksAsync(), Times.Never); + _capability.Verify(x => x.TryStopTestExecutionAsync(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task WhenTestExecutionIsStillRunning_TheDeadlineTriggers() + { + // Control for the test above: same timer, no completion signal, so the deadline must fire. Without + // this, that test would still pass if the timer were simply broken. + TaskCompletionSource triggered = new(TaskCreationOptions.RunContinuationsAsynchronously); + _policiesService.Setup(x => x.ExecuteDeadlineCallbacksAsync()).Callback(() => triggered.TrySetResult(true)).Returns(Task.CompletedTask); + + using AbortAtDeadlineExtension extension = CreateExtension(deadlineIn: TimeSpan.FromMilliseconds(300)); + + await WaitForAsync(triggered.Task); + _capability.Verify(x => x.TryStopTestExecutionAsync(It.IsAny()), Times.Once); + } + + [TestMethod] + public void FarFutureDeadlineIsScheduledInMultipleTimerIntervals() + { + DateTimeOffset deadline = Now + TimeSpan.FromDays(60); + var maxTimerDueTime = TimeSpan.FromMilliseconds(uint.MaxValue - 1); + + TimeSpan firstInterval = AbortAtDeadlineExtension.GetTimerDueTime(deadline, Now); + TimeSpan secondInterval = AbortAtDeadlineExtension.GetTimerDueTime(deadline, Now + firstInterval); + + Assert.AreEqual(maxTimerDueTime, firstInterval); + Assert.IsGreaterThan(firstInterval, deadline - Now); + Assert.IsGreaterThan(TimeSpan.Zero, secondInterval); + Assert.IsGreaterThan(secondInterval, firstInterval); + Assert.AreEqual(TimeSpan.Zero, AbortAtDeadlineExtension.GetTimerDueTime(deadline, deadline)); + } + + [TestMethod] + public async Task InvalidDeadlineIsVisibleWithoutDiagnosticLogging() + { + List displayedData = []; + using AbortAtDeadlineExtension extension = CreateExtension( + deadlineIn: TimeSpan.Zero, + onDisplayData: displayedData.Add, + deadlineValue: "not-a-deadline"); + + Assert.IsFalse(await extension.IsEnabledAsync()); + Assert.HasCount(1, displayedData); + WarningMessageOutputDeviceData warning = Assert.IsInstanceOfType(displayedData[0]); + Assert.Contains(EnvironmentVariableConstants.TESTINGPLATFORM_DEADLINE, warning.Message); + } + + [TestMethod] + public async Task InvertedMarginsAreVisibleWithoutDiagnosticLogging() + { + List displayedData = []; + using AbortAtDeadlineExtension extension = CreateExtension( + deadlineIn: TimeSpan.FromMinutes(1), + onDisplayData: displayedData.Add, + stopMargin: "10", + dumpMargin: "30", + isHangDumpEnabled: true); + + Assert.IsTrue(await extension.IsEnabledAsync()); + Assert.HasCount(1, displayedData); + WarningMessageOutputDeviceData warning = Assert.IsInstanceOfType(displayedData[0]); + Assert.Contains("00:00:30", warning.Message); + Assert.Contains("00:00:10", warning.Message); + } + + [TestMethod] + public async Task InvertedMarginsAreVisibleWhenDiagnosticLoggingThrows() + { + List displayedData = []; + using AbortAtDeadlineExtension extension = CreateExtension( + deadlineIn: TimeSpan.FromMinutes(1), + onDisplayData: displayedData.Add, + stopMargin: "10", + dumpMargin: "30", + isHangDumpEnabled: true, + throwOnSynchronousLog: true); + + Assert.IsTrue(await extension.IsEnabledAsync()); + Assert.HasCount(1, displayedData); + WarningMessageOutputDeviceData warning = Assert.IsInstanceOfType(displayedData[0]); + Assert.Contains("00:00:30", warning.Message); + Assert.Contains("00:00:10", warning.Message); + } + + [TestMethod] + public async Task InvertedMarginsWithoutHangDumpDoNotWarn() + { + List displayedData = []; + using AbortAtDeadlineExtension extension = CreateExtension( + deadlineIn: TimeSpan.FromMinutes(1), + onDisplayData: displayedData.Add, + stopMargin: "10", + dumpMargin: "30"); + + Assert.IsTrue(await extension.IsEnabledAsync()); + Assert.IsEmpty(displayedData); + } + + [TestMethod] + public async Task MissingGracefulStopCapabilityIsVisibleWithoutDiagnosticLogging() + { + List displayedData = []; + using AbortAtDeadlineExtension extension = CreateExtension( + deadlineIn: TimeSpan.FromMinutes(1), + onDisplayData: displayedData.Add, + stopMargin: "60", + hasCapability: false); + + Assert.IsFalse(await extension.IsEnabledAsync()); + Assert.HasCount(1, displayedData); + WarningMessageOutputDeviceData warning = Assert.IsInstanceOfType(displayedData[0]); + Assert.Contains(nameof(IGracefulStopTestExecutionCapability), warning.Message); + } + + [TestMethod] + public async Task WhenTheApproachingDeadlineLogNeverCompletes_TheGracefulStopIsRequestedFirst() + { + TaskCompletionSource reporting = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource release = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource stopped = new(TaskCreationOptions.RunContinuationsAsynchronously); + _capability + .Setup(x => x.TryStopTestExecutionAsync(It.IsAny())) + .Returns(() => + { + stopped.TrySetResult(true); + return Task.FromResult(true); + }); + + using AbortAtDeadlineExtension extension = CreateExtension( + deadlineIn: TimeSpan.Zero, + onLog: async logLevel => + { + if (logLevel == LogLevel.Information) + { + reporting.TrySetResult(true); + await release.Task; + } + }); + + try + { + await WaitForAsync(reporting.Task); + await WaitForAsync(stopped.Task); + _capability.Verify(x => x.TryStopTestExecutionAsync(It.IsAny()), Times.Once); + } + finally + { + release.TrySetResult(true); + } + } + + [TestMethod] + public async Task WhenTestExecutionCompletesAfterTheDeadlineClaimedTheRun_TheVerdictStands() + { + // Completion cannot simply revert the verdict: the graceful stop is what makes execution finish, so + // completion after a claimed deadline is the stop taking effect, not a run that got there on its own. + TaskCompletionSource stopping = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource release = new(TaskCreationOptions.RunContinuationsAsynchronously); + _capability + .Setup(x => x.TryStopTestExecutionAsync(It.IsAny())) + .Returns(async () => + { + stopping.TrySetResult(true); + await release.Task; + return true; + }); + + using AbortAtDeadlineExtension extension = CreateExtension(deadlineIn: TimeSpan.Zero); + + await WaitForAsync(stopping.Task); + extension.NotifyTestExecutionCompleted(); + release.SetResult(true); + await WaitForAsync(extension.WaitForDeadlineHandlingAsync()); + + _policiesService.Verify(x => x.ExecuteDeadlineCallbacksAsync(), Times.Once); + } + + [TestMethod] + public async Task WhenFrameworkWaitsSynchronouslyForExecutionCompletion_DeadlineHandlingDoesNotDeadlock() + { + AbortAtDeadlineExtension? extension = null; + TaskCompletionSource stopping = new(TaskCreationOptions.RunContinuationsAsynchronously); + _capability + .Setup(x => x.TryStopTestExecutionAsync(It.IsAny())) + .Returns(() => + { + stopping.TrySetResult(true); +#pragma warning disable VSTHRD103 // Intentionally simulate a capability that synchronously waits for teardown. + Task.Run(() => extension!.NotifyTestExecutionCompleted(), TestContext.CancellationToken).GetAwaiter().GetResult(); +#pragma warning restore VSTHRD103 + return Task.FromResult(true); + }); + + using (extension = CreateExtension(deadlineIn: TimeSpan.FromMilliseconds(300))) + { + await WaitForAsync(stopping.Task); + await WaitForAsync(extension.WaitForDeadlineHandlingAsync()); + } + + _policiesService.Verify(x => x.ExecuteDeadlineCallbacksAsync(), Times.Once); + } + + [TestMethod] + public async Task TheVerdictAndTheGracefulStopBothPrecedeTheUserFacingMessage() + { + // The capability accepts the stop before the verdict is committed, and the verdict is committed before + // the user-facing message. A delayed message therefore cannot delay either the stop or its outcome. + TaskCompletionSource displaying = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource release = new(TaskCreationOptions.RunContinuationsAsynchronously); + + using AbortAtDeadlineExtension extension = CreateExtension( + deadlineIn: TimeSpan.Zero, + onDisplay: async () => + { + displaying.TrySetResult(true); + await release.Task; + }); + + // Park the handler inside the message. Anything not done by now sits inside the window. + await WaitForAsync(displaying.Task); + _policiesService.Verify(x => x.ExecuteDeadlineCallbacksAsync(), Times.Once); + _capability.Verify(x => x.TryStopTestExecutionAsync(It.IsAny()), Times.Once); + + release.SetResult(true); + } + + [TestMethod] + public async Task WhenTheUserFacingMessageNeverCompletes_TheGracefulStopIsStillRequested() + { + // A wedged output device hands back a task that never completes, and a task that never completes never + // faults, so swallowing exceptions does not cover it -- only the bound does. The message is written + // after the stop, so what this pins is that the bound is still there: without it a wedged device would + // keep the handler task alive past the end of the run, and it would once again be able to swallow the + // stop entirely if the message ever moved back ahead of it. + TaskCompletionSource displaying = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource neverCompletes = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource stopped = new(TaskCreationOptions.RunContinuationsAsynchronously); + + _capability + .Setup(x => x.TryStopTestExecutionAsync(It.IsAny())) + .Returns(() => + { + stopped.TrySetResult(true); + return Task.FromResult(true); + }); + + using AbortAtDeadlineExtension extension = CreateExtension( + deadlineIn: TimeSpan.Zero, + onDisplay: () => + { + displaying.TrySetResult(true); + return neverCompletes.Task; + }, + reportTimeout: TimeSpan.FromMilliseconds(200)); + + await WaitForAsync(displaying.Task); + + // Already requested: the message runs after the stop, so a wedged device cannot swallow it. The bound + // is what then lets the handler finish rather than sitting on a task that never completes. + await WaitForAsync(stopped.Task); + _capability.Verify(x => x.TryStopTestExecutionAsync(It.IsAny()), Times.Once); + + Task handling = extension.WaitForDeadlineHandlingAsync(); + try + { + await WaitForAsync(handling); + Assert.IsFalse(neverCompletes.Task.IsCompleted); + } + finally + { + neverCompletes.TrySetResult(true); + } + } + + private async Task WaitForAsync(Task task) + { + Task completed = await Task.WhenAny(task, Task.Delay(TimeSpan.FromSeconds(30), TestContext.CancellationToken)); + Assert.AreSame(task, completed, "Timed out waiting for the deadline handler."); + await task; + } + + private AbortAtDeadlineExtension CreateExtension( + TimeSpan deadlineIn, + Func? onLog = null, + Func? onDisplay = null, + Action? onDisplayData = null, + TimeSpan? reportTimeout = null, + IGracefulStopTestExecutionCapability? capability = null, + string? deadlineValue = null, + string stopMargin = "0", + string? dumpMargin = null, + bool hasCapability = true, + bool isHangDumpEnabled = false, + bool throwOnSynchronousLog = false) + { + Mock environment = new(); + _ = environment.Setup(x => x.GetEnvironmentVariable(It.IsAny())).Returns((string?)null); + _ = environment + .Setup(x => x.GetEnvironmentVariable(EnvironmentVariableConstants.TESTINGPLATFORM_DEADLINE)) + .Returns(deadlineValue ?? (Now + deadlineIn).ToString("o", CultureInfo.InvariantCulture)); + + // A zero stop margin makes the stop instant the deadline itself, so deadlineIn is exactly how long the + // timer waits (measured against the fixed clock below, not wall-clock time when the test starts). + _ = environment + .Setup(x => x.GetEnvironmentVariable(EnvironmentVariableConstants.TESTINGPLATFORM_DEADLINE_STOP_MARGIN)) + .Returns(stopMargin); + _ = environment + .Setup(x => x.GetEnvironmentVariable(EnvironmentVariableConstants.TESTINGPLATFORM_DEADLINE_DUMP_MARGIN)) + .Returns(dumpMargin); + + var stopwatch = Stopwatch.StartNew(); + Mock clock = new(); + _ = clock.SetupGet(x => x.UtcNow).Returns(() => Now + stopwatch.Elapsed); + + Mock logger = new(); + _ = logger + .Setup(x => x.LogAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny>())) + .Returns((LogLevel logLevel, string _, Exception? _, Func _) + => onLog is null ? Task.CompletedTask : onLog(logLevel)); + if (throwOnSynchronousLog) + { + logger + .Setup(x => x.Log(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny>())) + .Throws(); + } + + Mock loggerFactory = new(); + _ = loggerFactory.Setup(x => x.CreateLogger(It.IsAny())).Returns(logger.Object); + + Mock outputDevice = new(); + _ = outputDevice + .Setup(x => x.DisplayAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((IOutputDeviceDataProducer _, IOutputDeviceData data, CancellationToken _) => + { + onDisplayData?.Invoke(data); + return onDisplay is null ? Task.CompletedTask : onDisplay(); + }); + + Mock cancellationTokenSource = new(); + _ = cancellationTokenSource.SetupGet(x => x.CancellationToken).Returns(_cts.Token); + + return new AbortAtDeadlineExtension( + environment.Object, + clock.Object, + hasCapability ? capability ?? _capability.Object : null, + _policiesService.Object, + cancellationTokenSource.Object, + outputDevice.Object, + loggerFactory.Object, + reportTimeout, + isHangDumpEnabled); + } +} + +#pragma warning restore TPEXP diff --git a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/Helpers/DeadlineHelperTests.cs b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/Helpers/DeadlineHelperTests.cs new file mode 100644 index 0000000000..52417dae92 --- /dev/null +++ b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/Helpers/DeadlineHelperTests.cs @@ -0,0 +1,175 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.Testing.Platform.Helpers; + +using Moq; + +namespace Microsoft.Testing.Platform.UnitTests; + +[TestClass] +public sealed class DeadlineHelperTests +{ + [TestMethod] + [DataRow(null)] + [DataRow("")] + [DataRow(" ")] + [DataRow("not-a-date")] + [DataRow("12345")] + [DataRow("01/02/2030 03:04:05")] + [DataRow("2030/01/02T03:04:05Z")] + [DataRow("January 2, 2030 03:04:05Z")] + [DataRow("2030-13-01T00:00:00Z")] // invalid month + [DataRow("2030-01-01T00:00:00")] + [DataRow("2030-01-01T00:00:00.1234567")] + public void TryGetDeadline_WhenUnsetOrMalformed_ReturnsFalse(string? raw) + { + IEnvironment environment = CreateEnvironment(EnvironmentVariableConstants.TESTINGPLATFORM_DEADLINE, raw); + + bool result = DeadlineHelper.TryGetDeadline(environment, out DateTimeOffset deadlineUtc); + + Assert.IsFalse(result); + Assert.AreEqual(default, deadlineUtc); + } + + [TestMethod] + public void TryGetDeadline_WhenUtcInstant_ReturnsInstantInUtc() + { + IEnvironment environment = CreateEnvironment(EnvironmentVariableConstants.TESTINGPLATFORM_DEADLINE, "2030-01-01T00:00:00Z"); + + bool result = DeadlineHelper.TryGetDeadline(environment, out DateTimeOffset deadlineUtc); + + Assert.IsTrue(result); + Assert.AreEqual(new DateTimeOffset(2030, 1, 1, 0, 0, 0, TimeSpan.Zero), deadlineUtc); + Assert.AreEqual(TimeSpan.Zero, deadlineUtc.Offset); + } + + [TestMethod] + public void TryGetDeadline_WhenInstantHasOffset_ConvertsToUtc() + { + IEnvironment environment = CreateEnvironment(EnvironmentVariableConstants.TESTINGPLATFORM_DEADLINE, "2030-01-01T00:00:00+02:00"); + + bool result = DeadlineHelper.TryGetDeadline(environment, out DateTimeOffset deadlineUtc); + + Assert.IsTrue(result); + // 00:00 at +02:00 is 22:00 the previous day in UTC. + Assert.AreEqual(new DateTimeOffset(2029, 12, 31, 22, 0, 0, TimeSpan.Zero), deadlineUtc); + Assert.AreEqual(TimeSpan.Zero, deadlineUtc.Offset); + } + + [TestMethod] + [DataRow("2030-01-01T00:00:00.1Z", 1_000_000L)] + [DataRow("2030-01-01T00:00:00.1234567Z", 1_234_567L)] + public void TryGetDeadline_WhenInstantHasFractionalSeconds_ReturnsInstantInUtc(string raw, long fractionalTicks) + { + IEnvironment environment = CreateEnvironment(EnvironmentVariableConstants.TESTINGPLATFORM_DEADLINE, raw); + + bool result = DeadlineHelper.TryGetDeadline(environment, out DateTimeOffset deadlineUtc); + + Assert.IsTrue(result); + Assert.AreEqual(new DateTimeOffset(2030, 1, 1, 0, 0, 0, TimeSpan.Zero).AddTicks(fractionalTicks), deadlineUtc); + Assert.AreEqual(TimeSpan.Zero, deadlineUtc.Offset); + } + + [TestMethod] + [DataRow("45", 45)] + [DataRow("45s", 45)] + [DataRow("2m", 120)] + [DataRow("0", 0)] + public void GetStopMargin_WhenParsable_ReturnsParsedValue(string raw, int expectedSeconds) + { + IEnvironment environment = CreateEnvironment(EnvironmentVariableConstants.TESTINGPLATFORM_DEADLINE_STOP_MARGIN, raw); + + Assert.AreEqual(TimeSpan.FromSeconds(expectedSeconds), DeadlineHelper.GetStopMargin(environment)); + } + + [TestMethod] + [DataRow(null)] + [DataRow("")] + [DataRow(" ")] + [DataRow("abc")] + [DataRow("-1s")] // negative is not accepted by the parser, so the default is used + public void GetStopMargin_WhenUnsetOrUnparsable_ReturnsDefault(string? raw) + { + IEnvironment environment = CreateEnvironment(EnvironmentVariableConstants.TESTINGPLATFORM_DEADLINE_STOP_MARGIN, raw); + + Assert.AreEqual(TimeSpan.FromSeconds(60), DeadlineHelper.GetStopMargin(environment)); + } + + [TestMethod] + [DataRow("15", 15)] + [DataRow("15s", 15)] + [DataRow("1m", 60)] + public void GetDumpMargin_WhenParsable_ReturnsParsedValue(string raw, int expectedSeconds) + { + IEnvironment environment = CreateEnvironment(EnvironmentVariableConstants.TESTINGPLATFORM_DEADLINE_DUMP_MARGIN, raw); + + Assert.AreEqual(TimeSpan.FromSeconds(expectedSeconds), DeadlineHelper.GetDumpMargin(environment)); + } + + [TestMethod] + [DataRow(null)] + [DataRow("")] + [DataRow("nonsense")] + public void GetDumpMargin_WhenUnsetOrUnparsable_ReturnsDefault(string? raw) + { + IEnvironment environment = CreateEnvironment(EnvironmentVariableConstants.TESTINGPLATFORM_DEADLINE_DUMP_MARGIN, raw); + + Assert.AreEqual(TimeSpan.FromSeconds(30), DeadlineHelper.GetDumpMargin(environment)); + } + + [TestMethod] + public void SubtractSaturating_WhenNoUnderflow_SubtractsMargin() + { + var instant = new DateTimeOffset(2030, 1, 1, 0, 0, 0, TimeSpan.Zero); + + DateTimeOffset result = DeadlineHelper.SubtractSaturating(instant, TimeSpan.FromSeconds(60)); + + Assert.AreEqual(new DateTimeOffset(2029, 12, 31, 23, 59, 0, TimeSpan.Zero), result); + } + + [TestMethod] + public void SubtractSaturating_WhenMarginIsZero_ReturnsInstant() + { + var instant = new DateTimeOffset(2030, 1, 1, 0, 0, 0, TimeSpan.Zero); + + Assert.AreEqual(instant, DeadlineHelper.SubtractSaturating(instant, TimeSpan.Zero)); + } + + [TestMethod] + public void SubtractSaturating_WhenMarginWouldUnderflow_ClampsToMinValue() + { + DateTimeOffset instant = DateTimeOffset.MinValue.AddSeconds(10); + + DateTimeOffset result = DeadlineHelper.SubtractSaturating(instant, TimeSpan.FromSeconds(60)); + + Assert.AreEqual(DateTimeOffset.MinValue, result); + } + + [TestMethod] + public void SubtractSaturating_WhenMarginEqualsAvailableRange_ReturnsMinValue() + { + DateTimeOffset instant = DateTimeOffset.MinValue.AddSeconds(60); + + // margin (60s) is not greater than the available range (60s), so the exact subtraction is used + // and lands precisely on MinValue. + DateTimeOffset result = DeadlineHelper.SubtractSaturating(instant, TimeSpan.FromSeconds(60)); + + Assert.AreEqual(DateTimeOffset.MinValue, result); + } + + private static IEnvironment CreateEnvironment(string variableName, string? value) + { + Mock environment = new(); + _ = environment.Setup(x => x.GetEnvironmentVariable(It.IsAny())).Returns((string?)null); + + // A null value models "variable unset"; the default mock already returns null, so only wire up + // an explicit (possibly empty/whitespace) value. + if (value is not null) + { + _ = environment.Setup(x => x.GetEnvironmentVariable(variableName)).Returns(value); + } + + return environment.Object; + } +} diff --git a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/Hosts/CommonHostTests.cs b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/Hosts/CommonHostTests.cs index 2af9bb4e36..9dfa0b555c 100644 --- a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/Hosts/CommonHostTests.cs +++ b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/Hosts/CommonHostTests.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. +using Microsoft.Testing.Platform.Capabilities.TestFramework; using Microsoft.Testing.Platform.Extensions; using Microsoft.Testing.Platform.Extensions.Messages; using Microsoft.Testing.Platform.Extensions.TestFramework; @@ -22,7 +23,50 @@ namespace Microsoft.Testing.Platform.UnitTests; public sealed class CommonHostTests { [TestMethod] - public async Task ExecuteRequestAsync_WhenSessionIsCancelled_UsesCancellationTokenNoneForDisplayAfterSessionEndRun() + public async Task RequestGracefulSessionStopAsync_UsesActiveRequestCapabilitiesBeforeApplicationCapability() + { + Mock applicationCapability = new(); + Mock requestCapability = new(); + ServiceProvider serviceProvider = new(); + serviceProvider.AddService(new TestFrameworkCapabilities(applicationCapability.Object)); + TestableCommonHost host = new(serviceProvider); + + await host.RegisterActiveGracefulStopCapabilityForTestingAsync(requestCapability.Object); + await host.RequestGracefulSessionStopForTestingAsync(); + + requestCapability.Verify(x => x.StopTestExecutionAsync(It.IsAny()), Times.Once); + applicationCapability.Verify(x => x.StopTestExecutionAsync(It.IsAny()), Times.Never); + + host.UnregisterActiveGracefulStopCapabilityForTesting(requestCapability.Object); + await host.RequestGracefulSessionStopForTestingAsync(); + + applicationCapability.Verify(x => x.StopTestExecutionAsync(It.IsAny()), Times.Once); + } + + [TestMethod] + public async Task RegisterActiveGracefulStopCapabilityAsync_AfterStoppedRequestUnregisters_StopsNextRequest() + { + Mock applicationCapability = new(); + Mock completedRequestCapability = new(); + Mock nextRequestCapability = new(); + ServiceProvider serviceProvider = new(); + serviceProvider.AddService(new TestFrameworkCapabilities(applicationCapability.Object)); + TestableCommonHost host = new(serviceProvider); + + await host.RegisterActiveGracefulStopCapabilityForTestingAsync(completedRequestCapability.Object); + await host.RequestGracefulSessionStopForTestingAsync(); + host.UnregisterActiveGracefulStopCapabilityForTesting(completedRequestCapability.Object); + await host.RegisterActiveGracefulStopCapabilityForTestingAsync(nextRequestCapability.Object); + + completedRequestCapability.Verify(x => x.StopTestExecutionAsync(It.IsAny()), Times.Once); + nextRequestCapability.Verify(x => x.StopTestExecutionAsync(It.IsAny()), Times.Once); + applicationCapability.Verify(x => x.StopTestExecutionAsync(It.IsAny()), Times.Never); + } + + [TestMethod] + [DataRow(false, 1)] + [DataRow(true, 0)] + public async Task ExecuteRequestAsync_WhenSessionIsCancelled_DisarmsStopPolicyOnlyForRun(bool isDiscoveryRequest, int expectedDisarmCount) { CancellationToken cancellationToken = new(canceled: true); @@ -50,9 +94,12 @@ public async Task ExecuteRequestAsync_WhenSessionIsCancelled_UsesCancellationTok .Setup(x => x.ExecuteAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ThrowsAsync(new OperationCanceledException(cancellationToken)); + Mock policiesServiceMock = new(); + ServiceProvider serviceProvider = new(); serviceProvider.AddService(testFrameworkInvokerMock.Object); serviceProvider.AddService(new TestCoverageResult()); + serviceProvider.AddService(policiesServiceMock.Object); Mock baseMessageBusMock = new(); baseMessageBusMock.Setup(x => x.DrainDataAsync()).Returns(Task.CompletedTask); @@ -66,13 +113,51 @@ await TestableCommonHost.ExecuteRequestForTestingAsync( serviceProvider, baseMessageBusMock.Object, testFrameworkMock.Object, - client); + client, + isDiscoveryRequest); Assert.IsNotNull(displayAfterToken); Assert.IsFalse(displayAfterToken!.Value.CanBeCanceled); outputDeviceMock.Verify(x => x.DisplayAfterSessionEndRunAsync(It.IsAny()), Times.Once); testSessionLifetimeHandlerMock.Verify(x => x.OnTestSessionFinishingAsync(It.IsAny()), Times.Once); + + // Disarming happens in a finally around the invoker, so it must also happen when the invoker threw + // because the session was canceled. Otherwise a deadline reached while the reporters finalize an + // already-canceled run would still mark it as truncated. + policiesServiceMock.Verify(x => x.NotifyTestExecutionCompleted(), Times.Exactly(expectedDisarmCount)); + } + + [TestMethod] + public async Task ExecuteRequestAsync_WhenSessionStartupFails_DisarmsStopPolicy() + { + Mock outputDeviceMock = new(); + outputDeviceMock + .Setup(x => x.DisplayBeforeSessionStartAsync(It.IsAny())) + .ThrowsAsync(new InvalidOperationException("session startup failure")); + + Mock sessionContextMock = new(); + sessionContextMock.SetupGet(x => x.CancellationToken).Returns(CancellationToken.None); + + Mock policiesServiceMock = new(); + ServiceProvider serviceProvider = new(); + serviceProvider.AddService(new TestCoverageResult()); + serviceProvider.AddService(policiesServiceMock.Object); + + Mock baseMessageBusMock = new(); + baseMessageBusMock.Setup(x => x.DisableAsync()).Returns(Task.CompletedTask); + + InvalidOperationException ex = await Assert.ThrowsExactlyAsync( + async () => await TestableCommonHost.ExecuteRequestForTestingAsync( + new ProxyOutputDevice(outputDeviceMock.Object, null), + sessionContextMock.Object, + serviceProvider, + baseMessageBusMock.Object, + new Mock().Object, + new ClientInfo("client", "1.0.0"))); + + Assert.AreEqual("session startup failure", ex.Message); + policiesServiceMock.Verify(x => x.NotifyTestExecutionCompleted(), Times.Once); } [TestMethod] @@ -116,6 +201,7 @@ public async Task ExecuteRequestAsync_WhenSessionIsCancelled_DisablesTheMessageB ServiceProvider serviceProvider = new(); serviceProvider.AddService(testFrameworkInvokerMock.Object); serviceProvider.AddService(new TestCoverageResult()); + serviceProvider.AddService(new Mock().Object); Mock baseMessageBusMock = new(); baseMessageBusMock.Setup(x => x.DrainDataAsync()).Returns(Task.CompletedTask); @@ -154,9 +240,12 @@ public async Task ExecuteRequestAsync_WhenDisablingTheMessageBusFails_DoesNotMas .Setup(x => x.ExecuteAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ThrowsAsync(new InvalidOperationException("test framework failure")); + Mock policiesServiceMock = new(); + ServiceProvider serviceProvider = new(); serviceProvider.AddService(testFrameworkInvokerMock.Object); serviceProvider.AddService(new TestCoverageResult()); + serviceProvider.AddService(policiesServiceMock.Object); Mock baseMessageBusMock = new(); baseMessageBusMock.Setup(x => x.DrainDataAsync()).Returns(Task.CompletedTask); @@ -174,6 +263,10 @@ public async Task ExecuteRequestAsync_WhenDisablingTheMessageBusFails_DoesNotMas // The safety net is best effort: it must never replace the exception that is already propagating. Assert.AreEqual("test framework failure", ex.Message); baseMessageBusMock.Verify(x => x.DisableAsync(), Times.Once); + + // Disarming sits in a finally around the invoker, so a failing test framework must not leave the + // deadline armed while the session tears down. + policiesServiceMock.Verify(x => x.NotifyTestExecutionCompleted(), Times.Once); } [TestMethod] @@ -394,8 +487,18 @@ public static Task ExecuteRequestForTestingAsync( ServiceProvider serviceProvider, BaseMessageBus baseMessageBus, ITestFramework testFramework, - ClientInfo client) - => ExecuteRequestAsync(outputDevice, testSessionInfo, serviceProvider, baseMessageBus, testFramework, client); + ClientInfo client, + bool isDiscoveryRequest = false) + => ExecuteRequestAsync(outputDevice, testSessionInfo, serviceProvider, baseMessageBus, testFramework, client, isDiscoveryRequest); + + public Task RegisterActiveGracefulStopCapabilityForTestingAsync(IGracefulStopTestExecutionCapability capability) + => RegisterActiveGracefulStopCapabilityAsync(capability); + + public Task RequestGracefulSessionStopForTestingAsync() + => RequestGracefulSessionStopAsync(CancellationToken.None); + + public void UnregisterActiveGracefulStopCapabilityForTesting(IGracefulStopTestExecutionCapability capability) + => UnregisterActiveGracefulStopCapability(capability); protected override Task InternalRunAsync(CancellationToken cancellationToken) => Task.FromResult(0); diff --git a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/Microsoft.Testing.Platform.UnitTests.csproj b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/Microsoft.Testing.Platform.UnitTests.csproj index 7e79ac576f..832b25fcdc 100644 --- a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/Microsoft.Testing.Platform.UnitTests.csproj +++ b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/Microsoft.Testing.Platform.UnitTests.csproj @@ -36,6 +36,7 @@ + diff --git a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/ServerMode/ServerTests.cs b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/ServerMode/ServerTests.cs index 6cd647aee2..d4a7ddfd22 100644 --- a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/ServerMode/ServerTests.cs +++ b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/ServerMode/ServerTests.cs @@ -299,6 +299,123 @@ public async Task DiscoveryRequestCanBeCanceled() Assert.AreEqual(0, result); } + [TestMethod] + public async Task DeadlineStateIsIsolatedBetweenServerRequests() + { + using var server = TcpServer.Create(); + List requestStates = []; + + string[] args = ["--no-banner", "--server", "--client-port", $"{server.Port}", "--internal-testingplatform-skipbuildercheck"]; + ITestApplicationBuilder builder = await TestApplication.CreateBuilderAsync(args); + builder.RegisterTestFramework( + _ => new TestFrameworkCapabilities(new RecordingGracefulStopCapability()), + (capabilities, serviceProvider) => + { + IStopPoliciesService stopPoliciesService = serviceProvider.GetRequiredService(); + RecordingGracefulStopCapability capability = Assert.IsInstanceOfType( + capabilities.GetCapability()); + ServerRequestState state = new( + stopPoliciesService, + serviceProvider.GetTestApplicationProcessExitCode(), + capability); + requestStates.Add(state); + + return new MockTestAdapter + { + DiscoveryAction = async context => + { + await stopPoliciesService.RegisterOnDeadlineCallbackAsync( + () => capability.TryStopTestExecutionAsync(CancellationToken.None)); + context.Complete(); + }, + }; + }); + var testApplication = (TestApplication)await builder.BuildAsync(); + testApplication.ServiceProvider.GetRequiredService().SuppressOutput(); + Task serverTask = Task.Run(testApplication.RunAsync); + + using CancellationTokenSource timeout = new(TimeoutHelper.DefaultHangTimeSpanTimeout); + using TcpClient client = await server.WaitForConnectionAsync(timeout.Token); + using NetworkStream stream = client.GetStream(); + using StreamWriter writer = new(stream, Encoding.UTF8); + TcpMessageHandler messageHandler = new( + client, + clientToServerStream: client.GetStream(), + serverToClientStream: client.GetStream(), + FormatterUtilities.CreateFormatter()); + + const string InitializeMessage = """ + { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "processId": 32, + "clientInfo": { "name": "testingplatform-unittests", "version": "1.0.0" }, + "capabilities": { + "testing": { + "debuggerProvider": true + } + } + } + } + """; + await WriteMessageAsync(writer, InitializeMessage); + _ = await WaitForMessage( + messageHandler, + rpcMessage => rpcMessage is ResponseMessage response && response.Id == 1, + "Wait initialize", + timeout.Token); + + const string FirstDiscoverMessage = """ + { + "jsonrpc": "2.0", + "id": 2, + "method": "testing/discoverTests", + "params": { + "runId": "00000000-0000-0000-0000-000000000001" + } + } + """; + await WriteMessageAsync(writer, FirstDiscoverMessage); + _ = await WaitForMessage( + messageHandler, + rpcMessage => rpcMessage is ResponseMessage response && response.Id == 2, + "Wait first discovery", + timeout.Token); + + const string SecondDiscoverMessage = """ + { + "jsonrpc": "2.0", + "id": 3, + "method": "testing/discoverTests", + "params": { + "runId": "00000000-0000-0000-0000-000000000002" + } + } + """; + await WriteMessageAsync(writer, SecondDiscoverMessage); + _ = await WaitForMessage( + messageHandler, + rpcMessage => rpcMessage is ResponseMessage response && response.Id == 3, + "Wait second discovery", + timeout.Token); + + Assert.HasCount(2, requestStates); + await requestStates[0].StopPoliciesService.ExecuteDeadlineCallbacksAsync(); + + Assert.IsTrue(requestStates[0].StopPoliciesService.IsDeadlineTriggered); + Assert.AreEqual(1, requestStates[0].GracefulStopCapability.StopCount); + Assert.AreEqual((int)ExitCode.TestExecutionStoppedAtDeadline, requestStates[0].TestApplicationResult.GetProcessExitCode()); + Assert.IsFalse(requestStates[1].StopPoliciesService.IsDeadlineTriggered); + Assert.AreEqual(0, requestStates[1].GracefulStopCapability.StopCount); + Assert.AreEqual((int)ExitCode.ZeroTests, requestStates[1].TestApplicationResult.GetProcessExitCode()); + + await WriteMessageAsync(writer, """{ "jsonrpc": "2.0", "method": "exit", "params": { } }"""); + + Assert.AreEqual(0, await serverTask); + } + [DataRow(JsonRpcMethods.TestingDiscoverTests)] [DataRow(JsonRpcMethods.TestingRunTests)] [TestMethod] @@ -449,6 +566,28 @@ private sealed class MockTestAdapter : ITestFramework public Task ExecuteRequestAsync(ExecuteRequestContext context) => DiscoveryAction is not null ? DiscoveryAction(context) : Task.CompletedTask; } + private sealed record ServerRequestState( + IStopPoliciesService StopPoliciesService, + ITestApplicationProcessExitCode TestApplicationResult, + RecordingGracefulStopCapability GracefulStopCapability); + + private sealed class RecordingGracefulStopCapability : IGracefulStopTestExecutionResultCapability + { + public int StopCount { get; private set; } + + public Task StopTestExecutionAsync(CancellationToken cancellationToken) + { + StopCount++; + return Task.CompletedTask; + } + + public Task TryStopTestExecutionAsync(CancellationToken cancellationToken) + { + StopCount++; + return Task.FromResult(true); + } + } + private sealed class TcpServer : IDisposable { public TcpServer(TcpListener listener) => Listener = listener; diff --git a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/Services/StopPoliciesServiceTests.cs b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/Services/StopPoliciesServiceTests.cs index ebc23ee8c6..6d22e8dc19 100644 --- a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/Services/StopPoliciesServiceTests.cs +++ b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/Services/StopPoliciesServiceTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. using Microsoft.Testing.Platform.Helpers; @@ -36,6 +36,205 @@ public void IsAbortTriggered_InitiallyFalse() Assert.IsFalse(service.IsAbortTriggered); } + [TestMethod] + public void IsTestExecutionCompleted_InitiallyFalse() + { + StopPoliciesService service = new(_cancellationTokenSource.Object); + Assert.IsFalse(service.IsTestExecutionCompleted); + } + + [TestMethod] + public void NotifyTestExecutionCompleted_SetsIsTestExecutionCompleted() + { + StopPoliciesService service = new(_cancellationTokenSource.Object); + + service.NotifyTestExecutionStarting(); + service.NotifyTestExecutionCompleted(); + + Assert.IsTrue(service.IsTestExecutionCompleted); + } + + [TestMethod] + public void NotifyTestExecutionStarting_AfterPreviousRequestCompleted_ClearsIsTestExecutionCompleted() + { + StopPoliciesService service = new(_cancellationTokenSource.Object); + service.NotifyTestExecutionCompleted(); + + Assert.IsTrue(service.IsTestExecutionCompleted); + + service.NotifyTestExecutionStarting(); + + Assert.IsFalse(service.IsTestExecutionCompleted); + } + + [TestMethod] + public void NotifyTestExecutionCompleted_WithAnotherExecutionActive_DoesNotSetIsTestExecutionCompleted() + { + StopPoliciesService service = new(_cancellationTokenSource.Object); + service.NotifyTestExecutionStarting(); + service.NotifyTestExecutionStarting(); + + service.NotifyTestExecutionCompleted(); + + Assert.IsFalse(service.IsTestExecutionCompleted); + + service.NotifyTestExecutionCompleted(); + + Assert.IsTrue(service.IsTestExecutionCompleted); + } + + [TestMethod] + public void NotifyTestExecutionCompleted_DoesNotAffectDeadlineTriggered() + { + StopPoliciesService service = new(_cancellationTokenSource.Object); + + service.NotifyTestExecutionCompleted(); + + // Completing execution gates future deadlines; it must not itself look like a deadline truncation. + Assert.IsFalse(service.IsDeadlineTriggered); + } + + [TestMethod] + public void IsDeadlineTriggered_InitiallyFalse() + { + StopPoliciesService service = new(_cancellationTokenSource.Object); + Assert.IsFalse(service.IsDeadlineTriggered); + } + + [TestMethod] + public async Task ExecuteDeadlineCallbacksAsync_SetsIsDeadlineTriggered() + { + StopPoliciesService service = new(_cancellationTokenSource.Object); + + await service.ExecuteDeadlineCallbacksAsync(); + + Assert.IsTrue(service.IsDeadlineTriggered); + } + + [TestMethod] + public async Task RequestScopedServices_IsolateDeadlineVerdictAndExecutionCompletion() + { + StopPoliciesService firstRequest = new(_cancellationTokenSource.Object); + StopPoliciesService secondRequest = new(_cancellationTokenSource.Object); + firstRequest.NotifyTestExecutionStarting(); + secondRequest.NotifyTestExecutionStarting(); + + await firstRequest.ExecuteDeadlineCallbacksAsync(); + secondRequest.NotifyTestExecutionCompleted(); + + Assert.IsTrue(firstRequest.IsDeadlineTriggered); + Assert.IsFalse(secondRequest.IsDeadlineTriggered); + Assert.IsTrue(secondRequest.IsTestExecutionCompleted); + Assert.IsFalse(firstRequest.IsTestExecutionCompleted); + } + + [TestMethod] + public async Task ExecuteDeadlineCallbacksAsync_InvokesRegisteredCallback() + { + StopPoliciesService service = new(_cancellationTokenSource.Object); + + int invocationCount = 0; + await service.RegisterOnDeadlineCallbackAsync(() => + { + invocationCount++; + return Task.CompletedTask; + }); + + await service.ExecuteDeadlineCallbacksAsync(); + + Assert.AreEqual(1, invocationCount); + Assert.IsTrue(service.IsDeadlineTriggered); + } + + [TestMethod] + public async Task ExecuteDeadlineCallbacksAsync_IsOneShot() + { + StopPoliciesService service = new(_cancellationTokenSource.Object); + + int invocationCount = 0; + await service.RegisterOnDeadlineCallbackAsync(() => + { + invocationCount++; + return Task.CompletedTask; + }); + + await service.ExecuteDeadlineCallbacksAsync(); + await service.ExecuteDeadlineCallbacksAsync(); + + Assert.AreEqual(1, invocationCount); + } + + [TestMethod] + public async Task RegisterOnDeadlineCallbackAsync_InvokesCallbackExactlyOnceIfAlreadyTriggered() + { + StopPoliciesService service = new(_cancellationTokenSource.Object); + await service.ExecuteDeadlineCallbacksAsync(); + + int invocationCount = 0; + await service.RegisterOnDeadlineCallbackAsync(() => + { + invocationCount++; + return Task.CompletedTask; + }); + + // The deadline is one-shot, so registering after it fired must invoke the callback right away and + // must not leave it queued for a second, never-arriving trigger. + await service.ExecuteDeadlineCallbacksAsync(); + + Assert.AreEqual(1, invocationCount); + } + + [TestMethod] + public async Task RegisterOnDeadlineCallbackAsync_RacingTheTrigger_InvokesEveryCallbackExactlyOnce() + { + // Registration used to be able to lose a callback: the registering thread could read the trigger flag + // as false, the trigger could then commit and snapshot a still-empty queue, and only afterwards would + // the callback be enqueued -- where a one-shot deadline never reaches it. Hammer registration against + // the trigger and assert every callback ran exactly once. + for (int attempt = 0; attempt < 50; attempt++) + { + StopPoliciesService service = new(_cancellationTokenSource.Object); + int[] invocationCounts = new int[8]; + + // Async start gate so every task is released at the same moment. Barrier would be the obvious + // choice but it is unsupported on browser, which this project targets. + TaskCompletionSource start = new(TaskCreationOptions.RunContinuationsAsynchronously); + + var tasks = new List(); + for (int i = 0; i < invocationCounts.Length; i++) + { + int index = i; + tasks.Add(Task.Run( + async () => + { + await start.Task; + await service.RegisterOnDeadlineCallbackAsync(() => + { + Interlocked.Increment(ref invocationCounts[index]); + return Task.CompletedTask; + }); + }, + TestContext.CancellationToken)); + } + + tasks.Add(Task.Run( + async () => + { + await start.Task; + await service.ExecuteDeadlineCallbacksAsync(); + }, + TestContext.CancellationToken)); + + start.SetResult(true); + await Task.WhenAll(tasks); + + for (int i = 0; i < invocationCounts.Length; i++) + { + Assert.AreEqual(1, invocationCounts[i], $"Callback {i} was invoked {invocationCounts[i]} times on attempt {attempt}."); + } + } + } + [TestMethod] public async Task ExecuteMaxFailedTestsCallbacksAsync_SetsIsMaxFailedTestsTriggered() { diff --git a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/Services/TestApplicationResultTests.cs b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/Services/TestApplicationResultTests.cs index 4825410167..9443ccbfd8 100644 --- a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/Services/TestApplicationResultTests.cs +++ b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/Services/TestApplicationResultTests.cs @@ -123,6 +123,99 @@ public void GetProcessExitCode_WithNoTestsAndCoverageThresholdFailure_ReturnsZer Assert.AreEqual((int)ExitCode.ZeroTests, testApplicationResult.GetProcessExitCode()); } + [TestMethod] + public async Task GetProcessExitCode_WithFailedTestAndDeadline_ReturnsTestFailure() + { + Mock policiesService = new(); + policiesService.SetupGet(service => service.IsDeadlineTriggered).Returns(true); + using TestApplicationResult testApplicationResult = CreateTestApplicationResult(policiesService.Object); + await testApplicationResult.ConsumeAsync( + new DummyProducer(), + new TestNodeUpdateMessage( + default, + new TestNode + { + Uid = "failed-test", + DisplayName = "FailedTest", + Properties = new PropertyBag(new FailedTestNodeStateProperty("failure")), + }), + CancellationToken.None); + + Assert.AreEqual((int)ExitCode.AtLeastOneTestFailed, testApplicationResult.GetProcessExitCode()); + } + + [TestMethod] + public void GetProcessExitCode_WithAbortAndDeadline_ReturnsAbort() + { + Mock policiesService = new(); + policiesService.SetupGet(service => service.IsAbortTriggered).Returns(true); + policiesService.SetupGet(service => service.IsDeadlineTriggered).Returns(true); + using TestApplicationResult testApplicationResult = CreateTestApplicationResult(policiesService.Object); + + Assert.AreEqual((int)ExitCode.TestSessionAborted, testApplicationResult.GetProcessExitCode()); + } + + [TestMethod] + public void GetProcessExitCode_WithNoTestsAndDeadline_ReturnsDeadline() + { + Mock policiesService = new(); + policiesService.SetupGet(service => service.IsDeadlineTriggered).Returns(true); + using TestApplicationResult testApplicationResult = CreateTestApplicationResult(policiesService.Object); + + Assert.AreEqual((int)ExitCode.TestExecutionStoppedAtDeadline, testApplicationResult.GetProcessExitCode()); + } + + [TestMethod] + public async Task GetProcessExitCode_WithOverlappingRequestDeadline_IsolatedPerRequest() + { + Mock cancellationTokenSource = new(); + cancellationTokenSource.SetupGet(x => x.CancellationToken).Returns(CancellationToken.None); + StopPoliciesService firstRequestPolicies = new(cancellationTokenSource.Object); + StopPoliciesService secondRequestPolicies = new(cancellationTokenSource.Object); + using TestApplicationResult firstRequestResult = CreateTestApplicationResult(firstRequestPolicies); + using TestApplicationResult secondRequestResult = CreateTestApplicationResult(secondRequestPolicies); + + await firstRequestPolicies.ExecuteDeadlineCallbacksAsync(); + + Assert.AreEqual((int)ExitCode.TestExecutionStoppedAtDeadline, firstRequestResult.GetProcessExitCode()); + Assert.AreEqual((int)ExitCode.ZeroTests, secondRequestResult.GetProcessExitCode()); + } + + [TestMethod] + public void GetProcessExitCode_WithMinimumExpectedTestsViolationAndDeadline_ReturnsDeadline() + { + Mock policiesService = new(); + policiesService.SetupGet(service => service.IsDeadlineTriggered).Returns(true); + using TestApplicationResult testApplicationResult = CreateTestApplicationResult( + policiesService.Object, + new CommandLineOption(PlatformCommandLineProvider.MinimumExpectedTestsOptionKey, ["1"])); + + Assert.AreEqual((int)ExitCode.TestExecutionStoppedAtDeadline, testApplicationResult.GetProcessExitCode()); + } + + [TestMethod] + public async Task GetProcessExitCode_WithCoverageThresholdFailureAndDeadline_ReturnsDeadline() + { + Mock policiesService = new(); + policiesService.SetupGet(service => service.IsDeadlineTriggered).Returns(true); + Mock coverageResult = new(); + coverageResult.SetupGet(result => result.HasThresholdFailure).Returns(true); + using TestApplicationResult testApplicationResult = CreateTestApplicationResult(policiesService.Object, coverageResult: coverageResult.Object); + await testApplicationResult.ConsumeAsync( + new DummyProducer(), + new TestNodeUpdateMessage( + default, + new TestNode + { + Uid = "passed-test", + DisplayName = "PassedTest", + Properties = new PropertyBag(PassedTestNodeStateProperty.CachedInstance), + }), + CancellationToken.None); + + Assert.AreEqual((int)ExitCode.TestExecutionStoppedAtDeadline, testApplicationResult.GetProcessExitCode()); + } + [TestMethod] public async Task ConsumeAsync_SupersededRetryAttempt_DoesNotCloseTheTestActivity() { @@ -525,6 +618,18 @@ public void GetProcessExitCodeAsync_IgnoreExitCodes(string? argument, int expect } } + private static TestApplicationResult CreateTestApplicationResult( + IStopPoliciesService policiesService, + ICommandLineOptions? commandLineOptions = null, + ITestCoverageResult? coverageResult = null) + => new( + Mock.Of(), + commandLineOptions ?? Mock.Of(), + Mock.Of(), + policiesService, + null, + coverageResult); + internal static IEnumerable FailedState() { yield return [new FailedTestNodeStateProperty()]; diff --git a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/TestHostControllers/TestConfigurationEnvironmentVariableProviderTests.cs b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/TestHostControllers/TestConfigurationEnvironmentVariableProviderTests.cs index 70e5c38dc6..91b9a0de1c 100644 --- a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/TestHostControllers/TestConfigurationEnvironmentVariableProviderTests.cs +++ b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/TestHostControllers/TestConfigurationEnvironmentVariableProviderTests.cs @@ -83,6 +83,78 @@ public async Task ValidateAsync_AlwaysSucceeds() Assert.IsTrue(result.IsValid); } + [TestMethod] + public async Task SystemProviderLocksDeadlineSnapshotAgainstChildOnlyOverrides() + { + var sourceVariables = new Hashtable + { + [EnvironmentVariableConstants.TESTINGPLATFORM_DEADLINE] = "2030-01-01T00:00:00Z", + }; + Mock environment = new(); + environment.Setup(x => x.GetEnvironmentVariables()).Returns(sourceVariables); + var systemProvider = new SystemEnvironmentVariableProvider(environment.Object); + EnvironmentVariables environmentVariables = new(new TestLoggerFactory()) + { + CurrentProvider = systemProvider, + }; + + await systemProvider.UpdateAsync(environmentVariables); + + Assert.IsTrue(environmentVariables.TryGetVariable( + EnvironmentVariableConstants.TESTINGPLATFORM_DEADLINE, + out OwnedEnvironmentVariable? deadline)); + Assert.AreEqual("2030-01-01T00:00:00Z", deadline!.Value); + Assert.IsTrue(deadline.IsLocked); + + Assert.IsTrue(environmentVariables.TryGetVariable( + EnvironmentVariableConstants.TESTINGPLATFORM_DEADLINE_STOP_MARGIN, + out OwnedEnvironmentVariable? stopMargin)); + Assert.AreEqual(string.Empty, stopMargin!.Value); + Assert.IsTrue(stopMargin.IsLocked); + + TestConfigurationEnvironmentVariableProvider testConfigurationProvider = await CreateProviderAsync( + $"{{\"environmentVariables\": {{\"{EnvironmentVariableConstants.TESTINGPLATFORM_DEADLINE}\": \"2040-01-01T00:00:00Z\"}}}}"); + Assert.IsTrue(await testConfigurationProvider.IsEnabledAsync()); + environmentVariables.CurrentProvider = testConfigurationProvider; + + InvalidOperationException exception = await Assert.ThrowsAsync( + () => testConfigurationProvider.UpdateAsync(environmentVariables)); + Assert.Contains(EnvironmentVariableConstants.TESTINGPLATFORM_DEADLINE, exception.Message); + } + + [TestMethod] + [OSCondition(ConditionMode.Include, OperatingSystems.Windows)] + public async Task SystemProviderLocksDeadlineSnapshotCaseInsensitivelyOnWindows() + { + string lowerCaseDeadline = EnvironmentVariableConstants.TESTINGPLATFORM_DEADLINE.ToLowerInvariant(); + var sourceVariables = new Hashtable + { + [lowerCaseDeadline] = "2030-01-01T00:00:00Z", + }; + Mock environment = new(); + environment.Setup(x => x.GetEnvironmentVariables()).Returns(sourceVariables); + var systemProvider = new SystemEnvironmentVariableProvider(environment.Object); + EnvironmentVariables environmentVariables = new(new TestLoggerFactory()) + { + CurrentProvider = systemProvider, + }; + await systemProvider.UpdateAsync(environmentVariables); + + Assert.IsTrue(environmentVariables.TryGetVariable( + EnvironmentVariableConstants.TESTINGPLATFORM_DEADLINE, + out OwnedEnvironmentVariable? deadline)); + Assert.AreEqual("2030-01-01T00:00:00Z", deadline!.Value); + Assert.IsTrue(deadline.IsLocked); + + TestConfigurationEnvironmentVariableProvider testConfigurationProvider = await CreateProviderAsync( + $"{{\"environmentVariables\": {{\"{EnvironmentVariableConstants.TESTINGPLATFORM_DEADLINE}\": \"2040-01-01T00:00:00Z\"}}}}"); + Assert.IsTrue(await testConfigurationProvider.IsEnabledAsync()); + environmentVariables.CurrentProvider = testConfigurationProvider; + + await Assert.ThrowsAsync( + () => testConfigurationProvider.UpdateAsync(environmentVariables)); + } + private static async Task CreateProviderAsync(string jsonFileContent) { Mock fileSystem = new();