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
70 changes: 40 additions & 30 deletions TUnit.AspNetCore.Core/TestWebApplicationFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,50 +26,60 @@ public abstract class TestWebApplicationFactory<TEntryPoint> : WebApplicationFac
// belong to its own request pipeline vs. a sibling factory's.
private readonly CorrelationScope _correlationScope = new();

// Serializes WithWebHostBuilder, which mutates the base factory's internal
// _derivedFactories List<T> 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<TEntryPoint> GetIsolatedFactory(
TestContext testContext,
WebApplicationTestOptions options,
Action<IServiceCollection> configureIsolatedServices,
Action<WebHostBuilderContext, IConfigurationBuilder> configureIsolatedAppConfiguration,
Action<IWebHostBuilder>? 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<IHttpMessageHandlerBuilderFilter, TUnitHttpClientFilter>());
}

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<IHttpMessageHandlerBuilderFilter, TUnitHttpClientFilter>());
}

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)
Expand Down
65 changes: 65 additions & 0 deletions TUnit.AspNetCore.Tests/IsolatedFactoryConcurrencyTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
using Microsoft.AspNetCore.Mvc.Testing;
using TUnit.AspNetCore;

namespace TUnit.AspNetCore.Tests;

/// <summary>
/// Regression tests for the race in <see cref="TestWebApplicationFactory{TEntryPoint}.GetIsolatedFactory"/>.
/// <para>
/// A shared (e.g. <c>SharedType.PerTestSession</c>) factory has <c>GetIsolatedFactory</c> invoked from
/// every test's parallel <c>Before(Test)</c> hook. Internally that calls
/// <see cref="WebApplicationFactory{TEntryPoint}.WithWebHostBuilder"/>, which appends to the base
/// factory's private <c>_derivedFactories</c> <see cref="List{T}"/> with no synchronization. Concurrent
/// <c>List.Add</c> calls tear the backing array (lost entries / null slots); the damage only surfaces
/// later as a <see cref="NullReferenceException"/> when the shared factory is disposed and enumerates
/// that list. These tests hammer the call concurrently and then dispose, reproducing that crash.
/// </para>
/// </summary>
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();

// WhenAll re-throws if any concurrent call hit a torn-array exception during Add.
var derived = await CreateIsolatedFactoriesConcurrentlyAsync(globalFactory);
await Assert.That(derived.Length).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<WebApplicationFactory<Program>[]> 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);
}
}
Loading