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

Information
Class: AsyncResponse.ReflectionExtensions.InvocationPlan
Assembly: AsyncResponse.Core
File(s): /home/runner/work/AsyncResponse/AsyncResponse/src/AsyncResponse.Core/ReflectionExtensions.cs
Line coverage
100%
Covered lines: 15
Uncovered lines: 0
Coverable lines: 15
Total lines: 428
Line coverage: 100%
Branch coverage
100%
Covered branches: 10
Total branches: 10
Branch coverage: 100%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
ConvertArguments(...)100%1010100%
Invoke(...)100%11100%
CopyPrefix(...)100%11100%

File(s)

/home/runner/work/AsyncResponse/AsyncResponse/src/AsyncResponse.Core/ReflectionExtensions.cs

#LineLine coverage
 1using System.Collections.Concurrent;
 2using System.Diagnostics.CodeAnalysis;
 3using System.Linq.Expressions;
 4using System.Reflection;
 5using System.Text.Json;
 6
 7namespace AsyncResponse;
 8
 9/// <summary>
 10/// Conversion and invocation helpers for the reflection-based callback machinery:
 11/// materializing untyped JSON payloads as CLR types, resolving placeholder parameters, and
 12/// invoking <see cref="ReflectionInvocationDto"/>s against the DI container.
 13/// </summary>
 14internal static class ReflectionExtensions
 15{
 16    private delegate ValueTask AsyncMethodInvoker(object service, object?[] args);
 17
 18    private static readonly ConcurrentDictionary<string, Type> ServiceTypes = new(StringComparer.Ordinal);
 19
 20    // Names that already failed a full assembly scan. Capacity-bounded so hostile inputs cannot
 21    // grow it without limit, and invalidated on the only events that can turn a miss into a hit —
 22    // a new assembly loading, or a custom resolver registering. At capacity, novel unresolvable
 23    // names fall back to scanning; correctness never depends on this cache.
 24    //
 25    // Entries are stamped with the invalidation GENERATION observed before their failed scan, not
 26    // just stored: a plain clear-on-register has a race — an in-flight miss that started against
 27    // the old resolver set can insert AFTER the clear, permanently poisoning the name. A stale
 28    // stamp (generation advanced mid-scan) makes the entry a non-hit, so the next lookup rescans
 29    // with the new resolvers.
 30    private static readonly ConcurrentDictionary<string, int> UnresolvableServiceTypes = new(StringComparer.Ordinal);
 31    private const int UnresolvableServiceTypeCacheCapacity = 1024;
 32    private static int _unresolvableGeneration;
 33
 34    // The AssemblyLoad invalidation hook is registered on first use (EnsureAssemblyLoadInvalidation
 35    // below), NOT from a static constructor and NOT from a module initializer: an explicit static
 36    // ctor forfeits beforefieldinit, adding a class-initialization check to every static access —
 37    // including the hand-tuned ConvertTo/As<T> hot path this file is benchmarked for — and
 38    // [ModuleInitializer] is analyzer-banned in library code (CA2255). First-call registration
 39    // costs one volatile read per type resolution, off the conversion hot path entirely.
 40    private static readonly object _assemblyLoadGate = new();
 41    private static bool _assemblyLoadHooked;
 42
 43    private static void EnsureAssemblyLoadInvalidation()
 44    {
 45        if (Volatile.Read(ref _assemblyLoadHooked))
 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.
 52        lock (_assemblyLoadGate)
 53        {
 54            if (_assemblyLoadHooked)
 55                return;
 56
 57            AppDomain.CurrentDomain.AssemblyLoad += static (_, _) => InvalidateUnresolvableServiceTypes();
 58            Volatile.Write(ref _assemblyLoadHooked, true);
 59        }
 60    }
 61
 62    /// <summary>
 63    /// Invalidates the negative type-resolution cache (a new resolver or assembly may resolve
 64    /// cached misses). The generation bump is what guarantees correctness for in-flight scans;
 65    /// the clear just reclaims memory.
 66    /// </summary>
 67    internal static void InvalidateUnresolvableServiceTypes()
 68    {
 69        Interlocked.Increment(ref _unresolvableGeneration);
 70        UnresolvableServiceTypes.Clear();
 71    }
 72    private static readonly ConcurrentDictionary<Type, ConversionPlan> ConversionPlans = new();
 73    private static readonly ConcurrentDictionary<InvocationPlanKey, InvocationPlan> InvocationPlans = new();
 74    private static readonly MethodInfo ToValueTaskMethod = typeof(ReflectionExtensions)
 75        .GetMethod(nameof(ToValueTask), BindingFlags.NonPublic | BindingFlags.Static)!;
 76    private static readonly MethodInfo AwaitGenericValueTaskMethod = typeof(ReflectionExtensions)
 77        .GetMethod(nameof(AwaitGenericValueTask), BindingFlags.NonPublic | BindingFlags.Static)!;
 78
 79    /// <summary>
 80    /// If <paramref name="o"/> is a <see cref="JsonElement"/> (or a JSON string), deserializes it
 81    /// into <typeparamref name="T"/>; if it already is a <typeparamref name="T"/>, casts it;
 82    /// otherwise falls back to <see cref="Convert.ChangeType(object, Type)"/>.
 83    /// Throws if <paramref name="o"/> is null and <typeparamref name="T"/> is a non-nullable value type.
 84    /// </summary>
 85    public static T As<T>(this object? o) => (T)o.ConvertTo(typeof(T))!;
 86
 87    /// <summary>
 88    /// Non-generic counterpart of <see cref="As{T}"/> for callers that only know the target type
 89    /// at runtime (e.g. classifying a payload against the type stored in the recovery state).
 90    /// </summary>
 91    public static object? ConvertTo(this object? o, Type targetType)
 92        => GetConversionPlan(targetType).Convert(o);
 93
 94    /// <summary>
 95    /// Resolves the requested service from the provider and invokes the described method,
 96    /// converting each parameter to the method's parameter type via <see cref="ConvertTo"/>.
 97    /// </summary>
 98    public static Task InvokeAsync(this IServiceProvider provider, ReflectionInvocationDto dto)
 99    {
 100        try
 101        {
 102            // 1) Opt-in authorization — deliberately BEFORE any type resolution: the check is
 103            // string-based, so an unauthorized name is rejected without paying the assembly scan
 104            // (otherwise attacker-reachable work under the very threat model the authorizer
 105            // exists for). When an IAsyncResponseCallbackAuthorizer is registered, only allowed
 106            // (service, method) pairs may be invoked — defense-in-depth even if the recovery
 107            // store or worker transport is compromised. No authorizer registered ⇒ allow all. The
 108            // built-in flow executor gets no exemption: RecoverAsync carries an attacker-choosable
 109            // payload that is checkpointed into the flow ledger, so under the stated threat model it
 110            // must be gated like every other target. The AuthorizeCallbacks allowlist includes it by
 111            // default (see AsyncResponseCallbackAllowList.AllowDurableFlowExecutor); custom
 112            // authorizers must allow it explicitly when durable flows are enabled.
 113            if (provider.GetService(typeof(IAsyncResponseCallbackAuthorizer)) is IAsyncResponseCallbackAuthorizer author
 114                && !authorizer.IsAllowed(dto.ServiceInterfaceFullName, dto.MethodName))
 115            {
 116                throw new InvalidOperationException(
 117                    $"Callback target '{dto.ServiceInterfaceFullName}.{dto.MethodName}' is not authorized by the registe
 118                    $"{nameof(IAsyncResponseCallbackAuthorizer)}; add it to the allowlist (AuthorizeCallbacks) to permit
 119            }
 120
 121            // 2) Load the service type by full name
 122            var serviceType = ResolveServiceType(dto.ServiceInterfaceFullName);
 123
 124            if (serviceType == null)
 125                throw new InvalidOperationException(
 126                    $"Type '{dto.ServiceInterfaceFullName}' not found in loaded assemblies.");
 127
 128            // 3) Resolve the service instance
 129            var service = provider.GetService(serviceType)
 130                       ?? throw new InvalidOperationException(
 131                            $"Service '{dto.ServiceInterfaceFullName}' is not registered.");
 132
 133            // 4) Resolve and cache method metadata + compiled invocation delegate.
 134            var plan = InvocationPlans.GetOrAdd(
 135                new InvocationPlanKey(serviceType, dto.MethodName, dto.Params.Length),
 136                static key => CreateInvocationPlan(key));
 137
 138            // 5) Convert only the arguments that need conversion, keeping already-typed arrays hot.
 139            var invocationArgs = plan.ConvertArguments(dto.Params);
 140
 141            // 6) Invoke through the compiled delegate and await Task/ValueTask results.
 142            var pending = plan.Invoke(service, invocationArgs);
 143            return pending.IsCompletedSuccessfully ? Task.CompletedTask : AwaitSlow(pending);
 144        }
 145        catch (Exception ex)
 146        {
 147            // Match async-method exception behavior without paying for a state machine on the hot path.
 148            return Task.FromException(ex);
 149        }
 150    }
 151
 152    private static async Task AwaitSlow(ValueTask pending)
 153        => await pending.ConfigureAwait(false);
 154
 155    // Internal: the durable-flow executor resolves persisted flow/input type names through the
 156    // same default-ALC scan + custom-resolver chain as persisted callback targets.
 157    [UnconditionalSuppressMessage("Trimming", "IL2026",
 158        Justification = "Persisted callback/flow targets are registered through APIs that root them: the expression-base
 159                        "registration APIs annotate TService with DynamicallyAccessedMembers, WithDurableFlow<TFlow, TIn
 160                        "flows, and the DTO-based registration APIs carry RequiresUnreferencedCode. A name that still ca
 161                        "resolved fails closed with an actionable error and a type-resolution-failure diagnostic instead
 162    internal static Type? ResolveServiceType(string serviceInterfaceFullName)
 163    {
 164        // Must precede any cache consult/populate: a miss cached without the invalidation hook
 165        // active could outlive a later assembly load that makes the name resolvable.
 166        EnsureAssemblyLoadInvalidation();
 167
 168        if (ServiceTypes.TryGetValue(serviceInterfaceFullName, out var cached))
 169        {
 170            return cached;
 171        }
 172
 173        // Fail fast on a name that already failed a full scan: without this, every delivery naming
 174        // an unresolvable type (a poisoned recovery row, a renamed class) re-walks every loaded
 175        // assembly on every attempt. Only a CURRENT-generation entry counts — a stale stamp means
 176        // the miss may have raced a resolver registration or assembly load, so it rescans.
 177        if (UnresolvableServiceTypes.TryGetValue(serviceInterfaceFullName, out var missGeneration)
 178            && missGeneration == Volatile.Read(ref _unresolvableGeneration))
 179        {
 180            AsyncResponseDiagnostics.RecordTypeResolutionFailure("service");
 181            return null;
 182        }
 183
 184        var generationBeforeScan = Volatile.Read(ref _unresolvableGeneration);
 185
 186        foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
 187        {
 188            var resolved = assembly.GetType(serviceInterfaceFullName, throwOnError: false);
 189            if (resolved is not null)
 190            {
 191                ServiceTypes.TryAdd(serviceInterfaceFullName, resolved);
 192                return resolved;
 193            }
 194        }
 195
 196        // Opt-in fallback for callback targets loaded into a non-default AssemblyLoadContext (plugins).
 197        var custom = AsyncResponseTypeResolution.Resolve(serviceInterfaceFullName);
 198        if (custom is not null)
 199        {
 200            ServiceTypes.TryAdd(serviceInterfaceFullName, custom);
 201            return custom;
 202        }
 203
 204        // Stamped with the generation observed BEFORE the scan: if a resolver registered while
 205        // this scan ran, the stamp is already stale and the entry never blocks a re-resolve.
 206        if (UnresolvableServiceTypes.Count < UnresolvableServiceTypeCacheCapacity)
 207            UnresolvableServiceTypes[serviceInterfaceFullName] = generationBeforeScan;
 208
 209        AsyncResponseDiagnostics.RecordTypeResolutionFailure("service");
 210        return null;
 211    }
 212
 213    [UnconditionalSuppressMessage("Trimming", "IL2075",
 214        Justification = "The service type reaching this plan was rooted at registration: expression-based registration A
 215                        "annotate TService with DynamicallyAccessedMembers(PublicMethods), and DTO-based registration ca
 216                        "RequiresUnreferencedCode. A method removed regardless (e.g. a job enqueued by a different, non-
 217                        "deployment) fails closed with an actionable 'no method' error.")]
 218    private static InvocationPlan CreateInvocationPlan(InvocationPlanKey key)
 219    {
 220        // Pick the overload by name + parameter count once, then reuse the compiled plan.
 221        var candidates = key.ServiceType.GetMethods(BindingFlags.Instance | BindingFlags.Public)
 222            .Where(m => m.Name == key.MethodName
 223                     && m.GetParameters().Length == key.ParameterCount)
 224            .ToArray();
 225
 226        if (candidates.Length == 0)
 227            throw new InvalidOperationException(
 228                $"No method '{key.MethodName}' with {key.ParameterCount} parameter(s) on '{key.ServiceType.Name}'.");
 229
 230        if (candidates.Length > 1)
 231            throw new InvalidOperationException(
 232                $"Method '{key.MethodName}' on '{key.ServiceType.Name}' has {candidates.Length} overloads with " +
 233                $"{key.ParameterCount} parameter(s); persisted callbacks cannot disambiguate overloads. " +
 234                "Give the callback target a unique name/arity.");
 235
 236        var method = candidates[0];
 237        var parameters = method.GetParameters();
 238        var converters = new ConversionPlan[parameters.Length];
 239        for (var i = 0; i < parameters.Length; i++)
 240        {
 241            var parameterType = parameters[i].ParameterType;
 242            if (parameterType.IsByRef)
 243            {
 244                throw new NotSupportedException(
 245                    $"Callback method '{method.Name}' on '{key.ServiceType.Name}' uses by-ref parameter '{parameters[i].
 246            }
 247
 248            converters[i] = GetConversionPlan(parameterType);
 249        }
 250
 251        if (method.ContainsGenericParameters)
 252        {
 253            throw new NotSupportedException(
 254                $"Callback method '{method.Name}' on '{key.ServiceType.Name}' has unbound generic parameters, which are 
 255        }
 256
 257        return new InvocationPlan(converters, CreateInvoker(method, parameters));
 258    }
 259
 260    private static AsyncMethodInvoker CreateInvoker(MethodInfo method, ParameterInfo[] parameters)
 261    {
 262        var service = Expression.Parameter(typeof(object), "service");
 263        var args = Expression.Parameter(typeof(object?[]), "args");
 264        var instance = Expression.Convert(service, method.DeclaringType!);
 265        var callArgs = new Expression[parameters.Length];
 266
 267        for (var i = 0; i < parameters.Length; i++)
 268        {
 269            var arg = Expression.ArrayIndex(args, Expression.Constant(i));
 270            callArgs[i] = Expression.Convert(arg, parameters[i].ParameterType);
 271        }
 272
 273        var call = Expression.Call(instance, method, callArgs);
 274        var body = ToValueTaskExpression(call, method.ReturnType);
 275        return Expression.Lambda<AsyncMethodInvoker>(body, service, args).Compile();
 276    }
 277
 278    [UnconditionalSuppressMessage("Trimming", "IL2060",
 279        Justification = "AwaitGenericValueTask<T> is instantiated over the callback method's ValueTask<T> result type. T
 280                        "callback method itself was rooted at registration, and for reference-type results shared generi
 281                        "always exists. Value-type results over a method never instantiated statically fail closed at di
 282                        "with a clear exception rather than silently misroute.")]
 283    [UnconditionalSuppressMessage("AOT", "IL3050",
 284        Justification = "Same contract: reference-type ValueTask<T> results use shared generic code under Native AOT; th
 285                        "exotic value-type case throws an actionable error at dispatch.")]
 286    private static Expression ToValueTaskExpression(MethodCallExpression call, Type returnType)
 287    {
 288        if (returnType == typeof(void))
 289            return Expression.Block(call, Expression.Default(typeof(ValueTask)));
 290
 291        if (typeof(Task).IsAssignableFrom(returnType))
 292            return Expression.Call(ToValueTaskMethod, Expression.Convert(call, typeof(Task)));
 293
 294        if (returnType == typeof(ValueTask))
 295            return call;
 296
 297        if (returnType.IsGenericType && returnType.GetGenericTypeDefinition() == typeof(ValueTask<>))
 298            return Expression.Call(AwaitGenericValueTaskMethod.MakeGenericMethod(returnType.GetGenericArguments()[0]), c
 299
 300        return Expression.Block(call, Expression.Default(typeof(ValueTask)));
 301    }
 302
 303    private static ValueTask ToValueTask(Task? task)
 304        => task is null ? default : new ValueTask(task);
 305
 306    private static async ValueTask AwaitGenericValueTask<T>(ValueTask<T> task)
 307        => await task.ConfigureAwait(false);
 308
 309    private static ConversionPlan GetConversionPlan(Type targetType)
 310    {
 311        ArgumentNullException.ThrowIfNull(targetType);
 312        return ConversionPlans.GetOrAdd(targetType, static type => new ConversionPlan(type));
 313    }
 314
 315    /// <summary>
 316    /// Given a callback template whose <c>Params</c> are <see cref="CallbackParam"/>s, produces a
 317    /// <see cref="ReflectionInvocationDto"/> whose <c>Params</c> are the real objects
 318    /// (payload, exception, correlation id, or literal values).
 319    /// </summary>
 320    public static ReflectionInvocationDto ResolveCallback(
 321        ReflectionCallDto template,
 322        object? payload,
 323        Exception? exception,
 324        string? correlationId)
 325    {
 326        var args = template.Params
 327            .Select(p => p.Placeholder switch
 328            {
 329                PlaceholderType.Payload => payload,
 330                PlaceholderType.Exception => exception,
 331                PlaceholderType.CorrelationId => correlationId,
 332                _ => p.Value
 333            })
 334            .ToArray();
 335
 336        return new ReflectionInvocationDto
 337        {
 338            ServiceInterfaceFullName = template.ServiceInterfaceFullName,
 339            MethodName = template.MethodName,
 340            Params = args
 341        };
 342    }
 343
 344    private readonly record struct InvocationPlanKey(Type ServiceType, string MethodName, int ParameterCount);
 345
 3346    private sealed class InvocationPlan(ConversionPlan[] converters, AsyncMethodInvoker invoker)
 347    {
 348        /// <summary>Runs the ConvertArguments operation.</summary>
 349        public object?[] ConvertArguments(object?[] args)
 350        {
 3351            object?[]? converted = null;
 352
 3353            for (var i = 0; i < converters.Length; i++)
 354            {
 3355                var raw = args[i];
 3356                var value = converters[i].Convert(raw);
 3357                if (!ReferenceEquals(value, raw))
 358                {
 3359                    converted ??= CopyPrefix(args, i);
 3360                    converted[i] = value;
 361                }
 3362                else if (converted is not null)
 363                {
 3364                    converted[i] = raw;
 365                }
 366            }
 367
 3368            return converted ?? args;
 369        }
 370
 371        /// <summary>Invokes the reflected operation.</summary>
 372        public ValueTask Invoke(object service, object?[] args)
 3373            => invoker(service, args);
 374
 375        private static object?[] CopyPrefix(object?[] args, int length)
 376        {
 3377            var copy = new object?[args.Length];
 3378            Array.Copy(args, copy, length);
 3379            return copy;
 380        }
 381    }
 382
 383    private sealed class ConversionPlan(Type targetType)
 384    {
 385        private readonly Type? _underlyingType = Nullable.GetUnderlyingType(targetType);
 386        private readonly Type _conversionType = Nullable.GetUnderlyingType(targetType) ?? targetType;
 387        private readonly bool _isNonNullableValueType = targetType.IsValueType && Nullable.GetUnderlyingType(targetType)
 388        private readonly bool _isString = targetType == typeof(string);
 389
 390        /// <summary>Converts the supplied value.</summary>
 391        public object? Convert(object? value)
 392        {
 393            // Handle JSON payloads (contract metadata resolved through the AOT-safe chain; loose
 394            // case-insensitive matching as before).
 395            if (value is JsonElement je)
 396            {
 397                return JsonSerializer.Deserialize(je, AsyncResponseJson.GetTypeInfo(targetType, AsyncResponseJson.CaseIn
 398            }
 399
 400            // JSON in a string
 401            if (value is string s && !_isString)
 402            {
 403                return JsonSerializer.Deserialize(s, AsyncResponseJson.GetTypeInfo(targetType, AsyncResponseJson.CaseIns
 404            }
 405
 406            // Already the correct CLR type (a boxed value also satisfies its nullable counterpart)
 407            if (targetType.IsInstanceOfType(value) || (_underlyingType?.IsInstanceOfType(value) ?? false))
 408            {
 409                return value;
 410            }
 411
 412            // Null handling
 413            if (value is null)
 414            {
 415                // The target being a non-nullable value type cannot represent null.
 416                if (_isNonNullableValueType)
 417                {
 418                    throw new InvalidCastException($"Cannot convert null to non-nullable type {targetType}.");
 419                }
 420
 421                return null;
 422            }
 423
 424            // Fallback for primitives
 425            return System.Convert.ChangeType(value, _conversionType);
 426        }
 427    }
 428}