From e5174b3dd475647dc88e3becd47121031d6fdf79 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Mon, 15 Jun 2026 15:31:51 +0100 Subject: [PATCH 1/2] fix(aspnetcore): serialize WithWebHostBuilder to stop _derivedFactories race A shared (e.g. SharedType.PerTestSession) GlobalFactory has GetIsolatedFactory called from every test's parallel Before(Test) hook. Internally that calls WebApplicationFactory.WithWebHostBuilder, which appends to the base factory's private _derivedFactories List with no synchronization. Concurrent List.Add tears the backing array (lost entries / null slots); the damage only surfaces later as a NullReferenceException inside WebApplicationFactory.DisposeAsync when the shared factory is disposed and enumerates that list (via ObjectTracker). Wrap the WithWebHostBuilder call in a per-factory lock. The call is synchronous and only does fast configuration, so a plain lock is enough; the expensive host build is throttled separately by ServerInitSemaphore. Add a regression test that hammers GetIsolatedFactory concurrently then disposes; it reproduces the exact NRE without the lock and passes with it. --- .../TestWebApplicationFactory.cs | 73 +++++++++++-------- .../IsolatedFactoryConcurrencyTests.cs | 68 +++++++++++++++++ 2 files changed, 111 insertions(+), 30 deletions(-) create mode 100644 TUnit.AspNetCore.Tests/IsolatedFactoryConcurrencyTests.cs diff --git a/TUnit.AspNetCore.Core/TestWebApplicationFactory.cs b/TUnit.AspNetCore.Core/TestWebApplicationFactory.cs index 9bed5b45c0..ec8e66978f 100644 --- a/TUnit.AspNetCore.Core/TestWebApplicationFactory.cs +++ b/TUnit.AspNetCore.Core/TestWebApplicationFactory.cs @@ -26,6 +26,16 @@ public abstract class TestWebApplicationFactory : WebApplicationFac // belong to its own request pipeline vs. a sibling factory's. private readonly CorrelationScope _correlationScope = new(); + // Serializes WithWebHostBuilder calls. The shared (e.g. PerTestSession) GlobalFactory + // has GetIsolatedFactory invoked from every test's parallel Before(Test) hook, and + // WithWebHostBuilder mutates the base factory's internal _derivedFactories List + // with no synchronization of its own. Concurrent List.Add tears the backing array + // (lost entries / null slots), which surfaces much later as a NullReferenceException + // when the GlobalFactory is disposed and enumerates that list. WithWebHostBuilder is + // synchronous and the work inside is fast configuration, so a plain lock is enough; + // the expensive host build is throttled separately by ServerInitSemaphore. + private readonly object _isolationLock = new(); + public WebApplicationFactory GetIsolatedFactory( TestContext testContext, WebApplicationTestOptions options, @@ -33,43 +43,46 @@ public WebApplicationFactory GetIsolatedFactory( Action configureIsolatedAppConfiguration, Action? configureWebHostBuilder = null) { - return WithWebHostBuilder(builder => + lock (_isolationLock) { - var configurationBuilder = new ConfigurationManager(); - ConfigureStartupConfiguration(configurationBuilder); - - foreach (var keyValuePair in configurationBuilder.AsEnumerable()) + return WithWebHostBuilder(builder => { - builder.UseSetting(keyValuePair.Key, keyValuePair.Value); - } + var configurationBuilder = new ConfigurationManager(); + ConfigureStartupConfiguration(configurationBuilder); - builder - .ConfigureAppConfiguration(configureIsolatedAppConfiguration) - .ConfigureTestServices(services => + foreach (var keyValuePair in configurationBuilder.AsEnumerable()) { - configureIsolatedServices(services); - services.AddSingleton(testContext); - services.AddTUnitLogging(testContext); + builder.UseSetting(keyValuePair.Key, keyValuePair.Value); + } - if (options.AutoPropagateHttpClientFactory) + builder + .ConfigureAppConfiguration(configureIsolatedAppConfiguration) + .ConfigureTestServices(services => { - services.TryAddEnumerable( - ServiceDescriptor.Singleton()); - } - - if (options.AutoConfigureOpenTelemetry) - { - AddTUnitOpenTelemetry(services, _correlationScope); - } - }); - - if (options.EnableHttpExchangeCapture) - { - builder.ConfigureTestServices(services => services.AddHttpExchangeCapture()); - } + configureIsolatedServices(services); + services.AddSingleton(testContext); + services.AddTUnitLogging(testContext); + + if (options.AutoPropagateHttpClientFactory) + { + services.TryAddEnumerable( + ServiceDescriptor.Singleton()); + } + + if (options.AutoConfigureOpenTelemetry) + { + AddTUnitOpenTelemetry(services, _correlationScope); + } + }); + + if (options.EnableHttpExchangeCapture) + { + builder.ConfigureTestServices(services => services.AddHttpExchangeCapture()); + } - configureWebHostBuilder?.Invoke(builder); - }); + configureWebHostBuilder?.Invoke(builder); + }); + } } protected virtual void ConfigureStartupConfiguration(IConfigurationBuilder configurationBuilder) diff --git a/TUnit.AspNetCore.Tests/IsolatedFactoryConcurrencyTests.cs b/TUnit.AspNetCore.Tests/IsolatedFactoryConcurrencyTests.cs new file mode 100644 index 0000000000..85065abe12 --- /dev/null +++ b/TUnit.AspNetCore.Tests/IsolatedFactoryConcurrencyTests.cs @@ -0,0 +1,68 @@ +using Microsoft.AspNetCore.Mvc.Testing; +using TUnit.AspNetCore; + +namespace TUnit.AspNetCore.Tests; + +/// +/// Regression tests for the race in . +/// +/// A shared (e.g. SharedType.PerTestSession) factory has GetIsolatedFactory invoked from +/// every test's parallel Before(Test) hook. Internally that calls +/// , which appends to the base +/// factory's private _derivedFactories with no synchronization. Concurrent +/// List.Add calls tear the backing array (lost entries / null slots); the damage only surfaces +/// later as a when the shared factory is disposed and enumerates +/// that list. These tests hammer the call concurrently and then dispose, reproducing that crash. +/// +/// +public class IsolatedFactoryConcurrencyTests +{ + private const int Concurrency = 64; + private const int Rounds = 8; + + [Test] + public async Task Concurrent_GetIsolatedFactory_Then_Dispose_Does_Not_Throw() + { + for (var round = 0; round < Rounds; round++) + { + var globalFactory = new TestWebAppFactory(); + + var derived = await CreateIsolatedFactoriesConcurrentlyAsync(globalFactory); + + // Every call must have produced a distinct, non-null derived factory. + await Assert.That(derived.Length).IsEqualTo(Concurrency); + await Assert.That(derived.All(f => f is not null)).IsTrue(); + await Assert.That(derived.Distinct().Count()).IsEqualTo(Concurrency); + + // Disposing the shared factory enumerates _derivedFactories; a torn list NREs here. + await Assert.That(async () => await globalFactory.DisposeAsync()).ThrowsNothing(); + } + } + + private static async Task[]> CreateIsolatedFactoriesConcurrentlyAsync( + TestWebAppFactory globalFactory) + { + var testContext = TestContext.Current!; + var options = new WebApplicationTestOptions(); + + // Release all workers at once to maximise the window for a concurrent List.Add tear. + var gate = new TaskCompletionSource(); + + var tasks = Enumerable.Range(0, Concurrency) + .Select(_ => Task.Run(async () => + { + await gate.Task; + return globalFactory.GetIsolatedFactory( + testContext, + options, + static _ => { }, + static (_, _) => { }, + static _ => { }); + })) + .ToArray(); + + gate.SetResult(); + + return await Task.WhenAll(tasks); + } +} From 6bcd8237cc69ecb3ebffa4d14ffa78c6432ec61d Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Mon, 15 Jun 2026 15:42:19 +0100 Subject: [PATCH 2/2] refactor(aspnetcore): trim isolation-lock comment and redundant test assertions --- TUnit.AspNetCore.Core/TestWebApplicationFactory.cs | 13 +++++-------- .../IsolatedFactoryConcurrencyTests.cs | 5 +---- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/TUnit.AspNetCore.Core/TestWebApplicationFactory.cs b/TUnit.AspNetCore.Core/TestWebApplicationFactory.cs index ec8e66978f..aa20d3ae60 100644 --- a/TUnit.AspNetCore.Core/TestWebApplicationFactory.cs +++ b/TUnit.AspNetCore.Core/TestWebApplicationFactory.cs @@ -26,14 +26,11 @@ public abstract class TestWebApplicationFactory : WebApplicationFac // belong to its own request pipeline vs. a sibling factory's. private readonly CorrelationScope _correlationScope = new(); - // Serializes WithWebHostBuilder calls. The shared (e.g. PerTestSession) GlobalFactory - // has GetIsolatedFactory invoked from every test's parallel Before(Test) hook, and - // WithWebHostBuilder mutates the base factory's internal _derivedFactories List - // with no synchronization of its own. Concurrent List.Add tears the backing array - // (lost entries / null slots), which surfaces much later as a NullReferenceException - // when the GlobalFactory is disposed and enumerates that list. WithWebHostBuilder is - // synchronous and the work inside is fast configuration, so a plain lock is enough; - // the expensive host build is throttled separately by ServerInitSemaphore. + // Serializes WithWebHostBuilder, which mutates the base factory's internal + // _derivedFactories List with no synchronization. Concurrent calls (one per + // parallel Before(Test) hook on a shared factory) tear the backing array, surfacing + // later as a NullReferenceException when the disposed factory enumerates it. The call + // is synchronous fast configuration, so a plain lock suffices. private readonly object _isolationLock = new(); public WebApplicationFactory GetIsolatedFactory( diff --git a/TUnit.AspNetCore.Tests/IsolatedFactoryConcurrencyTests.cs b/TUnit.AspNetCore.Tests/IsolatedFactoryConcurrencyTests.cs index 85065abe12..7e9f1d0d5d 100644 --- a/TUnit.AspNetCore.Tests/IsolatedFactoryConcurrencyTests.cs +++ b/TUnit.AspNetCore.Tests/IsolatedFactoryConcurrencyTests.cs @@ -27,12 +27,9 @@ public async Task Concurrent_GetIsolatedFactory_Then_Dispose_Does_Not_Throw() { var globalFactory = new TestWebAppFactory(); + // WhenAll re-throws if any concurrent call hit a torn-array exception during Add. var derived = await CreateIsolatedFactoriesConcurrentlyAsync(globalFactory); - - // Every call must have produced a distinct, non-null derived factory. await Assert.That(derived.Length).IsEqualTo(Concurrency); - await Assert.That(derived.All(f => f is not null)).IsTrue(); - await Assert.That(derived.Distinct().Count()).IsEqualTo(Concurrency); // Disposing the shared factory enumerates _derivedFactories; a torn list NREs here. await Assert.That(async () => await globalFactory.DisposeAsync()).ThrowsNothing();