diff --git a/docs/docs/execution/parallelism.md b/docs/docs/execution/parallelism.md index f52a7f0f17e..a9245df8b0b 100644 --- a/docs/docs/execution/parallelism.md +++ b/docs/docs/execution/parallelism.md @@ -131,6 +131,8 @@ Tests not assigned to any group run separately under normal parallel execution r The limit is shared across **all** tests referencing the same `IParallelLimit` type. Tests with a different limiter type or no limiter are unaffected. +An explicit `[ParallelLimiter]` always takes precedence over a limiter set programmatically through `TestRegisteredContext.SetParallelLimiter`, including one supplied by an executor. This precedence does not depend on registration callback order. Without an explicit attribute, the last programmatic limiter applies. + ```csharp using TUnit.Core; diff --git a/docs/docs/writing-tests/event-subscribing.md b/docs/docs/writing-tests/event-subscribing.md index 43cccf5be23..d622884acbb 100644 --- a/docs/docs/writing-tests/event-subscribing.md +++ b/docs/docs/writing-tests/event-subscribing.md @@ -21,6 +21,14 @@ The interfaces they can implement are: This can be useful especially when generating data that you need to track and maybe dispose later. By hooking into these events, we can do things like track and dispose our objects when we need. +## Registration callbacks for executors + +An executor that implements `ITestRegisteredEventReceiver` receives `OnTestRegistered` when a registration receiver installs it through `SetTestExecutor` or `SetHookExecutor`, unless it has already received the callback for that test. Its callback runs after the installing receiver returns. Dynamically installed executors are not globally sorted by their own `Order`. + +Each receiver instance receives this callback at most once per test, using reference identity. This also applies when the same instance is already an eligible event object, installs itself, or is installed as both the test and hook executor. Distinct instances that compare equal still receive separate callbacks. + +An explicit `[ParallelLimiter]` takes precedence over a limiter set through `TestRegisteredContext.SetParallelLimiter`, including one set by an executor, regardless of callback order. + ## Execution Stage Control > **Note**: This feature is available on .NET 8.0+ only due to default interface member requirements. diff --git a/src/TUnit.Core/Attributes/ParallelLimiterAttribute.cs b/src/TUnit.Core/Attributes/ParallelLimiterAttribute.cs index 5a3ae088149..60b23872bfb 100644 --- a/src/TUnit.Core/Attributes/ParallelLimiterAttribute.cs +++ b/src/TUnit.Core/Attributes/ParallelLimiterAttribute.cs @@ -57,9 +57,9 @@ public sealed class ParallelLimiterAttribute : TUnitAttribute, I public int Order => 0; /// -public ValueTask OnTestRegistered(TestRegisteredContext context) + public ValueTask OnTestRegistered(TestRegisteredContext context) { - context.SetParallelLimiter(new TParallelLimit()); + context.SetExplicitParallelLimiter(new TParallelLimit()); return default(ValueTask); } } diff --git a/src/TUnit.Core/Contexts/TestRegisteredContext.cs b/src/TUnit.Core/Contexts/TestRegisteredContext.cs index 7c0c462760b..23a9fe4c884 100644 --- a/src/TUnit.Core/Contexts/TestRegisteredContext.cs +++ b/src/TUnit.Core/Contexts/TestRegisteredContext.cs @@ -1,4 +1,5 @@ using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; using TUnit.Core.Interfaces; namespace TUnit.Core; @@ -8,6 +9,10 @@ namespace TUnit.Core; /// public class TestRegisteredContext { + private List? _executorReceivers; + private int _dispatchedExecutorReceiverCount; + private IParallelLimit? _explicitParallelLimiter; + public string TestName { get; } public string? CustomDisplayName { get; } public TestContext TestContext { get; } @@ -37,6 +42,7 @@ public TestRegisteredContext(TestContext testContext) public void SetTestExecutor(ITestExecutor executor) { DiscoveredTest.TestExecutor = executor; + QueueExecutorEventReceiver(executor); } /// @@ -46,13 +52,21 @@ public void SetTestExecutor(ITestExecutor executor) public void SetHookExecutor(IHookExecutor executor) { TestContext.CustomHookExecutor = executor; + QueueExecutorEventReceiver(executor); } /// - /// Sets the parallel limiter for the test + /// Sets the programmatic parallel limiter for the test. An explicit + /// takes precedence regardless of callback order. /// public void SetParallelLimiter(IParallelLimit parallelLimit) { + TestContext.ParallelLimiter = _explicitParallelLimiter ?? parallelLimit; + } + + internal void SetExplicitParallelLimiter(IParallelLimit parallelLimit) + { + _explicitParallelLimiter = parallelLimit; TestContext.ParallelLimiter = parallelLimit; } @@ -66,4 +80,52 @@ public void SetSkipped(string reason) TestContext.SkipReason = reason; TestContext.Metadata.TestDetails.ClassInstance = SkippedTestInstance.Instance; } + + /// + /// Queues an executor that is itself an for dispatch. + /// + /// The executor a registration receiver has just installed. + /// + /// An executor installed during registration may not have been collected by the engine's + /// eligible-object pass. An executor that is both an and an + /// arrives through two calls and is queued once. + /// + private void QueueExecutorEventReceiver(object executor) + { + if (executor is not ITestRegisteredEventReceiver receiver) + { + return; + } + + _executorReceivers ??= []; + + // Identity, not equality: two distinct executors that compare equal each own their callback. + foreach (var queued in _executorReceivers) + { + if (ReferenceEquals(queued, receiver)) + { + return; + } + } + + _executorReceivers.Add(receiver); + } + + /// + /// Dequeues the next executor awaiting its registration callback. + /// + /// When this method returns, contains the executor to dispatch to. + /// if an executor was awaiting dispatch; otherwise, . + /// Each executor is returned once, however many times it was installed. + internal bool TryDequeueExecutorEventReceiver([NotNullWhen(true)] out ITestRegisteredEventReceiver? receiver) + { + if (_executorReceivers is null || _dispatchedExecutorReceiverCount == _executorReceivers.Count) + { + receiver = null; + return false; + } + + receiver = _executorReceivers[_dispatchedExecutorReceiverCount++]; + return true; + } } diff --git a/src/TUnit.Core/Interfaces/ITestRegisteredEventReceiver.cs b/src/TUnit.Core/Interfaces/ITestRegisteredEventReceiver.cs index 9b9b76776bd..b1af3c5fb6a 100644 --- a/src/TUnit.Core/Interfaces/ITestRegisteredEventReceiver.cs +++ b/src/TUnit.Core/Interfaces/ITestRegisteredEventReceiver.cs @@ -21,6 +21,13 @@ namespace TUnit.Core.Interfaces; /// The property can be used to control the execution order /// when multiple implementations of this interface exist. /// +/// +/// Executors installed through or +/// during registration are invoked after +/// their installing receiver returns, rather than being globally sorted by their own Order. +/// Each receiver instance is invoked at most once per test, even when it is both an eligible +/// event object and an installed executor. Distinct instances are compared by reference identity. +/// /// public interface ITestRegisteredEventReceiver : IEventReceiver { diff --git a/src/TUnit.Core/TestContext.Execution.cs b/src/TUnit.Core/TestContext.Execution.cs index ddb1a35d5ce..04b803e3bcf 100644 --- a/src/TUnit.Core/TestContext.Execution.cs +++ b/src/TUnit.Core/TestContext.Execution.cs @@ -20,9 +20,10 @@ public partial class TestContext private volatile bool _testCancellationRequested; private List? _externalCancellationTokens; private CancellationTokenSource? _linkedCancellationTokenSource; + private CancellationToken _linkedCancellationBaseToken; // Token copies can escape into user code and remain in use through teardown (#6339). // Keep replaced sources alive until the complete test lifecycle has finished. - private List? _retiredLinkedCancellationTokenSources; + private List<(CancellationTokenSource Source, CancellationToken BaseToken)>? _retiredLinkedCancellationTokenSources; internal CancellationToken CancellationToken { get; private set; } // Linked source backing the per-test timeout token. Owned for the whole test lifecycle — the body @@ -297,13 +298,42 @@ private void RebuildLinkedCancellationTokenSource() if (_linkedCancellationTokenSource is { } previousLinkedCancellationTokenSource) { - (_retiredLinkedCancellationTokenSources ??= []).Add(previousLinkedCancellationTokenSource); + (_retiredLinkedCancellationTokenSources ??= []).Add((previousLinkedCancellationTokenSource, _linkedCancellationBaseToken)); } _linkedCancellationTokenSource = linkedCancellationTokenSource; + _linkedCancellationBaseToken = _baseCancellationToken; CancellationToken = linkedCancellationTokenSource.Token; } + internal bool IsLinkedCancellationToken(CancellationToken token, CancellationToken baseToken) + { + lock (Lock) + { + if (_linkedCancellationTokenSource is { } currentSource + && _linkedCancellationBaseToken == baseToken + && currentSource.Token == token) + { + return true; + } + + // A test can still use a captured token after another linked token or retry replaces it. + // Match its original base as well, so earlier attempts cannot become the current timeout. + if (_retiredLinkedCancellationTokenSources is { } retiredSources) + { + foreach (var retired in retiredSources) + { + if (retired.BaseToken == baseToken && retired.Source.Token == token) + { + return true; + } + } + } + + return false; + } + } + internal void DisposeLinkedCancellationTokenSources() { lock (Lock) @@ -329,7 +359,7 @@ private void DisposeCancellationTokenSourcesUnderLock() { for (var i = retiredLinkedCancellationTokenSources.Count - 1; i >= 0; i--) { - retiredLinkedCancellationTokenSources[i].Dispose(); + retiredLinkedCancellationTokenSources[i].Source.Dispose(); } _retiredLinkedCancellationTokenSources = null; diff --git a/src/TUnit.Engine/Helpers/TimeoutHelper.cs b/src/TUnit.Engine/Helpers/TimeoutHelper.cs index 1b997f4b519..f8f44ce0d84 100644 --- a/src/TUnit.Engine/Helpers/TimeoutHelper.cs +++ b/src/TUnit.Engine/Helpers/TimeoutHelper.cs @@ -1,3 +1,4 @@ +using TUnit.Core; using TUnit.Engine.Constants; namespace TUnit.Engine.Helpers; @@ -55,6 +56,7 @@ public static async Task ExecuteWithTimeoutAsync( /// Caller-owned CTS, already linked to . Not disposed here. /// The external token the CTS is linked to; used to distinguish timeout from external cancellation. /// Optional custom timeout message. If null, uses default message. + /// Tracks execution tokens linked to the timeout token, including captured tokens replaced during execution. /// Thrown when the timeout elapses before task completion. /// Thrown when cancellation is requested. public static async Task ExecuteWithTimeoutAsync( @@ -62,7 +64,8 @@ public static async Task ExecuteWithTimeoutAsync( TimeSpan timeout, CancellationTokenSource timeoutCts, CancellationToken externalToken, - string? timeoutMessage = null) + string? timeoutMessage = null, + TestContext? testContext = null) { // Set up cancellation detection BEFORE scheduling timeout to avoid race condition // where timeout fires before registration completes (with very small timeouts) @@ -78,39 +81,53 @@ public static async Task ExecuteWithTimeoutAsync( var winner = await Task.WhenAny(executionTask, cancelledTcs.Task).ConfigureAwait(false); - if (winner == cancelledTcs.Task) + if (winner == executionTask) { - // Determine if it was external cancellation or timeout - if (externalToken.IsCancellationRequested) + try + { + await executionTask.ConfigureAwait(false); + return; + } + catch (OperationCanceledException exception) + when (timeoutCts.IsCancellationRequested + && IsTimeoutCancellationToken(exception.CancellationToken, timeoutCts.Token, testContext)) { - throw new OperationCanceledException(externalToken); + // The operation can observe cancellation before the detection task wins WhenAny. + // Classify that cancellation through the same timeout/external-cancellation path. } + } - // Timeout occurred - give the execution task a brief grace period to clean up - var executionException = await ObserveExceptionDuringGracePeriodAsync(executionTask).ConfigureAwait(false); + // Determine if it was external cancellation or timeout + if (externalToken.IsCancellationRequested) + { + throw new OperationCanceledException(externalToken); + } - // Routine cancellation adds no useful context; preserve exceptions explicitly - // thrown while handling cancellation, such as Aspire's diagnostic exception. - var exceptionToPreserve = IsRoutineCancellation(executionException, timeoutCts.Token) - ? null - : executionException; + // Timeout occurred - give the execution task a brief grace period to clean up + var executionException = await ObserveExceptionDuringGracePeriodAsync(executionTask).ConfigureAwait(false); - // Even if task completed during grace period, timeout already elapsed so we throw - var baseMessage = timeoutMessage ?? $"Operation timed out after {timeout}"; - var diagnosticMessage = TimeoutDiagnostics.BuildTimeoutDiagnosticsMessage(baseMessage, executionTask, exceptionToPreserve); - throw new TimeoutException(diagnosticMessage, exceptionToPreserve); - } + // Routine cancellation adds no useful context; preserve exceptions explicitly + // thrown while handling cancellation, such as Aspire's diagnostic exception. + var exceptionToPreserve = IsRoutineCancellation(executionException, timeoutCts.Token, testContext) + ? null + : executionException; - await executionTask.ConfigureAwait(false); + // Even if task completed during grace period, timeout already elapsed so we throw + var baseMessage = timeoutMessage ?? $"Operation timed out after {timeout}"; + var diagnosticMessage = TimeoutDiagnostics.BuildTimeoutDiagnosticsMessage(baseMessage, executionTask, exceptionToPreserve); + throw new TimeoutException(diagnosticMessage, exceptionToPreserve); } - private static bool IsRoutineCancellation(Exception? exception, CancellationToken timeoutToken) + private static bool IsTimeoutCancellationToken(CancellationToken token, CancellationToken timeoutToken, TestContext? testContext) + => token == timeoutToken || testContext?.IsLinkedCancellationToken(token, timeoutToken) == true; + + private static bool IsRoutineCancellation(Exception? exception, CancellationToken timeoutToken, TestContext? testContext) { if (exception is not OperationCanceledException { InnerException: null } operationCanceledException - || operationCanceledException.CancellationToken != timeoutToken) + || !IsTimeoutCancellationToken(operationCanceledException.CancellationToken, timeoutToken, testContext)) { return false; } diff --git a/src/TUnit.Engine/Services/TestFilterService.cs b/src/TUnit.Engine/Services/TestFilterService.cs index ca49f71696e..d7830dccafe 100644 --- a/src/TUnit.Engine/Services/TestFilterService.cs +++ b/src/TUnit.Engine/Services/TestFilterService.cs @@ -61,6 +61,26 @@ public IReadOnlyCollection FilterTests(ITestExecutionFil return filteredTests; } + private async Task InvokeTestRegisteredReceiver(ITestRegisteredEventReceiver receiver, TestRegisteredContext context, + HashSet invokedReceivers) + { + // Record identity before invoking: a receiver can install itself as an executor. + if (!invokedReceivers.Add(receiver)) + { + return; + } + + try + { + await receiver.OnTestRegistered(context).ConfigureAwait(false); + } + catch (Exception ex) + { + await logger.LogErrorAsync($"Error in test registered event receiver: {ex.Message}").ConfigureAwait(false); + throw; + } + } + private async Task RegisterTest(AbstractExecutableTest test, bool isForExecution) { var registeredReceivers = test.Context.GetTestRegisteredReceivers(); @@ -83,17 +103,17 @@ private async Task RegisterTest(AbstractExecutableTest test, bool isForExecution }; test.Context.InternalDiscoveredTest = discoveredTest; + var invokedReceivers = new HashSet(Core.Helpers.ReferenceEqualityComparer.Instance); foreach (var receiver in registeredReceivers) { - try - { - await receiver.OnTestRegistered(registeredContext).ConfigureAwait(false); - } - catch (Exception ex) + await InvokeTestRegisteredReceiver(receiver, registeredContext, invokedReceivers).ConfigureAwait(false); + + // Dynamically installed executor callbacks run after their installer, regardless of their + // own Order. The shared identity set also covers executors in the original receiver list. + while (registeredContext.TryDequeueExecutorEventReceiver(out var executorReceiver)) { - await logger.LogErrorAsync($"Error in test registered event receiver: {ex.Message}").ConfigureAwait(false); - throw; + await InvokeTestRegisteredReceiver(executorReceiver, registeredContext, invokedReceivers).ConfigureAwait(false); } } } diff --git a/src/TUnit.Engine/TestExecutor.cs b/src/TUnit.Engine/TestExecutor.cs index 4ca09252de6..4450657ab08 100644 --- a/src/TUnit.Engine/TestExecutor.cs +++ b/src/TUnit.Engine/TestExecutor.cs @@ -367,7 +367,8 @@ await TimeoutHelper.ExecuteWithTimeoutAsync( testTimeout.Value, testBodyTimeoutCts, testCancellationToken, - timeoutMessage).ConfigureAwait(false); + timeoutMessage, + context).ConfigureAwait(false); } else { diff --git a/tests/TUnit.Engine.Tests/TimeoutTests1.cs b/tests/TUnit.Engine.Tests/TimeoutTests1.cs index 1274a03bf38..f6fc4ea7e26 100644 --- a/tests/TUnit.Engine.Tests/TimeoutTests1.cs +++ b/tests/TUnit.Engine.Tests/TimeoutTests1.cs @@ -5,6 +5,27 @@ namespace TUnit.Engine.Tests; public class TimeoutTests1(TestMode testMode) : InvokableTestBase(testMode) { + [Test] + public async Task LinkedTokensAreReportedAsTimeouts() + { + await RunTestsWithFilter( + "/*/*/LinkedTokenTimeoutTests/*", + [ + result => result.ResultSummary.Counters.Total.ShouldBe(3), + result => result.ResultSummary.Counters.Failed.ShouldBe(3), + result => + { + foreach (var test in result.Results) + { + test.Output!.ErrorInfo!.Message.ToLowerInvariant().ShouldContain("timed out"); + } + + result.Results.Single(test => test.TestName.Contains("CustomLinkedCancellationDiagnostic")) + .Output!.ErrorInfo!.Message.ShouldContain("Linked timeout diagnostic"); + } + ]); + } + [Test] public async Task Test() { diff --git a/tests/TUnit.TestProject/Bugs/6767/ExecutorEventReceiverTests.cs b/tests/TUnit.TestProject/Bugs/6767/ExecutorEventReceiverTests.cs new file mode 100644 index 00000000000..49ce7dff3c2 --- /dev/null +++ b/tests/TUnit.TestProject/Bugs/6767/ExecutorEventReceiverTests.cs @@ -0,0 +1,145 @@ +using TUnit.Core.Executors; +using TUnit.Core.Interfaces; +using TUnit.TestProject.Attributes; + +namespace TUnit.TestProject.Bugs._6767; + +/// +/// Regression test for https://github.com/thomhurst/TUnit/issues/6767 +/// An executor that also implements ITestRegisteredEventReceiver must receive +/// OnTestRegistered, so the parallel limit it declares during registration is applied. +/// +[EngineTest(ExpectedResult.Pass)] +[TestExecutor] +public class ExecutorEventReceiverTests +{ + private static int s_concurrent; + private static int s_peak; + + [Before(Class)] + public static void Reset() + { + s_concurrent = 0; + s_peak = 0; + } + + [Test] + public async Task ExecutorSetsTheLimiter() + { + await Assert.That(TestContext.Current!.Parallelism.Limiter).IsTypeOf(); + } + + [Test, Repeat(9)] + public Task Measure() => MeasureAsync(); + + [Test, DependsOn(nameof(Measure))] + public async Task PeakIsOne() + { + await Assert.That(s_peak).IsEqualTo(1); + } + + private static async Task MeasureAsync() + { + var current = Interlocked.Increment(ref s_concurrent); + + int old; + do + { + old = s_peak; + if (current <= old) + { + break; + } + } + while (Interlocked.CompareExchange(ref s_peak, current, old) != old); + + await Task.Delay(50).ConfigureAwait(false); + + Interlocked.Decrement(ref s_concurrent); + } +} + +/// +/// An executor is installed by an attribute part-way through registration, so it can only +/// declare its parallel limit through its own ITestRegisteredEventReceiver implementation. +/// +internal sealed class SerialExecutor : GenericAbstractExecutor, ITestRegisteredEventReceiver +{ + public int Order => 0; + + public ValueTask OnTestRegistered(TestRegisteredContext context) + { + context.SetParallelLimiter(new SerialLimit()); + return default; + } + + protected override ValueTask ExecuteAsync(Func action) => action(); +} + +internal sealed class SerialLimit : IParallelLimit +{ + public int Limit => 1; +} + +/// +/// Two distinct executors that compare equal each own their registration callback, so the queue +/// behind that dispatch has to hold them apart by reference and not by equality. +/// +[EngineTest(ExpectedResult.Pass)] +[TwoEqualExecutors] +public class EqualExecutorInstanceTests +{ + [Test] + public async Task BothInstancesAreRegistered() + { + using (Assert.Multiple()) + { + await Assert.That(TwoEqualExecutorsAttribute.TestExecutor!.IsRegistered).IsTrue(); + await Assert.That(TwoEqualExecutorsAttribute.HookExecutor!.IsRegistered).IsTrue(); + } + } +} + +internal sealed class TwoEqualExecutorsAttribute : Attribute, ITestRegisteredEventReceiver +{ + public static EqualByTypeExecutor? TestExecutor { get; private set; } + public static EqualByTypeExecutor? HookExecutor { get; private set; } + + public int Order => 0; + + public ValueTask OnTestRegistered(TestRegisteredContext context) + { + var testExecutor = new EqualByTypeExecutor(); + var hookExecutor = new EqualByTypeExecutor(); + + context.SetTestExecutor(testExecutor); + context.SetHookExecutor(hookExecutor); + + TestExecutor = testExecutor; + HookExecutor = hookExecutor; + + return default; + } +} + +/// +/// Every instance compares equal to every other, so a queue keyed on equality would drop the second. +/// +internal sealed class EqualByTypeExecutor : GenericAbstractExecutor, ITestRegisteredEventReceiver +{ + public bool IsRegistered { get; private set; } + + public int Order => 0; + + public ValueTask OnTestRegistered(TestRegisteredContext context) + { + IsRegistered = true; + return default; + } + + public override bool Equals(object? obj) => obj is EqualByTypeExecutor; + + public override int GetHashCode() => nameof(EqualByTypeExecutor).GetHashCode(); + + protected override ValueTask ExecuteAsync(Func action) => action(); +} diff --git a/tests/TUnit.TestProject/Bugs/6767/RegistrationReceiverRegressionTests.cs b/tests/TUnit.TestProject/Bugs/6767/RegistrationReceiverRegressionTests.cs new file mode 100644 index 00000000000..067281284ce --- /dev/null +++ b/tests/TUnit.TestProject/Bugs/6767/RegistrationReceiverRegressionTests.cs @@ -0,0 +1,186 @@ +using TUnit.Core.Executors; +using TUnit.Core.Interfaces; +using TUnit.TestProject.Attributes; + +namespace TUnit.TestProject.Bugs._6767; + +[EngineTest(ExpectedResult.Pass)] +public class ExecutorReceiverOverlapTests +{ + [Test] + [EligibleExecutor] + [InstallEligibleExecutor(-100)] + public async Task InstalledBeforeOriginalReceiver() + { + await Assert.That(TestContext.Current!.StateBag.Items["EligibleRegistrationCount"]).IsEqualTo(1); + await Assert.That((bool)TestContext.Current.StateBag.Items["AlreadyRegisteredAtInstallation"]!).IsFalse(); + } + + [Test] + [EligibleExecutor] + [InstallEligibleExecutor(100)] + public async Task InstalledAfterOriginalReceiver() + { + await Assert.That(TestContext.Current!.StateBag.Items["EligibleRegistrationCount"]).IsEqualTo(1); + await Assert.That((bool)TestContext.Current.StateBag.Items["AlreadyRegisteredAtInstallation"]!).IsTrue(); + } +} + +internal sealed class EligibleExecutorAttribute : Attribute, ITestExecutor, ITestRegisteredEventReceiver +{ + public int Order => 0; + + public ValueTask OnTestRegistered(TestRegisteredContext context) + { + context.StateBag.AddOrUpdate("EligibleRegistrationCount", 1, static (_, count) => (int)count! + 1); + return default; + } + + public ValueTask ExecuteTest(TestContext context, Func action) => action(); +} + +internal sealed class InstallEligibleExecutorAttribute(int order) : Attribute, ITestRegisteredEventReceiver +{ + public int Order => order; + + public ValueTask OnTestRegistered(TestRegisteredContext context) + { + var executor = context.TestDetails.GetAllAttributes().OfType().Single(); + context.StateBag["AlreadyRegisteredAtInstallation"] = context.StateBag.ContainsKey("EligibleRegistrationCount"); + context.SetTestExecutor(executor); + return default; + } +} + +[EngineTest(ExpectedResult.Pass)] +public class SelfInstallingExecutorTests +{ + [Test] + [SelfInstallingExecutor] + public async Task AttributeInstallsItselfOnlyOnce() + { + await Assert.That(TestContext.Current!.StateBag.Items["SelfRegistrationCount"]).IsEqualTo(1); + } +} + +internal sealed class SelfInstallingExecutorAttribute : Attribute, ITestExecutor, ITestRegisteredEventReceiver +{ + public int Order => 0; + + public ValueTask OnTestRegistered(TestRegisteredContext context) + { + context.StateBag.AddOrUpdate("SelfRegistrationCount", 1, static (_, count) => (int)count! + 1); + context.SetTestExecutor(this); + return default; + } + + public ValueTask ExecuteTest(TestContext context, Func action) => action(); +} + +[EngineTest(ExpectedResult.Pass)] +[TestExecutor] +public class ClassExecutorLimiterPrecedenceTests +{ + [Test] + [ParallelLimiter] + public async Task MethodLimiterOverridesClassExecutor() + { + await Assert.That(TestContext.Current!.Parallelism.Limiter).IsTypeOf(); + await Assert.That((bool)TestContext.Current.StateBag.Items["ExecutorSawExplicitLimiter"]!).IsTrue(); + } + + [Test] + public async Task ExecutorLimiterAppliesWithoutExplicitAttribute() + { + await Assert.That(TestContext.Current!.Parallelism.Limiter).IsTypeOf(); + await Assert.That(TestContext.Current.StateBag.Items["WideExecutorRegistrationCount"]).IsEqualTo(1); + } + + [Test] + [TestExecutor] + [ParallelLimiter] + public async Task ExplicitLimiterCanIncreaseExecutorLimit() + { + await Assert.That(TestContext.Current!.Parallelism.Limiter).IsTypeOf(); + } +} + +[EngineTest(ExpectedResult.Pass)] +public class ExplicitLimiterPrecedenceTests +{ + [Test] + [ParallelLimiter] + [SetWideLimiter(-100, false)] + public Task ProgrammaticLimiterBeforeAttribute() => AssertExplicitLimiter(false, false); + + [Test] + [ParallelLimiter] + [SetWideLimiter(100, false)] + public Task ProgrammaticLimiterAfterAttribute() => AssertExplicitLimiter(true, false); + + [Test] + [ParallelLimiter] + [SetWideLimiter(-100, true)] + public Task ExecutorBeforeAttribute() => AssertExplicitLimiter(false, true); + + [Test] + [ParallelLimiter] + [SetWideLimiter(100, true)] + public Task ExecutorAfterAttribute() => AssertExplicitLimiter(true, true); + + private static async Task AssertExplicitLimiter(bool explicitLimiterWasAlreadySet, bool executorInstalled) + { + var context = TestContext.Current!; + await Assert.That(context.Parallelism.Limiter).IsTypeOf(); + await Assert.That(context.StateBag.Items["ProgrammaticReceiverSawExplicitLimiter"]) + .IsEqualTo(explicitLimiterWasAlreadySet); + if (executorInstalled) + { + await Assert.That(context.StateBag.Items["ExecutorSawExplicitLimiter"]).IsEqualTo(explicitLimiterWasAlreadySet); + await Assert.That(context.StateBag.Items["WideExecutorRegistrationCount"]).IsEqualTo(1); + } + } +} + +internal sealed class SetWideLimiterAttribute(int order, bool installExecutor) : Attribute, ITestRegisteredEventReceiver +{ + public int Order => order; + + public ValueTask OnTestRegistered(TestRegisteredContext context) + { + context.StateBag["ProgrammaticReceiverSawExplicitLimiter"] = context.TestContext.Parallelism.Limiter is SerialLimit; + if (installExecutor) + { + var executor = new WideExecutor(); + context.SetTestExecutor(executor); + context.SetHookExecutor(executor); + } + else + { + context.SetParallelLimiter(new WideLimit()); + } + + return default; + } +} + +internal sealed class WideExecutor : GenericAbstractExecutor, ITestRegisteredEventReceiver +{ + // Dynamic dispatch follows the installer, even when this Order would place it later. + int IEventReceiver.Order => 1000; + + public ValueTask OnTestRegistered(TestRegisteredContext context) + { + context.StateBag["ExecutorSawExplicitLimiter"] = context.TestContext.Parallelism.Limiter is SerialLimit; + context.StateBag.AddOrUpdate("WideExecutorRegistrationCount", 1, static (_, count) => (int)count! + 1); + context.SetParallelLimiter(new WideLimit()); + return default; + } + + protected override ValueTask ExecuteAsync(Func action) => action(); +} + +internal sealed class WideLimit : IParallelLimit +{ + public int Limit => 8; +} diff --git a/tests/TUnit.TestProject/LinkedTokenTimeoutTests.cs b/tests/TUnit.TestProject/LinkedTokenTimeoutTests.cs new file mode 100644 index 00000000000..27145379ab8 --- /dev/null +++ b/tests/TUnit.TestProject/LinkedTokenTimeoutTests.cs @@ -0,0 +1,46 @@ +using TUnit.Core.Executors; +using TUnit.Core.Interfaces; +using TUnit.TestProject.Attributes; + +namespace TUnit.TestProject; + +[EngineTest(ExpectedResult.Failure)] +[TestExecutor] +[Timeout(1000)] +public class LinkedTokenTimeoutTests +{ + [Test] + public async Task CurrentLinkedToken(CancellationToken cancellationToken) + { + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + } + + [Test] + public async Task CapturedLinkedToken(CancellationToken cancellationToken) + { + TestContext.Current!.Execution.AddLinkedCancellationToken(CancellationToken.None); + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + } + + [Test] + public async Task CustomLinkedCancellationDiagnostic(CancellationToken cancellationToken) + { + try + { + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + } + catch (OperationCanceledException) + { + throw new OperationCanceledException("Linked timeout diagnostic", cancellationToken); + } + } +} + +internal sealed class LinkedTimeoutExecutor : ITestExecutor +{ + public ValueTask ExecuteTest(TestContext context, Func action) + { + context.Execution.AddLinkedCancellationToken(CancellationToken.None); + return action(); + } +} diff --git a/tests/TUnit.UnitTests/TimeoutHelperTests.cs b/tests/TUnit.UnitTests/TimeoutHelperTests.cs index 25d175e1f66..87925167409 100644 --- a/tests/TUnit.UnitTests/TimeoutHelperTests.cs +++ b/tests/TUnit.UnitTests/TimeoutHelperTests.cs @@ -1,3 +1,4 @@ +using TUnit.Core; using TUnit.Engine.Helpers; namespace TUnit.UnitTests; @@ -5,28 +6,24 @@ namespace TUnit.UnitTests; public class TimeoutHelperTests { [Test] - public async Task Timeout_Preserves_Exception_Thrown_During_Cancellation() + [Arguments(true)] + [Arguments(false)] + public async Task Timeout_Preserves_Exception_Thrown_During_Cancellation(bool executionCompletesFirst) { const string cancellationMessage = "Failed due to XYZ"; - var exception = await Assert.That(async () => - await TimeoutHelper.ExecuteWithTimeoutAsync( + var exception = await Assert.That(() => ExecuteWithControlledTimeoutAsync( async cancellationToken => { try { - await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + await Task.FromCanceled(cancellationToken); } catch (OperationCanceledException ex) { - // Keep execution incomplete long enough for timeout detection to win before - // cancellation diagnostics finish, matching the Aspire failure in #6688. - await Task.Delay(50); throw new OperationCanceledException(cancellationMessage, ex.CancellationToken); } - }, - TimeSpan.FromMilliseconds(50), - CancellationToken.None)) + }, executionCompletesFirst)) .ThrowsExactly(); await Assert.That(exception!.Message).Contains(cancellationMessage); @@ -35,23 +32,22 @@ await TimeoutHelper.ExecuteWithTimeoutAsync( } [Test] - public async Task Timeout_Does_Not_Preserve_Routine_Operation_Cancellation() + [Arguments(true)] + [Arguments(false)] + public async Task Timeout_Does_Not_Preserve_Routine_Operation_Cancellation(bool executionCompletesFirst) { - var exception = await Assert.That(async () => - await TimeoutHelper.ExecuteWithTimeoutAsync( + var exception = await Assert.That(() => ExecuteWithControlledTimeoutAsync( async cancellationToken => { try { - await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + await Task.FromCanceled(cancellationToken); } catch (OperationCanceledException) { cancellationToken.ThrowIfCancellationRequested(); } - }, - TimeSpan.FromMilliseconds(50), - CancellationToken.None)) + }, executionCompletesFirst)) .ThrowsExactly(); await Assert.That(exception!.InnerException).IsNull(); @@ -59,31 +55,225 @@ await TimeoutHelper.ExecuteWithTimeoutAsync( } [Test] - public async Task Timeout_Preserves_Custom_Task_Cancellation() + [Arguments(true)] + [Arguments(false)] + public async Task Timeout_Preserves_Custom_Task_Cancellation(bool executionCompletesFirst) { const string cancellationMessage = "Custom task cancellation diagnostic"; var diagnosticException = new InvalidOperationException("Inner diagnostic"); - var exception = await Assert.That(async () => - await TimeoutHelper.ExecuteWithTimeoutAsync( + var exception = await Assert.That(() => ExecuteWithControlledTimeoutAsync( async cancellationToken => { try { - await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + await Task.FromCanceled(cancellationToken); } catch (OperationCanceledException) { - await Task.Delay(50); throw new TaskCanceledException(cancellationMessage, diagnosticException, cancellationToken); } - }, - TimeSpan.FromMilliseconds(50), - CancellationToken.None)) + }, executionCompletesFirst)) .ThrowsExactly(); var taskCanceledException = await Assert.That(exception!.InnerException).IsTypeOf(); await Assert.That(taskCanceledException!.Message).IsEqualTo(cancellationMessage); await Assert.That(taskCanceledException.InnerException).IsSameReferenceAs(diagnosticException); } + + [Test] + public async Task External_Cancellation_Preserves_External_Token_When_Execution_Completes_First() + { + using var externalCts = new CancellationTokenSource(); + + var exception = await Assert.That(() => TimeoutHelper.ExecuteWithTimeoutAsync( + cancellationToken => + { + externalCts.Cancel(); + return Task.FromCanceled(cancellationToken); + }, Timeout.InfiniteTimeSpan, externalCts.Token)) + .ThrowsExactly(); + + await Assert.That(exception!.CancellationToken).IsEqualTo(externalCts.Token); + } + + [Test] + public async Task Independent_Operation_Cancellation_Is_Not_A_Timeout() + { + var operationToken = new CancellationToken(true); + + var exception = await Assert.That(() => TimeoutHelper.ExecuteWithTimeoutAsync( + _ => Task.FromCanceled(operationToken), Timeout.InfiniteTimeSpan, CancellationToken.None)) + .ThrowsExactly(); + + await Assert.That(exception!.CancellationToken).IsEqualTo(operationToken); + } + + [Test] + public async Task Independent_Operation_Cancellation_Is_Preserved_When_Timeout_Is_Also_Cancelled() + { + var operationToken = new CancellationToken(true); + + var exception = await Assert.That(() => ExecuteWithControlledTimeoutAsync( + _ => Task.FromCanceled(operationToken), executionCompletesFirst: true)) + .ThrowsExactly(); + + await Assert.That(exception!.CancellationToken).IsEqualTo(operationToken); + } + + [Test] + public async Task Tokenless_Operation_Cancellation_Is_Preserved_When_Timeout_Is_Also_Cancelled() + { + var expected = new OperationCanceledException("Independent cancellation"); + + var exception = await Assert.That(() => ExecuteWithControlledTimeoutAsync( + _ => Task.FromException(expected), executionCompletesFirst: true)) + .ThrowsExactly(); + + await Assert.That(exception).IsSameReferenceAs(expected); + } + + [Test] + public async Task Operation_Failure_Is_Preserved_Before_Timeout() + { + var expected = new InvalidOperationException("Operation failed"); + + var exception = await Assert.That(() => TimeoutHelper.ExecuteWithTimeoutAsync( + _ => Task.FromException(expected), Timeout.InfiniteTimeSpan, CancellationToken.None)) + .ThrowsExactly(); + + await Assert.That(exception).IsSameReferenceAs(expected); + } + + [Test] + public async Task Operation_Can_Complete_Before_Timeout() + { + await TimeoutHelper.ExecuteWithTimeoutAsync( + _ => Task.CompletedTask, Timeout.InfiniteTimeSpan, CancellationToken.None); + } + + [Test] + [Arguments(true, false)] + [Arguments(true, true)] + [Arguments(false, false)] + [Arguments(false, true)] + public async Task Linked_Timeout_Cancellation_Is_Classified_Without_Routine_Diagnostics( + bool executionCompletesFirst, bool rebuildAfterCapture) + { + var context = CreateContext(); + context.Execution.AddLinkedCancellationToken(CancellationToken.None); + + try + { + var exception = await Assert.That(() => ExecuteWithControlledTimeoutAsync( + _ => + { + var capturedToken = context.Execution.CancellationToken; + if (rebuildAfterCapture) + { + context.Execution.AddLinkedCancellationToken(CancellationToken.None); + } + + return Task.FromCanceled(capturedToken); + }, executionCompletesFirst, context)) + .ThrowsExactly(); + + await Assert.That(exception!.InnerException).IsNull(); + await Assert.That(exception.Message).DoesNotContain(nameof(TaskCanceledException)); + } + finally + { + context.DisposeLinkedCancellationTokenSources(); + context.RemoveFromRegistry(); + } + } + + [Test] + [Arguments(true)] + [Arguments(false)] + public async Task Linked_Timeout_Preserves_Custom_Diagnostics(bool executionCompletesFirst) + { + var context = CreateContext(); + context.Execution.AddLinkedCancellationToken(CancellationToken.None); + OperationCanceledException? expected = null; + + try + { + var exception = await Assert.That(() => ExecuteWithControlledTimeoutAsync( + _ => + { + expected = new OperationCanceledException("Linked timeout diagnostic", context.Execution.CancellationToken); + return Task.FromException(expected); + }, executionCompletesFirst, context)) + .ThrowsExactly(); + + await Assert.That(exception!.InnerException).IsSameReferenceAs(expected); + await Assert.That(exception.Message).Contains("Linked timeout diagnostic"); + } + finally + { + context.DisposeLinkedCancellationTokenSources(); + context.RemoveFromRegistry(); + } + } + + [Test] + public async Task Linked_Cancellation_From_An_Earlier_Base_Is_Not_The_Current_Timeout() + { + var context = CreateContext(); + using var earlierSource = new CancellationTokenSource(); + context.SetCancellationToken(earlierSource.Token); + context.Execution.AddLinkedCancellationToken(CancellationToken.None); + var earlierToken = context.Execution.CancellationToken; + earlierSource.Cancel(); + + try + { + var exception = await Assert.That(() => ExecuteWithControlledTimeoutAsync( + _ => Task.FromCanceled(earlierToken), executionCompletesFirst: true, context)) + .ThrowsExactly(); + + await Assert.That(exception!.CancellationToken).IsEqualTo(earlierToken); + } + finally + { + context.DisposeLinkedCancellationTokenSources(); + context.RemoveFromRegistry(); + } + } + + private static TestContext CreateContext() + { + var currentContext = TestContext.Current!; + return new TestContext(nameof(TimeoutHelperTests), currentContext.ServiceProvider, currentContext.ClassContext, + new TestBuilderContext { TestMetadata = currentContext.TestDetails.MethodMetadata }, CancellationToken.None); + } + + private static async Task ExecuteWithControlledTimeoutAsync( + Func operation, bool executionCompletesFirst, TestContext? testContext = null) + { + using var timeoutCts = new CancellationTokenSource(); + var releaseOperation = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var timeoutTask = TimeoutHelper.ExecuteWithTimeoutAsync( + cancellationToken => + { + testContext?.SetCancellationToken(cancellationToken); + // Cancel the caller-owned timeout source without depending on a timer or scheduler delay. + timeoutCts.Cancel(); + return executionCompletesFirst ? operation(cancellationToken) : CompleteAfterReleaseAsync(cancellationToken); + }, Timeout.InfiniteTimeSpan, timeoutCts, CancellationToken.None, testContext: testContext); + + // When both tasks are already complete, WhenAny selects execution (the first argument). + // Otherwise timeout detection wins before the operation is released into the grace period. + releaseOperation.SetResult(); + await timeoutTask; + return; + + async Task CompleteAfterReleaseAsync(CancellationToken cancellationToken) + { + await releaseOperation.Task; + await operation(cancellationToken); + } + } }