< Summary - AsyncResponse (Release / net8.0+net10.0 / unit+integration)

Information
Class: AsyncResponse.UnresolvableTypeNames
Assembly: AsyncResponse.Core
File(s): /_/src/AsyncResponse.Core/UnresolvableTypeNames.cs
Line coverage
95%
Covered lines: 19
Uncovered lines: 1
Coverable lines: 20
Total lines: 95
Line coverage: 95%
Branch coverage
75%
Covered branches: 6
Total branches: 8
Branch coverage: 75%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%11100%
EnsureAssemblyLoadInvalidation()75%4488.88%
Invalidate()100%11100%
GenerationBeforeScan()100%11100%
IsKnownMiss(...)100%22100%
RecordMiss(...)50%22100%

File(s)

/_/src/AsyncResponse.Core/UnresolvableTypeNames.cs

#LineLine coverage
 1using System.Collections.Concurrent;
 2
 3namespace AsyncResponse;
 4
 5/// <summary>
 6/// Shared negative cache for persisted type names (callback service interfaces and recovery
 7/// payload types) that already failed a full resolution pass — the default-context assembly scan
 8/// plus the <see cref="AsyncResponseTypeResolution"/> resolvers. Without it, every delivery naming
 9/// an unresolvable type (a poisoned recovery row, a renamed class) re-walks every loaded assembly
 10/// on every attempt.
 11/// <para>
 12/// Capacity-bounded so hostile inputs cannot grow it without limit (at capacity, novel
 13/// unresolvable names simply fall back to scanning — correctness never depends on this cache),
 14/// and invalidated on the only events that can turn a miss into a hit: a new assembly loading, or
 15/// a custom resolver registering.
 16/// </para>
 17/// <para>
 18/// Entries are stamped with the invalidation GENERATION observed before their failed scan, not
 19/// just stored: a plain clear-on-register has a race — an in-flight miss that started against the
 20/// old resolver set can insert AFTER the clear, permanently poisoning the name. A stale stamp
 21/// (generation advanced mid-scan) makes the entry a non-hit, so the next lookup rescans with the
 22/// new resolvers.
 23/// </para>
 24/// </summary>
 25internal static class UnresolvableTypeNames
 26{
 1627    private static readonly ConcurrentDictionary<string, int> Misses = new(StringComparer.Ordinal);
 28    private const int Capacity = 1024;
 29    private static int _generation;
 30
 31    // The AssemblyLoad invalidation hook is registered on first use, NOT from a static constructor
 32    // and NOT from a module initializer: an explicit static ctor forfeits beforefieldinit, adding a
 33    // class-initialization check to every static access, and [ModuleInitializer] is analyzer-banned
 34    // in library code (CA2255). First-call registration costs one volatile read per type
 35    // resolution, off every conversion hot path.
 1636    private static readonly object _assemblyLoadGate = new();
 37    private static bool _assemblyLoadHooked;
 38
 39    /// <summary>
 40    /// Must precede any cache consult/populate: a miss cached without the invalidation hook active
 41    /// could outlive a later assembly load that makes the name resolvable.
 42    /// </summary>
 43    internal static void EnsureAssemblyLoadInvalidation()
 44    {
 735945        if (Volatile.Read(ref _assemblyLoadHooked))
 734346            return;
 47
 48        // Attach-then-publish under a gate: publishing the flag before the handler was attached
 49        // let a concurrent thread proceed past the fast path, cache a miss, and race an assembly
 50        // load into the unhooked window — a false negative that nothing would ever invalidate.
 51        // The lock is cold-path only; the steady state is the single volatile read above.
 1652        lock (_assemblyLoadGate)
 53        {
 1654            if (_assemblyLoadHooked)
 055                return;
 56
 36257            AppDomain.CurrentDomain.AssemblyLoad += static (_, _) => Invalidate();
 1658            Volatile.Write(ref _assemblyLoadHooked, true);
 1659        }
 1660    }
 61
 62    /// <summary>
 63    /// Invalidates the cache (a new resolver or assembly may resolve cached misses). The
 64    /// generation bump is what guarantees correctness for in-flight scans; the clear just
 65    /// reclaims memory.
 66    /// </summary>
 67    internal static void Invalidate()
 68    {
 51469        Interlocked.Increment(ref _generation);
 51470        Misses.Clear();
 51471    }
 72
 73    /// <summary>Reads the generation to stamp on a miss recorded after an upcoming scan.</summary>
 33574    internal static int GenerationBeforeScan() => Volatile.Read(ref _generation);
 75
 76    /// <summary>
 77    /// Whether <paramref name="typeFullName"/> already failed a full scan in the CURRENT
 78    /// generation — a stale stamp means the miss may have raced a resolver registration or
 79    /// assembly load, so the caller rescans.
 80    /// </summary>
 81    internal static bool IsKnownMiss(string typeFullName)
 34982        => Misses.TryGetValue(typeFullName, out var missGeneration)
 34983           && missGeneration == Volatile.Read(ref _generation);
 84
 85    /// <summary>
 86    /// Records a failed full scan, stamped with the generation observed BEFORE the scan: if a
 87    /// resolver registered while the scan ran, the stamp is already stale and the entry never
 88    /// blocks a re-resolve.
 89    /// </summary>
 90    internal static void RecordMiss(string typeFullName, int generationBeforeScan)
 91    {
 4892        if (Misses.Count < Capacity)
 4893            Misses[typeFullName] = generationBeforeScan;
 4894    }
 95}