Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions TUnit.Core/AbstractExecutableTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion TUnit.Engine/Scheduling/ConstraintKeyScheduler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ private async Task ExecuteTestAndReleaseKeysAsync(
{
try
{
await _testRunner.ExecuteTestAsync(test, cancellationToken).ConfigureAwait(false);
await _testRunner.ExecuteTestWithoutExecutionTaskAsync(test, cancellationToken).ConfigureAwait(false);
}
finally
{
Expand Down
37 changes: 33 additions & 4 deletions TUnit.Engine/Scheduling/TestRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,19 +42,48 @@ 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<string, TaskCompletionSource<bool>> _executingTests = new();
private Exception? _firstFailFastException;

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)
{
return ExecuteTestInternalAsync(test, cancellationToken);
}

return ExecuteTestWithDependenciesAsync(test, cancellationToken);
}

private ValueTask ExecuteTestWithDependenciesAsync(AbstractExecutableTest test, CancellationToken cancellationToken)
{
if (_executingTests.TryGetValue(test.TestId, out var existingTcs))
{
return new ValueTask(existingTcs.Task);
}
var tcs = new TaskCompletionSource<bool>();
// Continuations may re-enter scheduling paths; keep them off the thread
// that publishes ledger completion.
var tcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
existingTcs = _executingTests.GetOrAdd(test.TestId, tcs);

if (existingTcs != tcs)
Expand Down
69 changes: 63 additions & 6 deletions TUnit.Engine/Scheduling/TestScheduler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,8 @@ public async Task<bool> 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.
Expand Down Expand Up @@ -317,10 +319,54 @@ private async Task ExecuteDynamicBatchAsync(List<AbstractExecutableTest> 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);
}

internal static void MarkDependencyRelatedTestsForExecutionDedup(IEnumerable<AbstractExecutableTest> tests)
{
HashSet<AbstractExecutableTest>? visitedDependencyTargets = null;
Stack<AbstractExecutableTest>? pendingDependencyTargets = null;

foreach (var test in tests)
{
if (test.Dependencies.Length == 0)
{
continue;
}

test.RequiresExecutionDedup = true;

visitedDependencyTargets ??= [];
// Shared across roots so a common dependency target is marked once even
// when multiple scheduled tests reference it.
pendingDependencyTargets ??= [];

foreach (var dependency in test.Dependencies)
{
pendingDependencyTargets.Push(dependency.Test);
}

while (pendingDependencyTargets.Count > 0)
{
var dependencyTarget = pendingDependencyTargets.Pop();

if (!visitedDependencyTargets.Add(dependencyTarget))
{
continue;
}

dependencyTarget.RequiresExecutionDedup = true;

foreach (var nestedDependency in dependencyTarget.Dependencies)
{
pendingDependencyTargets.Push(nestedDependency.Test);
}
}
}
}

private void MarkGlobalNotInParallelTests(GroupedTests grouped)
{
if (grouped.NotInParallel.Length == 0)
Expand Down Expand Up @@ -362,8 +408,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);
}
}

Expand All @@ -381,8 +426,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);
}
);
}
Expand All @@ -402,8 +446,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
{
Expand All @@ -415,6 +458,20 @@ private async Task ExecuteWithGlobalLimitAsync(
}
#endif

private async ValueTask ExecuteScheduledTestAsync(AbstractExecutableTest test, CancellationToken cancellationToken)
{
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);
}

// 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
Expand Down
Loading
Loading