diff --git a/TUnit.Core/Attributes/TestData/AsyncDependencyInjectionDataSourceSourceAttribute.cs b/TUnit.Core/Attributes/TestData/AsyncDependencyInjectionDataSourceSourceAttribute.cs deleted file mode 100644 index 3d7ea2468b..0000000000 --- a/TUnit.Core/Attributes/TestData/AsyncDependencyInjectionDataSourceSourceAttribute.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System.Diagnostics.CodeAnalysis; - -namespace TUnit.Core; - -public abstract class DependencyInjectionDataSourceAttribute : UntypedDataSourceGeneratorAttribute -{ - protected override IEnumerable> GenerateDataSources(DataGeneratorMetadata dataGeneratorMetadata) - { - yield return () => - { - // Create a new scope for each test execution - var scope = CreateScope(dataGeneratorMetadata); - - // Set up disposal for this specific scope in the current test context - dataGeneratorMetadata.TestBuilderContext.Current.Events.OnDispose += async (_, _) => - { - if (scope is IAsyncDisposable asyncDisposable) - { - await asyncDisposable.DisposeAsync().ConfigureAwait(false); - } - else if (scope is IDisposable disposable) - { - disposable.Dispose(); - } - }; - - return dataGeneratorMetadata.MembersToGenerate - .Select(m => m switch - { - PropertyMetadata prop => prop.Type, - ParameterMetadata param => param.Type, - ClassMetadata cls => cls.Type, - MethodMetadata method => method.Type, - _ => throw new InvalidOperationException($"Unknown member type: {m.GetType()}") - }) - .Select(x => Create(scope, x)) - .ToArray(); - }; - } - - public abstract TScope CreateScope(DataGeneratorMetadata dataGeneratorMetadata); - - public abstract object? Create(TScope scope, Type type); -} diff --git a/TUnit.Engine/Helpers/TimeoutHelper.cs b/TUnit.Engine/Helpers/TimeoutHelper.cs index 13d8b06057..2e21204d9f 100644 --- a/TUnit.Engine/Helpers/TimeoutHelper.cs +++ b/TUnit.Engine/Helpers/TimeoutHelper.cs @@ -10,6 +10,43 @@ internal static class TimeoutHelper /// private static readonly TimeSpan GracePeriod = TimeSpan.FromSeconds(1); + /// + /// Executes a ValueTask-returning operation with an optional timeout. + /// On the fast path (no timeout), returns the ValueTask directly without Task allocation. + /// + public static ValueTask ExecuteWithTimeoutAsync( + Func 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 valueTaskFactory, + TimeSpan timeout, + CancellationToken cancellationToken, + string? timeoutMessage) + { + await ExecuteWithTimeoutAsync( + async ct => + { + await valueTaskFactory(ct).ConfigureAwait(false); + return true; + }, + timeout, + cancellationToken, + timeoutMessage).ConfigureAwait(false); + } + /// /// 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. diff --git a/TUnit.Engine/Services/TestExecution/TestCoordinator.cs b/TUnit.Engine/Services/TestExecution/TestCoordinator.cs index 2a761b8f15..84e252c927 100644 --- a/TUnit.Engine/Services/TestExecution/TestCoordinator.cs +++ b/TUnit.Engine/Services/TestExecution/TestCoordinator.cs @@ -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); } @@ -256,7 +250,8 @@ await _eventReceiverOrchestrator.InvokeLastTestInSessionEventReceiversAsync( /// /// 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). /// private async ValueTask ExecuteTestLifecycleAsync(AbstractExecutableTest test, CancellationToken cancellationToken) { @@ -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 { diff --git a/TUnit.Engine/Services/TestGroupingService.cs b/TUnit.Engine/Services/TestGroupingService.cs index 21eb1c045a..7dead59b89 100644 --- a/TUnit.Engine/Services/TestGroupingService.cs +++ b/TUnit.Engine/Services/TestGroupingService.cs @@ -97,26 +97,26 @@ public async ValueTask GroupTestsByConstraintsAsync(IEnumerable 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); } } diff --git a/TUnit.Engine/TestExecutor.cs b/TUnit.Engine/TestExecutor.cs index dcb9cf2bc4..425160c71d 100644 --- a/TUnit.Engine/TestExecutor.cs +++ b/TUnit.Engine/TestExecutor.cs @@ -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. /// - 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; @@ -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 {