diff --git a/TUnit.Engine/Building/TestBuilder.cs b/TUnit.Engine/Building/TestBuilder.cs index 4a470817ee..7e81d243aa 100644 --- a/TUnit.Engine/Building/TestBuilder.cs +++ b/TUnit.Engine/Building/TestBuilder.cs @@ -1109,6 +1109,7 @@ private AbstractExecutableTest CreateFailedTestForDataGenerationError(TestMetada var testDetails = CreateFailedTestDetails(metadata, testId); var context = CreateFailedTestContext(metadata, testDetails); + var now = DateTimeOffset.UtcNow; return new FailedExecutableTest(exception) { @@ -1121,8 +1122,8 @@ private AbstractExecutableTest CreateFailedTestForDataGenerationError(TestMetada Result = new TestResult { State = TestState.Failed, - Start = DateTimeOffset.UtcNow, - End = DateTimeOffset.UtcNow, + Start = now, + End = now, Duration = TimeSpan.Zero, Exception = exception, ComputerName = EnvironmentHelper.MachineName, diff --git a/TUnit.Engine/Building/TestBuilderPipeline.cs b/TUnit.Engine/Building/TestBuilderPipeline.cs index 9cd01fd205..14a2c6dcb2 100644 --- a/TUnit.Engine/Building/TestBuilderPipeline.cs +++ b/TUnit.Engine/Building/TestBuilderPipeline.cs @@ -473,6 +473,7 @@ private AbstractExecutableTest CreateFailedTestForDataGenerationError(TestMetada context.Metadata.TestDetails = testDetails; + var now = DateTimeOffset.UtcNow; return new FailedExecutableTest(exception) { @@ -485,8 +486,8 @@ private AbstractExecutableTest CreateFailedTestForDataGenerationError(TestMetada Result = new TestResult { State = TestState.Failed, - Start = DateTimeOffset.UtcNow, - End = DateTimeOffset.UtcNow, + Start = now, + End = now, Duration = TimeSpan.Zero, Exception = exception, ComputerName = EnvironmentHelper.MachineName, @@ -525,6 +526,8 @@ private AbstractExecutableTest CreateFailedTestForGenericResolutionError(TestMet context.Metadata.TestDetails = testDetails; + var now = DateTimeOffset.UtcNow; + return new FailedExecutableTest(exception) { TestId = testId, @@ -536,8 +539,8 @@ private AbstractExecutableTest CreateFailedTestForGenericResolutionError(TestMet Result = new TestResult { State = TestState.Failed, - Start = DateTimeOffset.UtcNow, - End = DateTimeOffset.UtcNow, + Start = now, + End = now, Duration = TimeSpan.Zero, Exception = exception, ComputerName = EnvironmentHelper.MachineName, diff --git a/TUnit.Engine/Services/DiscoveryCircuitBreaker.cs b/TUnit.Engine/Services/DiscoveryCircuitBreaker.cs index 133e9c4a84..d7976fff03 100644 --- a/TUnit.Engine/Services/DiscoveryCircuitBreaker.cs +++ b/TUnit.Engine/Services/DiscoveryCircuitBreaker.cs @@ -11,7 +11,11 @@ public sealed class DiscoveryCircuitBreaker { private readonly long _maxMemoryBytes; private readonly TimeSpan _maxGenerationTime; +#if NET + private readonly long _startTimestamp; +#else private readonly Stopwatch _stopwatch; +#endif private readonly long _initialMemoryUsage; /// @@ -23,8 +27,12 @@ public DiscoveryCircuitBreaker( { _maxMemoryBytes = (long)(GetAvailableMemoryBytes() * maxMemoryPercentage); _maxGenerationTime = maxGenerationTime ?? EngineDefaults.MaxGenerationTime; +#if NET + _startTimestamp = Stopwatch.GetTimestamp(); +#else _stopwatch = Stopwatch.StartNew(); - +#endif + // Track initial memory to calculate growth GC.Collect(); GC.WaitForPendingFinalizers(); @@ -32,6 +40,15 @@ public DiscoveryCircuitBreaker( _initialMemoryUsage = GC.GetTotalMemory(false); } + private TimeSpan GetElapsed() + { +#if NET + return Stopwatch.GetElapsedTime(_startTimestamp); +#else + return _stopwatch.Elapsed; +#endif + } + /// /// Checks if the circuit breaker should trip based on current resource usage /// @@ -39,14 +56,14 @@ public DiscoveryCircuitBreaker( /// True if generation should continue, false if circuit breaker trips public bool ShouldContinue(int currentTestCount = 0) { - if (_stopwatch.Elapsed > _maxGenerationTime) + if (GetElapsed() > _maxGenerationTime) { return false; } var currentMemoryUsage = GC.GetTotalMemory(false); var memoryGrowth = currentMemoryUsage - _initialMemoryUsage; - + if (memoryGrowth > _maxMemoryBytes) { return false; @@ -62,14 +79,15 @@ internal DiscoveryResourceUsage GetResourceUsage() { var currentMemoryUsage = GC.GetTotalMemory(false); var memoryGrowth = currentMemoryUsage - _initialMemoryUsage; - + var elapsed = GetElapsed(); + return new DiscoveryResourceUsage { - ElapsedTime = _stopwatch.Elapsed, + ElapsedTime = elapsed, MaxTime = _maxGenerationTime, MemoryGrowthBytes = memoryGrowth, MaxMemoryBytes = _maxMemoryBytes, - TimeUsagePercentage = _stopwatch.Elapsed.TotalMilliseconds / _maxGenerationTime.TotalMilliseconds, + TimeUsagePercentage = elapsed.TotalMilliseconds / _maxGenerationTime.TotalMilliseconds, MemoryUsagePercentage = (double)memoryGrowth / _maxMemoryBytes }; } @@ -110,7 +128,9 @@ private static long GetAvailableMemoryBytes() public void Dispose() { - _stopwatch?.Stop(); +#if !NET + _stopwatch.Stop(); +#endif } } @@ -125,4 +145,4 @@ internal record DiscoveryResourceUsage public long MaxMemoryBytes { get; init; } public double TimeUsagePercentage { get; init; } public double MemoryUsagePercentage { get; init; } -} \ No newline at end of file +} diff --git a/TUnit.Engine/Services/TestExecution/RetryHelper.cs b/TUnit.Engine/Services/TestExecution/RetryHelper.cs index 7db0a93d73..7352cba6cb 100644 --- a/TUnit.Engine/Services/TestExecution/RetryHelper.cs +++ b/TUnit.Engine/Services/TestExecution/RetryHelper.cs @@ -4,7 +4,10 @@ namespace TUnit.Engine.Services.TestExecution; internal static class RetryHelper { - public static async Task ExecuteWithRetry(TestContext testContext, Func action) + private static readonly Task s_shouldRetryTrue = Task.FromResult(true); + private static readonly Task s_shouldRetryFalse = Task.FromResult(false); + + public static async Task ExecuteWithRetry(TestContext testContext, Func action) { var maxRetries = testContext.Metadata.TestDetails.RetryLimit; @@ -57,29 +60,29 @@ public static async Task ExecuteWithRetry(TestContext testContext, Func ac } } - private static async Task ShouldRetry(TestContext testContext, Exception ex, int attempt) + private static Task ShouldRetry(TestContext testContext, Exception ex, int attempt) { if (attempt >= testContext.Metadata.TestDetails.RetryLimit) { - return false; + return s_shouldRetryFalse; } if (testContext.RetryFunc == null) { // Default behavior: retry on any exception if within retry limit - return true; + return s_shouldRetryTrue; } - return await testContext.RetryFunc(testContext, ex, attempt + 1).ConfigureAwait(false); + return testContext.RetryFunc(testContext, ex, attempt + 1); } - private static async Task ApplyBackoffDelay(TestContext testContext, int attempt) + private static Task ApplyBackoffDelay(TestContext testContext, int attempt) { var backoffMs = testContext.Metadata.TestDetails.RetryBackoffMs; if (backoffMs <= 0) { - return; + return Task.CompletedTask; } var multiplier = testContext.Metadata.TestDetails.RetryBackoffMultiplier; @@ -87,7 +90,9 @@ private static async Task ApplyBackoffDelay(TestContext testContext, int attempt if (delayMs > 0) { - await Task.Delay(delayMs, testContext.CancellationToken).ConfigureAwait(false); + return Task.Delay(delayMs, testContext.CancellationToken); } + + return Task.CompletedTask; } } diff --git a/TUnit.Engine/Services/TestExecution/TestCoordinator.cs b/TUnit.Engine/Services/TestExecution/TestCoordinator.cs index 70d0f0a553..c7ea325f1b 100644 --- a/TUnit.Engine/Services/TestExecution/TestCoordinator.cs +++ b/TUnit.Engine/Services/TestExecution/TestCoordinator.cs @@ -117,10 +117,8 @@ private async ValueTask ExecuteTestInternalAsync(AbstractExecutableTest test, Ca // Slow path: use retry wrapper // Timeout is handled inside TestExecutor.ExecuteAsync, wrapping only the test body // (not hooks or data source initialization) — fixes #4772 - await RetryHelper.ExecuteWithRetry(test.Context, async () => - { - await ExecuteTestLifecycleAsync(test, cancellationToken).ConfigureAwait(false); - }).ConfigureAwait(false); + await RetryHelper.ExecuteWithRetry(test.Context, + () => ExecuteTestLifecycleAsync(test, cancellationToken)).ConfigureAwait(false); } _stateManager.MarkCompleted(test); diff --git a/TUnit.Engine/Services/TestExecution/TestMethodInvoker.cs b/TUnit.Engine/Services/TestExecution/TestMethodInvoker.cs index 451dddb20b..9e7a77e197 100644 --- a/TUnit.Engine/Services/TestExecution/TestMethodInvoker.cs +++ b/TUnit.Engine/Services/TestExecution/TestMethodInvoker.cs @@ -8,18 +8,14 @@ namespace TUnit.Engine.Services.TestExecution; /// internal sealed class TestMethodInvoker { - public async Task InvokeTestAsync(AbstractExecutableTest test, CancellationToken cancellationToken) + public ValueTask InvokeTestAsync(AbstractExecutableTest test, CancellationToken cancellationToken) { if (test.Context.InternalDiscoveredTest?.TestExecutor is { } testExecutor) { - await testExecutor.ExecuteTest(test.Context, - async () => await test.InvokeTestAsync(test.Context.Metadata.TestDetails.ClassInstance, cancellationToken)) - .ConfigureAwait(false); - } - else - { - await test.InvokeTestAsync(test.Context.Metadata.TestDetails.ClassInstance, cancellationToken) - .ConfigureAwait(false); + return testExecutor.ExecuteTest(test.Context, + () => new ValueTask(test.InvokeTestAsync(test.Context.Metadata.TestDetails.ClassInstance, cancellationToken))); } + + return new ValueTask(test.InvokeTestAsync(test.Context.Metadata.TestDetails.ClassInstance, cancellationToken)); } } \ No newline at end of file diff --git a/TUnit.Engine/TestExecutor.cs b/TUnit.Engine/TestExecutor.cs index 125ad8e676..4f513d36aa 100644 --- a/TUnit.Engine/TestExecutor.cs +++ b/TUnit.Engine/TestExecutor.cs @@ -59,7 +59,7 @@ await _beforeHookTaskCache.GetOrCreateBeforeTestSessionTask( // Register After Session hook to run on cancellation (guarantees cleanup) _afterHookPairTracker.RegisterAfterTestSessionHook( cancellationToken, - () => new ValueTask>(_hookExecutor.ExecuteAfterTestSessionHooksAsync(CancellationToken.None).AsTask())); + () => _hookExecutor.ExecuteAfterTestSessionHooksAsync(CancellationToken.None)); } /// @@ -95,7 +95,7 @@ await _beforeHookTaskCache.GetOrCreateBeforeAssemblyTask( _afterHookPairTracker.RegisterAfterAssemblyHook( testAssembly, cancellationToken, - (assembly) => new ValueTask>(_hookExecutor.ExecuteAfterAssemblyHooksAsync(assembly, CancellationToken.None).AsTask())); + (assembly) => _hookExecutor.ExecuteAfterAssemblyHooksAsync(assembly, CancellationToken.None)); await _eventReceiverOrchestrator.InvokeFirstTestInAssemblyEventReceiversAsync( executableTest.Context, @@ -323,7 +323,7 @@ private static async ValueTask ExecuteTestAsync(AbstractExecutableTest executabl if (executableTest.Context.InternalDiscoveredTest?.TestExecutor is { } testExecutor) { await testExecutor.ExecuteTest(executableTest.Context, - async () => await executableTest.InvokeTestAsync(executableTest.Context.Metadata.TestDetails.ClassInstance, cancellationToken)).ConfigureAwait(false); + () => new ValueTask(executableTest.InvokeTestAsync(executableTest.Context.Metadata.TestDetails.ClassInstance, cancellationToken))).ConfigureAwait(false); } else { @@ -351,7 +351,7 @@ internal async Task> ExecuteAfterClassAssemblyHooks(AbstractExec // Use AfterHookPairTracker to prevent double execution if already triggered by cancellation var assemblyExceptions = await _afterHookPairTracker.GetOrCreateAfterAssemblyTask( testAssembly, - (assembly) => new ValueTask>(_hookExecutor.ExecuteAfterAssemblyHooksAsync(assembly, cancellationToken).AsTask())).ConfigureAwait(false); + (assembly) => _hookExecutor.ExecuteAfterAssemblyHooksAsync(assembly, cancellationToken)).ConfigureAwait(false); exceptions.AddRange(assemblyExceptions); } @@ -367,7 +367,7 @@ public async Task> ExecuteAfterTestSessionHooksAsync(Cancellatio { // Use AfterHookPairTracker to prevent double execution if already triggered by cancellation var exceptions = await _afterHookPairTracker.GetOrCreateAfterTestSessionTask( - () => new ValueTask>(_hookExecutor.ExecuteAfterTestSessionHooksAsync(cancellationToken).AsTask())).ConfigureAwait(false); + () => _hookExecutor.ExecuteAfterTestSessionHooksAsync(cancellationToken)).ConfigureAwait(false); return exceptions; } @@ -375,17 +375,17 @@ public async Task> ExecuteAfterTestSessionHooksAsync(Cancellatio /// /// Execute discovery-level before hooks. /// - public async Task ExecuteBeforeTestDiscoveryHooksAsync(CancellationToken cancellationToken) + public ValueTask ExecuteBeforeTestDiscoveryHooksAsync(CancellationToken cancellationToken) { - await _hookExecutor.ExecuteBeforeTestDiscoveryHooksAsync(cancellationToken).ConfigureAwait(false); + return _hookExecutor.ExecuteBeforeTestDiscoveryHooksAsync(cancellationToken); } /// /// Execute discovery-level after hooks. /// - public async Task ExecuteAfterTestDiscoveryHooksAsync(CancellationToken cancellationToken) + public ValueTask ExecuteAfterTestDiscoveryHooksAsync(CancellationToken cancellationToken) { - await _hookExecutor.ExecuteAfterTestDiscoveryHooksAsync(cancellationToken).ConfigureAwait(false); + return _hookExecutor.ExecuteAfterTestDiscoveryHooksAsync(cancellationToken); } ///