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

Information
Class: AsyncResponse.InMemoryAsyncResponseChannel
Assembly: AsyncResponse.Core
File(s): /_/src/AsyncResponse.Core/InMemoryAsyncResponseChannel.cs
Line coverage
92%
Covered lines: 510
Uncovered lines: 42
Coverable lines: 552
Total lines: 1351
Line coverage: 92.3%
Branch coverage
90%
Covered branches: 236
Total branches: 260
Branch coverage: 90.7%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%22100%
CreateResponseWaiter(...)100%11100%
CreateRecoverableResponseWaiter(...)100%11100%
CreateResponseWaiterCore()100%2424100%
SetResponse(...)100%11100%
AsyncResponse.IRawAsyncResponsePublisher.SetRawResponse(...)100%11100%
AsyncResponse.IRawAsyncResponsePublisher.SetRawResponseJson(...)100%11100%
SetResponseCore()84.37%383281.81%
SetRawResponseJsonCore()79.16%292480%
SetException()78.57%342880.48%
CountActiveSubscribersAsync(...)100%44100%
AddSubscription(...)100%22100%
SnapshotSubscribers(...)100%22100%
DispatchResponsesAsync(...)100%22100%
DispatchRawJsonResponsesAsync(...)100%22100%
DispatchExceptionsAsync(...)100%22100%
DispatchManyAsync(...)93.75%1616100%
AbandonAllAsync()100%66100%
RemoveSubscription(...)100%44100%
ChannelName(...)100%11100%
.cctor()100%11100%
.ctor()100%11100%
get_Count()100%44100%
TryAdd(...)100%88100%
DrainForAbandon()25%5458.33%
Remove(...)100%1212100%
Snapshot()100%66100%
.ctor(...)100%11100%
get_Single()100%11100%
get_Many()100%11100%
get_Count()100%44100%
ForSingle(...)100%11100%
ForMany(...)100%11100%
.ctor(...)100%11100%
get_Id()100%11100%
get_CorrelationId()100%11100%
get_WaitActivity()100%11100%
get_Timeout()100%11100%
get_CleanupStarted()100%11100%
ArmTimeout()75%4490%
DispatchExceptionAsync(...)100%11100%
DispatchExceptionCoreAsync(...)100%66100%
DispatchSerialAsync(...)100%44100%
WaitAndDispatchAsync()100%44100%
ReleaseAfterDispatchAsync()100%11100%
ReleaseDispatch()75%44100%
CleanupOnceAsync()100%44100%
DisposeCleanupAsync()100%22100%
StartCleanupAsync()83.33%66100%
TryBeginTerminal()100%11100%
AbandonAsync()100%66100%
CleanupOnceAsTask()50%22100%
TimeoutAsync()100%1129.41%
TimeoutCoreAsync()100%44100%
.ctor(...)100%11100%
get_ResponseTask()100%11100%
DispatchResponseAsync(...)100%22100%
DispatchResponseUnserializedAsync(...)100%44100%
DispatchRawJsonResponseAsync(...)100%22100%
DispatchRawJsonResponseUnserializedAsync(...)100%44100%
DispatchResponseCoreAsync(...)100%11100%
DispatchRawJsonResponseCoreAsync(...)100%22100%
DispatchPayloadAsync(...)100%66100%
MaterializeAs(...)100%44100%
FaultAsync(...)100%22100%
AwaitCompletionPredicateAsync()100%44100%
SetTimeoutException(...)100%11100%
TrySetException(...)100%11100%
TrySetCanceled()100%11100%

File(s)

/_/src/AsyncResponse.Core/InMemoryAsyncResponseChannel.cs

#LineLine coverage
 1using Microsoft.Extensions.DependencyInjection;
 2using Microsoft.Extensions.Logging;
 3using Microsoft.Extensions.Options;
 4using System.Collections.Concurrent;
 5using System.Diagnostics;
 6using System.Runtime.CompilerServices;
 7
 8namespace AsyncResponse;
 9
 10/// <summary>
 11/// Process-local response channel registered by <c>AddAsyncResponse().WithInMemoryChannel()</c>.
 12/// It provides the async-response programming model without Redis or another broker-backed channel.
 13/// Waiters, subscriptions, and recovery state are all in memory and disappear when the process
 14/// exits.
 15/// <para>
 16/// The channel implements the full <see cref="IRecoverableAsyncResponseSubscriber"/> surface:
 17/// lost-subscriber recovery callbacks are stored in the (process-local) recovery store and fire
 18/// when a response arrives with no live waiter — the same routing the durable channels run.
 19/// Recovery therefore works within one process lifetime (and across the simulated restarts of
 20/// AsyncResponse.Testing, which preserves the store instance); only a real process exit loses it.
 21/// </para>
 22/// </summary>
 23internal sealed class InMemoryAsyncResponseChannel : IAsyncResponsePublisher, IRawAsyncResponsePublisher, IRecoverableAs
 24{
 127225    private readonly ConcurrentDictionary<string, SubscriptionGroup> _subscriptions = new(StringComparer.Ordinal);
 26    private readonly IRecoveryStateStore _recoveryStateStore;
 27    private readonly InMemoryAsyncResponseOptions _options;
 28    private readonly LostSubscriberCallbackDispatcher _lostSubscriberDispatcher;
 29    private readonly AsyncResponseContextPropagation _propagation;
 30    private readonly TimeProvider _timeProvider;
 31    private readonly ILogger<InMemoryAsyncResponseChannel> _logger;
 32
 33    /// <summary>Creates a process-local async-response channel.</summary>
 127234    public InMemoryAsyncResponseChannel(
 127235        IServiceScopeFactory scopeFactory,
 127236        IRecoveryStateStore recoveryStateStore,
 127237        IOptions<InMemoryAsyncResponseOptions> options,
 127238        AsyncResponseContextPropagation propagation,
 127239        ILogger<InMemoryAsyncResponseChannel> logger,
 127240        TimeProvider? timeProvider = null)
 41    {
 127242        _recoveryStateStore = recoveryStateStore;
 127243        _options = options.Value;
 127244        _options.Validate();
 126645        _propagation = propagation;
 126646        _timeProvider = timeProvider ?? TimeProvider.System;
 126647        _logger = logger;
 126648        _lostSubscriberDispatcher = new LostSubscriberCallbackDispatcher(scopeFactory, propagation, logger, _timeProvide
 126649    }
 50
 51    /// <inheritdoc />
 52    public Task<IAsyncResponseWaiter<T>> CreateResponseWaiter<T>(
 53        string correlationId,
 54        Func<T, ValueTask<bool>>? completionPredicate = null,
 55        TimeSpan? timeout = null) where T : IAsyncResponsePayload
 130256        => CreateResponseWaiterCore(
 130257            correlationId,
 130258            resumeCallback: null,
 130259            failureCallback: null,
 130260            completionPredicate,
 130261            timeout);
 62
 63    /// <inheritdoc />
 64    public Task<IAsyncResponseWaiter<T>> CreateRecoverableResponseWaiter<T>(
 65        string correlationId,
 66        ReflectionCallDto? resumeCallback = null,
 67        ReflectionCallDto? failureCallback = null,
 68        Func<T, ValueTask<bool>>? completionPredicate = null,
 69        TimeSpan? timeout = null) where T : IAsyncResponsePayload
 244570        => CreateResponseWaiterCore(
 244571            correlationId,
 244572            resumeCallback,
 244573            failureCallback,
 244574            completionPredicate,
 244575            timeout);
 76
 77    private async Task<IAsyncResponseWaiter<T>> CreateResponseWaiterCore<T>(
 78        string correlationId,
 79        ReflectionCallDto? resumeCallback,
 80        ReflectionCallDto? failureCallback,
 81        Func<T, ValueTask<bool>>? completionPredicate,
 82        TimeSpan? timeout) where T : IAsyncResponsePayload
 83    {
 374784        CorrelationIdGuard.ThrowIfUnusable(correlationId);
 85
 86        // Same contract as the durable channels: recovery callbacks are only meaningful when the
 87        // payload can say whether a late response resumes or fails the flow. Enforcing it here too
 88        // keeps the in-memory channel an honest stand-in — a flow that would fail this check on
 89        // Redis fails it identically in a test.
 373790        if ((resumeCallback is not null || failureCallback is not null)
 373791            && !AsyncResponsePayloadReflection.OverridesOnRecovery(typeof(T)))
 92        {
 693            throw new InvalidOperationException(
 694                $"Payload type '{typeof(T)}' registers lost-subscriber recovery callbacks on the in-memory channel " +
 695                $"but does not override {nameof(IAsyncResponsePayload)}.{nameof(IAsyncResponsePayload.OnRecovery)}(). " 
 696                "Override it to declare what each response does to the flow — RecoveryAction.Resume, " +
 697                "RecoveryAction.Fail, or RecoveryAction.KeepWaiting for non-terminal checkpoints; the recovery " +
 698                "routing needs this to classify a response that arrives after the waiter was lost.");
 99        }
 100
 3731101        var hasCustomPredicate = completionPredicate is not null;
 6987102        completionPredicate ??= static _ => new ValueTask<bool>(true);
 3731103        timeout ??= _options.DefaultTimeout ?? _options.RecoveryStateExpiry;
 104        // BEFORE any side effect: an unsupported resolved timeout (non-positive, or past the
 105        // ~49.7-day BCL timer ceiling) used to throw only at timer arming — after the
 106        // subscription and recovery state existed, leaking both — and zero used to slip through
 107        // on some channels entirely, insta-timing-out a fully registered waiter.
 3731108        AsyncResponseChannelOptions.EnsureWaiterTimeoutSupported(timeout.Value);
 109
 3725110        var activity = AsyncResponseDiagnostics.StartActivity("asyncresponse.wait", correlationId: correlationId);
 3725111        activity?.SetTag("asyncresponse.channel", "inmemory");
 3725112        AsyncResponseDiagnostics.SetPayloadType(activity, typeof(T));
 3725113        activity?.SetTag("asyncresponse.timeout_seconds", timeout.Value.TotalSeconds);
 114
 3725115        var subscription = new Subscription<T>(
 3725116            owner: this,
 3725117            correlationId,
 3725118            timeout.Value,
 3725119            completionPredicate,
 3725120            activity,
 3725121            // Only restore the subscribe-time ambient context during dispatch when there is a user
 3725122            // completion predicate to run under it. With the default (always-complete) predicate,
 3725123            // nothing on the dispatch path observes ambient context, so capturing it would only buy
 3725124            // a per-dispatch ExecutionContext.Run plus its capturing closure. The waiter's own
 3725125            // continuation flows its own context regardless (RunContinuationsAsynchronously).
 3725126            hasCustomPredicate ? ExecutionContext.Capture() : null);
 127
 3725128        AddSubscription(correlationId, subscription);
 129
 130        try
 131        {
 3725132            await _recoveryStateStore.SaveAsync(
 3725133                correlationId,
 3725134                new RecoveryState
 3725135                {
 3725136                    RegistrationId = subscription.Id,
 3725137                    CorrelationId = correlationId,
 3725138                    ResumeCallback = resumeCallback,
 3725139                    FailureCallback = failureCallback,
 3725140                    PayloadTypeFullName = typeof(T).FullName,
 3725141                    RegisteredAtUtc = _timeProvider.GetUtcNow().UtcDateTime,
 3725142                    Context = _propagation.Capture()
 3725143                },
 3725144                _options.RecoveryStateExpiry).ConfigureAwait(false);
 145
 3719146            if (subscription.CleanupStarted)
 147            {
 148                // A response settled the wait while the registration was still being written:
 149                // cleanup's delete ran before the save committed, so compensate with a second
 150                // delete. Best-effort, mirroring the broker channels — the waiter already holds
 151                // its response, so a failed delete must not fail the create; TTL and the recovery
 152                // watchdog back it.
 153                try
 154                {
 4155                    await _recoveryStateStore.TryDeleteAsync(correlationId, subscription.Id).ConfigureAwait(false);
 2156                }
 2157                catch (Exception ex)
 158                {
 2159                    _logger.LogError(ex, "Post-save recovery-state compensation delete failed for correlationId {Correla
 2160                }
 161            }
 162            else
 163            {
 3715164                subscription.ArmTimeout();
 165            }
 166
 3719167            if (_logger.IsEnabled(LogLevel.Debug))
 8168                _logger.LogDebug("Waiting for response on correlationId {CorrelationId} with timeout {Timeout}.", correl
 3719169        }
 6170        catch (Exception ex) when (subscription.ResponseTask.IsCompletedSuccessfully || subscription.ResponseTask.IsFaul
 171        {
 172            // The wait already settled: a dispatched response completed the waiter while the
 173            // registration step was still in flight, and the step — the recovery-state save —
 174            // then failed. The response in hand outranks the builder's "throw so the trigger
 175            // never fires" contract: rethrowing would discard a delivered response, the exact
 176            // loss this library exists to prevent, and the success path for this same
 177            // interleaving already returns the completed waiter. Cleanup runs on the dispatch
 178            // path, so nothing is leaked; a save that still committed is compensated above or
 179            // expires via TTL. The filter demands an actual settlement (result or fault): a
 180            // canceled task means NO response was delivered — e.g. a future channel-wide teardown
 181            // canceling in-flight registrations — and takes the rethrow path below.
 2182            _logger.LogWarning(ex,
 2183                "Registration step failed after a delivery settled correlationId {CorrelationId}; returning the complete
 2184                correlationId);
 2185        }
 4186        catch (Exception ex)
 187        {
 4188            _logger.LogError(ex, "Failed to create in-memory waiter for correlationId {CorrelationId}.", correlationId);
 4189            AsyncResponseDiagnostics.SetError(activity, ex);
 4190            await subscription.DisposeCleanupAsync().ConfigureAwait(false);
 191
 192            // Rethrow instead of returning a pre-faulted waiter: the builder's contract is that
 193            // the trigger runs only once the subscription AND recovery state exist. A returned
 194            // waiter would still let the trigger fire the remote operation with no registration
 195            // left to receive (or recover) its response. Cleanup cancels ResponseTask, so no
 196            // pending task is left behind.
 4197            throw;
 198        }
 199
 3721200        return new InMemoryAsyncResponseWaiter<T>(subscription.ResponseTask, subscription.DisposeCleanupAsync);
 3721201    }
 202
 203    /// <inheritdoc />
 204    public Task SetResponse<T>(T response, string correlationId, CancellationToken cancellationToken = default) where T 
 3841205        => SetResponseCore(response, correlationId, cancellationToken);
 206
 207    Task IRawAsyncResponsePublisher.SetRawResponse(object? response, string correlationId, CancellationToken cancellatio
 8208        => SetResponseCore(response, correlationId, cancellationToken);
 209
 210    Task IRawAsyncResponsePublisher.SetRawResponseJson(string responseJson, string correlationId, CancellationToken canc
 145211        => SetRawResponseJsonCore(new RawJsonResponse(responseJson), correlationId, cancellationToken);
 212
 213    // Intentionally duplicated with SetRawResponseJsonCore: this is a microbenchmarked publish
 214    // hot path. Earlier generic/delegate/helper refactors made the code prettier but measurably
 215    // regressed latency and throughput, so keep the typed path inline unless benchmarks prove out.
 216    private async Task SetResponseCore<T>(T response, string correlationId, CancellationToken cancellationToken)
 217    {
 3849218        using var activity = AsyncResponseDiagnostics.StartActivity("asyncresponse.set_response", ActivityKind.Producer)
 3849219        activity?.SetTag("asyncresponse.channel", "inmemory");
 3849220        AsyncResponseDiagnostics.SetPayloadType(activity, typeof(T));
 221
 3849222        AsyncResponseDiagnostics.SetCorrelationId(activity, correlationId);
 3849223        if (CorrelationIdGuard.IsUnpublishable(correlationId, _logger, activity, "the response"))
 4224            return;
 225
 226        try
 227        {
 3839228            var subscribers = SnapshotSubscribers(correlationId);
 3839229            activity?.SetTag("asyncresponse.subscribers", subscribers.Count);
 7682230            for (var attempt = 0; subscribers.Count == 0; attempt++)
 231            {
 75232                var result = await _lostSubscriberDispatcher
 75233                    .DispatchLostResponses(
 75234                        _recoveryStateStore,
 75235                        correlationId,
 75236                        response,
 75237                        ChannelName(correlationId),
 75238                        cancellationToken,
 73239                        hasLiveSubscriber: () => new ValueTask<bool>(SnapshotSubscribers(correlationId).Count > 0))
 75240                    .ConfigureAwait(false);
 241
 53242                if (!result.RetryLive)
 243                {
 51244                    AsyncResponseDiagnostics.SetLostSubscriberRoute(activity, result.Action, result.RouteMixed);
 51245                    AsyncResponseDiagnostics.RecordLostSubscriber("response", result.Action, result.CallbackInvoked, res
 51246                    activity?.SetTag("asyncresponse.recovery.callback_invoked", result.CallbackInvoked);
 247
 51248                    return;
 249                }
 250
 251                // A waiter registered between the snapshot and the recovery-state read — deliver
 252                // live instead of consuming its registration. An empty re-snapshot (the waiter
 253                // vanished again) loops back through the liveness-aware dispatch rather than
 254                // silently dropping a response a sibling registration may still be armed for; a
 255                // second contradiction leaves all state intact.
 2256                subscribers = SnapshotSubscribers(correlationId);
 2257                activity?.SetTag("asyncresponse.subscribers", subscribers.Count);
 2258                if (subscribers.Count == 0 && attempt >= 1)
 259                {
 260                    // Second contradiction: consuming registrations on this evidence would strip a
 261                    // live waiter of its recovery arm — leave all state intact and surface the
 262                    // non-delivery to the caller, whose retry machinery re-attempts (Redis/NATS
 263                    // parity). Returning here instead would silently drop the payload while the
 264                    // caller reports success.
 0265                    _logger.LogWarning(
 0266                        "Response for correlationId {CorrelationId} kept racing subscriber churn; recovery registrations
 0267                        correlationId);
 0268                    activity?.SetTag("asyncresponse.recovery.liveness_contradiction", true);
 0269                    throw new InvalidOperationException(
 0270                        $"In-memory delivery for correlationId '{correlationId}' found no subscribers twice while a live
 0271                        "appearing; the payload was not delivered and recovery registrations were left intact. Retry the
 0272                        "the waiter's subscription is stable.");
 273                }
 274            }
 275
 276            // One serialization per publish, shared by every waiter's materialization. Wire parity
 277            // is deliberate for SAME-type waiters too: handing the publisher's live instance
 278            // through shared one mutable reference across the fan-out and leaked [JsonIgnore]
 279            // state no broker-backed channel can deliver — each waiter gets its own declared-T
 280            // materialization, byte-equivalent to what Redis/NATS/DB waiters receive. The wire
 281            // form is UTF-8 bytes end to end (the earlier string round-trip paid a UTF-16
 282            // transcode both ways), serialized eagerly here: every waiter of a typed instance
 283            // needs it, and JsonElement/string/null payloads — which materialize through the
 284            // conversion path instead — skip it entirely.
 3766285            var wireBytes = response is System.Text.Json.JsonElement or string or null
 3766286                ? null
 3766287                : DeclaredWireSerializer<T>.Instance(response);
 3766288            await DispatchResponsesAsync(subscribers, response, wireBytes).ConfigureAwait(false);
 289
 3766290            if (_logger.IsEnabled(LogLevel.Debug))
 4291                _logger.LogDebug("Published response for correlationId {CorrelationId}. PayloadType: {PayloadType}. Subs
 3766292        }
 22293        catch (Exception ex)
 294        {
 22295            AsyncResponseDiagnostics.SetError(activity, ex);
 22296            throw;
 297        }
 3821298    }
 299
 300    // Intentionally duplicated with SetResponseCore: raw ingress has different dispatch and
 301    // recovery materialization costs, and keeping the branch inline avoids hot-path indirection.
 302    private async Task SetRawResponseJsonCore(RawJsonResponse response, string correlationId, CancellationToken cancella
 303    {
 145304        using var activity = AsyncResponseDiagnostics.StartActivity("asyncresponse.ingress.raw_response", ActivityKind.P
 145305        activity?.SetTag("asyncresponse.channel", "inmemory");
 306
 145307        AsyncResponseDiagnostics.SetCorrelationId(activity, correlationId);
 145308        if (CorrelationIdGuard.IsUnpublishable(correlationId, _logger, activity, "the raw response", dropContractViolati
 6309            return;
 310
 311        try
 312        {
 139313            var subscribers = SnapshotSubscribers(correlationId);
 139314            activity?.SetTag("asyncresponse.subscribers", subscribers.Count);
 282315            for (var attempt = 0; subscribers.Count == 0; attempt++)
 316            {
 70317                var result = await _lostSubscriberDispatcher
 70318                    .DispatchLostResponses(
 70319                        _recoveryStateStore,
 70320                        correlationId,
 70321                        response.DeserializeUntyped(),
 70322                        ChannelName(correlationId),
 70323                        cancellationToken,
 68324                        hasLiveSubscriber: () => new ValueTask<bool>(SnapshotSubscribers(correlationId).Count > 0))
 70325                    .ConfigureAwait(false);
 326
 62327                if (!result.RetryLive)
 328                {
 60329                    AsyncResponseDiagnostics.SetLostSubscriberRoute(activity, result.Action, result.RouteMixed);
 60330                    AsyncResponseDiagnostics.RecordLostSubscriber("response", result.Action, result.CallbackInvoked, res
 60331                    activity?.SetTag("asyncresponse.recovery.callback_invoked", result.CallbackInvoked);
 332
 60333                    return;
 334                }
 335
 336                // A waiter registered between the snapshot and the recovery-state read — deliver
 337                // live instead of consuming its registration. An empty re-snapshot (the waiter
 338                // vanished again) loops back through the liveness-aware dispatch rather than
 339                // silently dropping a response a sibling registration may still be armed for; a
 340                // second contradiction leaves all state intact.
 2341                subscribers = SnapshotSubscribers(correlationId);
 2342                activity?.SetTag("asyncresponse.subscribers", subscribers.Count);
 2343                if (subscribers.Count == 0 && attempt >= 1)
 344                {
 345                    // Second contradiction: consuming registrations on this evidence would strip a
 346                    // live waiter of its recovery arm — leave all state intact and surface the
 347                    // non-delivery to the caller, whose retry machinery re-attempts (Redis/NATS
 348                    // parity). Returning here instead would silently drop the payload while the
 349                    // caller reports success.
 0350                    _logger.LogWarning(
 0351                        "Response for correlationId {CorrelationId} kept racing subscriber churn; recovery registrations
 0352                        correlationId);
 0353                    activity?.SetTag("asyncresponse.recovery.liveness_contradiction", true);
 0354                    throw new InvalidOperationException(
 0355                        $"In-memory delivery for correlationId '{correlationId}' found no subscribers twice while a live
 0356                        "appearing; the payload was not delivered and recovery registrations were left intact. Retry the
 0357                        "the waiter's subscription is stable.");
 358                }
 359            }
 360
 71361            await DispatchRawJsonResponsesAsync(subscribers, response).ConfigureAwait(false);
 362
 71363            if (_logger.IsEnabled(LogLevel.Debug))
 2364                _logger.LogDebug("Published raw response for correlationId {CorrelationId}. Subscribers: {SubscriberCoun
 71365        }
 8366        catch (Exception ex)
 367        {
 8368            AsyncResponseDiagnostics.SetError(activity, ex);
 8369            throw;
 370        }
 137371    }
 372
 373    /// <inheritdoc />
 374    public async Task SetException(Exception exception, string correlationId, CancellationToken cancellationToken = defa
 375    {
 58376        ArgumentNullException.ThrowIfNull(exception);
 377
 56378        using var activity = AsyncResponseDiagnostics.StartActivity("asyncresponse.set_exception", ActivityKind.Producer
 56379        activity?.SetTag("asyncresponse.channel", "inmemory");
 56380        activity?.SetTag("asyncresponse.exception_type", exception.GetType().FullName ?? exception.GetType().Name);
 381
 56382        AsyncResponseDiagnostics.SetCorrelationId(activity, correlationId);
 56383        if (CorrelationIdGuard.IsUnpublishable(correlationId, _logger, activity, "the exception", exception))
 4384            return;
 385
 386        try
 387        {
 50388            var subscribers = SnapshotSubscribers(correlationId);
 50389            activity?.SetTag("asyncresponse.subscribers", subscribers.Count);
 104390            for (var attempt = 0; subscribers.Count == 0; attempt++)
 391            {
 32392                var result = await _lostSubscriberDispatcher
 32393                    .DispatchLostExceptions(
 32394                        _recoveryStateStore,
 32395                        correlationId,
 32396                        exception,
 32397                        ChannelName(correlationId),
 32398                        cancellationToken,
 30399                        hasLiveSubscriber: () => new ValueTask<bool>(SnapshotSubscribers(correlationId).Count > 0))
 32400                    .ConfigureAwait(false);
 401
 16402                if (!result.RetryLive)
 403                {
 14404                    activity?.SetTag("asyncresponse.recovery.callback_invoked", result.CallbackInvoked);
 14405                    AsyncResponseDiagnostics.RecordLostSubscriber("exception", action: RecoveryAction.Fail, result.Callb
 406
 14407                    return;
 408                }
 409
 410                // A waiter registered between the snapshot and the recovery-state read — deliver
 411                // live instead of consuming its registration. An empty re-snapshot (the waiter
 412                // vanished again) loops back through the liveness-aware dispatch rather than
 413                // silently dropping a response a sibling registration may still be armed for; a
 414                // second contradiction leaves all state intact.
 2415                subscribers = SnapshotSubscribers(correlationId);
 2416                activity?.SetTag("asyncresponse.subscribers", subscribers.Count);
 2417                if (subscribers.Count == 0 && attempt >= 1)
 418                {
 419                    // Second contradiction: consuming registrations on this evidence would strip a
 420                    // live waiter of its recovery arm — leave all state intact and surface the
 421                    // non-delivery to the caller, whose retry machinery re-attempts (Redis/NATS
 422                    // parity). Returning here instead would silently drop the payload while the
 423                    // caller reports success.
 0424                    _logger.LogWarning(
 0425                        "Response for correlationId {CorrelationId} kept racing subscriber churn; recovery registrations
 0426                        correlationId);
 0427                    activity?.SetTag("asyncresponse.recovery.liveness_contradiction", true);
 0428                    throw new InvalidOperationException(
 0429                        $"In-memory delivery for correlationId '{correlationId}' found no subscribers twice while a live
 0430                        "appearing; the payload was not delivered and recovery registrations were left intact. Retry the
 0431                        "the waiter's subscription is stable.");
 432                }
 433            }
 434
 20435            await DispatchExceptionsAsync(subscribers, exception).ConfigureAwait(false);
 436
 20437            if (_logger.IsEnabled(LogLevel.Debug))
 2438                _logger.LogDebug("Published exception for correlationId {CorrelationId}. Subscribers: {SubscriberCount}.
 20439        }
 16440        catch (Exception ex)
 441        {
 16442            AsyncResponseDiagnostics.SetError(activity, ex);
 16443            throw;
 444        }
 38445    }
 446
 447    /// <inheritdoc />
 448    public ValueTask<long> CountActiveSubscribersAsync(string correlationId, CancellationToken cancellationToken = defau
 449    {
 115450        if (string.IsNullOrWhiteSpace(correlationId))
 2451            return new ValueTask<long>(0L);
 452
 113453        long count = _subscriptions.TryGetValue(correlationId, out var subscribers) ? subscribers.Count : 0L;
 113454        return new ValueTask<long>(count);
 455    }
 456
 457    private void AddSubscription(string correlationId, SubscriptionBase subscription)
 458    {
 2459        while (true)
 460        {
 7437461            var group = _subscriptions.GetOrAdd(correlationId, static _ => new SubscriptionGroup());
 3729462            if (group.TryAdd(subscription))
 3727463                return;
 464
 2465            _subscriptions.TryRemove(new KeyValuePair<string, SubscriptionGroup>(correlationId, group));
 466        }
 467    }
 468
 469    [MethodImpl(MethodImplOptions.AggressiveInlining)]
 470    private SubscriptionSnapshot SnapshotSubscribers(string correlationId)
 4205471        => _subscriptions.TryGetValue(correlationId, out var subscribers)
 4205472            ? subscribers.Snapshot()
 4205473            : default;
 474
 475    [MethodImpl(MethodImplOptions.AggressiveInlining)]
 476    private static Task DispatchResponsesAsync(SubscriptionSnapshot subscribers, object? response, byte[]? wireBytes)
 477    {
 3766478        if (subscribers.Single is { } single)
 3756479            return single.DispatchResponseAsync(response, wireBytes);
 480
 10481        return DispatchManyAsync(
 10482            subscribers.Many,
 20483            static (subscriber, state) => subscriber.DispatchResponseAsync(state.Response, state.WireBytes),
 10484            (Response: response, WireBytes: wireBytes));
 485    }
 486
 487    [MethodImpl(MethodImplOptions.AggressiveInlining)]
 488    private static Task DispatchRawJsonResponsesAsync(SubscriptionSnapshot subscribers, RawJsonResponse response)
 489    {
 71490        if (subscribers.Single is { } single)
 69491            return single.DispatchRawJsonResponseAsync(response);
 492
 6493        return DispatchManyAsync(subscribers.Many, static (subscriber, state) => subscriber.DispatchRawJsonResponseAsync
 494    }
 495
 496    [MethodImpl(MethodImplOptions.AggressiveInlining)]
 497    private static Task DispatchExceptionsAsync(SubscriptionSnapshot subscribers, Exception exception)
 498    {
 20499        if (subscribers.Single is { } single)
 17500            return single.DispatchExceptionAsync(exception);
 501
 9502        return DispatchManyAsync(subscribers.Many, static (subscriber, state) => subscriber.DispatchExceptionAsync(state
 503    }
 504
 505    private static Task DispatchManyAsync<TState>(
 506        SubscriptionBase[]? subscribers,
 507        Func<SubscriptionBase, TState, Task> dispatch,
 508        TState state)
 509    {
 25510        if (subscribers is null || subscribers.Length == 0)
 4511            return Task.CompletedTask;
 512
 21513        Task? firstPending = null;
 21514        List<Task>? pending = null;
 122515        for (var i = 0; i < subscribers.Length; i++)
 516        {
 40517            var task = dispatch(subscribers[i], state);
 40518            if (task.IsCompletedSuccessfully)
 519                continue;
 520
 10521            if (firstPending is null)
 522            {
 6523                firstPending = task;
 6524                continue;
 525            }
 526
 4527            (pending ??= [firstPending]).Add(task);
 528        }
 529
 21530        return pending is not null
 21531            ? Task.WhenAll(pending)
 21532            : firstPending ?? Task.CompletedTask;
 533    }
 534
 535    /// <summary>
 536    /// Simulated process death: drop every live waiter WITHOUT touching the recovery store, which
 537    /// is what a crash actually does — the registration survives until its TTL and a late response
 538    /// routes through the lost-subscriber dispatcher.
 539    /// <para>
 540    /// Needed because this channel arms its waiter timeouts on the injected TimeProvider, and a
 541    /// test harness shares that clock across incarnations. With no abandon hook, the dead
 542    /// incarnation's timers stayed armed on the shared clock: advancing time fired them, completed
 543    /// a ResponseTask the docs promise never completes after a restart, and — worse — ran the
 544    /// cleanup that DELETES the registration from the shared recovery store, so the late response
 545    /// the restart test exists to assert found nothing and was dropped.
 546    /// </para>
 547    /// <para>The DB channels model the same rule as DrainThenCleanupAsync(deleteRecoveryState: false).</para>
 548    /// </summary>
 549    internal async ValueTask AbandonAllAsync()
 550    {
 96551        foreach (var correlationId in _subscriptions.Keys)
 552        {
 18553            if (!_subscriptions.TryRemove(correlationId, out var group))
 554                continue;
 555
 72556            foreach (var subscription in group.DrainForAbandon())
 18557                await subscription.AbandonAsync().ConfigureAwait(false);
 558        }
 30559    }
 560
 561    private void RemoveSubscription(string correlationId, Guid subscriptionId)
 562    {
 3713563        if (!_subscriptions.TryGetValue(correlationId, out var subscribers))
 20564            return;
 565
 3693566        if (subscribers.Remove(subscriptionId))
 3674567            _subscriptions.TryRemove(new KeyValuePair<string, SubscriptionGroup>(correlationId, subscribers));
 3693568    }
 569
 177570    private static string ChannelName(string correlationId) => $"inmemory:response:{correlationId}";
 571
 572    /// <summary>
 573    /// The declared-type wire serializer for typed fan-out, cached per declared type so the
 574    /// dispatch chain passes a static delegate — allocation-free on the publish hot path. Mirrors
 575    /// <c>AsyncResponseEnvelope&lt;T&gt;</c>: the payload is serialized as the publisher's
 576    /// declared type, never the runtime type.
 577    /// </summary>
 578    private static class DeclaredWireSerializer<TDeclared>
 579    {
 26580        public static readonly Func<object?, byte[]> Instance =
 3788581            static response => AsyncResponseJson.SerializeToUtf8Bytes((TDeclared)response!);
 582    }
 583
 584    private sealed class SubscriptionGroup
 585    {
 3712586        private readonly object _gate = new();
 587        private SubscriptionBase? _single;
 588        private List<SubscriptionBase>? _many;
 589        private bool _closed;
 590
 591        public int Count
 592        {
 593            get
 594            {
 61595                lock (_gate)
 61596                    return _single is not null ? 1 : _many?.Count ?? 0;
 61597            }
 598        }
 599
 600        /// <summary>Adds a subscription to this correlation-id group.</summary>
 601        public bool TryAdd(SubscriptionBase subscription)
 602        {
 3739603            lock (_gate)
 604            {
 3739605                if (_closed)
 4606                    return false;
 607
 3735608                if (_single is null && _many is null)
 609                {
 3712610                    _single = subscription;
 3712611                    return true;
 612                }
 613
 23614                if (_many is null)
 615                {
 19616                    _many = [_single!, subscription];
 19617                    _single = null;
 19618                    return true;
 619                }
 620
 4621                _many.Add(subscription);
 4622                return true;
 623            }
 3739624        }
 625
 626        /// <summary>Closes the group and returns everything still in it.</summary>
 627        public IReadOnlyList<SubscriptionBase> DrainForAbandon()
 628        {
 18629            lock (_gate)
 630            {
 18631                _closed = true;
 18632                if (_single is not null)
 633                {
 18634                    var only = new[] { _single };
 18635                    _single = null;
 18636                    return only;
 637                }
 638
 0639                if (_many is null)
 0640                    return [];
 641
 0642                var all = _many.ToArray();
 0643                _many = null;
 0644                return all;
 645            }
 18646        }
 647
 648        /// <summary>Removes a subscription and returns whether the group became empty.</summary>
 649        public bool Remove(Guid subscriptionId)
 650        {
 3705651            lock (_gate)
 652            {
 3705653                if (_single?.Id == subscriptionId)
 654                {
 3678655                    _single = null;
 3678656                    _closed = true;
 3678657                    return true;
 658                }
 659
 27660                if (_many is null)
 2661                    return false;
 662
 70663                for (var i = 0; i < _many.Count; i++)
 664                {
 33665                    if (_many[i].Id != subscriptionId)
 666                        continue;
 667
 23668                    _many.RemoveAt(i);
 669                    // _many is only ever created with two entries and collapses to _single at one,
 670                    // so it can never reach zero here — the group-empty signal is produced solely
 671                    // by the _single removal path above.
 23672                    if (_many.Count == 1)
 673                    {
 19674                        _single = _many[0];
 19675                        _many = null;
 676                    }
 677
 23678                    return false;
 679                }
 680
 2681                return false;
 682            }
 3705683        }
 684
 685        /// <summary>Captures the current subscriptions for lock-free dispatch outside the group lock.</summary>
 686        public SubscriptionSnapshot Snapshot()
 687        {
 3867688            lock (_gate)
 689            {
 3867690                if (_single is not null)
 3848691                    return SubscriptionSnapshot.ForSingle(_single);
 692
 19693                if (_many is { Count: > 0 })
 17694                    return SubscriptionSnapshot.ForMany(_many.ToArray());
 695
 2696                return default;
 697            }
 3867698        }
 699    }
 700
 701    private readonly struct SubscriptionSnapshot
 702    {
 703        private SubscriptionSnapshot(SubscriptionBase? single, SubscriptionBase[]? many)
 704        {
 3865705            Single = single;
 3865706            Many = many;
 3865707        }
 708
 9201709        public SubscriptionBase? Single { get; }
 406710        public SubscriptionBase[]? Many { get; }
 711        public int Count
 712        {
 713            [MethodImpl(MethodImplOptions.AggressiveInlining)]
 5344714            get => Single is not null ? 1 : Many?.Length ?? 0;
 715        }
 716
 717        /// <summary>Creates a snapshot containing one subscription.</summary>
 718        [MethodImpl(MethodImplOptions.AggressiveInlining)]
 3848719        public static SubscriptionSnapshot ForSingle(SubscriptionBase single) => new(single, null);
 720
 721        /// <summary>Creates a snapshot containing multiple subscriptions.</summary>
 722        [MethodImpl(MethodImplOptions.AggressiveInlining)]
 17723        public static SubscriptionSnapshot ForMany(SubscriptionBase[] many) => new(null, many);
 724    }
 725
 726    private abstract class SubscriptionBase
 727    {
 728        private readonly InMemoryAsyncResponseChannel _owner;
 729        private readonly Activity? _activity;
 3743730        private readonly object _cleanupSync = new();
 731        private SemaphoreSlim? _dispatchWaiters;
 732        private ITimer? _timeoutTimer;
 733        private Task? _cleanupTask;
 734        private int _dispatching;
 735        private int _dispatchWaiterCount;
 736        private int _terminal;
 737        private int _cleanupStarted;
 738
 739        /// <summary>Creates the common state for an in-memory waiter subscription.</summary>
 3743740        protected SubscriptionBase(InMemoryAsyncResponseChannel owner, string correlationId, TimeSpan timeout, Activity?
 741        {
 3743742            _owner = owner;
 3743743            CorrelationId = correlationId;
 3743744            Timeout = timeout;
 3743745            _activity = activity;
 3743746        }
 747
 748        /// <summary>Per-waiter registration id used for subscription and recovery-state cleanup.</summary>
 18613749        public Guid Id { get; } = Guid.NewGuid();
 7462750        protected string CorrelationId { get; }
 22751        protected Activity? WaitActivity => _activity;
 3717752        private TimeSpan Timeout { get; }
 753        public bool CleanupStarted
 754        {
 755            [MethodImpl(MethodImplOptions.AggressiveInlining)]
 15060756            get => Volatile.Read(ref _cleanupStarted) != 0;
 757        }
 758
 759        /// <summary>
 760        /// Arms the subscription timeout after registration has succeeded. The timer comes from the
 761        /// engine's <see cref="TimeProvider"/>, so a virtual clock (AsyncResponse.Testing) can fire
 762        /// production-sized timeouts instantly.
 763        /// </summary>
 764        public void ArmTimeout()
 765        {
 3719766            if (CleanupStarted)
 2767                return;
 768
 3717769            var timer = _owner._timeProvider.CreateTimer(static state =>
 3717770            {
 17771                _ = ((SubscriptionBase)state!).TimeoutAsync();
 3734772            }, this, Timeout, System.Threading.Timeout.InfiniteTimeSpan);
 773
 774            // Full fence, not Volatile.Write: this store and the CleanupStarted re-check below are
 775            // one half of a Dekker pair with StartCleanupAsync (store _cleanupStarted, then read
 776            // _timeoutTimer). Release-store/acquire-load does not order StoreLoad, so both sides
 777            // could read the other's pre-store value and neither would dispose the timer — leaving
 778            // it armed in the timer queue, rooting the subscription graph until it fires.
 3717779            Interlocked.Exchange(ref _timeoutTimer, timer);
 780
 781            // A response or explicit disposal can clean up between the guard and timer arming;
 782            // cleanup then missed the timer, so the armer disposes it (firing is still harmless —
 783            // TimeoutAsync no-ops behind CleanupStarted and the terminal latch).
 3717784            if (CleanupStarted)
 0785                timer.Dispose();
 3717786        }
 787
 788        /// <summary>
 789        /// Dispatches a typed or materializable response to this subscription.
 790        /// <paramref name="wireBytes"/> is the publisher's single DECLARED-type wire
 791        /// serialization (UTF-8 JSON) — what a broker envelope would carry — from which each
 792        /// waiter materializes its own instance; <c>null</c> when the response is a
 793        /// JsonElement/string/null payload that materializes through the conversion path.
 794        /// </summary>
 795        public abstract Task DispatchResponseAsync(object? response, byte[]? wireBytes);
 796
 797        /// <summary>Dispatches a raw JSON response to this subscription.</summary>
 798        public abstract Task DispatchRawJsonResponseAsync(RawJsonResponse response);
 799
 800        /// <summary>Faults this subscription with a published exception.</summary>
 801        public Task DispatchExceptionAsync(Exception exception)
 27802            => DispatchSerialAsync(
 27803                exception,
 54804                static (subscription, state) => subscription.DispatchExceptionCoreAsync(state));
 805
 806        private Task DispatchExceptionCoreAsync(Exception exception)
 807        {
 27808            if (CleanupStarted)
 2809                return Task.CompletedTask;
 810
 25811            if (!TryBeginTerminal())
 2812                return Task.CompletedTask;
 813
 23814            AsyncResponseDiagnostics.SetError(_activity, exception);
 815
 816            // Wire parity: every durable channel transmits only the message (plus, optionally, the
 817            // capped stack trace in Data["RemoteStackTrace"]) and faults the waiter with a plain
 818            // Exception — the concrete type never crosses the wire. Handing the publisher's live
 819            // instance through let a typed `catch` pass against this channel that can never match
 820            // in production, the same divergence DeclaredWireSerializer exists to prevent for
 821            // payloads.
 23822            var remoteFailure = new Exception(exception.Message);
 23823            var remoteStackTrace = RemoteStackTrace.ForWire(
 23824                exception.StackTrace,
 23825                _owner._options.IncludeRemoteStackTrace,
 23826                _owner._options.MaxRemoteStackTraceLength);
 23827            if (!string.IsNullOrEmpty(remoteStackTrace))
 2828                remoteFailure.Data["RemoteStackTrace"] = remoteStackTrace;
 23829            TrySetException(remoteFailure);
 23830            return CleanupOnceAsTask();
 831        }
 832
 833        /// <summary>
 834        /// Serializes every signal for one waiter. The uncontended path uses only an interlocked
 835        /// owner bit; the semaphore is created lazily if concurrent publishers actually contend.
 836        /// </summary>
 837        protected Task DispatchSerialAsync<TState>(
 838            TState state,
 839            Func<SubscriptionBase, TState, Task> dispatch)
 840        {
 3946841            if (Interlocked.CompareExchange(ref _dispatching, 1, 0) != 0)
 49842                return WaitAndDispatchAsync(this, state, dispatch);
 843
 844            Task task;
 845            try
 846            {
 3897847                task = dispatch(this, state);
 3895848            }
 2849            catch
 850            {
 2851                ReleaseDispatch();
 2852                throw;
 853            }
 854
 3895855            if (task.IsCompletedSuccessfully)
 856            {
 3849857                ReleaseDispatch();
 3849858                return task;
 859            }
 860
 46861            return ReleaseAfterDispatchAsync(this, task);
 862        }
 863
 864        private static async Task WaitAndDispatchAsync<TState>(
 865            SubscriptionBase subscription,
 866            TState state,
 867            Func<SubscriptionBase, TState, Task> dispatch)
 868        {
 49869            Interlocked.Increment(ref subscription._dispatchWaiterCount);
 870            try
 871            {
 49872                var waiters = LazyInitializer.EnsureInitialized(
 49873                    ref subscription._dispatchWaiters,
 58874                    static () => new SemaphoreSlim(0));
 98875                while (Interlocked.CompareExchange(ref subscription._dispatching, 1, 0) != 0)
 49876                    await waiters.WaitAsync().ConfigureAwait(false);
 49877            }
 878            finally
 879            {
 49880                Interlocked.Decrement(ref subscription._dispatchWaiterCount);
 881            }
 882
 883            try
 884            {
 49885                await dispatch(subscription, state).ConfigureAwait(false);
 49886            }
 887            finally
 888            {
 49889                subscription.ReleaseDispatch();
 890            }
 49891        }
 892
 893        private static async Task ReleaseAfterDispatchAsync(SubscriptionBase subscription, Task task)
 894        {
 895            try
 896            {
 46897                await task.ConfigureAwait(false);
 46898            }
 899            finally
 900            {
 46901                subscription.ReleaseDispatch();
 902            }
 46903        }
 904
 905        [MethodImpl(MethodImplOptions.AggressiveInlining)]
 906        private void ReleaseDispatch()
 907        {
 908            // Full fence, not Volatile.Write: the release-store/acquire-load pair below is a
 909            // StoreLoad sequence, which x86-64 (and ARM) may reorder — the waiter-count read
 910            // could execute before the flag store drains, miss a waiter that parked in between,
 911            // and skip the Release, leaving _dispatching == 0 with a parked waiter and no permit.
 3946912            Interlocked.Exchange(ref _dispatching, 0);
 3946913            if (Volatile.Read(ref _dispatchWaiterCount) > 0)
 49914                Volatile.Read(ref _dispatchWaiters)?.Release();
 3946915        }
 916
 917        /// <summary>Runs subscription, recovery-state, timeout, and activity cleanup once.</summary>
 918        public ValueTask CleanupOnceAsync()
 919        {
 920            Task cleanupTask;
 7412921            lock (_cleanupSync)
 922            {
 7412923                cleanupTask = _cleanupTask ??= StartCleanupAsync();
 7412924            }
 925
 7412926            return cleanupTask.IsCompletedSuccessfully
 7412927                ? ValueTask.CompletedTask
 7412928                : new ValueTask(cleanupTask);
 929        }
 930
 931        /// <summary>
 932        /// Dispose-path cleanup: DRAINS any in-flight dispatch before settling. A delivery may be
 933        /// mid <c>Until</c>-predicate holding a claimed terminal message; queueing a no-op through
 934        /// the per-waiter dispatch gate completes only after that delivery settled the task (or
 935        /// released the gate), so the cleanup's cancel afterwards is a genuine settlement — never
 936        /// a cancellation stealing an already-consumed response. Must NOT be called from dispatch
 937        /// code (which holds the gate): dispatch-triggered cleanup uses
 938        /// <see cref="CleanupOnceAsync"/> directly, with its task already settled.
 939        /// <para>
 940        /// The drain is bounded by <c>DisposalDrainTimeout</c>. A lapsed budget must not fall back
 941        /// to the cleanup's cancel — the wedged delivery holds a message the channel already
 942        /// claimed, and "canceled" would tell a re-attaching caller nothing was delivered — so it
 943        /// faults the task with the explicit indeterminate contract instead, routing durable flows
 944        /// to a fresh idempotent restart. The abandoned no-op marker runs harmlessly whenever the
 945        /// wedged dispatch finally releases the gate.
 946        /// </para>
 947        /// </summary>
 948        public async ValueTask DisposeCleanupAsync()
 949        {
 3737950            if (Volatile.Read(ref _cleanupStarted) == 0)
 951            {
 43952                var drainTimeout = _owner._options.DisposalDrainTimeout;
 953                try
 954                {
 86955                    await DispatchSerialAsync(0, static (_, _) => Task.CompletedTask)
 43956                        .WaitAsync(drainTimeout, _owner._timeProvider).ConfigureAwait(false);
 41957                }
 2958                catch (TimeoutException)
 959                {
 2960                    _owner._logger.LogWarning(
 2961                        "Disposal drain for correlationId {CorrelationId} did not finish within {DrainTimeout}; faulting
 2962                        CorrelationId, drainTimeout);
 2963                    AsyncResponseDiagnostics.SetError(_activity, "indeterminate_delivery", "Disposal drain timed out wit
 964                    // A TrySetResult from the late-finishing dispatch loses against this and is
 965                    // dropped; its cleanup call is a no-op behind the latch.
 2966                    TrySetException(new AsyncResponseIndeterminateDeliveryException(CorrelationId, drainTimeout));
 2967                }
 968            }
 969
 3737970            await CleanupOnceAsync().ConfigureAwait(false);
 3737971        }
 972
 973        private async Task StartCleanupAsync()
 974        {
 975            // Full fence, not Volatile.Write: the other half of the Dekker pair with ArmTimeout
 976            // (store _timeoutTimer, then read _cleanupStarted) — see the comment there.
 3711977            Interlocked.Exchange(ref _cleanupStarted, 1);
 978
 979            // A waiter disposed before any terminal signal must not leave ResponseTask pending
 980            // forever for callers that hold it directly. Cancellation is a no-op after a normal
 981            // completion, timeout, or fault.
 3711982            TrySetCanceled();
 983
 984            try
 985            {
 986                // Delete the recovery state BEFORE removing the subscription. In the reverse order
 987                // a publish landing in the window sees "no subscriber, state present" and fires a
 988                // spurious recovery callback for a wait that already reached a terminal state. In
 989                // this order the window shows a subscriber that drops the message (CleanupStarted)
 990                // — a late or duplicate terminal message is droppable; a resurrected recovery
 991                // callback is not.
 3711992                await _owner._recoveryStateStore.TryDeleteAsync(CorrelationId, Id).ConfigureAwait(false);
 3709993            }
 2994            catch (Exception ex)
 995            {
 996                // Best-effort, exactly as every other channel treats this delete (and as this
 997                // channel already treats its own post-save compensation delete): the state expires
 998                // on its own and the watchdog backs it. Letting it escape faulted the one-shot
 999                // cleanup task AFTER the waiter had already been completed, so the fault surfaced
 1000                // to the publisher — whose retry then found no subscriber but an intact
 1001                // registration and fired the recovery callback for a response the waiter already
 1002                // held. On the timeout path it was not observed at all.
 21003                _owner._logger.LogError(
 21004                    ex,
 21005                    "Failed to delete recovery state for correlationId {CorrelationId}; it will expire on its own.",
 21006                    CorrelationId);
 21007            }
 1008            finally
 1009            {
 37111010                _owner.RemoveSubscription(CorrelationId, Id);
 37111011                if (Volatile.Read(ref _timeoutTimer) is { } timer)
 36851012                    await timer.DisposeAsync().ConfigureAwait(false);
 37111013                _activity?.Dispose();
 1014            }
 37111015        }
 1016
 1017        /// <summary>Marks this subscription as terminal if no terminal signal has won yet.</summary>
 1018        [MethodImpl(MethodImplOptions.AggressiveInlining)]
 1019        protected bool TryBeginTerminal()
 36851020            => Interlocked.Exchange(ref _terminal, 1) == 0;
 1021
 1022        /// <summary>Stores the timeout exception on the concrete waiter task.</summary>
 1023        /// <summary>
 1024        /// Disarms this waiter as if its process had died: the timeout timer is disposed so it can
 1025        /// never fire on a shared clock, the task is cancelled for callers holding it, and the
 1026        /// recovery registration is deliberately left in place.
 1027        /// </summary>
 1028        public async ValueTask AbandonAsync()
 1029        {
 1030            // Latch the cleanup as already done: the flag alone only skipped the drain, and the
 1031            // zombie's own later disposal (a flow's waiter.DisposeAsync after the cancelled wait,
 1032            // or a caller's `await using`) still ran StartCleanupAsync — which deleted the very
 1033            // recovery registration this method exists to leave behind.
 181034            lock (_cleanupSync)
 1035            {
 181036                _cleanupTask ??= Task.CompletedTask;
 181037            }
 1038
 181039            Interlocked.Exchange(ref _cleanupStarted, 1);
 181040            _ = TryBeginTerminal();
 1041
 181042            if (Volatile.Read(ref _timeoutTimer) is { } timer)
 181043                await timer.DisposeAsync().ConfigureAwait(false);
 1044
 181045            TrySetCanceled();
 181046            _activity?.Dispose();
 181047        }
 1048
 1049        protected abstract void SetTimeoutException(Exception exception);
 1050
 1051        /// <summary>Attempts to fault the concrete waiter task.</summary>
 1052        public abstract void TrySetException(Exception exception);
 1053
 1054        /// <summary>Attempts to cancel the concrete waiter task (dispose before any terminal signal).</summary>
 1055        public abstract void TrySetCanceled();
 1056
 1057        /// <summary>Returns cleanup as a task for dispatch paths that already operate on <see cref="Task"/>.</summary>
 1058        [MethodImpl(MethodImplOptions.AggressiveInlining)]
 1059        protected Task CleanupOnceAsTask()
 1060        {
 36201061            var cleanup = CleanupOnceAsync();
 36201062            return cleanup.IsCompletedSuccessfully ? Task.CompletedTask : cleanup.AsTask();
 1063        }
 1064
 1065        /// <summary>
 1066        /// The timer callback's whole body. Nothing awaits it, so nothing may escape it: a fault
 1067        /// here is an unobserved task at best and, thrown synchronously out of the timer callback
 1068        /// (a logger that throws while the gate is free), an unhandled exception on a timer
 1069        /// thread — a process exit, with the waiter never settled. The durable channels wrap the
 1070        /// same body for the same reason.
 1071        /// <para>
 1072        /// The timeout queues behind the per-waiter dispatch gate so it cannot beat a delivery
 1073        /// that already claimed a message — and, like the dispose path's identical wait
 1074        /// (<see cref="DisposeCleanupAsync"/>), that wait is bounded by
 1075        /// <c>DisposalDrainTimeout</c>. Unbounded, a wedged <c>Until</c> predicate held the gate
 1076        /// forever and the timeout — the one mechanism that exists to end a wait nothing else
 1077        /// ends — never ran: the waiter hung where every durable channel faults it. A lapsed
 1078        /// budget faults the task as indeterminate rather than timed out, because the wedged
 1079        /// delivery holds a response that WAS received.
 1080        /// </para>
 1081        /// </summary>
 1082        private async Task TimeoutAsync()
 1083        {
 1084            try
 1085            {
 171086                var drainTimeout = _owner._options.DisposalDrainTimeout;
 1087                try
 1088                {
 341089                    await DispatchSerialAsync(0, static (subscription, _) => subscription.TimeoutCoreAsync())
 171090                        .WaitAsync(drainTimeout, _owner._timeProvider).ConfigureAwait(false);
 171091                }
 1092                catch (TimeoutException)
 1093                {
 1094                    // Settle first, report second: the log call is the part that can throw. The
 1095                    // abandoned timeout marker no-ops behind CleanupStarted whenever the wedged
 1096                    // dispatch finally releases the gate, and a late TrySetResult from it loses
 1097                    // against this fault.
 01098                    TrySetException(new AsyncResponseIndeterminateDeliveryException(CorrelationId, drainTimeout));
 01099                    AsyncResponseDiagnostics.SetError(_activity, "indeterminate_delivery", "Waiter timeout lapsed with a
 1100                    try
 1101                    {
 01102                        _owner._logger.LogWarning(
 01103                            "The waiter timeout for correlationId {CorrelationId} could not run within {DrainTimeout} be
 01104                            CorrelationId, drainTimeout);
 1105                    }
 1106                    finally
 1107                    {
 01108                        await CleanupOnceAsync().ConfigureAwait(false);
 1109                    }
 1110                }
 171111            }
 01112            catch (Exception ex)
 1113            {
 1114                try
 1115                {
 01116                    _owner._logger.LogError(ex, "Error handling waiter timeout for correlationId {CorrelationId}.", Corr
 01117                }
 01118                catch
 1119                {
 1120                    // The logger is what is failing; there is nowhere left to report to.
 01121                }
 01122            }
 171123        }
 1124
 1125        private async Task TimeoutCoreAsync()
 1126        {
 211127            if (CleanupStarted)
 21128                return;
 1129
 191130            if (!TryBeginTerminal())
 21131                return;
 1132
 1133            // The task is completed BEFORE anything that can throw: a logger or a metrics
 1134            // listener failing here used to leave the waiter terminal (no later signal can
 1135            // complete it) and unsettled — pending forever, with its timer already spent.
 171136            var exception = new TimeoutException($"Timed out waiting for response for correlationId {CorrelationId}.");
 171137            SetTimeoutException(exception);
 1138            try
 1139            {
 171140                _owner._logger.LogWarning("Timed out waiting for response for correlationId {CorrelationId}.", Correlati
 171141                AsyncResponseDiagnostics.RecordWaiterTimeout("inmemory");
 171142                AsyncResponseDiagnostics.SetError(_activity, "timeout", exception.Message);
 1143            }
 1144            finally
 1145            {
 171146                await CleanupOnceAsync().ConfigureAwait(false);
 1147            }
 211148        }
 1149    }
 1150
 1151    private sealed class Subscription<T> : SubscriptionBase where T : IAsyncResponsePayload
 1152    {
 1153        private readonly Func<T, ValueTask<bool>> _completionPredicate;
 1154        private readonly ExecutionContext? _capturedContext;
 37431155        private readonly TaskCompletionSource<T> _tcs = new(TaskCreationOptions.RunContinuationsAsynchronously);
 1156
 1157        /// <summary>Creates a typed in-memory waiter subscription.</summary>
 1158        public Subscription(
 1159            InMemoryAsyncResponseChannel owner,
 1160            string correlationId,
 1161            TimeSpan timeout,
 1162            Func<T, ValueTask<bool>> completionPredicate,
 1163            Activity? activity,
 1164            ExecutionContext? capturedContext)
 37431165            : base(owner, correlationId, timeout, activity)
 1166        {
 37431167            _completionPredicate = completionPredicate;
 37431168            _capturedContext = capturedContext;
 37431169        }
 1170
 37351171        public Task<T> ResponseTask => _tcs.Task;
 1172
 1173        /// <inheritdoc />
 1174        public override Task DispatchResponseAsync(object? response, byte[]? wireBytes)
 37821175            => DispatchSerialAsync(
 37821176                (Response: response, WireBytes: wireBytes),
 75641177                static (subscription, state) => ((Subscription<T>)subscription).DispatchResponseUnserializedAsync(state.
 1178
 1179        private Task DispatchResponseUnserializedAsync(object? response, byte[]? wireBytes)
 1180        {
 37821181            if (CleanupStarted)
 21182                return Task.CompletedTask;
 1183
 1184            // Restore the waiter's subscribe-time ambient context (trace, principal, …) so the
 1185            // completion predicate and any logging run under it, even when the response is delivered
 1186            // on a foreign thread such as a broker ingress callback.
 37801187            if (_capturedContext is null)
 32601188                return DispatchResponseCoreAsync(response, wireBytes);
 1189
 5201190            Task? dispatch = null;
 10401191            ExecutionContext.Run(_capturedContext, _ => dispatch = DispatchResponseCoreAsync(response, wireBytes), null)
 5201192            return dispatch!;
 1193        }
 1194
 1195        /// <inheritdoc />
 1196        public override Task DispatchRawJsonResponseAsync(RawJsonResponse response)
 751197            => DispatchSerialAsync(
 751198                response,
 1501199                static (subscription, state) => ((Subscription<T>)subscription).DispatchRawJsonResponseUnserializedAsync
 1200
 1201        private Task DispatchRawJsonResponseUnserializedAsync(RawJsonResponse response)
 1202        {
 751203            if (CleanupStarted)
 21204                return Task.CompletedTask;
 1205
 731206            if (_capturedContext is null)
 101207                return DispatchRawJsonResponseCoreAsync(response);
 1208
 631209            Task? dispatch = null;
 1261210            ExecutionContext.Run(_capturedContext, _ => dispatch = DispatchRawJsonResponseCoreAsync(response), null);
 631211            return dispatch!;
 1212        }
 1213
 1214        private Task DispatchResponseCoreAsync(object? response, byte[]? wireBytes)
 1215        {
 1216            T payload;
 1217            try
 1218            {
 37801219                payload = MaterializeAs(response, wireBytes);
 37741220            }
 61221            catch (Exception ex)
 1222            {
 61223                return FaultAsync(ex);
 1224            }
 1225
 37741226            return DispatchPayloadAsync(payload);
 61227        }
 1228
 1229        private Task DispatchRawJsonResponseCoreAsync(RawJsonResponse response)
 1230        {
 1231            try
 1232            {
 1233                // A literal-null body passes ThrowIfClearlyNotJson and deserializes without error
 1234                // (for reference-type payloads); it must fault the waiter, never complete it with
 1235                // a null payload — the same guard the ingress applies to worker messages.
 731236                var payload = response.Deserialize<T>()
 731237                    ?? throw new InvalidDataException("Response message deserialized to null.");
 691238                return DispatchPayloadAsync(payload);
 1239            }
 41240            catch (Exception ex)
 1241            {
 41242                return FaultAsync(ex);
 1243            }
 731244        }
 1245
 1246        // The ONE copy of the completion semantics — predicate, terminal transition, result,
 1247        // cleanup — that both the typed and the raw-ingress deliveries run once each has
 1248        // materialized its payload. The typed path used to carry its own inline copy from when it
 1249        // handed the publisher's instance straight through; since wire parity it deserializes on
 1250        // every delivery like the raw path does, so the second copy bought nothing and had to be
 1251        // kept in lockstep by hand (its catch had already drifted into a re-spelling of FaultAsync).
 1252        private Task DispatchPayloadAsync(T payload)
 1253        {
 1254            try
 1255            {
 38431256                var completion = _completionPredicate(payload);
 38391257                if (!completion.IsCompletedSuccessfully)
 801258                    return AwaitCompletionPredicateAsync(completion, payload);
 1259
 37591260                var finished = completion.Result;
 37591261                if (!finished || !TryBeginTerminal())
 1841262                    return Task.CompletedTask;
 1263
 35751264                _tcs.TrySetResult(payload);
 35751265                return CleanupOnceAsTask();
 1266            }
 41267            catch (Exception ex)
 1268            {
 41269                return FaultAsync(ex);
 1270            }
 38431271        }
 1272
 1273        // Wire parity for EVERY delivery, same-type included: the payload is re-materialized from
 1274        // the publisher's DECLARED-type wire JSON — the same representation a broker envelope
 1275        // carries, polymorphic discriminators included, [JsonIgnore] state excluded. Handing the
 1276        // publisher's live instance through (the old same-type fast path) aliased one mutable
 1277        // object across all same-type waiters and exposed in-process-only state no broker-backed
 1278        // channel can deliver. The publish serializes once (UTF-8 bytes); each waiter
 1279        // deserializes its own instance case-insensitively — the same property matching the
 1280        // string conversion path and every broker ingress apply. JsonElement/string/null payloads
 1281        // keep the existing conversion path.
 1282        //
 1283        // Through JsonSafety, like every other reader of a body the waiter did not write: a
 1284        // publisher's payload that does not fit the waiter's type (a string-valued dictionary
 1285        // published to an int-valued waiter) fails INSIDE the payload, and the reader's own
 1286        // JsonException names the offending key ("Path: $.Values['<customer id>']"). That message
 1287        // reached the waiter's task and, through SetError, the wait activity's status — the
 1288        // in-process exception to the body-free rule the broker channels enforce.
 1289        private static T MaterializeAs(object? response, byte[]? wireBytes)
 1290        {
 37801291            var payload = wireBytes is null
 37801292                ? response.As<T>()
 37801293                : JsonSafety.SafeDeserialize(wireBytes, AsyncResponseJson.GetTypeInfo<T>(AsyncResponseJson.CaseInsensiti
 1294
 1295            // A null (a published null object, a JSON-null JsonElement, a "null" string body)
 1296            // must fault the waiter, never complete it — the broker channels reject the same
 1297            // shape at the envelope, and the raw ingress path applies the equivalent guard.
 37761298            return payload ?? throw new InvalidDataException("Response payload materialized to null.");
 1299        }
 1300
 1301        private Task FaultAsync(Exception exception)
 1302        {
 281303            if (!TryBeginTerminal())
 61304                return Task.CompletedTask;
 1305
 221306            AsyncResponseDiagnostics.SetError(WaitActivity, exception);
 221307            _tcs.TrySetException(exception);
 221308            return CleanupOnceAsTask();
 1309        }
 1310
 1311        private async Task AwaitCompletionPredicateAsync(ValueTask<bool> completion, T payload)
 1312        {
 1313            try
 1314            {
 801315                var finished = await completion.ConfigureAwait(false);
 681316                if (!finished || !TryBeginTerminal())
 481317                    return;
 1318
 201319                _tcs.TrySetResult(payload);
 201320                await CleanupOnceAsync().ConfigureAwait(false);
 201321            }
 121322            catch (Exception ex)
 1323            {
 121324                await FaultAsync(ex).ConfigureAwait(false);
 1325            }
 801326        }
 1327
 1328        /// <inheritdoc />
 1329        protected override void SetTimeoutException(Exception exception)
 171330            => _tcs.TrySetException(exception);
 1331
 1332        /// <inheritdoc />
 1333        public override void TrySetException(Exception exception)
 251334            => _tcs.TrySetException(exception);
 1335
 1336        /// <inheritdoc />
 1337        public override void TrySetCanceled()
 37291338            => _tcs.TrySetCanceled();
 1339    }
 1340}
 1341
 1342internal sealed class InMemoryAsyncResponseWaiter<T>(
 1343    Task<T> _responseTask,
 1344    Func<ValueTask> _cleanupAsync) : IAsyncResponseWaiter<T> where T : IAsyncResponsePayload
 1345{
 1346    public Task<T> ResponseTask => _responseTask;
 1347
 1348    /// <inheritdoc />
 1349    public ValueTask DisposeAsync()
 1350        => _cleanupAsync();
 1351}

Methods/Properties

.ctor(Microsoft.Extensions.DependencyInjection.IServiceScopeFactory,AsyncResponse.IRecoveryStateStore,Microsoft.Extensions.Options.IOptions`1<AsyncResponse.InMemoryAsyncResponseOptions>,AsyncResponse.AsyncResponseContextPropagation,Microsoft.Extensions.Logging.ILogger`1<AsyncResponse.InMemoryAsyncResponseChannel>,System.TimeProvider)
CreateResponseWaiter(System.String,System.Func`2<T,System.Threading.Tasks.ValueTask`1<System.Boolean>>,System.Nullable`1<System.TimeSpan>)
CreateRecoverableResponseWaiter(System.String,AsyncResponse.ReflectionCallDto,AsyncResponse.ReflectionCallDto,System.Func`2<T,System.Threading.Tasks.ValueTask`1<System.Boolean>>,System.Nullable`1<System.TimeSpan>)
CreateResponseWaiterCore()
SetResponse(T,System.String,System.Threading.CancellationToken)
AsyncResponse.IRawAsyncResponsePublisher.SetRawResponse(System.Object,System.String,System.Threading.CancellationToken)
AsyncResponse.IRawAsyncResponsePublisher.SetRawResponseJson(System.String,System.String,System.Threading.CancellationToken)
SetResponseCore()
SetRawResponseJsonCore()
SetException()
CountActiveSubscribersAsync(System.String,System.Threading.CancellationToken)
AddSubscription(System.String,AsyncResponse.InMemoryAsyncResponseChannel/SubscriptionBase)
SnapshotSubscribers(System.String)
DispatchResponsesAsync(AsyncResponse.InMemoryAsyncResponseChannel/SubscriptionSnapshot,System.Object,System.Byte[])
DispatchRawJsonResponsesAsync(AsyncResponse.InMemoryAsyncResponseChannel/SubscriptionSnapshot,AsyncResponse.RawJsonResponse)
DispatchExceptionsAsync(AsyncResponse.InMemoryAsyncResponseChannel/SubscriptionSnapshot,System.Exception)
DispatchManyAsync(AsyncResponse.InMemoryAsyncResponseChannel/SubscriptionBase[],System.Func`3<AsyncResponse.InMemoryAsyncResponseChannel/SubscriptionBase,TState,System.Threading.Tasks.Task>,TState)
AbandonAllAsync()
RemoveSubscription(System.String,System.Guid)
ChannelName(System.String)
.cctor()
.ctor()
get_Count()
TryAdd(AsyncResponse.InMemoryAsyncResponseChannel/SubscriptionBase)
DrainForAbandon()
Remove(System.Guid)
Snapshot()
.ctor(AsyncResponse.InMemoryAsyncResponseChannel/SubscriptionBase,AsyncResponse.InMemoryAsyncResponseChannel/SubscriptionBase[])
get_Single()
get_Many()
get_Count()
ForSingle(AsyncResponse.InMemoryAsyncResponseChannel/SubscriptionBase)
ForMany(AsyncResponse.InMemoryAsyncResponseChannel/SubscriptionBase[])
.ctor(AsyncResponse.InMemoryAsyncResponseChannel,System.String,System.TimeSpan,System.Diagnostics.Activity)
get_Id()
get_CorrelationId()
get_WaitActivity()
get_Timeout()
get_CleanupStarted()
ArmTimeout()
DispatchExceptionAsync(System.Exception)
DispatchExceptionCoreAsync(System.Exception)
DispatchSerialAsync(TState,System.Func`3<AsyncResponse.InMemoryAsyncResponseChannel/SubscriptionBase,TState,System.Threading.Tasks.Task>)
WaitAndDispatchAsync()
ReleaseAfterDispatchAsync()
ReleaseDispatch()
CleanupOnceAsync()
DisposeCleanupAsync()
StartCleanupAsync()
TryBeginTerminal()
AbandonAsync()
CleanupOnceAsTask()
TimeoutAsync()
TimeoutCoreAsync()
.ctor(AsyncResponse.InMemoryAsyncResponseChannel,System.String,System.TimeSpan,System.Func`2<T,System.Threading.Tasks.ValueTask`1<System.Boolean>>,System.Diagnostics.Activity,System.Threading.ExecutionContext)
get_ResponseTask()
DispatchResponseAsync(System.Object,System.Byte[])
DispatchResponseUnserializedAsync(System.Object,System.Byte[])
DispatchRawJsonResponseAsync(AsyncResponse.RawJsonResponse)
DispatchRawJsonResponseUnserializedAsync(AsyncResponse.RawJsonResponse)
DispatchResponseCoreAsync(System.Object,System.Byte[])
DispatchRawJsonResponseCoreAsync(AsyncResponse.RawJsonResponse)
DispatchPayloadAsync(T)
MaterializeAs(System.Object,System.Byte[])
FaultAsync(System.Exception)
AwaitCompletionPredicateAsync()
SetTimeoutException(System.Exception)
TrySetException(System.Exception)
TrySetCanceled()