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

Information
Class: AsyncResponse.ReflectionExtensions
Assembly: AsyncResponse.Core
File(s): /_/src/AsyncResponse.Core/ReflectionExtensions.cs
Line coverage
95%
Covered lines: 210
Uncovered lines: 9
Coverable lines: 219
Total lines: 682
Line coverage: 95.8%
Branch coverage
95%
Covered branches: 122
Total branches: 128
Branch coverage: 95.3%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%11100%
InvalidateUnresolvableServiceTypes()100%11100%
InvalidateResolvedServiceTypes()100%11100%
As(...)100%11100%
ConvertTo(...)100%11100%
InvokeAsync(...)100%66100%
ThrowIfNotAuthorized(...)100%44100%
AwaitSlow()100%11100%
EnsureBindable(...)100%11100%
GetInvocationPlan(...)100%22100%
ResolveServiceType(...)100%1212100%
CacheServiceType(...)100%22100%
CreateInvocationPlan(...)100%1616100%
EnsureNotAsyncVoid(...)78.57%211466.66%
ThrowIfAsyncVoid(...)75%44100%
CandidateMethods(...)91.66%1212100%
IsCallbackCandidate(...)100%22100%
CreateInvoker(...)100%22100%
ToValueTaskExpression(...)100%1010100%
ToValueTask(...)100%22100%
AwaitGenericValueTask()100%11100%
GetConversionPlan(...)100%22100%
ResolveCallback(...)100%66100%
get_ServiceType()100%11100%
.ctor(...)100%11100%
ConvertArguments(...)100%1010100%
Invoke(...)100%22100%
CopyPrefix(...)100%11100%
.ctor(...)100%44100%
Convert(...)93.75%1616100%

File(s)

/_/src/AsyncResponse.Core/ReflectionExtensions.cs

#LineLine coverage
 1using System.Collections.Concurrent;
 2using System.Diagnostics.CodeAnalysis;
 3using System.Linq.Expressions;
 4using System.Reflection;
 5using System.Runtime.CompilerServices;
 6using System.Text.Json;
 7
 8namespace AsyncResponse;
 9
 10/// <summary>
 11/// Conversion and invocation helpers for the reflection-based callback machinery:
 12/// materializing untyped JSON payloads as CLR types, resolving placeholder parameters, and
 13/// invoking <see cref="ReflectionInvocationDto"/>s against the DI container.
 14/// </summary>
 15/// <summary>
 16/// A callback could not be WIRED UP — it is unauthorized, its persisted service type no longer
 17/// resolves on this build, or that service is not registered in DI. Distinct from a failure thrown
 18/// by the callback BODY, which is ordinary application code and may well be transient.
 19/// <para>
 20/// Derives from <see cref="InvalidOperationException"/> so every existing catch and message
 21/// assertion keeps working; it exists purely so retry policies can tell "this call can never
 22/// succeed, no matter how many times it runs" from "the dependency behind this call blipped".
 23/// Without the distinction, a permanently mis-wired callback burned the full retry ladder on the
 24/// publish path for every lost response.
 25/// </para>
 26/// </summary>
 27internal sealed class CallbackTargetUnresolvableException(string message) : InvalidOperationException(message);
 28
 29internal static class ReflectionExtensions
 30{
 31    private delegate ValueTask AsyncMethodInvoker(object service, object?[] args);
 32
 33    // Entries carry the resolver-registry generation observed before the scan that produced them,
 34    // mirroring the negative cache's stamp: a plain clear-on-unregister has a race — an in-flight
 35    // resolution that got its answer from the departing resolver can insert AFTER the clear,
 36    // permanently re-poisoning the name with the revoked type. A stale stamp makes the entry a
 37    // non-hit, so the next lookup rescans against the current resolver set.
 1638    private static readonly ConcurrentDictionary<string, (Type Type, int Generation)> ServiceTypes = new(StringComparer.
 39    private static int _resolvedServiceTypeGeneration;
 40
 41    /// <summary>
 42    /// Invalidates the shared negative type-resolution cache (a new resolver or assembly may
 43    /// resolve cached misses). Kept as a named seam for <see cref="AsyncResponseTypeResolution"/>;
 44    /// the machinery lives in <see cref="UnresolvableTypeNames"/>, shared with the payload
 45    /// classifier's resolution path.
 46    /// </summary>
 9647    internal static void InvalidateUnresolvableServiceTypes() => UnresolvableTypeNames.Invalidate();
 48
 49    /// <summary>
 50    /// Drops resolved service types as well as the negative cache. Used when a resolver is
 51    /// UNREGISTERED: the negative cache holds names that failed, but the harm after a revoke is in
 52    /// the positive entries — a name the departing resolver already answered keeps resolving to its
 53    /// type, so a revoked alias still dispatches to the old service and its assembly stays
 54    /// reachable through the cache. Both directions of the registry change therefore clear both.
 55    /// </summary>
 56    internal static void InvalidateResolvedServiceTypes()
 57    {
 58        // Bump BEFORE clearing: the bump is what fences in-flight scans (their pre-scan stamp goes
 59        // stale); the clear just reclaims memory.
 7260        Interlocked.Increment(ref _resolvedServiceTypeGeneration);
 7261        ServiceTypes.Clear();
 7262        UnresolvableTypeNames.Invalidate();
 7263    }
 1664    private static readonly ConcurrentDictionary<Type, ConversionPlan> ConversionPlans = new();
 1665    private static readonly ConcurrentDictionary<InvocationPlanKey, InvocationPlan> InvocationPlans = new();
 1666    private static readonly MethodInfo ToValueTaskMethod = typeof(ReflectionExtensions)
 1667        .GetMethod(nameof(ToValueTask), BindingFlags.NonPublic | BindingFlags.Static)!;
 1668    private static readonly MethodInfo AwaitGenericValueTaskMethod = typeof(ReflectionExtensions)
 1669        .GetMethod(nameof(AwaitGenericValueTask), BindingFlags.NonPublic | BindingFlags.Static)!;
 70
 71    /// <summary>
 72    /// If <paramref name="o"/> is a <see cref="JsonElement"/> (or a JSON string), deserializes it
 73    /// into <typeparamref name="T"/>; if it already is a <typeparamref name="T"/>, casts it;
 74    /// otherwise falls back to <see cref="Convert.ChangeType(object, Type)"/>.
 75    /// Throws if <paramref name="o"/> is null and <typeparamref name="T"/> is a non-nullable value type.
 76    /// </summary>
 2077    public static T As<T>(this object? o) => (T)o.ConvertTo(typeof(T))!;
 78
 79    /// <summary>
 80    /// Non-generic counterpart of <see cref="As{T}"/> for callers that only know the target type
 81    /// at runtime (e.g. classifying a payload against the type stored in the recovery state).
 82    /// </summary>
 83    public static object? ConvertTo(this object? o, Type targetType)
 30684        => GetConversionPlan(targetType).Convert(o);
 85
 86    /// <summary>
 87    /// Resolves the requested service from the provider and invokes the described method,
 88    /// converting each parameter to the method's parameter type via <see cref="ConvertTo"/>.
 89    /// </summary>
 90    public static Task InvokeAsync(this IServiceProvider provider, ReflectionInvocationDto dto)
 91    {
 92        try
 93        {
 94            // 1) Opt-in authorization — deliberately BEFORE any type resolution: the check is
 95            // string-based, so an unauthorized name is rejected without paying the assembly scan
 96            // (otherwise attacker-reachable work under the very threat model the authorizer
 97            // exists for). When an IAsyncResponseCallbackAuthorizer is registered, only allowed
 98            // (service, method) pairs may be invoked — defense-in-depth even if the recovery
 99            // store or worker transport is compromised. No authorizer registered ⇒ allow all. The
 100            // built-in flow executor gets no exemption: RecoverAsync carries an attacker-choosable
 101            // payload that is checkpointed into the flow ledger, so under the stated threat model it
 102            // must be gated like every other target. The AuthorizeCallbacks allowlist includes it by
 103            // default (see AsyncResponseCallbackAllowList.AllowDurableFlowExecutor); custom
 104            // authorizers must allow it explicitly when durable flows are enabled.
 105            //
 106            // Callers that restore propagated context around the invocation (the worker ingress,
 107            // the lost-subscriber dispatcher) authorize BEFORE they restore it and reach here
 108            // already cleared; this stays as the backstop for every other path.
 6673109            ThrowIfNotAuthorized(
 6673110                provider.GetService(typeof(IAsyncResponseCallbackAuthorizer)) as IAsyncResponseCallbackAuthorizer,
 6673111                dto.ServiceInterfaceFullName,
 6673112                dto.MethodName);
 113
 114            // 2) Load the service type by full name
 6665115            var serviceType = ResolveServiceType(dto.ServiceInterfaceFullName);
 116
 117            // The name is quoted through DescribeForDiagnostics: it is store-/stream-written text
 118            // on its way into an exception the ingress logs at Error.
 6665119            if (serviceType == null)
 22120                throw new CallbackTargetUnresolvableException(
 22121                    $"Type '{AsyncResponseTypeResolution.DescribeForDiagnostics(dto.ServiceInterfaceFullName)}' not foun
 122
 123            // 3) Resolve the service instance
 6643124            var service = provider.GetService(serviceType)
 6643125                       ?? throw new CallbackTargetUnresolvableException(
 6643126                            $"Service '{AsyncResponseTypeResolution.DescribeForDiagnostics(dto.ServiceInterfaceFullName)
 127
 128            // 4) Resolve and cache method metadata + compiled invocation delegate.
 6609129            var plan = GetInvocationPlan(serviceType, dto.MethodName, dto.Params.Length);
 130
 131            // 5) Convert only the arguments that need conversion, keeping already-typed arrays hot.
 6573132            var invocationArgs = plan.ConvertArguments(dto.Params);
 133
 134            // 6) Invoke through the compiled delegate and await Task/ValueTask results.
 6569135            var pending = plan.Invoke(service, invocationArgs);
 6517136            return pending.IsCompletedSuccessfully ? Task.CompletedTask : AwaitSlow(pending);
 137        }
 138        catch (Exception ex)
 139        {
 140            // Match async-method exception behavior without paying for a state machine on the hot path.
 156141            return Task.FromException(ex);
 142        }
 6673143    }
 144
 145    /// <summary>
 146    /// Applies the registered callback authorizer to a persisted <c>(service, method)</c> pair.
 147    /// A <c>null</c> authorizer means none is registered, which allows everything — the opt-in
 148    /// default (see <c>AsyncResponseCallbackAuthorizationExtensions</c>).
 149    /// <para>
 150    /// Exposed separately from <see cref="InvokeAsync"/> so callers can run it at the point the
 151    /// descriptor is still just untrusted strings, before anything the descriptor's own message
 152    /// carries has been given effect. The worker envelope and the recovery row both ship a
 153    /// propagated-context carrier that gets pushed onto ambient state (principal, tenant, logging
 154    /// scope) for the dispatch; authorizing after that restore let a message choose the very
 155    /// context an authorizer would consult to judge it. The check is string-only, so running it
 156    /// first also costs nothing and keeps an unauthorized name away from the assembly scan.
 157    /// </para>
 158    /// </summary>
 159    internal static void ThrowIfNotAuthorized(
 160        IAsyncResponseCallbackAuthorizer? authorizer,
 161        string serviceInterfaceFullName,
 162        string methodName)
 163    {
 11142164        if (authorizer is not null && !authorizer.IsAllowed(serviceInterfaceFullName, methodName))
 165        {
 10166            throw new CallbackTargetUnresolvableException(
 10167                $"Callback target '{AsyncResponseTypeResolution.DescribeForDiagnostics(serviceInterfaceFullName)}.{Diagn
 10168                $"{nameof(IAsyncResponseCallbackAuthorizer)}; add it to the allowlist (AuthorizeCallbacks) to permit it.
 169        }
 11132170    }
 171
 172    private static async Task AwaitSlow(ValueTask pending)
 2522173        => await pending.ConfigureAwait(false);
 174
 175    /// <summary>
 176    /// The one binding rule, applied at every boundary a callback crosses: the persisted
 177    /// <c>(service, method name, parameter count)</c> triple must select exactly one public
 178    /// instance method, with no by-ref parameters and no open generics. Expression-based
 179    /// registration calls this at conversion time (<see cref="CallbackExpressionConverter"/>), so a
 180    /// descriptor that could never dispatch — an overload set that shares a name and arity, which
 181    /// the compiler resolves happily but a name-plus-arity descriptor cannot — fails at the
 182    /// <c>EnqueueWorkerAsync</c>/<c>OnLostSubscriber*</c> call, in the caller's stack, instead of
 183    /// after publication where it burns transport retries or strands a recovery. Dispatch calls the
 184    /// same method, so the two can never disagree; the plan built here is the one dispatch reuses.
 185    /// </summary>
 186    /// <exception cref="CallbackTargetUnresolvableException">The descriptor does not bind to exactly one supported meth
 187    internal static void EnsureBindable(Type serviceType, string methodName, int parameterCount)
 9318188        => GetInvocationPlan(serviceType, methodName, parameterCount);
 189
 190    /// <summary>
 191    /// Resolves (and caches) the compiled plan for a <c>(service type, method, arity)</c> key.
 192    /// Collectible (plugin) service types are planned per call: a strong Type-keyed cache entry
 193    /// would pin the plugin's AssemblyLoadContext after unload.
 194    /// </summary>
 195    private static InvocationPlan GetInvocationPlan(Type serviceType, string methodName, int parameterCount)
 196    {
 15927197        var planKey = new InvocationPlanKey(serviceType, methodName, parameterCount);
 15927198        return serviceType.Assembly.IsCollectible
 15927199            ? CreateInvocationPlan(planKey)
 16175200            : InvocationPlans.GetOrAdd(planKey, static key => CreateInvocationPlan(key));
 201    }
 202
 203    // Internal: the durable-flow executor resolves persisted flow/input type names through the
 204    // same default-ALC scan + custom-resolver chain as persisted callback targets.
 205    [UnconditionalSuppressMessage("Trimming", "IL2026",
 206        Justification = "Persisted callback/flow targets are registered through APIs that root them: the expression-base
 207                        "registration APIs annotate TService with DynamicallyAccessedMembers, WithDurableFlow<TFlow, TIn
 208                        "flows, and the DTO-based registration APIs carry RequiresUnreferencedCode. A name that still ca
 209                        "resolved fails closed with an actionable error and a type-resolution-failure diagnostic instead
 210    internal static Type? ResolveServiceType(string serviceInterfaceFullName)
 211    {
 212        // Before ANY cache is consulted or filled, and before the parser: a name past the limits
 213        // can take the process down inside Type.GetType (see IsWithinResolutionLimits), and it
 214        // must not become a cache key either — the caches are name-keyed and sit in front of
 215        // callback authorization for the payload and flow paths, so without this an unauthorized
 216        // writer chose how many megabytes each of their entries held. Answered as any other
 217        // unresolvable name, so every caller keeps the drop/dead-letter route it already has.
 7035218        if (!AsyncResponseTypeResolution.IsWithinResolutionLimits(serviceInterfaceFullName))
 219        {
 2220            AsyncResponseDiagnostics.RecordTypeResolutionFailure("service");
 2221            return null;
 222        }
 223
 224        // Must precede any cache consult/populate: a miss cached without the invalidation hook
 225        // active could outlive a later assembly load that makes the name resolvable.
 7033226        UnresolvableTypeNames.EnsureAssemblyLoadInvalidation();
 227
 7033228        if (ServiceTypes.TryGetValue(serviceInterfaceFullName, out var cached)
 7033229            && cached.Generation == Volatile.Read(ref _resolvedServiceTypeGeneration))
 230        {
 6760231            return cached.Type;
 232        }
 233
 234        // Fail fast on a name that already failed a full scan: without this, every delivery naming
 235        // an unresolvable type (a poisoned recovery row, a renamed class) re-walks every loaded
 236        // assembly on every attempt. Only a CURRENT-generation entry counts — a stale stamp means
 237        // the miss may have raced a resolver registration or assembly load, so it rescans.
 273238        if (UnresolvableTypeNames.IsKnownMiss(serviceInterfaceFullName))
 239        {
 12240            AsyncResponseDiagnostics.RecordTypeResolutionFailure("service");
 12241            return null;
 242        }
 243
 261244        var generationBeforeScan = UnresolvableTypeNames.GenerationBeforeScan();
 261245        var resolvedGenerationBeforeScan = Volatile.Read(ref _resolvedServiceTypeGeneration);
 246
 247        // Loaded assemblies only — every component of the name, generic arguments included (see
 248        // ResolveLoaded): the wire-supplied name must never make the process load an assembly.
 261249        var resolved = AsyncResponseTypeResolution.ResolveLoaded(serviceInterfaceFullName);
 261250        if (resolved is not null)
 251        {
 215252            CacheServiceType(serviceInterfaceFullName, resolved, resolvedGenerationBeforeScan);
 215253            return resolved;
 254        }
 255
 256        // Opt-in fallback for callback targets loaded into a non-default AssemblyLoadContext (plugins).
 46257        var custom = AsyncResponseTypeResolution.Resolve(serviceInterfaceFullName);
 46258        if (custom is not null)
 259        {
 16260            CacheServiceType(serviceInterfaceFullName, custom, resolvedGenerationBeforeScan);
 16261            return custom;
 262        }
 263
 30264        UnresolvableTypeNames.RecordMiss(serviceInterfaceFullName, generationBeforeScan);
 265
 30266        AsyncResponseDiagnostics.RecordTypeResolutionFailure("service");
 30267        return null;
 268    }
 269
 270    /// <summary>
 271    /// Caches a resolved service type — unless its assembly is collectible: a strong process-wide
 272    /// cache entry would pin the plugin's AssemblyLoadContext and keep an unloaded plugin's
 273    /// assemblies alive until process exit. Collectible-context types stay resolve-per-call (a
 274    /// cold path only plugin hosts hit); the negative cache is name-keyed and unaffected.
 275    /// </summary>
 276    private static void CacheServiceType(string serviceInterfaceFullName, Type resolved, int generationBeforeScan)
 277    {
 278        // Indexer, not TryAdd: a stale-stamped survivor of a raced unregister must be replaced by
 279        // the fresh scan's answer, not shadow it.
 231280        if (!resolved.Assembly.IsCollectible)
 227281            ServiceTypes[serviceInterfaceFullName] = (resolved, generationBeforeScan);
 231282    }
 283
 284    [UnconditionalSuppressMessage("Trimming", "IL2075",
 285        Justification = "The service type reaching this plan was rooted at registration: expression-based registration A
 286                        "annotate TService with DynamicallyAccessedMembers(PublicMethods), and DTO-based registration ca
 287                        "RequiresUnreferencedCode. A method removed regardless (e.g. a job enqueued by a different, non-
 288                        "deployment) fails closed with an actionable 'no method' error.")]
 289    private static InvocationPlan CreateInvocationPlan(InvocationPlanKey key)
 290    {
 291        // Pick the overload by name + parameter count once, then reuse the compiled plan.
 292        //
 293        // Interfaces do NOT inherit members in reflection: Type.GetMethods on an interface returns
 294        // only that interface's own declarations, and FlattenHierarchy does not change it. Searching
 295        // the service type alone therefore failed every callback and worker job whose method is
 296        // declared on a BASE interface — code that compiles cleanly, registers cleanly, and then
 297        // threw "no method" on every dispatch attempt forever. The base interfaces are searched too,
 298        // deduped by declaring type so a re-declaration does not read as an ambiguous overload.
 250299        var candidates = CandidateMethods(key.ServiceType)
 901300            .Where(m => m.Name == key.MethodName
 901301                     && m.GetParameters().Length == key.ParameterCount)
 541302            .DistinctBy(m => (m.DeclaringType, m.Name, string.Join(',', m.GetParameters().Select(p => p.ParameterType.Fu
 250303            .ToArray();
 304
 250305        if (candidates.Length == 0)
 22306            throw new CallbackTargetUnresolvableException(
 22307                $"No method '{DiagnosticText.EscapedExcerpt(key.MethodName, 256)}' with {key.ParameterCount} parameter(s
 308
 228309        if (candidates.Length > 1)
 10310            throw new CallbackTargetUnresolvableException(
 10311                $"Method '{key.MethodName}' on '{key.ServiceType.Name}' has {candidates.Length} overloads with " +
 10312                $"{key.ParameterCount} parameter(s); persisted callbacks cannot disambiguate overloads. " +
 10313                "Give the callback target a unique name/arity.");
 314
 218315        var method = candidates[0];
 218316        var parameters = method.GetParameters();
 218317        var converters = new ConversionPlan[parameters.Length];
 994318        for (var i = 0; i < parameters.Length; i++)
 319        {
 283320            var parameterType = parameters[i].ParameterType;
 283321            if (parameterType.IsByRef)
 322            {
 4323                throw new CallbackTargetUnresolvableException(
 4324                    $"Callback method '{method.Name}' on '{key.ServiceType.Name}' uses by-ref parameter '{parameters[i].
 325            }
 326
 279327            converters[i] = GetConversionPlan(parameterType);
 328        }
 329
 214330        if (method.ContainsGenericParameters)
 331        {
 4332            throw new CallbackTargetUnresolvableException(
 4333                $"Callback method '{method.Name}' on '{key.ServiceType.Name}' has unbound generic parameters, which are 
 334        }
 335
 336        // A void-returning target is awaited as "already complete" (ToValueTaskExpression), which
 337        // is exactly right for a synchronous method and exactly wrong for an `async void` one: its
 338        // body is still running at the first await when the invoker returns, so the worker job is
 339        // acknowledged, the DI scope disposed, and any later exception lost to the thread pool.
 340        // A concrete (class-typed) service exposes the implementation here, so it is rejected at
 341        // plan time; an interface hides it behind the DI resolution, so the plan carries the
 342        // interface method and checks the resolved implementation on invoke.
 210343        MethodInfo? voidMethod = null;
 210344        if (method.ReturnType == typeof(void))
 345        {
 8346            if (!key.ServiceType.IsInterface)
 2347                ThrowIfAsyncVoid(method, key.ServiceType);
 6348            voidMethod = method;
 349        }
 350
 208351        return new InvocationPlan(converters, CreateInvoker(method, parameters), voidMethod);
 352    }
 353
 354    /// <summary>
 355    /// Implementations already verified to be synchronous for a given void interface method, keyed
 356    /// by the concrete service type. Collectible (plugin) types are never cached — a Type key would
 357    /// pin their AssemblyLoadContext — and are re-checked per call, the plugin-host cold path.
 358    /// </summary>
 16359    private static readonly ConcurrentDictionary<(Type Implementation, MethodInfo Method), bool> VerifiedSynchronousVoid
 360
 361    /// <summary>
 362    /// Rejects an <c>async void</c> implementation of a void-returning callback target before it is
 363    /// invoked. The C# compiler marks every <c>async</c> method with
 364    /// <see cref="AsyncStateMachineAttribute"/>; a <c>void</c> return with that marker is the one
 365    /// shape a caller can neither await nor observe faults from. Failing open when the interface
 366    /// map is unavailable (a runtime without it) keeps the historical behavior there.
 367    /// </summary>
 368    [UnconditionalSuppressMessage("Trimming", "IL2072",
 369        Justification = "GetInterfaceMap is asked for the callback method's own declaring interface, which the registrat
 370                        "already rooted (DynamicallyAccessedMembers(PublicMethods) on TService); no member beyond the on
 371                        "invocation itself needs is required, and an unavailable map fails open to the pre-existing beha
 372    [UnconditionalSuppressMessage("Trimming", "IL2075",
 373        Justification = "The implementation type is the DI-resolved service for an interface rooted at registration " +
 374                        "(DynamicallyAccessedMembers(PublicMethods|Interfaces) on TService, or WithDurableFlow's static 
 375                        "the interface map needs only the members the invocation itself already requires. If the runtime
 376                        "produce the map the check fails open — the pre-existing behavior — never closed.")]
 377    private static void EnsureNotAsyncVoid(object service, MethodInfo voidMethod)
 378    {
 8379        var implementationType = service.GetType();
 8380        var cacheable = !implementationType.Assembly.IsCollectible;
 8381        if (cacheable && VerifiedSynchronousVoid.ContainsKey((implementationType, voidMethod)))
 2382            return;
 383
 6384        MethodInfo? implementation = null;
 385        try
 386        {
 6387            var declaringType = voidMethod.DeclaringType!;
 6388            if (declaringType.IsInterface)
 389            {
 6390                if (declaringType.IsAssignableFrom(implementationType))
 391                {
 6392                    var map = implementationType.GetInterfaceMap(declaringType);
 6393                    var index = Array.IndexOf(map.InterfaceMethods, voidMethod);
 6394                    if (index >= 0)
 6395                        implementation = map.TargetMethods[index];
 396                }
 397            }
 398            else
 399            {
 400                // Class-typed service: the plan already rejected the declared method; a derived
 401                // registration may override it, so look the override up on the resolved type.
 0402                implementation = implementationType.GetMethod(
 0403                    voidMethod.Name,
 0404                    BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic,
 0405                    binder: null,
 0406                    voidMethod.GetParameters().Select(p => p.ParameterType).ToArray(),
 0407                    modifiers: null);
 408            }
 6409        }
 0410        catch (Exception ex) when (ex is NotSupportedException or ArgumentException or TypeLoadException or AmbiguousMat
 411        {
 412            // No map on this runtime, or a shape it cannot answer for: fail open.
 0413            implementation = null;
 0414        }
 415
 6416        if (implementation is not null)
 6417            ThrowIfAsyncVoid(implementation, implementationType);
 418
 4419        if (cacheable)
 4420            VerifiedSynchronousVoid.TryAdd((implementationType, voidMethod), true);
 4421    }
 422
 423    private static void ThrowIfAsyncVoid(MethodInfo implementation, Type implementationType)
 424    {
 8425        if (implementation.ReturnType != typeof(void) || !implementation.IsDefined(typeof(AsyncStateMachineAttribute), i
 4426            return;
 427
 4428        throw new CallbackTargetUnresolvableException(
 4429            $"'{implementationType.FullName}.{implementation.Name}' is an async void method. The dispatcher cannot await
 4430            "recovery callback would be acknowledged, and its DI scope disposed, while the body is still running at its 
 4431            "later exception would escape to the thread pool. Return Task or ValueTask instead (a synchronous void metho
 432    }
 433
 434    /// <summary>
 435    /// The service type's own public instance methods plus, for an interface, those of every base
 436    /// interface — the members a caller can legally invoke through it, which is what a persisted
 437    /// callback names.
 438    /// </summary>
 439    /// <summary>
 440    /// Shared justification: the caller already carries the trimming contract — expression-based
 441    /// registration annotates TService with DynamicallyAccessedMembers(PublicMethods) and
 442    /// DTO-based registration carries RequiresUnreferencedCode. A base-interface method trimmed
 443    /// away fails closed with the same actionable "no method" error a missing method gives.
 444    /// Deliberately NOT an iterator: a suppression cannot reach a compiler-generated MoveNext.
 445    /// </summary>
 446    private const string TrimmingJustification =
 447        "The caller carries the trimming contract (DynamicallyAccessedMembers on TService, or RequiresUnreferencedCode "
 448        "for DTO-based registration); a trimmed-away base-interface method fails closed with the same 'no method' error.
 449
 450    [UnconditionalSuppressMessage(
 451        "Trimming",
 452        "IL2070:UnrecognizedReflectionPattern",
 453        Justification = TrimmingJustification)]
 454    [UnconditionalSuppressMessage("Trimming", "IL2075:UnrecognizedReflectionPattern", Justification = TrimmingJustificat
 455    private static List<MethodInfo> CandidateMethods(Type serviceType)
 456    {
 250457        var candidates = new List<MethodInfo>();
 2582458        foreach (var method in serviceType.GetMethods(BindingFlags.Instance | BindingFlags.Public))
 459        {
 1041460            if (IsCallbackCandidate(method))
 899461                candidates.Add(method);
 462        }
 463
 250464        if (!serviceType.IsInterface)
 18465            return candidates;
 466
 468467        foreach (var baseInterface in serviceType.GetInterfaces())
 468        {
 8469            foreach (var method in baseInterface.GetMethods(BindingFlags.Instance | BindingFlags.Public))
 470            {
 2471                if (IsCallbackCandidate(method))
 2472                    candidates.Add(method);
 473            }
 474        }
 475
 232476        return candidates;
 477    }
 478
 479    /// <summary>
 480    /// Callback targets are ordinary methods. Property and event accessors and operators are
 481    /// <see cref="MethodBase.IsSpecialName"/>, and <see cref="object"/>'s members are never a
 482    /// callback — without this filter a type-level <c>Allow&lt;T&gt;()</c> on the callback
 483    /// allowlist also authorized every property SETTER (and <c>GetHashCode</c>/<c>ToString</c>),
 484    /// so a worker-transport writer could aim <c>set_ApiKey</c> at a DI singleton and change
 485    /// process-wide state. The plan accepts <c>void</c> returns, so nothing downstream caught it.
 486    /// </summary>
 487    private static bool IsCallbackCandidate(MethodInfo method)
 1043488        => !method.IsSpecialName && method.DeclaringType != typeof(object);
 489
 490    private static AsyncMethodInvoker CreateInvoker(MethodInfo method, ParameterInfo[] parameters)
 491    {
 208492        var service = Expression.Parameter(typeof(object), "service");
 208493        var args = Expression.Parameter(typeof(object?[]), "args");
 208494        var instance = Expression.Convert(service, method.DeclaringType!);
 208495        var callArgs = new Expression[parameters.Length];
 496
 966497        for (var i = 0; i < parameters.Length; i++)
 498        {
 275499            var arg = Expression.ArrayIndex(args, Expression.Constant(i));
 275500            callArgs[i] = Expression.Convert(arg, parameters[i].ParameterType);
 501        }
 502
 208503        var call = Expression.Call(instance, method, callArgs);
 208504        var body = ToValueTaskExpression(call, method.ReturnType);
 208505        return Expression.Lambda<AsyncMethodInvoker>(body, service, args).Compile();
 506    }
 507
 508    [UnconditionalSuppressMessage("Trimming", "IL2060",
 509        Justification = "AwaitGenericValueTask<T> is instantiated over the callback method's ValueTask<T> result type. T
 510                        "callback method itself was rooted at registration, and for reference-type results shared generi
 511                        "always exists. Value-type results over a method never instantiated statically fail closed at di
 512                        "with a clear exception rather than silently misroute.")]
 513    [UnconditionalSuppressMessage("AOT", "IL3050",
 514        Justification = "Same contract: reference-type ValueTask<T> results use shared generic code under Native AOT; th
 515                        "exotic value-type case throws an actionable error at dispatch.")]
 516    private static Expression ToValueTaskExpression(MethodCallExpression call, Type returnType)
 517    {
 208518        if (returnType == typeof(void))
 6519            return Expression.Block(call, Expression.Default(typeof(ValueTask)));
 520
 202521        if (typeof(Task).IsAssignableFrom(returnType))
 194522            return Expression.Call(ToValueTaskMethod, Expression.Convert(call, typeof(Task)));
 523
 8524        if (returnType == typeof(ValueTask))
 4525            return call;
 526
 4527        if (returnType.IsGenericType && returnType.GetGenericTypeDefinition() == typeof(ValueTask<>))
 2528            return Expression.Call(AwaitGenericValueTaskMethod.MakeGenericMethod(returnType.GetGenericArguments()[0]), c
 529
 2530        return Expression.Block(call, Expression.Default(typeof(ValueTask)));
 531    }
 532
 533    private static ValueTask ToValueTask(Task? task)
 6505534        => task is null ? default : new ValueTask(task);
 535
 536    private static async ValueTask AwaitGenericValueTask<T>(ValueTask<T> task)
 2537        => await task.ConfigureAwait(false);
 538
 539    private static ConversionPlan GetConversionPlan(Type targetType)
 540    {
 541        // Collectible (plugin) target types are planned per call: a strong Type-keyed cache entry
 542        // would pin the plugin's AssemblyLoadContext after unload. Cold path — only plugin hosts
 543        // resolve conversions for collectible types, and only on recovery/callback traffic.
 585544        ArgumentNullException.ThrowIfNull(targetType);
 545
 585546        if (targetType.Assembly.IsCollectible)
 4547            return new ConversionPlan(targetType);
 548
 685549        return ConversionPlans.GetOrAdd(targetType, static type => new ConversionPlan(type));
 550    }
 551
 552    /// <summary>
 553    /// Given a callback template whose <c>Params</c> are <see cref="CallbackParam"/>s, produces a
 554    /// <see cref="ReflectionInvocationDto"/> whose <c>Params</c> are the real objects
 555    /// (payload, exception, correlation id, or literal values).
 556    /// </summary>
 557    public static ReflectionInvocationDto ResolveCallback(
 558        ReflectionCallDto template,
 559        object? payload,
 560        Exception? exception,
 561        string? correlationId)
 562    {
 6577563        var args = template.Params
 6438564            .Select(p => p.Placeholder switch
 6438565            {
 167566                PlaceholderType.Payload => payload,
 122567                PlaceholderType.Exception => exception,
 50568                PlaceholderType.CorrelationId => correlationId,
 6099569                _ => p.Value
 6438570            })
 6577571            .ToArray();
 572
 6577573        return new ReflectionInvocationDto
 6577574        {
 6577575            ServiceInterfaceFullName = template.ServiceInterfaceFullName,
 6577576            MethodName = template.MethodName,
 6577577            Params = args
 6577578        };
 579    }
 580
 1518581    private readonly record struct InvocationPlanKey(Type ServiceType, string MethodName, int ParameterCount);
 582
 208583    private sealed class InvocationPlan(ConversionPlan[] converters, AsyncMethodInvoker invoker, MethodInfo? voidMethod)
 584    {
 585        /// <summary>Runs the ConvertArguments operation.</summary>
 586        public object?[] ConvertArguments(object?[] args)
 587        {
 6573588            object?[]? converted = null;
 589
 25986590            for (var i = 0; i < converters.Length; i++)
 591            {
 6424592                var raw = args[i];
 6424593                var value = converters[i].Convert(raw);
 6420594                if (!ReferenceEquals(value, raw))
 595                {
 6035596                    converted ??= CopyPrefix(args, i);
 6035597                    converted[i] = value;
 598                }
 385599                else if (converted is not null)
 600                {
 4601                    converted[i] = raw;
 602                }
 603            }
 604
 6569605            return converted ?? args;
 606        }
 607
 608        /// <summary>Invokes the reflected operation.</summary>
 609        public ValueTask Invoke(object service, object?[] args)
 610        {
 611            // Only void-returning plans pay for the implementation check; Task/ValueTask targets
 612            // are awaited for real and need none.
 6569613            if (voidMethod is not null)
 8614                EnsureNotAsyncVoid(service, voidMethod);
 615
 6567616            return invoker(service, args);
 617        }
 618
 619        private static object?[] CopyPrefix(object?[] args, int length)
 620        {
 4068621            var copy = new object?[args.Length];
 4068622            Array.Copy(args, copy, length);
 4068623            return copy;
 624        }
 625    }
 626
 108627    private sealed class ConversionPlan(Type targetType)
 628    {
 108629        private readonly Type? _underlyingType = Nullable.GetUnderlyingType(targetType);
 108630        private readonly Type _conversionType = Nullable.GetUnderlyingType(targetType) ?? targetType;
 108631        private readonly bool _isNonNullableValueType = targetType.IsValueType && Nullable.GetUnderlyingType(targetType)
 108632        private readonly bool _isString = targetType == typeof(string);
 633
 634        /// <summary>Converts the supplied value.</summary>
 635        public object? Convert(object? value)
 636        {
 637            // Handle JSON payloads (contract metadata resolved through the AOT-safe chain; loose
 638            // case-insensitive matching as before). Through JsonSafety, not the raw serializer:
 639            // this is the reader pass that walks the payload's own property names and dictionary
 640            // keys into the parameter type, and a raw JsonException quotes them ("Path:
 641            // $.<key>") — the worker ingress logs the exception that escapes here, so the payload
 642            // must not be in it (docs/security.md: the library never logs a message body).
 6730643            if (value is JsonElement je)
 644            {
 6162645                return JsonSafety.SafeDeserialize(je, targetType, AsyncResponseJson.CaseInsensitive);
 646            }
 647
 648            // Already the correct CLR type (a boxed value also satisfies its nullable counterpart).
 649            // This must precede the JSON-in-a-string fallback: values that crossed a serialization
 650            // boundary arrive as JsonElement (see AsyncResponseJsonContext), so a raw string here
 651            // is a LIVE in-memory value (a literal captured by the callback expression, or the
 652            // CorrelationId placeholder) — JSON-parsing it into an object-typed parameter would
 653            // throw on any plain string like "ORD-42".
 568654            if (targetType.IsInstanceOfType(value) || (_underlyingType?.IsInstanceOfType(value) ?? false))
 655            {
 387656                return value;
 657            }
 658
 659            // JSON in a string (a target the string cannot satisfy directly); same body-free
 660            // failure contract as the element branch above.
 181661            if (value is string s && !_isString)
 662            {
 171663                return JsonSafety.SafeDeserialize(s, targetType, AsyncResponseJson.CaseInsensitive);
 664            }
 665
 666            // Null handling
 10667            if (value is null)
 668            {
 669                // The target being a non-nullable value type cannot represent null.
 8670                if (_isNonNullableValueType)
 671                {
 2672                    throw new InvalidCastException($"Cannot convert null to non-nullable type {targetType}.");
 673                }
 674
 6675                return null;
 676            }
 677
 678            // Fallback for primitives
 2679            return System.Convert.ChangeType(value, _conversionType);
 680        }
 681    }
 682}