From a1737a916584242756dc2a7879744c342c12e35f Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Tue, 31 Mar 2026 19:32:07 +0100 Subject: [PATCH 1/5] perf(aspnetcore): prevent thread pool starvation during parallel test server init WebApplicationFactory.Server is a synchronous property that blocks the calling thread while building the DI container and starting the host. When many tests start in parallel, this holds async continuation threads for the full duration of app startup, exhausting the thread pool and causing cascading slowdowns as tests queue for available threads. Fix by offloading the synchronous Server access to Task.Run (freeing the async thread immediately) and capping concurrent builds with a SemaphoreSlim (ProcessorCount * 2) to prevent flooding the thread pool. --- TUnit.AspNetCore/WebApplicationTest.cs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/TUnit.AspNetCore/WebApplicationTest.cs b/TUnit.AspNetCore/WebApplicationTest.cs index a81923172e..304fa6a6d8 100644 --- a/TUnit.AspNetCore/WebApplicationTest.cs +++ b/TUnit.AspNetCore/WebApplicationTest.cs @@ -9,6 +9,12 @@ namespace TUnit.AspNetCore; public abstract class WebApplicationTest { + // Shared across all generic instantiations of WebApplicationTest. + // WebApplicationFactory.Server is synchronous; Task.Run prevents blocking async threads, + // and this semaphore caps concurrent DI container builds to avoid thread pool starvation. + internal static readonly SemaphoreSlim ServerInitSemaphore = + new(Environment.ProcessorCount * 2, Environment.ProcessorCount * 2); + /// /// Gets a unique identifier for this test instance. /// Delegates to to ensure consistency @@ -118,8 +124,15 @@ public async Task InitializeFactoryAsync(TestContext testContext) (_, config) => ConfigureTestConfiguration(config), ConfigureWebHostBuilder)); - // Eagerly start the test server to catch configuration errors early - _ = _factory.Server; + await ServerInitSemaphore.WaitAsync(); + try + { + await Task.Run(() => _ = _factory.Server); + } + finally + { + ServerInitSemaphore.Release(); + } } [After(HookType.Test)] From 315e11fc4ed281fb65fc741752fe099f072abd7f Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Tue, 31 Mar 2026 19:49:16 +0100 Subject: [PATCH 2/5] fix(aspnetcore): respect cancellation token while waiting for server init slot If a test is cancelled while waiting for a semaphore slot, WaitAsync now propagates the cancellation rather than blocking indefinitely. --- TUnit.AspNetCore/WebApplicationTest.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TUnit.AspNetCore/WebApplicationTest.cs b/TUnit.AspNetCore/WebApplicationTest.cs index 304fa6a6d8..1a988d0ca6 100644 --- a/TUnit.AspNetCore/WebApplicationTest.cs +++ b/TUnit.AspNetCore/WebApplicationTest.cs @@ -124,7 +124,7 @@ public async Task InitializeFactoryAsync(TestContext testContext) (_, config) => ConfigureTestConfiguration(config), ConfigureWebHostBuilder)); - await ServerInitSemaphore.WaitAsync(); + await ServerInitSemaphore.WaitAsync(testContext.Execution.CancellationToken); try { await Task.Run(() => _ = _factory.Server); From 1ba592382d2940bcaf422ed4f487b94743754f38 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Tue, 31 Mar 2026 19:58:46 +0100 Subject: [PATCH 3/5] refactor(aspnetcore): tighten semaphore visibility and cap concurrent server builds at 8 - Change ServerInitSemaphore from internal to protected: private would break subclass access, internal was broader than needed - Cap concurrent builds at Min(ProcessorCount*2, 8): startup is reflection/I/O-bound so ProcessorCount alone over-provisions on high-core-count CI machines --- TUnit.AspNetCore/WebApplicationTest.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/TUnit.AspNetCore/WebApplicationTest.cs b/TUnit.AspNetCore/WebApplicationTest.cs index 1a988d0ca6..0554a05707 100644 --- a/TUnit.AspNetCore/WebApplicationTest.cs +++ b/TUnit.AspNetCore/WebApplicationTest.cs @@ -12,8 +12,11 @@ public abstract class WebApplicationTest // Shared across all generic instantiations of WebApplicationTest. // WebApplicationFactory.Server is synchronous; Task.Run prevents blocking async threads, // and this semaphore caps concurrent DI container builds to avoid thread pool starvation. - internal static readonly SemaphoreSlim ServerInitSemaphore = - new(Environment.ProcessorCount * 2, Environment.ProcessorCount * 2); + // Capped at 8: startup is reflection/I/O-bound, not CPU-bound, so ProcessorCount alone + // would allow too many concurrent builds on high-core-count machines. + private static readonly int _maxConcurrentServerInits = Math.Min(Environment.ProcessorCount * 2, 8); + protected static readonly SemaphoreSlim ServerInitSemaphore = + new(_maxConcurrentServerInits, _maxConcurrentServerInits); /// /// Gets a unique identifier for this test instance. From 5bcc570a5416d2374f4df0dc424297adc85f7fce Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Tue, 31 Mar 2026 20:08:41 +0100 Subject: [PATCH 4/5] fix(aspnetcore): forward cancellation token into Task.Run for server build Passes the test cancellation token to Task.Run so that if a test is already cancelled when the slot is acquired, the task is not started. Also adds a comment clarifying the semaphore guards only Server access, not the factory configuration above it. --- TUnit.AspNetCore/WebApplicationTest.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/TUnit.AspNetCore/WebApplicationTest.cs b/TUnit.AspNetCore/WebApplicationTest.cs index 0554a05707..9ca712f8ed 100644 --- a/TUnit.AspNetCore/WebApplicationTest.cs +++ b/TUnit.AspNetCore/WebApplicationTest.cs @@ -127,10 +127,12 @@ public async Task InitializeFactoryAsync(TestContext testContext) (_, config) => ConfigureTestConfiguration(config), ConfigureWebHostBuilder)); + // Semaphore guards only the Server property access (the synchronous host build), + // not the factory creation above which is fast synchronous configuration. await ServerInitSemaphore.WaitAsync(testContext.Execution.CancellationToken); try { - await Task.Run(() => _ = _factory.Server); + await Task.Run(() => _ = _factory.Server, testContext.Execution.CancellationToken); } finally { From b47bc90925d27c662ad85db7122708e5c8e800af Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Tue, 31 Mar 2026 20:28:55 +0100 Subject: [PATCH 5/5] refactor(aspnetcore): tighten semaphore to private protected protected would expose ServerInitSemaphore to user-defined subclasses outside this assembly once shipped as a NuGet package. private protected restricts access to derived classes within this assembly only, which is the correct boundary since WebApplicationTest has an internal constructor. --- TUnit.AspNetCore/WebApplicationTest.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TUnit.AspNetCore/WebApplicationTest.cs b/TUnit.AspNetCore/WebApplicationTest.cs index 9ca712f8ed..fd31cf9d7d 100644 --- a/TUnit.AspNetCore/WebApplicationTest.cs +++ b/TUnit.AspNetCore/WebApplicationTest.cs @@ -15,7 +15,7 @@ public abstract class WebApplicationTest // Capped at 8: startup is reflection/I/O-bound, not CPU-bound, so ProcessorCount alone // would allow too many concurrent builds on high-core-count machines. private static readonly int _maxConcurrentServerInits = Math.Min(Environment.ProcessorCount * 2, 8); - protected static readonly SemaphoreSlim ServerInitSemaphore = + private protected static readonly SemaphoreSlim ServerInitSemaphore = new(_maxConcurrentServerInits, _maxConcurrentServerInits); ///