Skip to content

AddCache is not safe to call concurrently — static registration dictionary races #55

Description

@poxet

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions