From c7d9fc3cfd3007fb6d289c52a67755bf2a584be4 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sat, 2 May 2026 14:44:53 +0100 Subject: [PATCH 1/5] perf(engine): skip execution ledger for independent tests --- TUnit.Core/AbstractExecutableTest.cs | 5 + TUnit.Engine/Scheduling/TestRunner.cs | 21 +- TUnit.Engine/Scheduling/TestScheduler.cs | 21 ++ TUnit.UnitTests/TestRunnerTests.cs | 276 +++++++++++++++++++++++ 4 files changed, 320 insertions(+), 3 deletions(-) create mode 100644 TUnit.UnitTests/TestRunnerTests.cs diff --git a/TUnit.Core/AbstractExecutableTest.cs b/TUnit.Core/AbstractExecutableTest.cs index cb467d1c12..232115a618 100644 --- a/TUnit.Core/AbstractExecutableTest.cs +++ b/TUnit.Core/AbstractExecutableTest.cs @@ -30,6 +30,11 @@ public abstract class AbstractExecutableTest // avoiding a per-test scan over ParallelConstraints in the hot path. internal bool RequiresGlobalNotInParallelLock { get; set; } + // Set by the scheduler for tests that either have dependencies or are the target of + // another test's dependency. These tests can be reached from both the scheduler and + // dependency recursion, so TestRunner must keep using its execution dedup ledger. + internal bool RequiresExecutionDedup { get; set; } + public required TestContext Context { get; diff --git a/TUnit.Engine/Scheduling/TestRunner.cs b/TUnit.Engine/Scheduling/TestRunner.cs index 0ddbf8e632..ab9b4c7347 100644 --- a/TUnit.Engine/Scheduling/TestRunner.cs +++ b/TUnit.Engine/Scheduling/TestRunner.cs @@ -42,13 +42,28 @@ internal TestRunner( _notInParallelLock = notInParallelLock; } - // Dedup ledger for re-entrant ExecuteTestAsync calls (dependency recursion, scheduler races). - // Entries are intentionally retained for the session: a late dependency lookup must still - // observe the in-flight or completed TCS. Session-scoped lifetime bounds growth to O(test count). + // Dedup ledger for tests involved in dependency relationships. Entries are intentionally + // retained for the session: a late dependency lookup must still observe the in-flight or + // completed TCS. Session-scoped lifetime bounds growth to O(test count). private readonly ConcurrentDictionary> _executingTests = new(); private Exception? _firstFailFastException; public ValueTask ExecuteTestAsync(AbstractExecutableTest test, CancellationToken cancellationToken) + { + if (!test.RequiresExecutionDedup) + { + if (test.ExecutionTask is { } executionTask) + { + return new ValueTask(executionTask); + } + + return ExecuteTestInternalAsync(test, cancellationToken); + } + + return ExecuteTestWithDependenciesAsync(test, cancellationToken); + } + + private ValueTask ExecuteTestWithDependenciesAsync(AbstractExecutableTest test, CancellationToken cancellationToken) { if (_executingTests.TryGetValue(test.TestId, out var existingTcs)) { diff --git a/TUnit.Engine/Scheduling/TestScheduler.cs b/TUnit.Engine/Scheduling/TestScheduler.cs index 0c855235ff..5003d14971 100644 --- a/TUnit.Engine/Scheduling/TestScheduler.cs +++ b/TUnit.Engine/Scheduling/TestScheduler.cs @@ -144,6 +144,8 @@ public async Task ScheduleAndExecuteAsync( // Group tests by their parallel constraints var groupedTests = await _groupingService.GroupTestsByConstraintsAsync(executableTests).ConfigureAwait(false); + MarkDependencyRelatedTestsForExecutionDedup(executableTests); + // Suites with no global [NotInParallel] tests skip the runtime exclusion // lock entirely. Once enabled, the flag is monotonic — dynamic batches // that introduce NIP later (see ExecuteDynamicBatchAsync) keep it on. @@ -317,10 +319,29 @@ private async Task ExecuteDynamicBatchAsync(List dynamic // tests with [NotInParallel(key)], [ParallelGroup(...)] etc. honour their constraints // instead of being silently dropped. var groupedDynamicTests = await _groupingService.GroupTestsByConstraintsAsync(dynamicTests.ToArray()).ConfigureAwait(false); + MarkDependencyRelatedTestsForExecutionDedup(dynamicTests); MarkGlobalNotInParallelTests(groupedDynamicTests); await ExecuteAllPhasesAsync(groupedDynamicTests, cancellationToken).ConfigureAwait(false); } + private static void MarkDependencyRelatedTestsForExecutionDedup(IEnumerable tests) + { + foreach (var test in tests) + { + if (test.Dependencies.Length == 0) + { + continue; + } + + test.RequiresExecutionDedup = true; + + foreach (var dependency in test.Dependencies) + { + dependency.Test.RequiresExecutionDedup = true; + } + } + } + private void MarkGlobalNotInParallelTests(GroupedTests grouped) { if (grouped.NotInParallel.Length == 0) diff --git a/TUnit.UnitTests/TestRunnerTests.cs b/TUnit.UnitTests/TestRunnerTests.cs new file mode 100644 index 0000000000..5a064f2ca7 --- /dev/null +++ b/TUnit.UnitTests/TestRunnerTests.cs @@ -0,0 +1,276 @@ +using System.Collections.Concurrent; +using System.Reflection; +using TUnit.Core; +using TUnit.Engine.Interfaces; +using TUnit.Engine.Scheduling; +using TUnit.Engine.Services.TestExecution; + +namespace TUnit.UnitTests; + +public class TestRunnerTests +{ + [Test] + public async Task ExecuteTestAsync_WithNoDependencies_DoesNotUseExecutingTestsLedger() + { + var runner = CreateRunner(out var coordinator); + var test = CreateTest("no-dependencies"); + + await runner.ExecuteTestAsync(test, CancellationToken.None); + + await Assert.That(GetExecutingTestsCount(runner)).IsEqualTo(0); + await Assert.That(coordinator.GetCallCount(test)).IsEqualTo(1); + } + + [Test] + public async Task ExecuteTestAsync_WithNoDependenciesAndExistingExecutionTask_AwaitsExistingTask() + { + var runner = CreateRunner(out var coordinator); + var test = CreateTest("existing-execution-task"); + var existingExecution = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + test.ExecutionTask = existingExecution.Task; + + var execution = runner.ExecuteTestAsync(test, CancellationToken.None); + + await Assert.That(execution.IsCompleted).IsFalse(); + + existingExecution.SetResult(); + await execution; + + await Assert.That(GetExecutingTestsCount(runner)).IsEqualTo(0); + await Assert.That(coordinator.GetCallCount(test)).IsEqualTo(0); + } + + [Test] + public async Task ExecuteTestAsync_WithDependencies_UsesExecutingTestsLedger() + { + var runner = CreateRunner(out _); + var dependency = CreateTest("dependency", requiresExecutionDedup: true); + var test = CreateTest("with-dependencies", [dependency]); + + await runner.ExecuteTestAsync(test, CancellationToken.None); + + await Assert.That(GetExecutingTestsCount(runner)).IsEqualTo(2); + } + + [Test] + public async Task ExecuteTestAsync_WithNoDependenciesButDependencyTarget_UsesExecutingTestsLedger() + { + var runner = CreateRunner(out var coordinator); + var test = CreateTest("dependency-target", requiresExecutionDedup: true); + + await runner.ExecuteTestAsync(test, CancellationToken.None); + + await Assert.That(GetExecutingTestsCount(runner)).IsEqualTo(1); + await Assert.That(coordinator.GetCallCount(test)).IsEqualTo(1); + } + + [Test] + public async Task ExecuteTestAsync_WithDependencies_DeduplicatesConcurrentAttempts() + { + var runner = CreateRunner(out var coordinator); + var dependency = CreateTest("dependency", requiresExecutionDedup: true); + var test = CreateTest("with-dependencies", [dependency]); + var releaseExecution = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + coordinator.SetExecutionTask(test, releaseExecution.Task); + + var first = runner.ExecuteTestAsync(test, CancellationToken.None); + var second = runner.ExecuteTestAsync(test, CancellationToken.None); + + releaseExecution.SetResult(); + await Task.WhenAll(first.AsTask(), second.AsTask()); + + await Assert.That(coordinator.GetCallCount(test)).IsEqualTo(1); + await Assert.That(GetExecutingTestsCount(runner)).IsEqualTo(2); + } + + private static TestRunner CreateRunner(out FakeTestCoordinator coordinator) + { + coordinator = new FakeTestCoordinator(); + + return new TestRunner( + coordinator, + new FakeMessageBus(), + isFailFastEnabled: false, + new CancellationTokenSource(), + logger: null!, + new TestStateManager(), + new ParallelLimitLockProvider(), + new NotInParallelLock()); + } + + private static AbstractExecutableTest CreateTest( + string testId, + AbstractExecutableTest[]? dependencies = null, + bool requiresExecutionDedup = false) + { + var metadata = CreateMetadata(testId); + var beforeDiscoveryContext = new BeforeTestDiscoveryContext { TestFilter = null }; + var discoveryContext = new TestDiscoveryContext(beforeDiscoveryContext) { TestFilter = null }; + var sessionContext = new TestSessionContext(discoveryContext) + { + Id = Guid.NewGuid().ToString(), + TestFilter = null + }; + var assemblyContext = new AssemblyHookContext(sessionContext) + { + Assembly = typeof(TestRunnerTests).Assembly + }; + var classContext = new ClassHookContext(assemblyContext) + { + ClassType = typeof(TestRunnerTests) + }; + var builderContext = new TestBuilderContext + { + TestMetadata = metadata.MethodMetadata + }; + var context = new TestContext(testId, new FakeServiceProvider(), classContext, builderContext, CancellationToken.None); + + var test = new StubExecutableTest + { + TestId = testId, + Metadata = metadata, + Arguments = [], + Context = context, + Dependencies = dependencies?.Select(dependency => new ResolvedDependency + { + Test = dependency, + Metadata = TestDependency.FromMethodName(dependency.Metadata.TestMethodName) + }).ToArray() ?? [] + }; + + test.RequiresExecutionDedup = requiresExecutionDedup || test.Dependencies.Length > 0; + + foreach (var dependency in test.Dependencies) + { + dependency.Test.RequiresExecutionDedup = true; + } + + return test; + } + + private static TestMetadata CreateMetadata(string testId) + { + var classMetadata = new ClassMetadata + { + Type = typeof(TestRunnerTests), + TypeInfo = new ConcreteType(typeof(TestRunnerTests)), + Name = nameof(TestRunnerTests), + Namespace = typeof(TestRunnerTests).Namespace ?? string.Empty, + Assembly = new AssemblyMetadata + { + Name = typeof(TestRunnerTests).Assembly.GetName().Name ?? string.Empty + }, + Parent = null, + Parameters = [], + Properties = [] + }; + + return new TestMetadata + { + TestClassType = typeof(TestRunnerTests), + TestMethodName = testId, + TestName = testId, + FilePath = "Unknown", + LineNumber = 0, + AttributeFactory = () => [], + MethodMetadata = new MethodMetadata + { + Type = typeof(TestRunnerTests), + TypeInfo = new ConcreteType(typeof(TestRunnerTests)), + Name = testId, + GenericTypeCount = 0, + ReturnType = typeof(void), + ReturnTypeInfo = new ConcreteType(typeof(void)), + Parameters = [], + Class = classMetadata + }, + DataSources = [], + ClassDataSources = [], + PropertyDataSources = [] + }; + } + + private static int GetExecutingTestsCount(TestRunner runner) + { + var field = typeof(TestRunner).GetField("_executingTests", BindingFlags.Instance | BindingFlags.NonPublic)!; + var executingTests = (ConcurrentDictionary>)field.GetValue(runner)!; + return executingTests.Count; + } + + private sealed class StubExecutableTest : AbstractExecutableTest + { + public override Task CreateInstanceAsync() => Task.FromResult(new object()); + + public override Task InvokeTestAsync(object instance, CancellationToken cancellationToken) => Task.CompletedTask; + } + + private sealed class FakeTestCoordinator : ITestCoordinator + { + private readonly Lock _lock = new(); + private readonly Dictionary _callCounts = []; + private readonly Dictionary _executionTasks = []; + + public int GetCallCount(AbstractExecutableTest test) + { + lock (_lock) + { + return _callCounts.GetValueOrDefault(test.TestId); + } + } + + public void SetExecutionTask(AbstractExecutableTest test, Task executionTask) + { + lock (_lock) + { + _executionTasks[test.TestId] = executionTask; + } + } + + public ValueTask ExecuteTestAsync(AbstractExecutableTest test, CancellationToken cancellationToken) + { + Task? executionTask; + lock (_lock) + { + _callCounts[test.TestId] = _callCounts.GetValueOrDefault(test.TestId) + 1; + _executionTasks.TryGetValue(test.TestId, out executionTask); + } + + if (executionTask is not null) + { + return CompleteAfterAsync(test, executionTask); + } + + test.SetResult(TestState.Passed); + return default; + } + + private static async ValueTask CompleteAfterAsync(AbstractExecutableTest test, Task executionTask) + { + await executionTask.ConfigureAwait(false); + test.SetResult(TestState.Passed); + } + } + + private sealed class FakeMessageBus : ITUnitMessageBus + { + public ValueTask Discovered(TestContext testContext) => default; + + public ValueTask InProgress(TestContext testContext) => default; + + public ValueTask Passed(TestContext testContext, DateTimeOffset start) => default; + + public ValueTask Failed(TestContext testContext, Exception exception, DateTimeOffset start) => default; + + public ValueTask Skipped(TestContext testContext, string reason) => default; + + public ValueTask Cancelled(TestContext testContext, DateTimeOffset start) => default; + + public ValueTask SessionArtifact(Artifact artifact) => default; + } + + private sealed class FakeServiceProvider : IServiceProvider + { + public object? GetService(Type serviceType) => null; + } +} From 24d08d4b041a6c1f8802c045ab6ebbafcb92f492 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sat, 2 May 2026 15:05:36 +0100 Subject: [PATCH 2/5] fix(engine): tighten dependency execution dedup --- TUnit.Engine/Scheduling/TestRunner.cs | 2 + TUnit.Engine/Scheduling/TestScheduler.cs | 45 ++++++++++++++++---- TUnit.UnitTests/TestRunnerTests.cs | 52 ++++++++++++------------ 3 files changed, 65 insertions(+), 34 deletions(-) diff --git a/TUnit.Engine/Scheduling/TestRunner.cs b/TUnit.Engine/Scheduling/TestRunner.cs index ab9b4c7347..933fb0e333 100644 --- a/TUnit.Engine/Scheduling/TestRunner.cs +++ b/TUnit.Engine/Scheduling/TestRunner.cs @@ -48,6 +48,8 @@ internal TestRunner( private readonly ConcurrentDictionary> _executingTests = new(); private Exception? _firstFailFastException; + internal int ExecutingTestsCount => _executingTests.Count; + public ValueTask ExecuteTestAsync(AbstractExecutableTest test, CancellationToken cancellationToken) { if (!test.RequiresExecutionDedup) diff --git a/TUnit.Engine/Scheduling/TestScheduler.cs b/TUnit.Engine/Scheduling/TestScheduler.cs index 5003d14971..dcfd079c4e 100644 --- a/TUnit.Engine/Scheduling/TestScheduler.cs +++ b/TUnit.Engine/Scheduling/TestScheduler.cs @@ -324,8 +324,11 @@ private async Task ExecuteDynamicBatchAsync(List dynamic await ExecuteAllPhasesAsync(groupedDynamicTests, cancellationToken).ConfigureAwait(false); } - private static void MarkDependencyRelatedTestsForExecutionDedup(IEnumerable tests) + internal static void MarkDependencyRelatedTestsForExecutionDedup(IEnumerable tests) { + HashSet? visitedDependencyTargets = null; + Stack? pendingDependencyTargets = null; + foreach (var test in tests) { if (test.Dependencies.Length == 0) @@ -335,9 +338,29 @@ private static void MarkDependencyRelatedTestsForExecutionDedup(IEnumerable 0) + { + var dependencyTarget = pendingDependencyTargets.Pop(); + + if (!visitedDependencyTargets.Add(dependencyTarget)) + { + continue; + } + + dependencyTarget.RequiresExecutionDedup = true; + + foreach (var nestedDependency in dependencyTarget.Dependencies) + { + pendingDependencyTargets.Push(nestedDependency.Test); + } } } } @@ -383,8 +406,7 @@ private async Task ExecuteSequentiallyAsync( { foreach (var test in tests) { - test.ExecutionTask ??= _testRunner.ExecuteTestAsync(test, cancellationToken).AsTask(); - await test.ExecutionTask.ConfigureAwait(false); + await ExecuteScheduledTestAsync(test, cancellationToken).ConfigureAwait(false); } } @@ -402,8 +424,7 @@ private Task ExecuteWithGlobalLimitAsync( }, async (test, ct) => { - test.ExecutionTask ??= _testRunner.ExecuteTestAsync(test, ct).AsTask(); - await test.ExecutionTask.ConfigureAwait(false); + await ExecuteScheduledTestAsync(test, ct).ConfigureAwait(false); } ); } @@ -423,8 +444,7 @@ private async Task ExecuteWithGlobalLimitAsync( await globalSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); try { - test.ExecutionTask ??= _testRunner.ExecuteTestAsync(test, cancellationToken).AsTask(); - await test.ExecutionTask.ConfigureAwait(false); + await ExecuteScheduledTestAsync(test, cancellationToken).ConfigureAwait(false); } finally { @@ -436,6 +456,15 @@ private async Task ExecuteWithGlobalLimitAsync( } #endif + private async ValueTask ExecuteScheduledTestAsync(AbstractExecutableTest test, CancellationToken cancellationToken) + { + // Scheduler grouping partitions each batch, so each test reaches this path once. + // Dependency recursion is the re-entrant path and those tests are marked for the + // TestRunner dedup ledger before scheduling begins. + test.ExecutionTask ??= _testRunner.ExecuteTestAsync(test, cancellationToken).AsTask(); + await test.ExecutionTask.ConfigureAwait(false); + } + // The cancellation token is forwarded only so WaitForTasksWithFailFastHandling can // distinguish a fail-fast cancellation from a normal task fault when both phases run. // Tasks `a` and `b` are already started and carry their own token internally, so the diff --git a/TUnit.UnitTests/TestRunnerTests.cs b/TUnit.UnitTests/TestRunnerTests.cs index 5a064f2ca7..94a797d3e8 100644 --- a/TUnit.UnitTests/TestRunnerTests.cs +++ b/TUnit.UnitTests/TestRunnerTests.cs @@ -1,5 +1,3 @@ -using System.Collections.Concurrent; -using System.Reflection; using TUnit.Core; using TUnit.Engine.Interfaces; using TUnit.Engine.Scheduling; @@ -17,7 +15,7 @@ public async Task ExecuteTestAsync_WithNoDependencies_DoesNotUseExecutingTestsLe await runner.ExecuteTestAsync(test, CancellationToken.None); - await Assert.That(GetExecutingTestsCount(runner)).IsEqualTo(0); + await Assert.That(runner.ExecutingTestsCount).IsEqualTo(0); await Assert.That(coordinator.GetCallCount(test)).IsEqualTo(1); } @@ -36,7 +34,7 @@ public async Task ExecuteTestAsync_WithNoDependenciesAndExistingExecutionTask_Aw existingExecution.SetResult(); await execution; - await Assert.That(GetExecutingTestsCount(runner)).IsEqualTo(0); + await Assert.That(runner.ExecutingTestsCount).IsEqualTo(0); await Assert.That(coordinator.GetCallCount(test)).IsEqualTo(0); } @@ -44,23 +42,25 @@ public async Task ExecuteTestAsync_WithNoDependenciesAndExistingExecutionTask_Aw public async Task ExecuteTestAsync_WithDependencies_UsesExecutingTestsLedger() { var runner = CreateRunner(out _); - var dependency = CreateTest("dependency", requiresExecutionDedup: true); + var dependency = CreateTest("dependency"); var test = CreateTest("with-dependencies", [dependency]); + TestScheduler.MarkDependencyRelatedTestsForExecutionDedup([test]); await runner.ExecuteTestAsync(test, CancellationToken.None); - await Assert.That(GetExecutingTestsCount(runner)).IsEqualTo(2); + await Assert.That(runner.ExecutingTestsCount).IsEqualTo(2); } [Test] public async Task ExecuteTestAsync_WithNoDependenciesButDependencyTarget_UsesExecutingTestsLedger() { var runner = CreateRunner(out var coordinator); - var test = CreateTest("dependency-target", requiresExecutionDedup: true); + var test = CreateTest("dependency-target"); + test.RequiresExecutionDedup = true; await runner.ExecuteTestAsync(test, CancellationToken.None); - await Assert.That(GetExecutingTestsCount(runner)).IsEqualTo(1); + await Assert.That(runner.ExecutingTestsCount).IsEqualTo(1); await Assert.That(coordinator.GetCallCount(test)).IsEqualTo(1); } @@ -68,8 +68,9 @@ public async Task ExecuteTestAsync_WithNoDependenciesButDependencyTarget_UsesExe public async Task ExecuteTestAsync_WithDependencies_DeduplicatesConcurrentAttempts() { var runner = CreateRunner(out var coordinator); - var dependency = CreateTest("dependency", requiresExecutionDedup: true); + var dependency = CreateTest("dependency"); var test = CreateTest("with-dependencies", [dependency]); + TestScheduler.MarkDependencyRelatedTestsForExecutionDedup([test]); var releaseExecution = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); coordinator.SetExecutionTask(test, releaseExecution.Task); @@ -81,7 +82,21 @@ public async Task ExecuteTestAsync_WithDependencies_DeduplicatesConcurrentAttemp await Task.WhenAll(first.AsTask(), second.AsTask()); await Assert.That(coordinator.GetCallCount(test)).IsEqualTo(1); - await Assert.That(GetExecutingTestsCount(runner)).IsEqualTo(2); + await Assert.That(runner.ExecutingTestsCount).IsEqualTo(2); + } + + [Test] + public async Task MarkDependencyRelatedTestsForExecutionDedup_MarksTransitiveDependencyTargetsOutsideBatch() + { + var leaf = CreateTest("leaf"); + var middle = CreateTest("middle", [leaf]); + var root = CreateTest("root", [middle]); + + TestScheduler.MarkDependencyRelatedTestsForExecutionDedup([root]); + + await Assert.That(root.RequiresExecutionDedup).IsTrue(); + await Assert.That(middle.RequiresExecutionDedup).IsTrue(); + await Assert.That(leaf.RequiresExecutionDedup).IsTrue(); } private static TestRunner CreateRunner(out FakeTestCoordinator coordinator) @@ -101,8 +116,7 @@ private static TestRunner CreateRunner(out FakeTestCoordinator coordinator) private static AbstractExecutableTest CreateTest( string testId, - AbstractExecutableTest[]? dependencies = null, - bool requiresExecutionDedup = false) + AbstractExecutableTest[]? dependencies = null) { var metadata = CreateMetadata(testId); var beforeDiscoveryContext = new BeforeTestDiscoveryContext { TestFilter = null }; @@ -139,13 +153,6 @@ private static AbstractExecutableTest CreateTest( }).ToArray() ?? [] }; - test.RequiresExecutionDedup = requiresExecutionDedup || test.Dependencies.Length > 0; - - foreach (var dependency in test.Dependencies) - { - dependency.Test.RequiresExecutionDedup = true; - } - return test; } @@ -191,13 +198,6 @@ private static TestMetadata CreateMetadata(string testId) }; } - private static int GetExecutingTestsCount(TestRunner runner) - { - var field = typeof(TestRunner).GetField("_executingTests", BindingFlags.Instance | BindingFlags.NonPublic)!; - var executingTests = (ConcurrentDictionary>)field.GetValue(runner)!; - return executingTests.Count; - } - private sealed class StubExecutableTest : AbstractExecutableTest { public override Task CreateInstanceAsync() => Task.FromResult(new object()); From 0eaafc4aeacf641317311b7edae6fdaa51ff6599 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sat, 2 May 2026 16:04:52 +0100 Subject: [PATCH 3/5] fix(engine): avoid scheduler task self-deadlock --- TUnit.Engine/Scheduling/TestRunner.cs | 7 +------ TUnit.Engine/Scheduling/TestScheduler.cs | 11 ++++++++--- TUnit.UnitTests/TestRunnerTests.cs | 7 ++----- 3 files changed, 11 insertions(+), 14 deletions(-) diff --git a/TUnit.Engine/Scheduling/TestRunner.cs b/TUnit.Engine/Scheduling/TestRunner.cs index 933fb0e333..96d0a132cc 100644 --- a/TUnit.Engine/Scheduling/TestRunner.cs +++ b/TUnit.Engine/Scheduling/TestRunner.cs @@ -54,11 +54,6 @@ public ValueTask ExecuteTestAsync(AbstractExecutableTest test, CancellationToken { if (!test.RequiresExecutionDedup) { - if (test.ExecutionTask is { } executionTask) - { - return new ValueTask(executionTask); - } - return ExecuteTestInternalAsync(test, cancellationToken); } @@ -71,7 +66,7 @@ private ValueTask ExecuteTestWithDependenciesAsync(AbstractExecutableTest test, { return new ValueTask(existingTcs.Task); } - var tcs = new TaskCompletionSource(); + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); existingTcs = _executingTests.GetOrAdd(test.TestId, tcs); if (existingTcs != tcs) diff --git a/TUnit.Engine/Scheduling/TestScheduler.cs b/TUnit.Engine/Scheduling/TestScheduler.cs index dcfd079c4e..e5485b7a1a 100644 --- a/TUnit.Engine/Scheduling/TestScheduler.cs +++ b/TUnit.Engine/Scheduling/TestScheduler.cs @@ -458,9 +458,14 @@ private async Task ExecuteWithGlobalLimitAsync( private async ValueTask ExecuteScheduledTestAsync(AbstractExecutableTest test, CancellationToken cancellationToken) { - // Scheduler grouping partitions each batch, so each test reaches this path once. - // Dependency recursion is the re-entrant path and those tests are marked for the - // TestRunner dedup ledger before scheduling begins. + if (!test.RequiresExecutionDedup) + { + await _testRunner.ExecuteTestAsync(test, cancellationToken).ConfigureAwait(false); + return; + } + + // Dependency recursion is the re-entrant path. Only those tests reuse the + // scheduler task slot; independent tests may also use it for constraint wrappers. test.ExecutionTask ??= _testRunner.ExecuteTestAsync(test, cancellationToken).AsTask(); await test.ExecutionTask.ConfigureAwait(false); } diff --git a/TUnit.UnitTests/TestRunnerTests.cs b/TUnit.UnitTests/TestRunnerTests.cs index 94a797d3e8..04e15607a4 100644 --- a/TUnit.UnitTests/TestRunnerTests.cs +++ b/TUnit.UnitTests/TestRunnerTests.cs @@ -20,7 +20,7 @@ public async Task ExecuteTestAsync_WithNoDependencies_DoesNotUseExecutingTestsLe } [Test] - public async Task ExecuteTestAsync_WithNoDependenciesAndExistingExecutionTask_AwaitsExistingTask() + public async Task ExecuteTestAsync_WithNoDependenciesAndExistingExecutionTask_IgnoresSchedulerTask() { var runner = CreateRunner(out var coordinator); var test = CreateTest("existing-execution-task"); @@ -29,13 +29,10 @@ public async Task ExecuteTestAsync_WithNoDependenciesAndExistingExecutionTask_Aw var execution = runner.ExecuteTestAsync(test, CancellationToken.None); - await Assert.That(execution.IsCompleted).IsFalse(); - - existingExecution.SetResult(); await execution; await Assert.That(runner.ExecutingTestsCount).IsEqualTo(0); - await Assert.That(coordinator.GetCallCount(test)).IsEqualTo(0); + await Assert.That(coordinator.GetCallCount(test)).IsEqualTo(1); } [Test] From 0d15129fef40a236f8a5f9cea2e19853c05e3323 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sat, 2 May 2026 16:16:55 +0100 Subject: [PATCH 4/5] fix(engine): preserve execution task contract --- .../Scheduling/ConstraintKeyScheduler.cs | 2 +- TUnit.Engine/Scheduling/TestRunner.cs | 17 +++++++++++++++++ TUnit.Engine/Scheduling/TestScheduler.cs | 2 ++ TUnit.UnitTests/TestRunnerTests.cs | 19 ++++++++++++++++++- 4 files changed, 38 insertions(+), 2 deletions(-) diff --git a/TUnit.Engine/Scheduling/ConstraintKeyScheduler.cs b/TUnit.Engine/Scheduling/ConstraintKeyScheduler.cs index e3a3788e4b..d845cb82b6 100644 --- a/TUnit.Engine/Scheduling/ConstraintKeyScheduler.cs +++ b/TUnit.Engine/Scheduling/ConstraintKeyScheduler.cs @@ -145,7 +145,7 @@ private async Task ExecuteTestAndReleaseKeysAsync( { try { - await _testRunner.ExecuteTestAsync(test, cancellationToken).ConfigureAwait(false); + await _testRunner.ExecuteTestWithoutExecutionTaskAsync(test, cancellationToken).ConfigureAwait(false); } finally { diff --git a/TUnit.Engine/Scheduling/TestRunner.cs b/TUnit.Engine/Scheduling/TestRunner.cs index 96d0a132cc..09980ddaee 100644 --- a/TUnit.Engine/Scheduling/TestRunner.cs +++ b/TUnit.Engine/Scheduling/TestRunner.cs @@ -51,6 +51,21 @@ internal TestRunner( internal int ExecutingTestsCount => _executingTests.Count; public ValueTask ExecuteTestAsync(AbstractExecutableTest test, CancellationToken cancellationToken) + { + if (!test.RequiresExecutionDedup) + { + if (test.ExecutionTask is { } executionTask) + { + return new ValueTask(executionTask); + } + + return ExecuteTestInternalAsync(test, cancellationToken); + } + + return ExecuteTestWithDependenciesAsync(test, cancellationToken); + } + + internal ValueTask ExecuteTestWithoutExecutionTaskAsync(AbstractExecutableTest test, CancellationToken cancellationToken) { if (!test.RequiresExecutionDedup) { @@ -66,6 +81,8 @@ private ValueTask ExecuteTestWithDependenciesAsync(AbstractExecutableTest test, { return new ValueTask(existingTcs.Task); } + // Continuations may re-enter scheduling paths; keep them off the thread + // that publishes ledger completion. var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); existingTcs = _executingTests.GetOrAdd(test.TestId, tcs); diff --git a/TUnit.Engine/Scheduling/TestScheduler.cs b/TUnit.Engine/Scheduling/TestScheduler.cs index e5485b7a1a..6376421290 100644 --- a/TUnit.Engine/Scheduling/TestScheduler.cs +++ b/TUnit.Engine/Scheduling/TestScheduler.cs @@ -339,6 +339,8 @@ internal static void MarkDependencyRelatedTestsForExecutionDedup(IEnumerable Date: Sat, 2 May 2026 16:24:33 +0100 Subject: [PATCH 5/5] test(engine): clarify fake parameter usage --- TUnit.UnitTests/TestRunnerTests.cs | 62 +++++++++++++++++++++++++----- 1 file changed, 53 insertions(+), 9 deletions(-) diff --git a/TUnit.UnitTests/TestRunnerTests.cs b/TUnit.UnitTests/TestRunnerTests.cs index 161a5fdef2..0f5b05da56 100644 --- a/TUnit.UnitTests/TestRunnerTests.cs +++ b/TUnit.UnitTests/TestRunnerTests.cs @@ -216,7 +216,12 @@ private sealed class StubExecutableTest : AbstractExecutableTest { public override Task CreateInstanceAsync() => Task.FromResult(new object()); - public override Task InvokeTestAsync(object instance, CancellationToken cancellationToken) => Task.CompletedTask; + public override Task InvokeTestAsync(object instance, CancellationToken cancellationToken) + { + _ = instance; + _ = cancellationToken; + return Task.CompletedTask; + } } private sealed class FakeTestCoordinator : ITestCoordinator @@ -243,6 +248,8 @@ public void SetExecutionTask(AbstractExecutableTest test, Task executionTask) public ValueTask ExecuteTestAsync(AbstractExecutableTest test, CancellationToken cancellationToken) { + _ = cancellationToken; + Task? executionTask; lock (_lock) { @@ -268,23 +275,60 @@ private static async ValueTask CompleteAfterAsync(AbstractExecutableTest test, T private sealed class FakeMessageBus : ITUnitMessageBus { - public ValueTask Discovered(TestContext testContext) => default; + public ValueTask Discovered(TestContext testContext) + { + _ = testContext; + return default; + } - public ValueTask InProgress(TestContext testContext) => default; + public ValueTask InProgress(TestContext testContext) + { + _ = testContext; + return default; + } - public ValueTask Passed(TestContext testContext, DateTimeOffset start) => default; + public ValueTask Passed(TestContext testContext, DateTimeOffset start) + { + _ = testContext; + _ = start; + return default; + } - public ValueTask Failed(TestContext testContext, Exception exception, DateTimeOffset start) => default; + public ValueTask Failed(TestContext testContext, Exception exception, DateTimeOffset start) + { + _ = testContext; + _ = exception; + _ = start; + return default; + } - public ValueTask Skipped(TestContext testContext, string reason) => default; + public ValueTask Skipped(TestContext testContext, string reason) + { + _ = testContext; + _ = reason; + return default; + } - public ValueTask Cancelled(TestContext testContext, DateTimeOffset start) => default; + public ValueTask Cancelled(TestContext testContext, DateTimeOffset start) + { + _ = testContext; + _ = start; + return default; + } - public ValueTask SessionArtifact(Artifact artifact) => default; + public ValueTask SessionArtifact(Artifact artifact) + { + _ = artifact; + return default; + } } private sealed class FakeServiceProvider : IServiceProvider { - public object? GetService(Type serviceType) => null; + public object? GetService(Type serviceType) + { + _ = serviceType; + return null; + } } }