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
24 changes: 20 additions & 4 deletions src/TUnit.Engine/Scheduling/ConstraintKeyScheduler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,10 @@ public async ValueTask ExecuteTestsWithConstraintsAsync(
// Process each test
foreach (var (test, constraintKeys, priority) in sortedTests)
{
var startSignal = new TaskCompletionSource<bool>();
// Run continuations asynchronously: a releasing test signals waiters from its
// finally block, and an inline continuation would run the next (possibly
// synchronous) test on that thread before the remaining waiters are signalled.
var startSignal = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
Comment thread
thomhurst marked this conversation as resolved.
Resolved

bool canStart;
lock (lockObject)
Expand Down Expand Up @@ -104,7 +107,13 @@ public async ValueTask ExecuteTestsWithConstraintsAsync(
await _logger.LogTraceAsync($"Starting test {test.TestId} with constraint keys: {string.Join(", ", constraintKeys)}").ConfigureAwait(false);
startSignal.SetResult(true);

var testTask = ExecuteTestAndReleaseKeysAsync(test, constraintKeys, lockedKeys, lockObject, waitingTestIndex, cancellationToken);
// Dispatch to the thread pool: invoking directly would run a synchronous
// test body on this loop's thread, serializing every test with distinct keys.
// Pass CancellationToken.None so the delegate always runs: the keys are already
// taken, and only its finally block releases them and signals waiting tests.
var testTask = Task.Run(
() => ExecuteTestAndReleaseKeysAsync(test, constraintKeys, lockedKeys, lockObject, waitingTestIndex, cancellationToken),
CancellationToken.None);
test.ExecutionTask = testTask;
activeTasks.Add(testTask);
}
Expand Down Expand Up @@ -136,8 +145,15 @@ private async Task WaitAndExecuteTestAsync(
WaitingTestIndex waitingTestIndex,
CancellationToken cancellationToken)
{
// Wait for signal to start
await startSignal.Task.ConfigureAwait(false);
// Wait for signal to start, always yielding. The scheduling loop calls this method
// directly, and a running test can release our keys between the loop queueing us and
// this await. Awaiting that completed signal normally would run a synchronous test
// body on the loop's thread and stall every test after it (#7075).
#if NET8_0_OR_GREATER
await startSignal.Task.ConfigureAwait(ConfigureAwaitOptions.ForceYielding);
#else
await new ForceYieldingTaskAwaitable(startSignal.Task);
#endif

if (_logger.IsTraceEnabled)
await _logger.LogTraceAsync($"Starting previously queued test {test.TestId} with constraint keys: {string.Join(", ", constraintKeys)}").ConfigureAwait(false);
Expand Down
26 changes: 26 additions & 0 deletions src/TUnit.Engine/Scheduling/ForceYieldingTaskAwaitable.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
#if !NET8_0_OR_GREATER
using System.Runtime.CompilerServices;

namespace TUnit.Engine.Scheduling;

/// <summary>
/// Stand-in for <c>ConfigureAwait(ConfigureAwaitOptions.ForceYielding)</c>, which needs .NET 8.
/// Never reports completion, so the awaiting method always yields, and resumes without a
/// captured context. Registering a continuation on an already-completed task queues it to the
/// thread pool rather than running it inline.
/// </summary>
internal readonly struct ForceYieldingTaskAwaitable(Task task) : ICriticalNotifyCompletion
{
private readonly ConfiguredTaskAwaitable.ConfiguredTaskAwaiter _awaiter = task.ConfigureAwait(false).GetAwaiter();

public ForceYieldingTaskAwaitable GetAwaiter() => this;

public bool IsCompleted => false;

public void GetResult() => _awaiter.GetResult();

public void OnCompleted(Action continuation) => _awaiter.OnCompleted(continuation);

public void UnsafeOnCompleted(Action continuation) => _awaiter.UnsafeOnCompleted(continuation);
}
#endif
264 changes: 264 additions & 0 deletions tests/TUnit.UnitTests/ConstraintKeySchedulerTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,264 @@
using Microsoft.Testing.Platform.CommandLine;
using Microsoft.Testing.Platform.Extensions.OutputDevice;
using Microsoft.Testing.Platform.OutputDevice;
using TUnit.Core;
using TUnit.Engine.CommandLineProviders;
using TUnit.Engine.Interfaces;
using TUnit.Engine.Logging;
using TUnit.Engine.Scheduling;
using TUnit.Engine.Services;
using TUnit.Engine.Services.TestExecution;
using MtpLogLevel = Microsoft.Testing.Platform.Logging.LogLevel;

namespace TUnit.UnitTests;

// Regression tests for #7075: synchronous tests with distinct [NotInParallel] keys must
// run concurrently. Each test blocks its thread on a barrier that only opens once every
// participant has started, so serialized execution times out instead of passing.
public class ConstraintKeySchedulerTests
{
private static readonly TimeSpan BarrierTimeout = TimeSpan.FromSeconds(30);

[Test]
public async Task SynchronousTests_WithDistinctKeys_RunConcurrently()
{
var coordinator = new BlockingTestCoordinator(participantCount: 4);
var scheduler = CreateScheduler(coordinator);

var tests = Enumerable.Range(1, 4)
.Select(i => Keyed($"distinct-{i}", priority: 0, $"key-{i}"))
.ToArray();

await scheduler.ExecuteTestsWithConstraintsAsync(tests, CancellationToken.None);

await Assert.That(coordinator.TimedOutTests).IsEmpty();
await Assert.That(coordinator.CompletedCount).IsEqualTo(4);
}

[Test]
public async Task SynchronousWaiters_ReleasedByTheSameTest_RunConcurrently()
{
// The gate holds both keys until both waiters are queued behind it, so both are
// released together from the gate's key-release path. Neither waiter may run inline
// on the releasing thread, or the other is never signalled while the first blocks.
var bothWaitersQueued = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var coordinator = new BlockingTestCoordinator(participantCount: 2, nonBlockingTestId: "gate", nonBlockingTestGate: bothWaitersQueued.Task);
var outputDevice = new TraceOutputDevice(text =>
{
if (text.StartsWith("Queueing test waiter-2 ", StringComparison.Ordinal))
{
bothWaitersQueued.TrySetResult();
}

return Task.CompletedTask;
});
var scheduler = CreateScheduler(coordinator, outputDevice);

(AbstractExecutableTest, IReadOnlyList<string>, int)[] tests =
[
Keyed("gate", priority: 0, "key-1", "key-2"),
Keyed("waiter-1", priority: 1, "key-1"),
Keyed("waiter-2", priority: 1, "key-2")
];

await scheduler.ExecuteTestsWithConstraintsAsync(tests, CancellationToken.None);

await Assert.That(coordinator.TimedOutTests).IsEmpty();
await Assert.That(coordinator.CompletedCount).IsEqualTo(3);
await Assert.That(outputDevice.Contains("Unblocking waiting test waiter-1 ")).IsTrue();
await Assert.That(outputDevice.Contains("Unblocking waiting test waiter-2 ")).IsTrue();
}

[Test]
public async Task SynchronousWaiter_ReleasedBeforeItAwaits_DoesNotBlockLaterTests()
{
// The gate finishes and frees the waiter's key after the scheduling loop queues the
// waiter but before the loop awaits the waiter's start signal. The waiter must still
// leave the loop's thread, or "later" is never started and the barrier times out.
// The gate holds its key until the waiter is queued, so the waiter always takes the
// queued path regardless of how quickly the thread pool runs the gate.
(AbstractExecutableTest Test, IReadOnlyList<string>, int)[] tests =
[
Keyed("gate", priority: 0, "key-1"),
Keyed("waiter", priority: 1, "key-1"),
Keyed("later", priority: 2, "key-2")
];
var gate = tests[0].Test;

var waiterQueued = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var coordinator = new BlockingTestCoordinator(participantCount: 2, nonBlockingTestId: "gate", nonBlockingTestGate: waiterQueued.Task);
var outputDevice = new TraceOutputDevice(text =>
{
if (!text.StartsWith("Queueing test waiter ", StringComparison.Ordinal))
{
return Task.CompletedTask;
}

// Runs on the scheduling loop between queueing the waiter and awaiting its signal:
// let the gate finish, and hold the loop until it has released the waiter's key.
waiterQueued.TrySetResult();
return gate.ExecutionTask!;
});
var scheduler = CreateScheduler(coordinator, outputDevice);

await scheduler.ExecuteTestsWithConstraintsAsync(tests, CancellationToken.None);

await Assert.That(coordinator.TimedOutTests).IsEmpty();
await Assert.That(coordinator.CompletedCount).IsEqualTo(3);
await Assert.That(outputDevice.Contains("Unblocking waiting test waiter ")).IsTrue();
}

private static (AbstractExecutableTest, IReadOnlyList<string>, int) Keyed(string testId, int priority, params string[] keys)
=> (TestRunnerTests.CreateTest(testId), keys, priority);

private static ConstraintKeyScheduler CreateScheduler(ITestCoordinator coordinator, TraceOutputDevice? outputDevice = null)
{
var logger = outputDevice is null
? new TUnitFrameworkLogger(null!, null!, null!, new LogLevelProvider(new CommandLineOptions(logLevel: null)))
: new TUnitFrameworkLogger(null!, outputDevice, new NoOpLogger(), new LogLevelProvider(new CommandLineOptions(logLevel: "Trace")));

var runner = new TestRunner(
coordinator,
new TestRunnerTests.FakeMessageBus(),
isFailFastEnabled: false,
new CancellationTokenSource(),
logger,
new TestStateManager(),
new ParallelLimitLockProvider(),
new NotInParallelLock());

return new ConstraintKeyScheduler(runner, logger, new HashSetPool());
}

// Runs each test body synchronously, as a non-async [Test] method would, and returns an
// already-completed ValueTask so nothing in the pipeline yields on the test's behalf.
// The non-blocking test skips the barrier and optionally waits for a gate first.
private sealed class BlockingTestCoordinator(
int participantCount,
string? nonBlockingTestId = null,
Task? nonBlockingTestGate = null) : ITestCoordinator
{
private readonly Barrier _barrier = new(participantCount);
private readonly Lock _lock = new();
private readonly List<string> _timedOutTests = [];
private int _completedCount;

public IReadOnlyList<string> TimedOutTests
{
get
{
lock (_lock)
{
return [.. _timedOutTests];
}
}
}

public int CompletedCount => Volatile.Read(ref _completedCount);

public ValueTask ExecuteTestAsync(AbstractExecutableTest test, CancellationToken cancellationToken)
{
if (test.TestId == nonBlockingTestId)
{
return nonBlockingTestGate is null
? Complete(test)
: CompleteAfterGateAsync(test, nonBlockingTestGate, cancellationToken);
}

if (!_barrier.SignalAndWait(BarrierTimeout, cancellationToken))
{
lock (_lock)
{
_timedOutTests.Add(test.TestId);
}
}

return Complete(test);
}

private async ValueTask CompleteAfterGateAsync(AbstractExecutableTest test, Task gate, CancellationToken cancellationToken)
{
try
{
await gate.WaitAsync(BarrierTimeout, cancellationToken).ConfigureAwait(false);
}
catch (TimeoutException)
{
lock (_lock)
{
_timedOutTests.Add(test.TestId);
}
}

await Complete(test).ConfigureAwait(false);
}

private ValueTask Complete(AbstractExecutableTest test)
{
Interlocked.Increment(ref _completedCount);
test.SetResult(TestState.Passed);
return default;
}
}

// Receives the scheduler's trace messages, letting a test act at a known point in the
// scheduling loop (for example, between queueing a waiter and awaiting its signal).
private sealed class TraceOutputDevice(Func<string, Task> onMessage) : IOutputDevice
{
private readonly Lock _lock = new();
private readonly List<string> _messages = [];

public bool Contains(string prefix)
{
lock (_lock)
{
return _messages.Exists(message => message.StartsWith(prefix, StringComparison.Ordinal));
}
}

public Task DisplayAsync(IOutputDeviceDataProducer producer, IOutputDeviceData data, CancellationToken cancellationToken)
{
if (data is not FormattedTextOutputDeviceData textData)
{
return Task.CompletedTask;
}

lock (_lock)
{
_messages.Add(textData.Text);
}

return onMessage(textData.Text);
}
}

private sealed class NoOpLogger : Microsoft.Testing.Platform.Logging.ILogger
{
public Task LogAsync<TState>(MtpLogLevel logLevel, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
=> Task.CompletedTask;

public void Log<TState>(MtpLogLevel logLevel, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
{
}

public bool IsEnabled(MtpLogLevel logLevel) => false;
}

private sealed class CommandLineOptions(string? logLevel) : ICommandLineOptions
{
public bool IsOptionSet(string optionName)
=> logLevel is not null && optionName == LogLevelCommandProvider.LogLevelOption;

public bool TryGetOptionArgumentList(string optionName, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out string[]? arguments)
{
if (IsOptionSet(optionName))
{
arguments = [logLevel!];
return true;
}

arguments = null;
return false;
}
}
}
4 changes: 2 additions & 2 deletions tests/TUnit.UnitTests/TestRunnerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ private static TestRunner CreateRunner(out FakeTestCoordinator coordinator)
new NotInParallelLock());
}

private static AbstractExecutableTest CreateTest(
internal static AbstractExecutableTest CreateTest(
string testId,
AbstractExecutableTest[]? dependencies = null)
{
Expand Down Expand Up @@ -311,7 +311,7 @@ private static async ValueTask CompleteAfterAsync(AbstractExecutableTest test, T
}
}

private sealed class FakeMessageBus : ITUnitMessageBus
internal sealed class FakeMessageBus : ITUnitMessageBus
{
public ValueTask Discovered(TestContext testContext)
{
Expand Down
Loading