| File: Commands\Test\MTP\TestRunPolicy.cs | Web Access |
| Project: src\sdk\src\Cli\dotnet\dotnet.csproj (dotnet) |
// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics; namespace Microsoft.DotNet.Cli.Commands.Test; internal enum TestRunCancellationReason { None, MaximumFailedTests, Timeout, } internal sealed class TestRunPolicy : IDisposable { private const int CompletedState = -1; internal static readonly TimeSpan DefaultCancellationGracePeriod = TimeSpan.FromSeconds(30); private readonly int? _maximumFailedTests; private readonly TimeSpan? _timeout; private readonly Action<TestRunCancellationReason>? _onCancellation; private readonly TaskCompletionSource<TestRunCancellationReason> _cancellation = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly CancellationTokenSource _cancellationTokenSource = new(); private readonly Lock _timerLock = new(); private Timer? _timer; private TimeSpan? _remainingTimeout; private long _timerStartedTimestamp; private int _activeTestApplications; private int _failedTests; private int _reason; private bool _disposed; public TestRunPolicy( int? maximumFailedTests, TimeSpan? timeout, TimeSpan? cancellationGracePeriod = null, Action<TestRunCancellationReason>? onCancellation = null) { if (maximumFailedTests is <= 0) { throw new ArgumentOutOfRangeException(nameof(maximumFailedTests)); } if (timeout.HasValue && timeout.Value <= TimeSpan.Zero) { throw new ArgumentOutOfRangeException(nameof(timeout)); } _maximumFailedTests = maximumFailedTests; _timeout = timeout; _remainingTimeout = timeout; _onCancellation = onCancellation; CancellationGracePeriod = cancellationGracePeriod ?? DefaultCancellationGracePeriod; } public CancellationToken Token => _cancellationTokenSource.Token; public Task<TestRunCancellationReason> Cancellation => _cancellation.Task; public TestRunCancellationReason Reason { get { int reason = Volatile.Read(ref _reason); return reason == CompletedState ? TestRunCancellationReason.None : (TestRunCancellationReason)reason; } } public int FailedTests => Volatile.Read(ref _failedTests); public TimeSpan CancellationGracePeriod { get; } public void OnTestApplicationStarted() { if (_timeout is null) { return; } lock (_timerLock) { _activeTestApplications++; if (_activeTestApplications == 1 && !_disposed && Reason == TestRunCancellationReason.None && _remainingTimeout is { } remainingTimeout) { _timerStartedTimestamp = Stopwatch.GetTimestamp(); // A concurrent OnTestApplicationExited can drive _remainingTimeout non-positive a // moment before it flips Reason to Timeout, so clamp to avoid passing a negative due // time to Timer (which throws ArgumentOutOfRangeException). A zero due time fires // immediately and OnTimeout performs the cancellation. TimeSpan dueTime = remainingTimeout > TimeSpan.Zero ? remainingTimeout : TimeSpan.Zero; _timer = new Timer( static state => ((TestRunPolicy)state!).OnTimeout(), this, dueTime, Timeout.InfiniteTimeSpan); } } } public void OnTestApplicationExited() { bool timeoutReached = false; lock (_timerLock) { if (_timeout is null) { return; } if (_activeTestApplications <= 0) { throw new InvalidOperationException("A test application exited without a matching start."); } _activeTestApplications--; if (_activeTestApplications == 0 && _timer is not null) { _timer.Dispose(); _timer = null; TimeSpan elapsed = Stopwatch.GetElapsedTime(_timerStartedTimestamp); _remainingTimeout -= elapsed; timeoutReached = _remainingTimeout <= TimeSpan.Zero; } } if (timeoutReached) { TryCancel(TestRunCancellationReason.Timeout); } } public void ReportFailedTests(int count) { if (count <= 0) { return; } int failedTests = Interlocked.Add(ref _failedTests, count); if (_maximumFailedTests is { } maximumFailedTests && failedTests >= maximumFailedTests) { TryCancel(TestRunCancellationReason.MaximumFailedTests); } } public TestRunCancellationReason Complete() { int state = Interlocked.CompareExchange( ref _reason, CompletedState, (int)TestRunCancellationReason.None); lock (_timerLock) { _timer?.Dispose(); _timer = null; } return state is (int)TestRunCancellationReason.None or CompletedState ? TestRunCancellationReason.None : (TestRunCancellationReason)state; } private void TryCancel(TestRunCancellationReason reason) { if (Interlocked.CompareExchange(ref _reason, (int)reason, (int)TestRunCancellationReason.None) != (int)TestRunCancellationReason.None) { return; } _onCancellation?.Invoke(reason); _cancellation.TrySetResult(reason); _cancellationTokenSource.Cancel(); } private void OnTimeout() { lock (_timerLock) { if (_activeTestApplications > 0 && !_disposed) { TryCancel(TestRunCancellationReason.Timeout); } } } public void Dispose() { Complete(); lock (_timerLock) { _disposed = true; } _cancellationTokenSource.Dispose(); } }