Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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; }
}
}
18 changes: 10 additions & 8 deletions TUnit.Engine/Services/TestExecution/RetryHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ namespace TUnit.Engine.Services.TestExecution;

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

Expand Down Expand Up @@ -57,37 +57,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 Task.FromResult(false);
}

if (testContext.RetryFunc == null)
{
// Default behavior: retry on any exception if within retry limit
return true;
return Task.FromResult(true);
}

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
15 changes: 6 additions & 9 deletions TUnit.Engine/Services/TestExecution/TestMethodInvoker.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,15 @@ namespace TUnit.Engine.Services.TestExecution;
/// </summary>
internal sealed class TestMethodInvoker
{
public async Task InvokeTestAsync(AbstractExecutableTest test, CancellationToken cancellationToken)
public Task 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)))
.AsTask();
}

return test.InvokeTestAsync(test.Context.Metadata.TestDetails.ClassInstance, cancellationToken);
}
}
3 changes: 2 additions & 1 deletion TUnit.Engine/Services/TestExecution/TestStateManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,9 @@ public void MarkFailed(AbstractExecutableTest test, Exception exception)
}
else
{
var now = DateTimeOffset.UtcNow;
test.State = TestState.Failed;
test.EndTime ??= DateTimeOffset.UtcNow;
test.EndTime ??= now;
test.Result = new TestResult
{
State = TestState.Failed,
Expand Down
10 changes: 5 additions & 5 deletions TUnit.Engine/TestExecutor.cs
Original file line number Diff line number Diff line change
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 @@ -375,17 +375,17 @@ public async Task<List<Exception>> ExecuteAfterTestSessionHooksAsync(Cancellatio
/// <summary>
/// Execute discovery-level before hooks.
/// </summary>
public async Task ExecuteBeforeTestDiscoveryHooksAsync(CancellationToken cancellationToken)
public Task ExecuteBeforeTestDiscoveryHooksAsync(CancellationToken cancellationToken)
{
await _hookExecutor.ExecuteBeforeTestDiscoveryHooksAsync(cancellationToken).ConfigureAwait(false);
return _hookExecutor.ExecuteBeforeTestDiscoveryHooksAsync(cancellationToken).AsTask();
}

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

/// <summary>
Expand Down
Loading