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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions TUnit.Engine/Building/TestBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand All @@ -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,
Expand Down
11 changes: 7 additions & 4 deletions TUnit.Engine/Building/TestBuilderPipeline.cs
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,7 @@ private AbstractExecutableTest CreateFailedTestForDataGenerationError(TestMetada

context.Metadata.TestDetails = testDetails;

var now = DateTimeOffset.UtcNow;

return new FailedExecutableTest(exception)
{
Expand All @@ -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,
Expand Down Expand Up @@ -525,6 +526,8 @@ private AbstractExecutableTest CreateFailedTestForGenericResolutionError(TestMet

context.Metadata.TestDetails = testDetails;

var now = DateTimeOffset.UtcNow;

return new FailedExecutableTest(exception)
{
TestId = testId,
Expand All @@ -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,
Expand Down
36 changes: 28 additions & 8 deletions TUnit.Engine/Services/DiscoveryCircuitBreaker.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/// <summary>
Expand All @@ -23,30 +27,43 @@ 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();
GC.Collect();
_initialMemoryUsage = GC.GetTotalMemory(false);
}

private TimeSpan GetElapsed()
{
#if NET
return Stopwatch.GetElapsedTime(_startTimestamp);
#else
return _stopwatch.Elapsed;
#endif
}

/// <summary>
/// Checks if the circuit breaker should trip based on current resource usage
/// </summary>
/// <param name="currentTestCount">Current number of generated tests (for logging only)</param>
/// <returns>True if generation should continue, false if circuit breaker trips</returns>
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;
Expand All @@ -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
};
}
Expand Down Expand Up @@ -110,7 +128,9 @@ private static long GetAvailableMemoryBytes()

public void Dispose()
{
_stopwatch?.Stop();
#if !NET
_stopwatch.Stop();
#endif
}
}

Expand All @@ -125,4 +145,4 @@ internal record DiscoveryResourceUsage
public long MaxMemoryBytes { get; init; }
public double TimeUsagePercentage { get; init; }
public double MemoryUsagePercentage { get; init; }
}
}
21 changes: 13 additions & 8 deletions TUnit.Engine/Services/TestExecution/RetryHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ namespace TUnit.Engine.Services.TestExecution;

internal static class RetryHelper
{
public static async Task ExecuteWithRetry(TestContext testContext, Func<Task> action)
private static readonly Task<bool> s_shouldRetryTrue = Task.FromResult(true);
private static readonly Task<bool> s_shouldRetryFalse = Task.FromResult(false);

public static async Task ExecuteWithRetry(TestContext testContext, Func<ValueTask> action)
{
var maxRetries = testContext.Metadata.TestDetails.RetryLimit;

Expand Down Expand Up @@ -57,37 +60,39 @@ public static async Task ExecuteWithRetry(TestContext testContext, Func<Task> ac
}
}

private static async Task<bool> ShouldRetry(TestContext testContext, Exception ex, int attempt)
private static Task<bool> 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;
var delayMs = (int)(backoffMs * Math.Pow(multiplier, attempt));

if (delayMs > 0)
{
await Task.Delay(delayMs, testContext.CancellationToken).ConfigureAwait(false);
return Task.Delay(delayMs, testContext.CancellationToken);
}

return Task.CompletedTask;
}
}
6 changes: 2 additions & 4 deletions TUnit.Engine/Services/TestExecution/TestCoordinator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
14 changes: 5 additions & 9 deletions TUnit.Engine/Services/TestExecution/TestMethodInvoker.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,14 @@ namespace TUnit.Engine.Services.TestExecution;
/// </summary>
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));
}
}
18 changes: 9 additions & 9 deletions TUnit.Engine/TestExecutor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ await _beforeHookTaskCache.GetOrCreateBeforeTestSessionTask(
// Register After Session hook to run on cancellation (guarantees cleanup)
_afterHookPairTracker.RegisterAfterTestSessionHook(
cancellationToken,
() => new ValueTask<List<Exception>>(_hookExecutor.ExecuteAfterTestSessionHooksAsync(CancellationToken.None).AsTask()));
() => _hookExecutor.ExecuteAfterTestSessionHooksAsync(CancellationToken.None));
}

/// <summary>
Expand Down Expand Up @@ -95,7 +95,7 @@ await _beforeHookTaskCache.GetOrCreateBeforeAssemblyTask(
_afterHookPairTracker.RegisterAfterAssemblyHook(
testAssembly,
cancellationToken,
(assembly) => new ValueTask<List<Exception>>(_hookExecutor.ExecuteAfterAssemblyHooksAsync(assembly, CancellationToken.None).AsTask()));
(assembly) => _hookExecutor.ExecuteAfterAssemblyHooksAsync(assembly, CancellationToken.None));

await _eventReceiverOrchestrator.InvokeFirstTestInAssemblyEventReceiversAsync(
executableTest.Context,
Expand Down Expand Up @@ -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
{
Expand Down Expand Up @@ -351,7 +351,7 @@ internal async Task<List<Exception>> ExecuteAfterClassAssemblyHooks(AbstractExec
// Use AfterHookPairTracker to prevent double execution if already triggered by cancellation
var assemblyExceptions = await _afterHookPairTracker.GetOrCreateAfterAssemblyTask(
testAssembly,
(assembly) => new ValueTask<List<Exception>>(_hookExecutor.ExecuteAfterAssemblyHooksAsync(assembly, cancellationToken).AsTask())).ConfigureAwait(false);
(assembly) => _hookExecutor.ExecuteAfterAssemblyHooksAsync(assembly, cancellationToken)).ConfigureAwait(false);
exceptions.AddRange(assemblyExceptions);
}

Expand All @@ -367,25 +367,25 @@ public async Task<List<Exception>> ExecuteAfterTestSessionHooksAsync(Cancellatio
{
// Use AfterHookPairTracker to prevent double execution if already triggered by cancellation
var exceptions = await _afterHookPairTracker.GetOrCreateAfterTestSessionTask(
() => new ValueTask<List<Exception>>(_hookExecutor.ExecuteAfterTestSessionHooksAsync(cancellationToken).AsTask())).ConfigureAwait(false);
() => _hookExecutor.ExecuteAfterTestSessionHooksAsync(cancellationToken)).ConfigureAwait(false);

return exceptions;
}

/// <summary>
/// Execute discovery-level before hooks.
/// </summary>
public async Task ExecuteBeforeTestDiscoveryHooksAsync(CancellationToken cancellationToken)
public ValueTask ExecuteBeforeTestDiscoveryHooksAsync(CancellationToken cancellationToken)
{
await _hookExecutor.ExecuteBeforeTestDiscoveryHooksAsync(cancellationToken).ConfigureAwait(false);
return _hookExecutor.ExecuteBeforeTestDiscoveryHooksAsync(cancellationToken);
}

/// <summary>
/// Execute discovery-level after hooks.
/// </summary>
public async Task ExecuteAfterTestDiscoveryHooksAsync(CancellationToken cancellationToken)
public ValueTask ExecuteAfterTestDiscoveryHooksAsync(CancellationToken cancellationToken)
{
await _hookExecutor.ExecuteAfterTestDiscoveryHooksAsync(cancellationToken).ConfigureAwait(false);
return _hookExecutor.ExecuteAfterTestDiscoveryHooksAsync(cancellationToken);
}

/// <summary>
Expand Down
Loading