| | | 1 | | using System.Collections.Concurrent; |
| | | 2 | | |
| | | 3 | | namespace 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> |
| | | 25 | | internal static class UnresolvableTypeNames |
| | | 26 | | { |
| | 16 | 27 | | 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. |
| | 16 | 36 | | 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 | | { |
| | 7359 | 45 | | if (Volatile.Read(ref _assemblyLoadHooked)) |
| | 7343 | 46 | | 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. |
| | 16 | 52 | | lock (_assemblyLoadGate) |
| | | 53 | | { |
| | 16 | 54 | | if (_assemblyLoadHooked) |
| | 0 | 55 | | return; |
| | | 56 | | |
| | 362 | 57 | | AppDomain.CurrentDomain.AssemblyLoad += static (_, _) => Invalidate(); |
| | 16 | 58 | | Volatile.Write(ref _assemblyLoadHooked, true); |
| | 16 | 59 | | } |
| | 16 | 60 | | } |
| | | 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 | | { |
| | 514 | 69 | | Interlocked.Increment(ref _generation); |
| | 514 | 70 | | Misses.Clear(); |
| | 514 | 71 | | } |
| | | 72 | | |
| | | 73 | | /// <summary>Reads the generation to stamp on a miss recorded after an upcoming scan.</summary> |
| | 335 | 74 | | 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) |
| | 349 | 82 | | => Misses.TryGetValue(typeFullName, out var missGeneration) |
| | 349 | 83 | | && 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 | | { |
| | 48 | 92 | | if (Misses.Count < Capacity) |
| | 48 | 93 | | Misses[typeFullName] = generationBeforeScan; |
| | 48 | 94 | | } |
| | | 95 | | } |