WebApplicationFactory.WithWebHostBuilder isn't thread-safe — concurrent calls corrupt _derivedFactories and crash on dispose
- Dominant language
- C#
- Stars
- 38.4k
- Forks
- 10.9k
- Avg merge
- 2d 10h
- Merged PRs (30d)
- 281
Description
### Describe the bug
`WithWebHostBuilder` appends the new derived factory to the parent's private `_derivedFactories` `List`, and `Dispose`/`DisposeAsync` later enumerate that same list. Neither access is synchronized.
Calling `WithWebHostBuilder` concurrently on one factory races the `List.Add` — the backing array can tear during a resize, leaving a `null` slot (or dropping entries). It usually goes unnoticed until the parent is disposed and `DisposeAsyncCore` walks the list:
```csharp
foreach (var factory in _derivedFactories)
{
await ((IAsyncDisposable)factory).DisposeAsync(); // factory is null -> NRE
}
```
```
System.NullReferenceException: Object reference not set to an instance of an object.
at Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactory`1.DisposeAsync()
```
### Why we hit this
We maintain a test framework (TUnit) with a parallel ASP.NET Core integration-testing helper. The isolation pattern is: one shared root `WebApplicationFactory` per session, and each test derives its own per-test factory via `WithWebHostBuilder(...)` to layer per-test service overrides. With tests running in parallel, many `WithWebHostBuilder` calls hit the same root at once — exactly this race. Being a race, it surfaces as an intermittent NRE at teardown rather than a clear failure.
We've worked around it by serializing the call with a lock, but deriving per-test factories from a shared root is a natural way to parallelize, so it seems worth fixing (or documenting) upstream.
### Repro
```csharp
var root = new WebApplicationFactory();
await Parallel.ForEachAsync(Enumerable.Range(0, 64), (_, _) =>
{
root.WithWebHostBuilder(_ => { });
return ValueTask.CompletedTask;
});
await root.DisposeAsync(); // intermittently throws NullReferenceException
```
### Expected behavior
Either `WithWebHostBuilder` (and the dispose enumeration) is safe to call concurrently, or the docs state a single factory must not be derived from concurrently.
### Version
`Microsoft.AspNetCore.Mvc.Testing` 10.0.9; same unsynchronized `List` on `main` and back through net8.
Related: #32329 — also touches `_derivedFactories`, but that's nested factories not disposing; separate issue.
Contributor guide
Assessment
This issue has not been assessed yet.