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

Information
Class: AsyncResponse.AsyncResponseTypeResolution
Assembly: AsyncResponse.Core
File(s): /_/src/AsyncResponse.Core/AsyncResponseTypeResolution.cs
Line coverage
96%
Covered lines: 86
Uncovered lines: 3
Coverable lines: 89
Total lines: 317
Line coverage: 96.6%
Branch coverage
89%
Covered branches: 43
Total branches: 48
Branch coverage: 89.5%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%11100%
RegisterResolver(...)100%11100%
RegisterAssembly(...)100%11100%
Unregister(...)50%2292.3%
.ctor(...)100%11100%
Dispose()100%22100%
IsWithinResolutionLimits(...)100%2020100%
DescribeForDiagnostics(...)75%4480%
ResolveLoaded(...)78.57%141496.15%
IsDefinedIn(...)100%11100%
Resolve(...)100%66100%
Reset()100%11100%

File(s)

/_/src/AsyncResponse.Core/AsyncResponseTypeResolution.cs

#LineLine coverage
 1using System.Diagnostics.CodeAnalysis;
 2using System.Reflection;
 3
 4namespace AsyncResponse;
 5
 6/// <summary>
 7/// Opt-in extensibility for resolving the service and payload types named in persisted callbacks and
 8/// recovery state. By default AsyncResponse resolves a type name against the assemblies loaded into
 9/// the default <see cref="System.Runtime.Loader.AssemblyLoadContext"/>
 10/// (<c>AppDomain.CurrentDomain.GetAssemblies()</c>). Apps that load callback targets or payload types
 11/// into a <em>separate</em> <c>AssemblyLoadContext</c> — plugin hosts, dynamic-load scenarios — can
 12/// register an extra resolver (or assembly) here so those types resolve too, instead of the recovery
 13/// callback silently failing because the type was invisible to the default context.
 14/// <para>
 15/// This is process-wide and additive: registered resolvers are consulted only when the default scan
 16/// does not find the type. Registering nothing preserves the default behavior exactly.
 17/// </para>
 18/// <para>
 19/// <b>Unloadable (collectible) contexts:</b> the library's resolution caches skip types from
 20/// collectible assemblies (resolving them per call), so AsyncResponse never pins a collectible
 21/// <c>AssemblyLoadContext</c> on its own. A registration made HERE is the exception, and it is
 22/// yours to manage: the resolver delegate lives in a process-wide list and, for
 23/// <see cref="RegisterAssembly"/>, strongly holds the assembly — which keeps its context alive for
 24/// the life of the process. Both registration methods therefore return an
 25/// <see cref="IDisposable"/>; dispose it before unloading the plugin, or the context never
 26/// collects. <c>System.Text.Json</c> pins any collectible type it serializes through
 27/// runtime-internal caches regardless — so for unloadable plugins, keep payload types and callback
 28/// service interfaces in a shared non-collectible contracts assembly and load only implementations
 29/// into the collectible context (see <c>docs/security.md</c>).
 30/// </para>
 31/// </summary>
 32public static class AsyncResponseTypeResolution
 33{
 234    private static volatile Func<string, Type?>[] _resolvers = [];
 235    private static readonly object _gate = new();
 36
 37    /// <summary>
 38    /// Registers a custom resolver consulted (after the default assembly scan) when resolving a
 39    /// persisted type name. The resolver returns the resolved <see cref="Type"/> or <c>null</c>.
 40    /// </summary>
 41    /// <returns>
 42    /// A handle that removes this registration when disposed. Disposal is idempotent and safe from
 43    /// any thread. Ignoring it keeps the resolver — and everything its closure captures — for the
 44    /// life of the process; a host loading plugins into a collectible <c>AssemblyLoadContext</c>
 45    /// must dispose it before unloading, or the context is pinned.
 46    /// </returns>
 47    public static IDisposable RegisterResolver(Func<string, Type?> resolver)
 48    {
 3649        ArgumentNullException.ThrowIfNull(resolver);
 3450        lock (_gate)
 51        {
 3452            _resolvers = [.. _resolvers, resolver];
 3453        }
 54
 55        // A new resolver can turn previously-unresolvable names into hits; drop the negative cache.
 3456        ReflectionExtensions.InvalidateUnresolvableServiceTypes();
 3457        return new ResolverRegistration(resolver);
 58    }
 59
 60    /// <summary>
 61    /// Registers an assembly (typically one loaded into a non-default <c>AssemblyLoadContext</c>) to
 62    /// be searched for persisted type names.
 63    /// </summary>
 64    /// <returns>
 65    /// A handle that removes the registration when disposed. <b>Required</b> for a collectible
 66    /// assembly: the registration holds a strong reference to it, so until this is disposed the
 67    /// assembly's <c>AssemblyLoadContext</c> cannot unload.
 68    /// </returns>
 69    [RequiresUnreferencedCode("Resolves persisted type names against the assembly by string; a trimmed app may have remo
 70                              "those types. Plugin/dynamic-load scenarios are inherently incompatible with trimming the 
 71    public static IDisposable RegisterAssembly(Assembly assembly)
 72    {
 673        ArgumentNullException.ThrowIfNull(assembly);
 874        return RegisterResolver(name => assembly.GetType(name, throwOnError: false));
 75    }
 76
 77    /// <summary>Removes one registered resolver; a no-op when it is already gone.</summary>
 78    private static void Unregister(Func<string, Type?> resolver)
 79    {
 1080        lock (_gate)
 81        {
 1082            var current = _resolvers;
 1083            var index = Array.IndexOf(current, resolver);
 1084            if (index < 0)
 085                return;
 86
 1087            var remaining = new Func<string, Type?>[current.Length - 1];
 1088            Array.Copy(current, remaining, index);
 1089            Array.Copy(current, index + 1, remaining, index, current.Length - index - 1);
 1090            _resolvers = remaining;
 1091        }
 92
 93        // Names this resolver was answering must stop resolving to its types — including the ones
 94        // already ANSWERED. The positive caches key a resolved Type by name, so leaving them
 95        // populated meant a revoked alias kept serving the old type (and kept its assembly alive)
 96        // for the life of the process, which is most of what disposing the handle is for.
 1097        ReflectionExtensions.InvalidateResolvedServiceTypes();
 1098        PayloadRecoveryClassifier.InvalidateResolvedPayloadTypes();
 1099    }
 100
 101    private sealed class ResolverRegistration : IDisposable
 102    {
 103        // Cleared on dispose, not just unregistered: a caller that keeps the handle around (a
 104        // field on a plugin host, a using-scoped variable still in scope) would otherwise keep the
 105        // delegate — and everything its closure captured, including the plugin assembly — reachable
 106        // through the handle itself, which is exactly the pinning the handle exists to end.
 107        private Func<string, Type?>? _resolver;
 108
 68109        public ResolverRegistration(Func<string, Type?> resolver) => _resolver = resolver;
 110
 111        /// <summary>Removes the registration and drops this handle's reference. Idempotent.</summary>
 112        public void Dispose()
 113        {
 14114            if (Interlocked.Exchange(ref _resolver, null) is { } resolver)
 10115                Unregister(resolver);
 14116        }
 117    }
 118
 119    /// <summary>
 120    /// Longest persisted type name that is resolved, in UTF-16 code units. The names this library
 121    /// writes are <see cref="Type.FullName"/> values, which spell every generic argument
 122    /// assembly-qualified — roughly 150–250 units per component (namespace-qualified name plus
 123    /// <c>, Assembly, Version=…, Culture=…, PublicKeyToken=…</c>). 4096 leaves room for about
 124    /// twenty such components (an eight-element <c>ValueTuple</c> of generic payloads fits), the
 125    /// node budget <c>System.Reflection.Metadata.TypeNameParseOptions.MaxNodes</c> defaults to,
 126    /// while keeping a hostile name three orders of magnitude under the inbound message budget —
 127    /// and keeping the name-keyed resolution caches from holding megabyte keys.
 128    /// </summary>
 129    internal const int MaxTypeNameLength = 4096;
 130
 131    /// <summary>
 132    /// Deepest <c>[</c> nesting that is resolved: eight levels of generic nesting in the
 133    /// assembly-qualified form (<c>Outer`1[[Inner`1[[…]], asm]]</c> costs two brackets a level).
 134    /// </summary>
 135    internal const int MaxTypeNameNesting = 16;
 136
 137    /// <summary>
 138    /// Most <c>[</c> a resolved name may hold in total — generic argument lists, the
 139    /// assembly-qualified wrapper around each argument, and array ranks together. This is the
 140    /// bound on a CHAIN of decorations (<c>T[][][]…</c>), which nests no deeper than one bracket
 141    /// however long it grows.
 142    /// </summary>
 143    internal const int MaxTypeNameBrackets = 64;
 144
 145    /// <summary>
 146    /// Whether <paramref name="fullName"/> may be handed to the CLR type-name parser. Every
 147    /// resolution path — the default scan, the registered resolvers, and both name-keyed caches in
 148    /// front of them — asks this FIRST.
 149    /// <para>
 150    /// <c>Type.GetType</c> and <c>Assembly.GetType</c> parse generic arguments by recursion and
 151    /// build array/pointer/by-ref decorations as a chain that is then resolved by recursion, with
 152    /// no depth limit of their own inside the runtime. A persisted name is written by whoever can
 153    /// write the recovery store or the worker stream, and a few hundred kilobytes of
 154    /// <c>A`1[[A`1[[…</c> — far inside the inbound message budget — overflows the stack of the
 155    /// thread that parses it. A <see cref="StackOverflowException"/> cannot be caught: the process
 156    /// exits, the message is still unacknowledged, and every worker the broker redelivers it to
 157    /// exits the same way. The payload type name is resolved before any callback is chosen, so no
 158    /// callback authorizer stands in front of it. The scan below is a single iterative pass, so
 159    /// the guard cannot itself be driven deep.
 160    /// </para>
 161    /// <para>
 162    /// Deliberately conservative rather than a second parser: a backslash-escaped bracket is
 163    /// counted as a bracket, and <c>&amp;</c> / <c>*</c> are refused wherever they appear. No
 164    /// callback service, payload, flow, or flow-input type is a pointer, a by-ref, or an
 165    /// unknown-bound array, and a name refused here is simply unresolvable to the caller — the
 166    /// outcome it already has for a renamed type.
 167    /// </para>
 168    /// </summary>
 169    internal static bool IsWithinResolutionLimits(string fullName)
 170    {
 8185171        if (fullName.Length > MaxTypeNameLength)
 10172            return false;
 173
 8175174        var depth = 0;
 8175175        var brackets = 0;
 730192176        foreach (var unit in fullName)
 177        {
 178            switch (unit)
 179            {
 180                case '[':
 380181                    if (++depth > MaxTypeNameNesting || ++brackets > MaxTypeNameBrackets)
 4182                        return false;
 183                    break;
 184                case ']':
 340185                    if (depth > 0)
 340186                        depth--;
 340187                    break;
 188                case '&' or '*':
 6189                    return false;
 190            }
 191        }
 192
 8165193        return true;
 194    }
 195
 196    /// <summary>
 197    /// A persisted type name as it may appear in a log line or an exception message. Within the
 198    /// resolution limits it is the whole name — so an ordinary name reads exactly as before — with
 199    /// control characters and unpaired surrogates escaped; past them it is a short escaped excerpt
 200    /// plus the real length. Never megabytes of store-written text, and never its raw line breaks,
 201    /// copied into an Error log on every delivery.
 202    /// </summary>
 203    internal static string DescribeForDiagnostics(string? fullName)
 204    {
 121205        if (fullName is null)
 0206            return string.Empty;
 207
 121208        return IsWithinResolutionLimits(fullName)
 121209            ? DiagnosticText.EscapedExcerpt(fullName, MaxTypeNameLength)
 121210            : $"{DiagnosticText.EscapedExcerpt(fullName, 80)} ({fullName.Length} UTF-16 code units; outside the persiste
 211    }
 212
 213    /// <summary>
 214    /// The default scan: resolves a persisted type name against the assemblies ALREADY loaded into
 215    /// the process, and only those. The name is parsed with the full CLR type-name grammar, so a
 216    /// generic instantiation whose argument is assembly-qualified — chosen by whoever can write
 217    /// the recovery store or the worker stream — made <c>Assembly.GetType</c> LOAD that assembly
 218    /// (and everything it references) on the way to a verdict, and then ran the argument type's
 219    /// constructor and setters through deserialization before the marker-interface gate ever
 220    /// looked at it. Supplying the resolvers confines every component of the name to what is
 221    /// loaded, which is the contract this class documents; the registered resolvers above are
 222    /// consulted only when this returns <c>null</c>.
 223    /// <para>
 224    /// "Loaded" means the snapshot taken here, and every resolved component is checked against
 225    /// it. <c>Assembly.GetType</c> follows type forwarders, and the facades nearly every process
 226    /// has loaded (<c>netstandard</c>, <c>mscorlib</c>, <c>System.Runtime</c>) forward to most of
 227    /// the framework: asking <c>netstandard</c> for <c>System.Net.Mail.SmtpClient</c> makes the
 228    /// runtime load <c>System.Net.Mail</c> and answer with a type from it. That load cannot be
 229    /// prevented from here — it is limited to the framework's own assemblies, never a file the
 230    /// name's author supplies — but its result can be refused, so a persisted name still only
 231    /// ever resolves to a type from an assembly the process had already loaded for itself.
 232    /// </para>
 233    /// </summary>
 234    [RequiresUnreferencedCode("Resolves a persisted type name by string; a trimmed app may have removed the type.")]
 235    internal static Type? ResolveLoaded(string fullName)
 236    {
 237        // Backstop: the caching resolvers in front of this refuse such a name before they touch
 238        // their caches, but nothing may reach the recursive parser below without the check.
 335239        if (!IsWithinResolutionLimits(fullName))
 0240            return null;
 241
 335242        var loaded = AppDomain.CurrentDomain.GetAssemblies();
 335243        return Type.GetType(
 335244            fullName,
 1140245            assemblyResolver: name => Array.Find(loaded, assembly => AssemblyName.ReferenceMatchesDefinition(name, assem
 335246            typeResolver: (assembly, typeName, ignoreCase) =>
 335247            {
 339248                if (assembly is not null)
 335249                {
 6250                    return assembly.GetType(typeName, throwOnError: false, ignoreCase) is { } named && IsDefinedIn(loade
 6251                        ? named
 6252                        : null;
 335253                }
 335254
 28179255                foreach (var candidate in loaded)
 335256                {
 335257                    // A forwarded hit from outside the snapshot is skipped, not final: a later
 335258                    // candidate may define the same name itself.
 13891259                    if (candidate.GetType(typeName, throwOnError: false, ignoreCase) is { } type && IsDefinedIn(loaded, 
 269260                        return type;
 335261                }
 335262
 64263                return null;
 335264            },
 335265            throwOnError: false);
 266    }
 267
 268    /// <summary>Whether <paramref name="type"/> comes from one of the snapshotted assemblies rather than from one a typ
 269    private static bool IsDefinedIn(Assembly[] loaded, Type type)
 275270        => Array.IndexOf(loaded, type.Assembly) >= 0;
 271
 272    /// <summary>Consults the registered resolvers in order; returns the first non-null match, or <c>null</c>.</summary>
 273    internal static Type? Resolve(string fullName)
 274    {
 275        // RegisterAssembly's resolver is Assembly.GetType — the same recursive parser as the
 276        // default scan — and an application resolver is as likely to call Type.GetType itself.
 84277        if (!IsWithinResolutionLimits(fullName))
 2278            return null;
 279
 234280        foreach (var resolver in _resolvers)
 281        {
 282            try
 283            {
 50284                if (resolver(fullName) is { } type)
 30285                    return type;
 16286            }
 4287            catch
 288            {
 289                // A misbehaving custom resolver must never break recovery resolution; skip to the
 290                // next. Counted, though: swallowed without a trace, a resolver that throws on every
 291                // call looked identical to one that simply had no answer, and the recovery
 292                // callbacks it should have resolved just kept failing to route with nothing to
 293                // explain why.
 4294                AsyncResponseDiagnostics.RecordTypeResolutionFailure("resolver");
 4295            }
 296        }
 297
 52298        return null;
 30299    }
 300
 301    /// <summary>Clears all registered resolvers. Test seam only.</summary>
 302    internal static void Reset()
 303    {
 62304        lock (_gate)
 305        {
 62306            _resolvers = [];
 62307        }
 308
 62309        ReflectionExtensions.InvalidateUnresolvableServiceTypes();
 310
 311        // Same as Unregister: names the removed resolvers already ANSWERED must stop resolving,
 312        // so the positive caches are dropped too — otherwise a reset leaks resolved types (and
 313        // their assemblies) into whatever runs next in the process.
 62314        ReflectionExtensions.InvalidateResolvedServiceTypes();
 62315        PayloadRecoveryClassifier.InvalidateResolvedPayloadTypes();
 62316    }
 317}