From dcf31471dee2733e7de50f2ea4c17957aea38e2e Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Mon, 17 Aug 2026 18:32:36 +0100 Subject: [PATCH 1/2] perf(engine): skip unused scheduler work --- src/TUnit.Engine/Scheduling/TestScheduler.cs | 103 +++++++++++------- .../Services/TestGroupingService.cs | 20 ++++ 2 files changed, 86 insertions(+), 37 deletions(-) diff --git a/src/TUnit.Engine/Scheduling/TestScheduler.cs b/src/TUnit.Engine/Scheduling/TestScheduler.cs index 63764212902..f422e58ae9c 100644 --- a/src/TUnit.Engine/Scheduling/TestScheduler.cs +++ b/src/TUnit.Engine/Scheduling/TestScheduler.cs @@ -92,42 +92,10 @@ public async Task ScheduleAndExecuteAsync( if (_logger.IsDebugEnabled) await _logger.LogDebugAsync($"Scheduling execution of {testList.Count} tests").ConfigureAwait(false); - var circularDependencies = _circularDependencyDetector.DetectCircularDependencies(testList); - - var testsInCircularDependencies = new HashSet(); - - foreach (var (test, dependencyChain) in circularDependencies) - { - // Format the error message to match the expected format - var simpleNames = new List(dependencyChain.Count); - foreach (var t in dependencyChain) - { - simpleNames.Add($"{t.Metadata.TestClassType.Name}.{t.Metadata.TestMethodName}"); - } - - var errorMessage = $"DependsOn Conflict: {string.Join(" > ", simpleNames)}"; - var exception = new CircularDependencyException(errorMessage); - - // Mark all tests in the dependency chain as failed - foreach (var chainTest in dependencyChain) - { - if (testsInCircularDependencies.Add(chainTest)) - { - _testStateManager.MarkCircularDependencyFailed(chainTest, exception); - TestSessionContext.Current?.MarkFailure(); - await _messageBus.Failed(chainTest.Context, exception, DateTimeOffset.UtcNow).ConfigureAwait(false); - } - } - } - - var executableTests = new List(testList.Count); - foreach (var test in testList) - { - if (!testsInCircularDependencies.Contains(test)) - { - executableTests.Add(test); - } - } + var hasDependencies = HasDependencies(testList); + var executableTests = hasDependencies + ? await RemoveCircularDependenciesAsync(testList).ConfigureAwait(false) + : testList; if (executableTests.Count == 0) { @@ -144,7 +112,10 @@ public async Task ScheduleAndExecuteAsync( // Group tests by their parallel constraints var groupedTests = await _groupingService.GroupTestsByConstraintsAsync(executableTests).ConfigureAwait(false); - MarkDependencyRelatedTestsForExecutionDedup(executableTests); + if (hasDependencies) + { + MarkDependencyRelatedTestsForExecutionDedup(executableTests); + } // Suites with no global [NotInParallel] tests skip the runtime exclusion // lock entirely. Once enabled, the flag is monotonic — dynamic batches @@ -171,6 +142,64 @@ public async Task ScheduleAndExecuteAsync( return true; } + private static bool HasDependencies(List tests) + { + foreach (var test in tests) + { + if (test.Dependencies.Length != 0) + { + return true; + } + } + + return false; + } + + private async Task> RemoveCircularDependenciesAsync( + List tests) + { + var circularDependencies = _circularDependencyDetector.DetectCircularDependencies(tests); + var testsInCircularDependencies = new HashSet(); + + foreach (var (_, dependencyChain) in circularDependencies) + { + var simpleNames = new List(dependencyChain.Count); + foreach (var test in dependencyChain) + { + simpleNames.Add($"{test.Metadata.TestClassType.Name}.{test.Metadata.TestMethodName}"); + } + + var exception = new CircularDependencyException( + $"DependsOn Conflict: {string.Join(" > ", simpleNames)}"); + + foreach (var test in dependencyChain) + { + if (testsInCircularDependencies.Add(test)) + { + _testStateManager.MarkCircularDependencyFailed(test, exception); + TestSessionContext.Current?.MarkFailure(); + await _messageBus.Failed(test.Context, exception, DateTimeOffset.UtcNow).ConfigureAwait(false); + } + } + } + + if (testsInCircularDependencies.Count == 0) + { + return tests; + } + + var executableTests = new List(tests.Count - testsInCircularDependencies.Count); + foreach (var test in tests) + { + if (!testsInCircularDependencies.Contains(test)) + { + executableTests.Add(test); + } + } + + return executableTests; + } + #if NET [System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode("Test execution involves reflection for hooks and initialization")] #endif diff --git a/src/TUnit.Engine/Services/TestGroupingService.cs b/src/TUnit.Engine/Services/TestGroupingService.cs index 87d0eed6fc8..9fc2519283e 100644 --- a/src/TUnit.Engine/Services/TestGroupingService.cs +++ b/src/TUnit.Engine/Services/TestGroupingService.cs @@ -61,11 +61,13 @@ private static GroupedTests GroupTestsByConstraintsCore(IEnumerable collection ? collection.Count : 0; var testsWithKeys = new List<(AbstractExecutableTest Test, TestSortKey Key)>(testCount > 0 ? testCount : 16); + var hasParallelConstraints = false; foreach (var test in tests) { NotInParallelConstraint? notInParallelConstraint = null; foreach (var constraint in test.Context.ParallelConstraints) { + hasParallelConstraints = true; if (constraint is NotInParallelConstraint nip) { notInParallelConstraint = nip; @@ -94,6 +96,24 @@ private static GroupedTests GroupTestsByConstraintsCore(IEnumerable(estimatedCount / 4); var keyedNotInParallelList = new List<(AbstractExecutableTest Test, string ClassName, IReadOnlyList ConstraintKeys, TestPriority Priority)>(estimatedCount / 8); From 4745662ad2b8c9712a1ae31a69cf6c7a5848640b Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Mon, 17 Aug 2026 19:01:51 +0100 Subject: [PATCH 2/2] fix(engine): harden dependency scheduling --- src/TUnit.Engine/Scheduling/TestScheduler.cs | 25 ++++++++-- .../Services/CircularDependencyDetector.cs | 16 +++++-- tests/TUnit.UnitTests/TestRunnerTests.cs | 48 +++++++++++++++++-- 3 files changed, 74 insertions(+), 15 deletions(-) diff --git a/src/TUnit.Engine/Scheduling/TestScheduler.cs b/src/TUnit.Engine/Scheduling/TestScheduler.cs index f422e58ae9c..f18917a2bff 100644 --- a/src/TUnit.Engine/Scheduling/TestScheduler.cs +++ b/src/TUnit.Engine/Scheduling/TestScheduler.cs @@ -94,7 +94,7 @@ public async Task ScheduleAndExecuteAsync( var hasDependencies = HasDependencies(testList); var executableTests = hasDependencies - ? await RemoveCircularDependenciesAsync(testList).ConfigureAwait(false) + ? await RemoveCircularDependenciesAsync(testList, cancellationToken).ConfigureAwait(false) : testList; if (executableTests.Count == 0) @@ -156,16 +156,22 @@ private static bool HasDependencies(List tests) } private async Task> RemoveCircularDependenciesAsync( - List tests) + List tests, + CancellationToken cancellationToken) { - var circularDependencies = _circularDependencyDetector.DetectCircularDependencies(tests); - var testsInCircularDependencies = new HashSet(); + cancellationToken.ThrowIfCancellationRequested(); + var circularDependencies = _circularDependencyDetector.DetectCircularDependencies(tests, cancellationToken); + HashSet? testsInCircularDependencies = null; foreach (var (_, dependencyChain) in circularDependencies) { + cancellationToken.ThrowIfCancellationRequested(); + testsInCircularDependencies ??= []; + var simpleNames = new List(dependencyChain.Count); foreach (var test in dependencyChain) { + cancellationToken.ThrowIfCancellationRequested(); simpleNames.Add($"{test.Metadata.TestClassType.Name}.{test.Metadata.TestMethodName}"); } @@ -174,6 +180,7 @@ private async Task> RemoveCircularDependenciesAsync foreach (var test in dependencyChain) { + cancellationToken.ThrowIfCancellationRequested(); if (testsInCircularDependencies.Add(test)) { _testStateManager.MarkCircularDependencyFailed(test, exception); @@ -183,7 +190,7 @@ private async Task> RemoveCircularDependenciesAsync } } - if (testsInCircularDependencies.Count == 0) + if (testsInCircularDependencies is null) { return tests; } @@ -191,6 +198,7 @@ private async Task> RemoveCircularDependenciesAsync var executableTests = new List(tests.Count - testsInCircularDependencies.Count); foreach (var test in tests) { + cancellationToken.ThrowIfCancellationRequested(); if (!testsInCircularDependencies.Contains(test)) { executableTests.Add(test); @@ -386,6 +394,13 @@ internal static void MarkDependencyRelatedTestsForExecutionDedup(IEnumerable /// Tests to analyze for circular dependencies + /// Token used to cancel graph traversal /// List of tests with circular dependencies and their dependency chains public List<(AbstractExecutableTest Test, List DependencyChain)> DetectCircularDependencies( - IEnumerable tests) + IEnumerable tests, + CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); var testList = tests as IList ?? tests.ToList(); var circularDependencies = new List<(AbstractExecutableTest Test, List DependencyChain)>(); var visitedStates = new Dictionary(capacity: testList.Count); @@ -23,6 +26,7 @@ internal sealed class CircularDependencyDetector foreach (var test in testList) { + cancellationToken.ThrowIfCancellationRequested(); if (visitedStates.ContainsKey(test.TestId)) { continue; @@ -32,7 +36,7 @@ internal sealed class CircularDependencyDetector pathBuffer.Clear(); // Typical cycle depth is small (2-5 tests), pre-size to 4 - if (HasCycleDfs(test, testList, visitedStates, pathBuffer)) + if (HasCycleDfs(test, visitedStates, pathBuffer, cancellationToken)) { // Found a cycle - add all tests in the cycle to circular dependencies var cycle = new List(pathBuffer); @@ -52,10 +56,11 @@ private enum VisitState private bool HasCycleDfs( AbstractExecutableTest test, - IList allTests, Dictionary visitedStates, - List currentPath) + List currentPath, + CancellationToken cancellationToken) { + cancellationToken.ThrowIfCancellationRequested(); if (visitedStates.TryGetValue(test.TestId, out var state)) { if (state == VisitState.Visiting) @@ -78,7 +83,8 @@ private bool HasCycleDfs( // Check all dependencies foreach (var dependency in test.Dependencies) { - if (HasCycleDfs(dependency.Test, allTests, visitedStates, currentPath)) + cancellationToken.ThrowIfCancellationRequested(); + if (HasCycleDfs(dependency.Test, visitedStates, currentPath, cancellationToken)) { return true; } diff --git a/tests/TUnit.UnitTests/TestRunnerTests.cs b/tests/TUnit.UnitTests/TestRunnerTests.cs index 0f5b05da56c..4b5c3221bc6 100644 --- a/tests/TUnit.UnitTests/TestRunnerTests.cs +++ b/tests/TUnit.UnitTests/TestRunnerTests.cs @@ -1,6 +1,8 @@ using TUnit.Core; +using TUnit.Core.Exceptions; using TUnit.Engine.Interfaces; using TUnit.Engine.Scheduling; +using TUnit.Engine.Services; using TUnit.Engine.Services.TestExecution; namespace TUnit.UnitTests; @@ -113,6 +115,40 @@ public async Task MarkDependencyRelatedTestsForExecutionDedup_MarksTransitiveDep await Assert.That(leaf.RequiresExecutionDedup).IsTrue(); } + [Test, Timeout(5_000)] + public async Task ExecuteTestAsync_WithFailedCircularDependency_SkipsDependentWithoutReentry( + CancellationToken cancellationToken) + { + var runner = CreateRunner(out var coordinator); + var firstCycleTest = CreateTest("cycle-a"); + var secondCycleTest = CreateTest("cycle-b", [firstCycleTest]); + firstCycleTest.Dependencies = [CreateResolvedDependency(secondCycleTest)]; + var dependent = CreateTest("dependent", [firstCycleTest]); + var exception = new CircularDependencyException("cycle"); + var stateManager = new TestStateManager(); + stateManager.MarkCircularDependencyFailed(firstCycleTest, exception); + stateManager.MarkCircularDependencyFailed(secondCycleTest, exception); + TestScheduler.MarkDependencyRelatedTestsForExecutionDedup([dependent]); + + await runner.ExecuteTestAsync(dependent, cancellationToken); + + await Assert.That(dependent.State).IsEqualTo(TestState.Skipped); + await Assert.That(coordinator.GetCallCount(firstCycleTest)).IsEqualTo(0); + await Assert.That(coordinator.GetCallCount(secondCycleTest)).IsEqualTo(0); + } + + [Test] + public async Task DetectCircularDependencies_WithCancelledToken_Throws() + { + var detector = new CircularDependencyDetector(); + var test = CreateTest("test"); + using var cancellationTokenSource = new CancellationTokenSource(); + cancellationTokenSource.Cancel(); + + await Assert.That(() => detector.DetectCircularDependencies([test], cancellationTokenSource.Token)) + .ThrowsExactly(); + } + private static TestRunner CreateRunner(out FakeTestCoordinator coordinator) { coordinator = new FakeTestCoordinator(); @@ -160,16 +196,18 @@ private static AbstractExecutableTest CreateTest( Metadata = metadata, Arguments = [], Context = context, - Dependencies = dependencies?.Select(dependency => new ResolvedDependency - { - Test = dependency, - Metadata = TestDependency.FromMethodName(dependency.Metadata.TestMethodName) - }).ToArray() ?? [] + Dependencies = dependencies?.Select(CreateResolvedDependency).ToArray() ?? [] }; return test; } + private static ResolvedDependency CreateResolvedDependency(AbstractExecutableTest dependency) => new() + { + Test = dependency, + Metadata = TestDependency.FromMethodName(dependency.Metadata.TestMethodName) + }; + private static TestMetadata CreateMetadata(string testId) { var classMetadata = new ClassMetadata