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

Information
Class: AsyncResponse.CallbackTargetUnresolvableException
Assembly: AsyncResponse.Core
File(s): /_/src/AsyncResponse.Core/ReflectionExtensions.cs
Line coverage
100%
Covered lines: 1
Uncovered lines: 0
Coverable lines: 1
Total lines: 682
Line coverage: 100%
Branch coverage
N/A
Covered branches: 0
Total branches: 0
Branch coverage: N/A
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%

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>
 11027internal 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.
 38    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>
 47    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.
 60        Interlocked.Increment(ref _resolvedServiceTypeGeneration);
 61        ServiceTypes.Clear();
 62        UnresolvableTypeNames.Invalidate();
 63    }
 64    private static readonly ConcurrentDictionary<Type, ConversionPlan> ConversionPlans = new();
 65    private static readonly ConcurrentDictionary<InvocationPlanKey, InvocationPlan> InvocationPlans = new();
 66    private static readonly MethodInfo ToValueTaskMethod = typeof(ReflectionExtensions)
 67        .GetMethod(nameof(ToValueTask), BindingFlags.NonPublic | BindingFlags.Static)!;
 68    private static readonly MethodInfo AwaitGenericValueTaskMethod = typeof(ReflectionExtensions)
 69        .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>
 77    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)
 84        => 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.
 109            ThrowIfNotAuthorized(
 110                provider.GetService(typeof(IAsyncResponseCallbackAuthorizer)) as IAsyncResponseCallbackAuthorizer,
 111                dto.ServiceInterfaceFullName,
 112                dto.MethodName);
 113
 114            // 2) Load the service type by full name
 115            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.
 119            if (serviceType == null)
 120                throw new CallbackTargetUnresolvableException(
 121                    $"Type '{AsyncResponseTypeResolution.DescribeForDiagnostics(dto.ServiceInterfaceFullName)}' not foun
 122
 123            // 3) Resolve the service instance
 124            var service = provider.GetService(serviceType)
 125                       ?? throw new CallbackTargetUnresolvableException(
 126                            $"Service '{AsyncResponseTypeResolution.DescribeForDiagnostics(dto.ServiceInterfaceFullName)
 127
 128            // 4) Resolve and cache method metadata + compiled invocation delegate.
 129            var plan = GetInvocationPlan(serviceType, dto.MethodName, dto.Params.Length);
 130
 131            // 5) Convert only the arguments that need conversion, keeping already-typed arrays hot.
 132            var invocationArgs = plan.ConvertArguments(dto.Params);
 133
 134            // 6) Invoke through the compiled delegate and await Task/ValueTask results.
 135            var pending = plan.Invoke(service, invocationArgs);
 136            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.
 141            return Task.FromException(ex);
 142        }
 143    }
 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    {
 164        if (authorizer is not null && !authorizer.IsAllowed(serviceInterfaceFullName, methodName))
 165        {
 166            throw new CallbackTargetUnresolvableException(
 167                $"Callback target '{AsyncResponseTypeResolution.DescribeForDiagnostics(serviceInterfaceFullName)}.{Diagn
 168                $"{nameof(IAsyncResponseCallbackAuthorizer)}; add it to the allowlist (AuthorizeCallbacks) to permit it.
 169        }
 170    }
 171
 172    private static async Task AwaitSlow(ValueTask pending)
 173        => 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)
 188        => 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    {
 197        var planKey = new InvocationPlanKey(serviceType, methodName, parameterCount);
 198        return serviceType.Assembly.IsCollectible
 199            ? CreateInvocationPlan(planKey)
 200            : 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.
 218        if (!AsyncResponseTypeResolution.IsWithinResolutionLimits(serviceInterfaceFullName))
 219        {
 220            AsyncResponseDiagnostics.RecordTypeResolutionFailure("service");
 221            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.
 226        UnresolvableTypeNames.EnsureAssemblyLoadInvalidation();
 227
 228        if (ServiceTypes.TryGetValue(serviceInterfaceFullName, out var cached)
 229            && cached.Generation == Volatile.Read(ref _resolvedServiceTypeGeneration))
 230        {
 231            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.
 238        if (UnresolvableTypeNames.IsKnownMiss(serviceInterfaceFullName))
 239        {
 240            AsyncResponseDiagnostics.RecordTypeResolutionFailure("service");
 241            return null;
 242        }
 243
 244        var generationBeforeScan = UnresolvableTypeNames.GenerationBeforeScan();
 245        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.
 249        var resolved = AsyncResponseTypeResolution.ResolveLoaded(serviceInterfaceFullName);
 250        if (resolved is not null)
 251        {
 252            CacheServiceType(serviceInterfaceFullName, resolved, resolvedGenerationBeforeScan);
 253            return resolved;
 254        }
 255
 256        // Opt-in fallback for callback targets loaded into a non-default AssemblyLoadContext (plugins).
 257        var custom = AsyncResponseTypeResolution.Resolve(serviceInterfaceFullName);
 258        if (custom is not null)
 259        {
 260            CacheServiceType(serviceInterfaceFullName, custom, resolvedGenerationBeforeScan);
 261            return custom;
 262        }
 263
 264        UnresolvableTypeNames.RecordMiss(serviceInterfaceFullName, generationBeforeScan);
 265
 266        AsyncResponseDiagnostics.RecordTypeResolutionFailure("service");
 267        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.
 280        if (!resolved.Assembly.IsCollectible)
 281            ServiceTypes[serviceInterfaceFullName] = (resolved, generationBeforeScan);
 282    }
 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.
 299        var candidates = CandidateMethods(key.ServiceType)
 300            .Where(m => m.Name == key.MethodName
 301                     && m.GetParameters().Length == key.ParameterCount)
 302            .DistinctBy(m => (m.DeclaringType, m.Name, string.Join(',', m.GetParameters().Select(p => p.ParameterType.Fu
 303            .ToArray();
 304
 305        if (candidates.Length == 0)
 306            throw new CallbackTargetUnresolvableException(
 307                $"No method '{DiagnosticText.EscapedExcerpt(key.MethodName, 256)}' with {key.ParameterCount} parameter(s
 308
 309        if (candidates.Length > 1)
 310            throw new CallbackTargetUnresolvableException(
 311                $"Method '{key.MethodName}' on '{key.ServiceType.Name}' has {candidates.Length} overloads with " +
 312                $"{key.ParameterCount} parameter(s); persisted callbacks cannot disambiguate overloads. " +
 313                "Give the callback target a unique name/arity.");
 314
 315        var method = candidates[0];
 316        var parameters = method.GetParameters();
 317        var converters = new ConversionPlan[parameters.Length];
 318        for (var i = 0; i < parameters.Length; i++)
 319        {
 320            var parameterType = parameters[i].ParameterType;
 321            if (parameterType.IsByRef)
 322            {
 323                throw new CallbackTargetUnresolvableException(
 324                    $"Callback method '{method.Name}' on '{key.ServiceType.Name}' uses by-ref parameter '{parameters[i].
 325            }
 326
 327            converters[i] = GetConversionPlan(parameterType);
 328        }
 329
 330        if (method.ContainsGenericParameters)
 331        {
 332            throw new CallbackTargetUnresolvableException(
 333                $"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.
 343        MethodInfo? voidMethod = null;
 344        if (method.ReturnType == typeof(void))
 345        {
 346            if (!key.ServiceType.IsInterface)
 347                ThrowIfAsyncVoid(method, key.ServiceType);
 348            voidMethod = method;
 349        }
 350
 351        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>
 359    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    {
 379        var implementationType = service.GetType();
 380        var cacheable = !implementationType.Assembly.IsCollectible;
 381        if (cacheable && VerifiedSynchronousVoid.ContainsKey((implementationType, voidMethod)))
 382            return;
 383
 384        MethodInfo? implementation = null;
 385        try
 386        {
 387            var declaringType = voidMethod.DeclaringType!;
 388            if (declaringType.IsInterface)
 389            {
 390                if (declaringType.IsAssignableFrom(implementationType))
 391                {
 392                    var map = implementationType.GetInterfaceMap(declaringType);
 393                    var index = Array.IndexOf(map.InterfaceMethods, voidMethod);
 394                    if (index >= 0)
 395                        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.
 402                implementation = implementationType.GetMethod(
 403                    voidMethod.Name,
 404                    BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic,
 405                    binder: null,
 406                    voidMethod.GetParameters().Select(p => p.ParameterType).ToArray(),
 407                    modifiers: null);
 408            }
 409        }
 410        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.
 413            implementation = null;
 414        }
 415
 416        if (implementation is not null)
 417            ThrowIfAsyncVoid(implementation, implementationType);
 418
 419        if (cacheable)
 420            VerifiedSynchronousVoid.TryAdd((implementationType, voidMethod), true);
 421    }
 422
 423    private static void ThrowIfAsyncVoid(MethodInfo implementation, Type implementationType)
 424    {
 425        if (implementation.ReturnType != typeof(void) || !implementation.IsDefined(typeof(AsyncStateMachineAttribute), i
 426            return;
 427
 428        throw new CallbackTargetUnresolvableException(
 429            $"'{implementationType.FullName}.{implementation.Name}' is an async void method. The dispatcher cannot await
 430            "recovery callback would be acknowledged, and its DI scope disposed, while the body is still running at its 
 431            "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    {
 457        var candidates = new List<MethodInfo>();
 458        foreach (var method in serviceType.GetMethods(BindingFlags.Instance | BindingFlags.Public))
 459        {
 460            if (IsCallbackCandidate(method))
 461                candidates.Add(method);
 462        }
 463
 464        if (!serviceType.IsInterface)
 465            return candidates;
 466
 467        foreach (var baseInterface in serviceType.GetInterfaces())
 468        {
 469            foreach (var method in baseInterface.GetMethods(BindingFlags.Instance | BindingFlags.Public))
 470            {
 471                if (IsCallbackCandidate(method))
 472                    candidates.Add(method);
 473            }
 474        }
 475
 476        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)
 488        => !method.IsSpecialName && method.DeclaringType != typeof(object);
 489
 490    private static AsyncMethodInvoker CreateInvoker(MethodInfo method, ParameterInfo[] parameters)
 491    {
 492        var service = Expression.Parameter(typeof(object), "service");
 493        var args = Expression.Parameter(typeof(object?[]), "args");
 494        var instance = Expression.Convert(service, method.DeclaringType!);
 495        var callArgs = new Expression[parameters.Length];
 496
 497        for (var i = 0; i < parameters.Length; i++)
 498        {
 499            var arg = Expression.ArrayIndex(args, Expression.Constant(i));
 500            callArgs[i] = Expression.Convert(arg, parameters[i].ParameterType);
 501        }
 502
 503        var call = Expression.Call(instance, method, callArgs);
 504        var body = ToValueTaskExpression(call, method.ReturnType);
 505        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    {
 518        if (returnType == typeof(void))
 519            return Expression.Block(call, Expression.Default(typeof(ValueTask)));
 520
 521        if (typeof(Task).IsAssignableFrom(returnType))
 522            return Expression.Call(ToValueTaskMethod, Expression.Convert(call, typeof(Task)));
 523
 524        if (returnType == typeof(ValueTask))
 525            return call;
 526
 527        if (returnType.IsGenericType && returnType.GetGenericTypeDefinition() == typeof(ValueTask<>))
 528            return Expression.Call(AwaitGenericValueTaskMethod.MakeGenericMethod(returnType.GetGenericArguments()[0]), c
 529
 530        return Expression.Block(call, Expression.Default(typeof(ValueTask)));
 531    }
 532
 533    private static ValueTask ToValueTask(Task? task)
 534        => task is null ? default : new ValueTask(task);
 535
 536    private static async ValueTask AwaitGenericValueTask<T>(ValueTask<T> task)
 537        => 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.
 544        ArgumentNullException.ThrowIfNull(targetType);
 545
 546        if (targetType.Assembly.IsCollectible)
 547            return new ConversionPlan(targetType);
 548
 549        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    {
 563        var args = template.Params
 564            .Select(p => p.Placeholder switch
 565            {
 566                PlaceholderType.Payload => payload,
 567                PlaceholderType.Exception => exception,
 568                PlaceholderType.CorrelationId => correlationId,
 569                _ => p.Value
 570            })
 571            .ToArray();
 572
 573        return new ReflectionInvocationDto
 574        {
 575            ServiceInterfaceFullName = template.ServiceInterfaceFullName,
 576            MethodName = template.MethodName,
 577            Params = args
 578        };
 579    }
 580
 581    private readonly record struct InvocationPlanKey(Type ServiceType, string MethodName, int ParameterCount);
 582
 583    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        {
 588            object?[]? converted = null;
 589
 590            for (var i = 0; i < converters.Length; i++)
 591            {
 592                var raw = args[i];
 593                var value = converters[i].Convert(raw);
 594                if (!ReferenceEquals(value, raw))
 595                {
 596                    converted ??= CopyPrefix(args, i);
 597                    converted[i] = value;
 598                }
 599                else if (converted is not null)
 600                {
 601                    converted[i] = raw;
 602                }
 603            }
 604
 605            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.
 613            if (voidMethod is not null)
 614                EnsureNotAsyncVoid(service, voidMethod);
 615
 616            return invoker(service, args);
 617        }
 618
 619        private static object?[] CopyPrefix(object?[] args, int length)
 620        {
 621            var copy = new object?[args.Length];
 622            Array.Copy(args, copy, length);
 623            return copy;
 624        }
 625    }
 626
 627    private sealed class ConversionPlan(Type targetType)
 628    {
 629        private readonly Type? _underlyingType = Nullable.GetUnderlyingType(targetType);
 630        private readonly Type _conversionType = Nullable.GetUnderlyingType(targetType) ?? targetType;
 631        private readonly bool _isNonNullableValueType = targetType.IsValueType && Nullable.GetUnderlyingType(targetType)
 632        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).
 643            if (value is JsonElement je)
 644            {
 645                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".
 654            if (targetType.IsInstanceOfType(value) || (_underlyingType?.IsInstanceOfType(value) ?? false))
 655            {
 656                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.
 661            if (value is string s && !_isString)
 662            {
 663                return JsonSafety.SafeDeserialize(s, targetType, AsyncResponseJson.CaseInsensitive);
 664            }
 665
 666            // Null handling
 667            if (value is null)
 668            {
 669                // The target being a non-nullable value type cannot represent null.
 670                if (_isNonNullableValueType)
 671                {
 672                    throw new InvalidCastException($"Cannot convert null to non-nullable type {targetType}.");
 673                }
 674
 675                return null;
 676            }
 677
 678            // Fallback for primitives
 679            return System.Convert.ChangeType(value, _conversionType);
 680        }
 681    }
 682}

Methods/Properties

.ctor(System.String)