Repository navigation
fix(engine): run synchronous keyed NotInParallel tests concurrently #7087
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
c003b97
fix(engine): run synchronous keyed NotInParallel tests concurrently
thomhurst e85ffc9
fix(engine): force keyed waiters off the scheduling loop thread
thomhurst 04d70f3
test(engine): hold keyed gate until waiter is queued
thomhurst ae84e8c
chore: merge origin/main into fix/7075-keyed-scheduler-yield
thomhurst File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.