Skip to content

This file was deleted.

37 changes: 37 additions & 0 deletions TUnit.Engine/Helpers/TimeoutHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,43 @@ internal static class TimeoutHelper
/// </summary>
private static readonly TimeSpan GracePeriod = TimeSpan.FromSeconds(1);

/// <summary>
/// Executes a ValueTask-returning operation with an optional timeout.
/// On the fast path (no timeout), returns the ValueTask directly without Task allocation.
/// </summary>
public static ValueTask ExecuteWithTimeoutAsync(
Func<CancellationToken, ValueTask> valueTaskFactory,
TimeSpan? timeout,
CancellationToken cancellationToken,
string? timeoutMessage = null)
{
// Fast path: no timeout - return ValueTask directly (zero allocation, no state machine)
if (!timeout.HasValue)
{
return valueTaskFactory(cancellationToken);
}

// Timeout path: convert to Task for WhenAny support
return new ValueTask(ExecuteWithTimeoutCoreAsync(valueTaskFactory, timeout.Value, cancellationToken, timeoutMessage));
}

private static async Task ExecuteWithTimeoutCoreAsync(
Func<CancellationToken, ValueTask> valueTaskFactory,
TimeSpan timeout,
CancellationToken cancellationToken,
string? timeoutMessage)
{
await ExecuteWithTimeoutAsync<bool>(
async ct =>
{
await valueTaskFactory(ct).ConfigureAwait(false);
return true;
},
timeout,
cancellationToken,
timeoutMessage).ConfigureAwait(false);
}

/// <summary>
/// Executes a task with an optional timeout. If the timeout elapses before the task completes,
/// control is returned to the caller immediately with a TimeoutException.
Expand Down
20 changes: 8 additions & 12 deletions TUnit.Engine/Services/TestExecution/TestCoordinator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -112,18 +112,12 @@ private async ValueTask ExecuteTestInternalAsync(AbstractExecutableTest test, Ca
}
else
{
// Slow path: use retry and timeout wrappers
// 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 () =>
{
var timeoutMessage = testTimeout.HasValue
? $"Test '{test.Context.Metadata.TestDetails.TestName}' timed out after {testTimeout.Value}"
: null;

await TimeoutHelper.ExecuteWithTimeoutAsync(
ct => ExecuteTestLifecycleAsync(test, ct).AsTask(),
testTimeout,
cancellationToken,
timeoutMessage).ConfigureAwait(false);
await ExecuteTestLifecycleAsync(test, cancellationToken).ConfigureAwait(false);
}).ConfigureAwait(false);
}

Expand Down Expand Up @@ -256,7 +250,8 @@ await _eventReceiverOrchestrator.InvokeLastTestInSessionEventReceiversAsync(

/// <summary>
/// Core test lifecycle execution: instance creation, initialization, execution, and disposal.
/// Extracted to allow bypassing retry/timeout wrappers when not needed.
/// Timeout is passed through to TestExecutor.ExecuteAsync, which applies it only to the test
/// body — hooks and data source initialization run outside the timeout scope (fixes #4772).
/// </summary>
private async ValueTask ExecuteTestLifecycleAsync(AbstractExecutableTest test, CancellationToken cancellationToken)
{
Expand Down Expand Up @@ -299,7 +294,8 @@ private async ValueTask ExecuteTestLifecycleAsync(AbstractExecutableTest test, C
{
_testInitializer.PrepareTest(test, cancellationToken);
test.Context.RestoreExecutionContext();
await _testExecutor.ExecuteAsync(test, _testInitializer, cancellationToken).ConfigureAwait(false);
var testTimeout = test.Context.Metadata.TestDetails.Timeout;
await _testExecutor.ExecuteAsync(test, _testInitializer, cancellationToken, testTimeout).ConfigureAwait(false);
}
finally
{
Expand Down
8 changes: 4 additions & 4 deletions TUnit.Engine/Services/TestGroupingService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -97,26 +97,26 @@ public async ValueTask<GroupedTests> GroupTestsByConstraintsAsync(IEnumerable<Ab
if (parallelGroup != null && notInParallel != null)
{
// Test has both ParallelGroup and NotInParallel constraints
await _logger.LogDebugAsync($"Test '{test.TestId}': → ConstrainedParallelGroup '{parallelGroup.Group}' + NotInParallel{parallelLimiterInfo}").ConfigureAwait(false);
await _logger.LogTraceAsync($"Test '{test.TestId}': → ConstrainedParallelGroup '{parallelGroup.Group}' + NotInParallel{parallelLimiterInfo}").ConfigureAwait(false);
ProcessCombinedConstraints(test, sortKey.ClassFullName, parallelGroup, notInParallel, constrainedParallelGroups);
}
else if (parallelGroup != null)
{
// Only ParallelGroup constraint
await _logger.LogDebugAsync($"Test '{test.TestId}': → ParallelGroup '{parallelGroup.Group}'{parallelLimiterInfo}").ConfigureAwait(false);
await _logger.LogTraceAsync($"Test '{test.TestId}': → ParallelGroup '{parallelGroup.Group}'{parallelLimiterInfo}").ConfigureAwait(false);
ProcessParallelGroupConstraint(test, parallelGroup, parallelGroups);
}
else if (notInParallel != null)
{
// Only NotInParallel constraint
var keys = notInParallel.NotInParallelConstraintKeys.Count > 0 ? $" (keys: {string.Join(", ", notInParallel.NotInParallelConstraintKeys)})" : "";
await _logger.LogDebugAsync($"Test '{test.TestId}': → NotInParallel{keys}{parallelLimiterInfo}").ConfigureAwait(false);
await _logger.LogTraceAsync($"Test '{test.TestId}': → NotInParallel{keys}{parallelLimiterInfo}").ConfigureAwait(false);
ProcessNotInParallelConstraint(test, sortKey.ClassFullName, notInParallel, notInParallelList, keyedNotInParallelList);
}
else
{
// No constraints - can run in parallel
await _logger.LogDebugAsync($"Test '{test.TestId}': → Parallel (no constraints){parallelLimiterInfo}").ConfigureAwait(false);
await _logger.LogTraceAsync($"Test '{test.TestId}': → Parallel (no constraints){parallelLimiterInfo}").ConfigureAwait(false);
parallelTests.Add(test);
}
}
Expand Down
15 changes: 12 additions & 3 deletions TUnit.Engine/TestExecutor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ await _beforeHookTaskCache.GetOrCreateBeforeTestSessionTask(
/// Creates a test executor delegate that wraps the provided executor with hook orchestration.
/// Uses focused services that follow SRP to manage lifecycle and execution.
/// </summary>
public async ValueTask ExecuteAsync(AbstractExecutableTest executableTest, TestInitializer testInitializer, CancellationToken cancellationToken)
public async ValueTask ExecuteAsync(AbstractExecutableTest executableTest, TestInitializer testInitializer, CancellationToken cancellationToken, TimeSpan? testTimeout = null)
{

var testClass = executableTest.Metadata.TestClassType;
Expand Down Expand Up @@ -132,10 +132,19 @@ await Timings.Record("BeforeTest", executableTest.Context,

executableTest.Context.RestoreExecutionContext();

// Timeout is now enforced at TestCoordinator level (wrapping entire lifecycle)
// Only the test body is subject to the [Timeout] — hooks and data source
// initialization run outside the timeout scope (fixes #4772)
try
{
await ExecuteTestAsync(executableTest, cancellationToken).ConfigureAwait(false);
var timeoutMessage = testTimeout.HasValue
? $"Test '{executableTest.Context.Metadata.TestDetails.TestName}' timed out after {testTimeout.Value}"
: null;

await TimeoutHelper.ExecuteWithTimeoutAsync(
ct => ExecuteTestAsync(executableTest, ct),
testTimeout,
cancellationToken,
timeoutMessage).ConfigureAwait(false);
}
finally
{
Expand Down
Loading