CacheRegistrationExtensions merges each AddCache call into a process-wide static dictionary without synchronization, so two hosts built concurrently in one process can throw.
private static readonly Dictionary<Type, CacheTypeOptions> _configuredPersistTypes = new();
private static void AppendPreviousRegistrations(CacheOptions o)
{
var previouslyRegisteredTypes = _configuredPersistTypes.ToArray(); // sizes its array from Count
foreach (var item in o.GetRegistered())
{
_configuredPersistTypes.TryAdd(item.Key, item.Value); // grows it from another thread
}
...
}
ToArray() on a Dictionary allocates from Count and then copies. If another thread is inside TryAdd on the same static instance, the copy overruns the array it just sized:
System.ArgumentException : Destination array is not long enough to copy all the items in the collection.
at System.Collections.Generic.Dictionary`2.CopyTo(KeyValuePair`2[] array, Int32 index)
at System.Linq.Enumerable.ICollectionToArray[TSource](ICollection`1 collection)
at Tharga.Cache.CacheRegistrationExtensions.AppendPreviousRegistrations(CacheOptions o)
at Tharga.Cache.CacheRegistrationExtensions.AddCache(IServiceCollection serviceCollection, Action`1 options)
How we hit it
An xUnit integration-test assembly where each test constructs its own WebApplicationFactory<Program>. xUnit runs test classes in parallel, so several hosts build at once, each calling AddCache (for us transitively, via AddQuilt4NetApplicationInsightsClientRemote). It appeared only after the assembly grew past a handful of concurrent boots, which made it read as a flaky test rather than as a race — the failure names an array copy, and nothing in the failing test mentions caching.
Worked around with [assembly: CollectionBehavior(DisableTestParallelization = true)].
Why it seems worth fixing rather than documenting
AddCache is a registration API, and registration is normally safe to call from anywhere. Nothing in the signature suggests process-wide shared state, so a consumer has no reason to serialize it. Any host that builds two service providers concurrently — parallel tests, a multi-tenant host spinning up isolated containers, a WebApplicationFactory used from more than one fixture — can hit it, and it will present as an unrelated intermittent failure.
Possible directions
- A
lock around the read-and-merge in AppendPreviousRegistrations, which is the smallest fix.
ConcurrentDictionary — note this alone is not sufficient: ToArray() on a ConcurrentDictionary is atomic, so it would fix this particular throw, but the read-then-merge would still interleave.
- Scoping the accumulated registrations to the
IServiceCollection rather than to the process, which removes the shared state entirely. This looks like the real fix, though it presumably changes behaviour for hosts that deliberately rely on cross-call accumulation.
Observed on Tharga.Cache 1.0.0, .NET 10.
CacheRegistrationExtensionsmerges eachAddCachecall into a process-widestaticdictionary without synchronization, so two hosts built concurrently in one process can throw.ToArray()on aDictionaryallocates fromCountand then copies. If another thread is insideTryAddon the same static instance, the copy overruns the array it just sized:How we hit it
An xUnit integration-test assembly where each test constructs its own
WebApplicationFactory<Program>. xUnit runs test classes in parallel, so several hosts build at once, each callingAddCache(for us transitively, viaAddQuilt4NetApplicationInsightsClientRemote). It appeared only after the assembly grew past a handful of concurrent boots, which made it read as a flaky test rather than as a race — the failure names an array copy, and nothing in the failing test mentions caching.Worked around with
[assembly: CollectionBehavior(DisableTestParallelization = true)].Why it seems worth fixing rather than documenting
AddCacheis a registration API, and registration is normally safe to call from anywhere. Nothing in the signature suggests process-wide shared state, so a consumer has no reason to serialize it. Any host that builds two service providers concurrently — parallel tests, a multi-tenant host spinning up isolated containers, aWebApplicationFactoryused from more than one fixture — can hit it, and it will present as an unrelated intermittent failure.Possible directions
lockaround the read-and-merge inAppendPreviousRegistrations, which is the smallest fix.ConcurrentDictionary— note this alone is not sufficient:ToArray()on aConcurrentDictionaryis atomic, so it would fix this particular throw, but the read-then-merge would still interleave.IServiceCollectionrather than to the process, which removes the shared state entirely. This looks like the real fix, though it presumably changes behaviour for hosts that deliberately rely on cross-call accumulation.Observed on
Tharga.Cache1.0.0, .NET 10.