Skip to content
Merged
Changes from 1 commit
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
Prev Previous commit
review: address #5693 feedback — DOP cap + comment
- Align unlimited path with default path: both now cap at
  ProcessorCount * 4 so opting into unlimited isn't a regression.
- Correct comment about Parallel.ForEachAsync default behaviour.
  • Loading branch information
thomhurst committed Apr 24, 2026
commit 9c43f0bc50182b6b93f6b57bf953b980fc635e21
78 changes: 23 additions & 55 deletions TUnit.Engine/Scheduling/TestScheduler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,6 @@ namespace TUnit.Engine.Scheduling;

internal sealed class TestScheduler : ITestScheduler
{
// Cap the unlimited-parallelism path to ProcessorCount * 2 to avoid oversubscribing
// the thread pool / IOCP when thousands of continuations are scheduled at once.
// Without this, Parallel.ForEachAsync defaults to ProcessorCount which still produces
// near-simultaneous continuations that saturate GetQueuedCompletionStatus under load.
private static readonly int UnlimitedPathMaxDop = Environment.ProcessorCount * 2;

private readonly TUnitFrameworkLogger _logger;
private readonly ITestGroupingService _groupingService;
private readonly ITUnitMessageBus _messageBus;
Expand All @@ -33,7 +27,7 @@ internal sealed class TestScheduler : ITestScheduler
private readonly StaticPropertyHandler _staticPropertyHandler;
private readonly IDynamicTestQueue _dynamicTestQueue;
private readonly Lazy<int> _maxParallelism;
private readonly Lazy<SemaphoreSlim?> _maxParallelismSemaphore;
private readonly Lazy<SemaphoreSlim> _maxParallelismSemaphore;

public TestScheduler(
TUnitFrameworkLogger logger,
Expand Down Expand Up @@ -63,10 +57,8 @@ public TestScheduler(

_maxParallelism = new Lazy<int>(() => GetMaxParallelism(logger, commandLineOptions));

_maxParallelismSemaphore = new Lazy<SemaphoreSlim?>(() =>
_maxParallelism.Value == int.MaxValue
? null
: new SemaphoreSlim(_maxParallelism.Value, _maxParallelism.Value));
_maxParallelismSemaphore = new Lazy<SemaphoreSlim>(() =>
new SemaphoreSlim(_maxParallelism.Value, _maxParallelism.Value));
}

#if NET8_0_OR_GREATER
Expand Down Expand Up @@ -314,45 +306,14 @@ private async Task ProcessDynamicTestQueueAsync(CancellationToken cancellationTo
#if NET8_0_OR_GREATER
[System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode("Test execution involves reflection for hooks and initialization")]
#endif
private async Task ExecuteTestsAsync(
private Task ExecuteTestsAsync(
AbstractExecutableTest[] tests,
CancellationToken cancellationToken)
{
var semaphore = _maxParallelismSemaphore.Value;
if (semaphore != null)
{
await ExecuteWithGlobalLimitAsync(tests, semaphore, cancellationToken).ConfigureAwait(false);
}
else
{
#if NET8_0_OR_GREATER
// Use Parallel.ForEachAsync for bounded concurrency (eliminates unbounded Task.Run queue depth)
// Cap MaxDegreeOfParallelism to ProcessorCount * 2 even on the unlimited path so
// large test counts do not saturate the IOCP thread with simultaneous continuations.
await Parallel.ForEachAsync(
tests,
new ParallelOptions
{
MaxDegreeOfParallelism = UnlimitedPathMaxDop,
CancellationToken = cancellationToken
},
async (test, ct) =>
{
test.ExecutionTask ??= _testRunner.ExecuteTestAsync(test, ct).AsTask();
await test.ExecutionTask.ConfigureAwait(false);
}
).ConfigureAwait(false);
#else
// Fallback for netstandard2.0: use Task.WhenAll (still better than unbounded Task.Run)
var tasks = new Task[tests.Length];
for (var i = 0; i < tests.Length; i++)
{
var test = tests[i];
tasks[i] = test.ExecutionTask ??= _testRunner.ExecuteTestAsync(test, cancellationToken).AsTask();
}
await Task.WhenAll(tasks).ConfigureAwait(false);
#endif
}
// All paths run through the semaphore-backed limiter so the DOP cap is
// unified in GetMaxParallelism. "Unlimited" is resolved to the default
// cap (ProcessorCount * 4) there rather than being a separate code path.
return ExecuteWithGlobalLimitAsync(tests, _maxParallelismSemaphore.Value, cancellationToken);
}

#if NET8_0_OR_GREATER
Expand Down Expand Up @@ -438,16 +399,24 @@ private async Task WaitForTasksWithFailFastHandling(IEnumerable<Task> tasks, Can

private static int GetMaxParallelism(ILogger logger, ICommandLineOptions commandLineOptions)
{
// "Unlimited" (caller passed 0) resolves to the same cap as the default path.
// Parallel.ForEachAsync with MaxDegreeOfParallelism = -1 is truly unbounded —
// with thousands of tests this saturates IOCP threads, so we always apply a cap.
// ProcessorCount * 4 is empirically sized for async/IO-bound workloads.
var defaultLimit = Environment.ProcessorCount * 4;

// Check command line argument first (highest priority)
if (commandLineOptions.TryGetOptionArgumentList(
MaximumParallelTestsCommandProvider.MaximumParallelTests,
out var args) && args.Length > 0 && int.TryParse(args[0], out var maxParallelTests))
{
if (maxParallelTests == 0)
{
// 0 means unlimited (backwards compat for advanced users)
logger.LogDebug("Maximum parallel tests: unlimited (from command line)");
return int.MaxValue;
// 0 historically meant "unlimited"; we now treat it the same as the
// default cap so the unlimited path is not a regression against the
// default path.
logger.LogDebug($"Maximum parallel tests: unlimited requested from command line, capped to default {defaultLimit} to avoid IOCP saturation");
return defaultLimit;
}

if (maxParallelTests > 0)
Expand All @@ -463,8 +432,8 @@ private static int GetMaxParallelism(ILogger logger, ICommandLineOptions command
{
if (envLimit == 0)
{
logger.LogDebug("Maximum parallel tests: unlimited (from TUNIT_MAX_PARALLEL_TESTS environment variable)");
return int.MaxValue;
logger.LogDebug($"Maximum parallel tests: unlimited requested from TUNIT_MAX_PARALLEL_TESTS, capped to default {defaultLimit} to avoid IOCP saturation");
return defaultLimit;
}

if (envLimit > 0)
Expand All @@ -479,8 +448,8 @@ private static int GetMaxParallelism(ILogger logger, ICommandLineOptions command
{
if (codeLimit == 0)
{
logger.LogDebug("Maximum parallel tests: unlimited (from TUnitSettings)");
return int.MaxValue;
logger.LogDebug($"Maximum parallel tests: unlimited requested from TUnitSettings, capped to default {defaultLimit} to avoid IOCP saturation");
return defaultLimit;
}

logger.LogDebug($"Maximum parallel tests limit set to {codeLimit} (from TUnitSettings)");
Expand All @@ -489,7 +458,6 @@ private static int GetMaxParallelism(ILogger logger, ICommandLineOptions command

// Default: 4x CPU cores (empirically optimized for async/IO-bound workloads)
// Users can override via --maximum-parallel-tests or TUNIT_MAX_PARALLEL_TESTS
var defaultLimit = Environment.ProcessorCount * 4;
logger.LogDebug($"Maximum parallel tests limit defaulting to {defaultLimit} ({Environment.ProcessorCount} processors * 4)");
return defaultLimit;
}
Expand Down
Loading