| | | 1 | | using System.Collections.Concurrent; |
| | | 2 | | using System.Diagnostics.CodeAnalysis; |
| | | 3 | | using System.Linq.Expressions; |
| | | 4 | | using System.Reflection; |
| | | 5 | | using System.Runtime.CompilerServices; |
| | | 6 | | using System.Text.Json; |
| | | 7 | | |
| | | 8 | | namespace 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> |
| | 110 | 27 | | internal sealed class CallbackTargetUnresolvableException(string message) : InvalidOperationException(message); |
| | | 28 | | |
| | | 29 | | internal 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<T>()</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 | | } |