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

Information
Class: AsyncResponse.InMemoryAsyncResponseChannel.SubscriptionSnapshot
Assembly: AsyncResponse.Core
File(s): /home/runner/work/AsyncResponse/AsyncResponse/src/AsyncResponse.Core/InMemoryAsyncResponseChannel.cs
Line coverage
100%
Covered lines: 6
Uncovered lines: 0
Coverable lines: 6
Total lines: 1000
Line coverage: 100%
Branch coverage
100%
Covered branches: 4
Total branches: 4
Branch coverage: 100%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
get_Count()100%44100%
ForSingle(...)100%11100%
ForMany(...)100%11100%

File(s)

/home/runner/work/AsyncResponse/AsyncResponse/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/// </summary>
 16internal sealed class InMemoryAsyncResponseChannel : IAsyncResponsePublisher, IRawAsyncResponsePublisher, IAsyncResponse
 17{
 18    private readonly ConcurrentDictionary<string, SubscriptionGroup> _subscriptions = new(StringComparer.Ordinal);
 19    private readonly IRecoveryStateStore _recoveryStateStore;
 20    private readonly InMemoryAsyncResponseOptions _options;
 21    private readonly LostSubscriberCallbackDispatcher _lostSubscriberDispatcher;
 22    private readonly AsyncResponseContextPropagation _propagation;
 23    private readonly ILogger<InMemoryAsyncResponseChannel> _logger;
 24
 25    /// <summary>Creates a process-local async-response channel.</summary>
 26    public InMemoryAsyncResponseChannel(
 27        IServiceScopeFactory scopeFactory,
 28        IRecoveryStateStore recoveryStateStore,
 29        IOptions<InMemoryAsyncResponseOptions> options,
 30        AsyncResponseContextPropagation propagation,
 31        ILogger<InMemoryAsyncResponseChannel> logger)
 32    {
 33        _recoveryStateStore = recoveryStateStore;
 34        _options = options.Value;
 35        _options.ValidateShared(nameof(InMemoryAsyncResponseOptions));
 36        _propagation = propagation;
 37        _logger = logger;
 38        _lostSubscriberDispatcher = new LostSubscriberCallbackDispatcher(scopeFactory, propagation, logger);
 39    }
 40
 41    /// <inheritdoc />
 42    public async Task<IAsyncResponseWaiter<T>> CreateResponseWaiter<T>(
 43        string correlationId,
 44        Func<T, ValueTask<bool>>? completionPredicate = null,
 45        TimeSpan? timeout = null) where T : IAsyncResponsePayload
 46    {
 47        if (string.IsNullOrWhiteSpace(correlationId))
 48            throw new ArgumentNullException(nameof(correlationId), "CorrelationId must not be empty or whitespace.");
 49
 50        var hasCustomPredicate = completionPredicate is not null;
 51        completionPredicate ??= static _ => new ValueTask<bool>(true);
 52        timeout ??= _options.DefaultTimeout ?? _options.RecoveryStateExpiry;
 53        // BEFORE any side effect: an unsupported resolved timeout (non-positive, or past the
 54        // ~49.7-day BCL timer ceiling) used to throw only at timer arming — after the
 55        // subscription and recovery state existed, leaking both — and zero used to slip through
 56        // on some channels entirely, insta-timing-out a fully registered waiter.
 57        AsyncResponseChannelOptions.EnsureWaiterTimeoutSupported(timeout.Value);
 58
 59        var activity = AsyncResponseDiagnostics.StartActivity("asyncresponse.wait", correlationId: correlationId);
 60        activity?.SetTag("asyncresponse.channel", "inmemory");
 61        AsyncResponseDiagnostics.SetPayloadType(activity, typeof(T));
 62        activity?.SetTag("asyncresponse.timeout_seconds", timeout.Value.TotalSeconds);
 63
 64        var subscription = new Subscription<T>(
 65            owner: this,
 66            correlationId,
 67            timeout.Value,
 68            completionPredicate,
 69            activity,
 70            // Only restore the subscribe-time ambient context during dispatch when there is a user
 71            // completion predicate to run under it. With the default (always-complete) predicate,
 72            // nothing on the dispatch path observes ambient context, so capturing it would only buy
 73            // a per-dispatch ExecutionContext.Run plus its capturing closure. The waiter's own
 74            // continuation flows its own context regardless (RunContinuationsAsynchronously).
 75            hasCustomPredicate ? ExecutionContext.Capture() : null);
 76
 77        AddSubscription(correlationId, subscription);
 78
 79        try
 80        {
 81            await _recoveryStateStore.SaveAsync(
 82                correlationId,
 83                new RecoveryState
 84                {
 85                    RegistrationId = subscription.Id,
 86                    CorrelationId = correlationId,
 87                    PayloadTypeFullName = typeof(T).FullName,
 88                    RegisteredAtUtc = DateTime.UtcNow,
 89                    Context = _propagation.Capture()
 90                },
 91                _options.RecoveryStateExpiry).ConfigureAwait(false);
 92
 93            if (subscription.CleanupStarted)
 94                await _recoveryStateStore.TryDeleteAsync(correlationId, subscription.Id).ConfigureAwait(false);
 95            else
 96                subscription.ArmTimeout();
 97
 98            if (_logger.IsEnabled(LogLevel.Debug))
 99                _logger.LogDebug("Waiting for response on correlationId {CorrelationId} with timeout {Timeout}.", correl
 100        }
 101        catch (Exception ex)
 102        {
 103            _logger.LogError(ex, "Failed to create in-memory waiter for correlationId {CorrelationId}.", correlationId);
 104            AsyncResponseDiagnostics.SetError(activity, ex);
 105            await subscription.DisposeCleanupAsync().ConfigureAwait(false);
 106
 107            // Rethrow instead of returning a pre-faulted waiter: the builder's contract is that
 108            // the trigger runs only once the subscription AND recovery state exist. A returned
 109            // waiter would still let the trigger fire the remote operation with no registration
 110            // left to receive (or recover) its response. Cleanup cancels ResponseTask, so no
 111            // pending task is left behind.
 112            throw;
 113        }
 114
 115        return new InMemoryAsyncResponseWaiter<T>(subscription.ResponseTask, subscription.DisposeCleanupAsync);
 116    }
 117
 118    /// <inheritdoc />
 119    public Task SetResponse<T>(T response, string correlationId, CancellationToken cancellationToken = default) where T 
 120        => SetResponseCore(response, correlationId, cancellationToken);
 121
 122    Task IRawAsyncResponsePublisher.SetRawResponse(object? response, string correlationId, CancellationToken cancellatio
 123        => SetResponseCore(response, correlationId, cancellationToken);
 124
 125    Task IRawAsyncResponsePublisher.SetRawResponseJson(string responseJson, string correlationId, CancellationToken canc
 126        => SetRawResponseJsonCore(new RawJsonResponse(responseJson), correlationId, cancellationToken);
 127
 128    // Intentionally duplicated with SetRawResponseJsonCore: this is a microbenchmarked publish
 129    // hot path. Earlier generic/delegate/helper refactors made the code prettier but measurably
 130    // regressed latency and throughput, so keep the typed path inline unless benchmarks prove out.
 131    private async Task SetResponseCore<T>(T response, string correlationId, CancellationToken cancellationToken)
 132    {
 133        using var activity = AsyncResponseDiagnostics.StartActivity("asyncresponse.set_response", ActivityKind.Producer)
 134        activity?.SetTag("asyncresponse.channel", "inmemory");
 135        AsyncResponseDiagnostics.SetPayloadType(activity, typeof(T));
 136
 137        AsyncResponseDiagnostics.SetCorrelationId(activity, correlationId);
 138        if (string.IsNullOrWhiteSpace(correlationId))
 139        {
 140            _logger.LogWarning("CorrelationId is null; cannot publish the response.");
 141            AsyncResponseDiagnostics.SetError(activity, "correlation_id_null", "CorrelationId is null; cannot publish th
 142            return;
 143        }
 144
 145        try
 146        {
 147            var subscribers = SnapshotSubscribers(correlationId);
 148            activity?.SetTag("asyncresponse.subscribers", subscribers.Count);
 149            if (subscribers.Count == 0)
 150            {
 151                var result = await _lostSubscriberDispatcher
 152                    .DispatchLostResponses(
 153                        _recoveryStateStore,
 154                        correlationId,
 155                        response,
 156                        ChannelName(correlationId),
 157                        cancellationToken,
 158                        hasLiveSubscriber: () => new ValueTask<bool>(SnapshotSubscribers(correlationId).Count > 0))
 159                    .ConfigureAwait(false);
 160
 161                if (!result.RetryLive)
 162                {
 163                    AsyncResponseDiagnostics.SetLostSubscriberRoute(activity, result.ShouldResume);
 164                    AsyncResponseDiagnostics.RecordLostSubscriber("response", result.ShouldResume, result.CallbackInvoke
 165                    activity?.SetTag("asyncresponse.recovery.callback_invoked", result.CallbackInvoked);
 166
 167                    return;
 168                }
 169
 170                // A waiter registered between the snapshot and the recovery-state read — deliver
 171                // live instead of consuming its registration.
 172                subscribers = SnapshotSubscribers(correlationId);
 173                activity?.SetTag("asyncresponse.subscribers", subscribers.Count);
 174            }
 175
 176            await DispatchResponsesAsync(subscribers, response).ConfigureAwait(false);
 177
 178            if (_logger.IsEnabled(LogLevel.Debug))
 179                _logger.LogDebug("Published response for correlationId {CorrelationId}. PayloadType: {PayloadType}. Subs
 180        }
 181        catch (Exception ex)
 182        {
 183            AsyncResponseDiagnostics.SetError(activity, ex);
 184            throw;
 185        }
 186    }
 187
 188    // Intentionally duplicated with SetResponseCore: raw ingress has different dispatch and
 189    // recovery materialization costs, and keeping the branch inline avoids hot-path indirection.
 190    private async Task SetRawResponseJsonCore(RawJsonResponse response, string correlationId, CancellationToken cancella
 191    {
 192        using var activity = AsyncResponseDiagnostics.StartActivity("asyncresponse.ingress.raw_response", ActivityKind.P
 193        activity?.SetTag("asyncresponse.channel", "inmemory");
 194
 195        AsyncResponseDiagnostics.SetCorrelationId(activity, correlationId);
 196        if (string.IsNullOrWhiteSpace(correlationId))
 197        {
 198            _logger.LogWarning("CorrelationId is null; cannot publish the raw response.");
 199            AsyncResponseDiagnostics.SetError(activity, "correlation_id_null", "CorrelationId is null; cannot publish th
 200            return;
 201        }
 202
 203        try
 204        {
 205            var subscribers = SnapshotSubscribers(correlationId);
 206            activity?.SetTag("asyncresponse.subscribers", subscribers.Count);
 207            if (subscribers.Count == 0)
 208            {
 209                var result = await _lostSubscriberDispatcher
 210                    .DispatchLostResponses(
 211                        _recoveryStateStore,
 212                        correlationId,
 213                        response.DeserializeUntyped(),
 214                        ChannelName(correlationId),
 215                        cancellationToken,
 216                        hasLiveSubscriber: () => new ValueTask<bool>(SnapshotSubscribers(correlationId).Count > 0))
 217                    .ConfigureAwait(false);
 218
 219                if (!result.RetryLive)
 220                {
 221                    AsyncResponseDiagnostics.SetLostSubscriberRoute(activity, result.ShouldResume);
 222                    AsyncResponseDiagnostics.RecordLostSubscriber("response", result.ShouldResume, result.CallbackInvoke
 223                    activity?.SetTag("asyncresponse.recovery.callback_invoked", result.CallbackInvoked);
 224
 225                    return;
 226                }
 227
 228                // A waiter registered between the snapshot and the recovery-state read — deliver
 229                // live instead of consuming its registration.
 230                subscribers = SnapshotSubscribers(correlationId);
 231                activity?.SetTag("asyncresponse.subscribers", subscribers.Count);
 232            }
 233
 234            await DispatchRawJsonResponsesAsync(subscribers, response).ConfigureAwait(false);
 235
 236            if (_logger.IsEnabled(LogLevel.Debug))
 237                _logger.LogDebug("Published raw response for correlationId {CorrelationId}. Subscribers: {SubscriberCoun
 238        }
 239        catch (Exception ex)
 240        {
 241            AsyncResponseDiagnostics.SetError(activity, ex);
 242            throw;
 243        }
 244    }
 245
 246    /// <inheritdoc />
 247    public async Task SetException(Exception exception, string correlationId, CancellationToken cancellationToken = defa
 248    {
 249        ArgumentNullException.ThrowIfNull(exception);
 250
 251        using var activity = AsyncResponseDiagnostics.StartActivity("asyncresponse.set_exception", ActivityKind.Producer
 252        activity?.SetTag("asyncresponse.channel", "inmemory");
 253        activity?.SetTag("asyncresponse.exception_type", exception.GetType().FullName ?? exception.GetType().Name);
 254
 255        AsyncResponseDiagnostics.SetCorrelationId(activity, correlationId);
 256        if (string.IsNullOrWhiteSpace(correlationId))
 257        {
 258            _logger.LogWarning("CorrelationId is null; cannot publish the exception. Exception: {ExceptionMessage}", exc
 259            AsyncResponseDiagnostics.SetError(activity, "correlation_id_null", "CorrelationId is null; cannot publish th
 260            return;
 261        }
 262
 263        try
 264        {
 265            var subscribers = SnapshotSubscribers(correlationId);
 266            activity?.SetTag("asyncresponse.subscribers", subscribers.Count);
 267            if (subscribers.Count == 0)
 268            {
 269                var result = await _lostSubscriberDispatcher
 270                    .DispatchLostExceptions(
 271                        _recoveryStateStore,
 272                        correlationId,
 273                        exception,
 274                        ChannelName(correlationId),
 275                        cancellationToken,
 276                        hasLiveSubscriber: () => new ValueTask<bool>(SnapshotSubscribers(correlationId).Count > 0))
 277                    .ConfigureAwait(false);
 278
 279                if (!result.RetryLive)
 280                {
 281                    activity?.SetTag("asyncresponse.recovery.callback_invoked", result.CallbackInvoked);
 282                    AsyncResponseDiagnostics.RecordLostSubscriber("exception", shouldResume: false, result.CallbackInvok
 283
 284                    return;
 285                }
 286
 287                // A waiter registered between the snapshot and the recovery-state read — deliver
 288                // live instead of consuming its registration.
 289                subscribers = SnapshotSubscribers(correlationId);
 290                activity?.SetTag("asyncresponse.subscribers", subscribers.Count);
 291            }
 292
 293            await DispatchExceptionsAsync(subscribers, exception).ConfigureAwait(false);
 294
 295            if (_logger.IsEnabled(LogLevel.Debug))
 296                _logger.LogDebug("Published exception for correlationId {CorrelationId}. Subscribers: {SubscriberCount}.
 297        }
 298        catch (Exception ex)
 299        {
 300            AsyncResponseDiagnostics.SetError(activity, ex);
 301            throw;
 302        }
 303    }
 304
 305    /// <inheritdoc />
 306    public ValueTask<long> CountActiveSubscribersAsync(string correlationId, CancellationToken cancellationToken = defau
 307    {
 308        if (string.IsNullOrWhiteSpace(correlationId))
 309            return new ValueTask<long>(0L);
 310
 311        long count = _subscriptions.TryGetValue(correlationId, out var subscribers) ? subscribers.Count : 0L;
 312        return new ValueTask<long>(count);
 313    }
 314
 315    private void AddSubscription(string correlationId, SubscriptionBase subscription)
 316    {
 317        while (true)
 318        {
 319            var group = _subscriptions.GetOrAdd(correlationId, static _ => new SubscriptionGroup());
 320            if (group.TryAdd(subscription))
 321                return;
 322
 323            _subscriptions.TryRemove(new KeyValuePair<string, SubscriptionGroup>(correlationId, group));
 324        }
 325    }
 326
 327    [MethodImpl(MethodImplOptions.AggressiveInlining)]
 328    private SubscriptionSnapshot SnapshotSubscribers(string correlationId)
 329        => _subscriptions.TryGetValue(correlationId, out var subscribers)
 330            ? subscribers.Snapshot()
 331            : default;
 332
 333    [MethodImpl(MethodImplOptions.AggressiveInlining)]
 334    private static Task DispatchResponsesAsync(SubscriptionSnapshot subscribers, object? response)
 335    {
 336        if (subscribers.Single is { } single)
 337            return single.DispatchResponseAsync(response);
 338
 339        return DispatchManyAsync(subscribers.Many, static (subscriber, state) => subscriber.DispatchResponseAsync(state)
 340    }
 341
 342    [MethodImpl(MethodImplOptions.AggressiveInlining)]
 343    private static Task DispatchRawJsonResponsesAsync(SubscriptionSnapshot subscribers, RawJsonResponse response)
 344    {
 345        if (subscribers.Single is { } single)
 346            return single.DispatchRawJsonResponseAsync(response);
 347
 348        return DispatchManyAsync(subscribers.Many, static (subscriber, state) => subscriber.DispatchRawJsonResponseAsync
 349    }
 350
 351    [MethodImpl(MethodImplOptions.AggressiveInlining)]
 352    private static Task DispatchExceptionsAsync(SubscriptionSnapshot subscribers, Exception exception)
 353    {
 354        if (subscribers.Single is { } single)
 355            return single.DispatchExceptionAsync(exception);
 356
 357        return DispatchManyAsync(subscribers.Many, static (subscriber, state) => subscriber.DispatchExceptionAsync(state
 358    }
 359
 360    private static Task DispatchManyAsync<TState>(
 361        SubscriptionBase[]? subscribers,
 362        Func<SubscriptionBase, TState, Task> dispatch,
 363        TState state)
 364    {
 365        if (subscribers is null || subscribers.Length == 0)
 366            return Task.CompletedTask;
 367
 368        Task? firstPending = null;
 369        List<Task>? pending = null;
 370        for (var i = 0; i < subscribers.Length; i++)
 371        {
 372            var task = dispatch(subscribers[i], state);
 373            if (task.IsCompletedSuccessfully)
 374                continue;
 375
 376            if (firstPending is null)
 377            {
 378                firstPending = task;
 379                continue;
 380            }
 381
 382            (pending ??= [firstPending]).Add(task);
 383        }
 384
 385        return pending is not null
 386            ? Task.WhenAll(pending)
 387            : firstPending ?? Task.CompletedTask;
 388    }
 389
 390    private void RemoveSubscription(string correlationId, Guid subscriptionId)
 391    {
 392        if (!_subscriptions.TryGetValue(correlationId, out var subscribers))
 393            return;
 394
 395        if (subscribers.Remove(subscriptionId))
 396            _subscriptions.TryRemove(new KeyValuePair<string, SubscriptionGroup>(correlationId, subscribers));
 397    }
 398
 399    private static string ChannelName(string correlationId) => $"inmemory:response:{correlationId}";
 400
 401    private sealed class SubscriptionGroup
 402    {
 403        private readonly object _gate = new();
 404        private SubscriptionBase? _single;
 405        private List<SubscriptionBase>? _many;
 406        private bool _closed;
 407
 408        public int Count
 409        {
 410            get
 411            {
 412                lock (_gate)
 413                    return _single is not null ? 1 : _many?.Count ?? 0;
 414            }
 415        }
 416
 417        /// <summary>Adds a subscription to this correlation-id group.</summary>
 418        public bool TryAdd(SubscriptionBase subscription)
 419        {
 420            lock (_gate)
 421            {
 422                if (_closed)
 423                    return false;
 424
 425                if (_single is null && _many is null)
 426                {
 427                    _single = subscription;
 428                    return true;
 429                }
 430
 431                if (_many is null)
 432                {
 433                    _many = [_single!, subscription];
 434                    _single = null;
 435                    return true;
 436                }
 437
 438                _many.Add(subscription);
 439                return true;
 440            }
 441        }
 442
 443        /// <summary>Removes a subscription and returns whether the group became empty.</summary>
 444        public bool Remove(Guid subscriptionId)
 445        {
 446            lock (_gate)
 447            {
 448                if (_single?.Id == subscriptionId)
 449                {
 450                    _single = null;
 451                    _closed = true;
 452                    return true;
 453                }
 454
 455                if (_many is null)
 456                    return false;
 457
 458                for (var i = 0; i < _many.Count; i++)
 459                {
 460                    if (_many[i].Id != subscriptionId)
 461                        continue;
 462
 463                    _many.RemoveAt(i);
 464                    // _many is only ever created with two entries and collapses to _single at one,
 465                    // so it can never reach zero here — the group-empty signal is produced solely
 466                    // by the _single removal path above.
 467                    if (_many.Count == 1)
 468                    {
 469                        _single = _many[0];
 470                        _many = null;
 471                    }
 472
 473                    return false;
 474                }
 475
 476                return false;
 477            }
 478        }
 479
 480        /// <summary>Captures the current subscriptions for lock-free dispatch outside the group lock.</summary>
 481        public SubscriptionSnapshot Snapshot()
 482        {
 483            lock (_gate)
 484            {
 485                if (_single is not null)
 486                    return SubscriptionSnapshot.ForSingle(_single);
 487
 488                if (_many is { Count: > 0 })
 489                    return SubscriptionSnapshot.ForMany(_many.ToArray());
 490
 491                return default;
 492            }
 493        }
 494    }
 495
 496    private readonly struct SubscriptionSnapshot
 497    {
 498        private SubscriptionSnapshot(SubscriptionBase? single, SubscriptionBase[]? many)
 499        {
 2500            Single = single;
 2501            Many = many;
 2502        }
 503
 504        public SubscriptionBase? Single { get; }
 505        public SubscriptionBase[]? Many { get; }
 506        public int Count
 507        {
 508            [MethodImpl(MethodImplOptions.AggressiveInlining)]
 2509            get => Single is not null ? 1 : Many?.Length ?? 0;
 510        }
 511
 512        /// <summary>Creates a snapshot containing one subscription.</summary>
 513        [MethodImpl(MethodImplOptions.AggressiveInlining)]
 2514        public static SubscriptionSnapshot ForSingle(SubscriptionBase single) => new(single, null);
 515
 516        /// <summary>Creates a snapshot containing multiple subscriptions.</summary>
 517        [MethodImpl(MethodImplOptions.AggressiveInlining)]
 2518        public static SubscriptionSnapshot ForMany(SubscriptionBase[] many) => new(null, many);
 519    }
 520
 521    private abstract class SubscriptionBase
 522    {
 523        private readonly InMemoryAsyncResponseChannel _owner;
 524        private readonly CancellationTokenSource _timeoutCts;
 525        private readonly Activity? _activity;
 526        private readonly object _cleanupSync = new();
 527        private SemaphoreSlim? _dispatchWaiters;
 528        private CancellationTokenRegistration _timeoutRegistration;
 529        private Task? _cleanupTask;
 530        private int _dispatching;
 531        private int _dispatchWaiterCount;
 532        private int _terminal;
 533        private int _cleanupStarted;
 534
 535        /// <summary>Creates the common state for an in-memory waiter subscription.</summary>
 536        protected SubscriptionBase(InMemoryAsyncResponseChannel owner, string correlationId, TimeSpan timeout, Activity?
 537        {
 538            _owner = owner;
 539            CorrelationId = correlationId;
 540            Timeout = timeout;
 541            _activity = activity;
 542            _timeoutCts = new CancellationTokenSource();
 543        }
 544
 545        /// <summary>Per-waiter registration id used for subscription and recovery-state cleanup.</summary>
 546        public Guid Id { get; } = Guid.NewGuid();
 547        protected string CorrelationId { get; }
 548        protected Activity? WaitActivity => _activity;
 549        private TimeSpan Timeout { get; }
 550        public bool CleanupStarted
 551        {
 552            [MethodImpl(MethodImplOptions.AggressiveInlining)]
 553            get => Volatile.Read(ref _cleanupStarted) != 0;
 554        }
 555
 556        /// <summary>Arms the subscription timeout after registration has succeeded.</summary>
 557        public void ArmTimeout()
 558        {
 559            if (CleanupStarted)
 560                return;
 561
 562            try
 563            {
 564                _timeoutRegistration = _timeoutCts.Token.Register(static state =>
 565                {
 566                    _ = ((SubscriptionBase)state!).TimeoutAsync();
 567                }, this);
 568
 569                if (CleanupStarted)
 570                {
 571                    _timeoutRegistration.Dispose();
 572                    return;
 573                }
 574
 575                _timeoutCts.CancelAfter(Timeout);
 576            }
 577            catch (ObjectDisposedException)
 578            {
 579                // A response or explicit disposal can clean up between the guard and timer arming.
 580            }
 581        }
 582
 583        /// <summary>Dispatches a typed or materializable response to this subscription.</summary>
 584        public abstract Task DispatchResponseAsync(object? response);
 585
 586        /// <summary>Dispatches a raw JSON response to this subscription.</summary>
 587        public abstract Task DispatchRawJsonResponseAsync(RawJsonResponse response);
 588
 589        /// <summary>Faults this subscription with a published exception.</summary>
 590        public Task DispatchExceptionAsync(Exception exception)
 591            => DispatchSerialAsync(
 592                exception,
 593                static (subscription, state) => subscription.DispatchExceptionCoreAsync(state));
 594
 595        private Task DispatchExceptionCoreAsync(Exception exception)
 596        {
 597            if (CleanupStarted)
 598                return Task.CompletedTask;
 599
 600            if (!TryBeginTerminal())
 601                return Task.CompletedTask;
 602
 603            AsyncResponseDiagnostics.SetError(_activity, exception);
 604            TrySetException(exception);
 605            return CleanupOnceAsTask();
 606        }
 607
 608        /// <summary>
 609        /// Serializes every signal for one waiter. The uncontended path uses only an interlocked
 610        /// owner bit; the semaphore is created lazily if concurrent publishers actually contend.
 611        /// </summary>
 612        protected Task DispatchSerialAsync<TState>(
 613            TState state,
 614            Func<SubscriptionBase, TState, Task> dispatch)
 615        {
 616            if (Interlocked.CompareExchange(ref _dispatching, 1, 0) != 0)
 617                return WaitAndDispatchAsync(this, state, dispatch);
 618
 619            Task task;
 620            try
 621            {
 622                task = dispatch(this, state);
 623            }
 624            catch
 625            {
 626                ReleaseDispatch();
 627                throw;
 628            }
 629
 630            if (task.IsCompletedSuccessfully)
 631            {
 632                ReleaseDispatch();
 633                return task;
 634            }
 635
 636            return ReleaseAfterDispatchAsync(this, task);
 637        }
 638
 639        private static async Task WaitAndDispatchAsync<TState>(
 640            SubscriptionBase subscription,
 641            TState state,
 642            Func<SubscriptionBase, TState, Task> dispatch)
 643        {
 644            Interlocked.Increment(ref subscription._dispatchWaiterCount);
 645            try
 646            {
 647                var waiters = LazyInitializer.EnsureInitialized(
 648                    ref subscription._dispatchWaiters,
 649                    static () => new SemaphoreSlim(0));
 650                while (Interlocked.CompareExchange(ref subscription._dispatching, 1, 0) != 0)
 651                    await waiters.WaitAsync().ConfigureAwait(false);
 652            }
 653            finally
 654            {
 655                Interlocked.Decrement(ref subscription._dispatchWaiterCount);
 656            }
 657
 658            try
 659            {
 660                await dispatch(subscription, state).ConfigureAwait(false);
 661            }
 662            finally
 663            {
 664                subscription.ReleaseDispatch();
 665            }
 666        }
 667
 668        private static async Task ReleaseAfterDispatchAsync(SubscriptionBase subscription, Task task)
 669        {
 670            try
 671            {
 672                await task.ConfigureAwait(false);
 673            }
 674            finally
 675            {
 676                subscription.ReleaseDispatch();
 677            }
 678        }
 679
 680        [MethodImpl(MethodImplOptions.AggressiveInlining)]
 681        private void ReleaseDispatch()
 682        {
 683            Volatile.Write(ref _dispatching, 0);
 684            if (Volatile.Read(ref _dispatchWaiterCount) > 0)
 685                Volatile.Read(ref _dispatchWaiters)?.Release();
 686        }
 687
 688        /// <summary>Runs subscription, recovery-state, timeout, and activity cleanup once.</summary>
 689        public ValueTask CleanupOnceAsync()
 690        {
 691            Task cleanupTask;
 692            lock (_cleanupSync)
 693            {
 694                cleanupTask = _cleanupTask ??= StartCleanupAsync();
 695            }
 696
 697            return cleanupTask.IsCompletedSuccessfully
 698                ? ValueTask.CompletedTask
 699                : new ValueTask(cleanupTask);
 700        }
 701
 702        /// <summary>
 703        /// Dispose-path cleanup: DRAINS any in-flight dispatch before settling. A delivery may be
 704        /// mid <c>Until</c>-predicate holding a claimed terminal message; queueing a no-op through
 705        /// the per-waiter dispatch gate completes only after that delivery settled the task (or
 706        /// released the gate), so the cleanup's cancel afterwards is a genuine settlement — never
 707        /// a cancellation stealing an already-consumed response. Must NOT be called from dispatch
 708        /// code (which holds the gate): dispatch-triggered cleanup uses
 709        /// <see cref="CleanupOnceAsync"/> directly, with its task already settled.
 710        /// <para>
 711        /// The drain is bounded by <c>DisposalDrainTimeout</c>. A lapsed budget must not fall back
 712        /// to the cleanup's cancel — the wedged delivery holds a message the channel already
 713        /// claimed, and "canceled" would tell a re-attaching caller nothing was delivered — so it
 714        /// faults the task with the explicit indeterminate contract instead, routing durable flows
 715        /// to a fresh idempotent restart. The abandoned no-op marker runs harmlessly whenever the
 716        /// wedged dispatch finally releases the gate.
 717        /// </para>
 718        /// </summary>
 719        public async ValueTask DisposeCleanupAsync()
 720        {
 721            if (Volatile.Read(ref _cleanupStarted) == 0)
 722            {
 723                var drainTimeout = _owner._options.DisposalDrainTimeout;
 724                try
 725                {
 726                    await DispatchSerialAsync(0, static (_, _) => Task.CompletedTask)
 727                        .WaitAsync(drainTimeout).ConfigureAwait(false);
 728                }
 729                catch (TimeoutException)
 730                {
 731                    _owner._logger.LogWarning(
 732                        "Disposal drain for correlationId {CorrelationId} did not finish within {DrainTimeout}; faulting
 733                        CorrelationId, drainTimeout);
 734                    AsyncResponseDiagnostics.SetError(_activity, "indeterminate_delivery", "Disposal drain timed out wit
 735                    // A TrySetResult from the late-finishing dispatch loses against this and is
 736                    // dropped; its cleanup call is a no-op behind the latch.
 737                    TrySetException(new AsyncResponseIndeterminateDeliveryException(CorrelationId, drainTimeout));
 738                }
 739            }
 740
 741            await CleanupOnceAsync().ConfigureAwait(false);
 742        }
 743
 744        private async Task StartCleanupAsync()
 745        {
 746            Volatile.Write(ref _cleanupStarted, 1);
 747
 748            // A waiter disposed before any terminal signal must not leave ResponseTask pending
 749            // forever for callers that hold it directly. Cancellation is a no-op after a normal
 750            // completion, timeout, or fault.
 751            TrySetCanceled();
 752
 753            try
 754            {
 755                // Delete the recovery state BEFORE removing the subscription. In the reverse order
 756                // a publish landing in the window sees "no subscriber, state present" and fires a
 757                // spurious recovery callback for a wait that already reached a terminal state. In
 758                // this order the window shows a subscriber that drops the message (CleanupStarted)
 759                // — a late or duplicate terminal message is droppable; a resurrected recovery
 760                // callback is not.
 761                await _owner._recoveryStateStore.TryDeleteAsync(CorrelationId, Id).ConfigureAwait(false);
 762            }
 763            finally
 764            {
 765                _owner.RemoveSubscription(CorrelationId, Id);
 766                await _timeoutRegistration.DisposeAsync().ConfigureAwait(false);
 767                _timeoutCts.Dispose();
 768                _activity?.Dispose();
 769            }
 770        }
 771
 772        /// <summary>Marks this subscription as terminal if no terminal signal has won yet.</summary>
 773        [MethodImpl(MethodImplOptions.AggressiveInlining)]
 774        protected bool TryBeginTerminal()
 775            => Interlocked.Exchange(ref _terminal, 1) == 0;
 776
 777        /// <summary>Stores the timeout exception on the concrete waiter task.</summary>
 778        protected abstract void SetTimeoutException(Exception exception);
 779
 780        /// <summary>Attempts to fault the concrete waiter task.</summary>
 781        public abstract void TrySetException(Exception exception);
 782
 783        /// <summary>Attempts to cancel the concrete waiter task (dispose before any terminal signal).</summary>
 784        public abstract void TrySetCanceled();
 785
 786        /// <summary>Returns cleanup as a task for dispatch paths that already operate on <see cref="Task"/>.</summary>
 787        [MethodImpl(MethodImplOptions.AggressiveInlining)]
 788        protected Task CleanupOnceAsTask()
 789        {
 790            var cleanup = CleanupOnceAsync();
 791            return cleanup.IsCompletedSuccessfully ? Task.CompletedTask : cleanup.AsTask();
 792        }
 793
 794        private Task TimeoutAsync()
 795            => DispatchSerialAsync(
 796                0,
 797                static (subscription, _) => subscription.TimeoutCoreAsync());
 798
 799        private Task TimeoutCoreAsync()
 800        {
 801            if (CleanupStarted)
 802                return Task.CompletedTask;
 803
 804            if (!TryBeginTerminal())
 805                return Task.CompletedTask;
 806
 807            _owner._logger.LogWarning("Timed out waiting for response for correlationId {CorrelationId}.", CorrelationId
 808            AsyncResponseDiagnostics.RecordWaiterTimeout("inmemory");
 809
 810            var exception = new TimeoutException($"Timed out waiting for response for correlationId {CorrelationId}.");
 811            AsyncResponseDiagnostics.SetError(_activity, "timeout", exception.Message);
 812            SetTimeoutException(exception);
 813            return CleanupOnceAsTask();
 814        }
 815    }
 816
 817    private sealed class Subscription<T> : SubscriptionBase where T : IAsyncResponsePayload
 818    {
 819        private readonly Func<T, ValueTask<bool>> _completionPredicate;
 820        private readonly ExecutionContext? _capturedContext;
 821        private readonly TaskCompletionSource<T> _tcs = new(TaskCreationOptions.RunContinuationsAsynchronously);
 822
 823        /// <summary>Creates a typed in-memory waiter subscription.</summary>
 824        public Subscription(
 825            InMemoryAsyncResponseChannel owner,
 826            string correlationId,
 827            TimeSpan timeout,
 828            Func<T, ValueTask<bool>> completionPredicate,
 829            Activity? activity,
 830            ExecutionContext? capturedContext)
 831            : base(owner, correlationId, timeout, activity)
 832        {
 833            _completionPredicate = completionPredicate;
 834            _capturedContext = capturedContext;
 835        }
 836
 837        public Task<T> ResponseTask => _tcs.Task;
 838
 839        /// <inheritdoc />
 840        public override Task DispatchResponseAsync(object? response)
 841            => DispatchSerialAsync(
 842                response,
 843                static (subscription, state) => ((Subscription<T>)subscription).DispatchResponseUnserializedAsync(state)
 844
 845        private Task DispatchResponseUnserializedAsync(object? response)
 846        {
 847            if (CleanupStarted)
 848                return Task.CompletedTask;
 849
 850            // Restore the waiter's subscribe-time ambient context (trace, principal, …) so the
 851            // completion predicate and any logging run under it, even when the response is delivered
 852            // on a foreign thread such as a broker ingress callback.
 853            if (_capturedContext is null)
 854                return DispatchResponseCoreAsync(response);
 855
 856            Task? dispatch = null;
 857            ExecutionContext.Run(_capturedContext, _ => dispatch = DispatchResponseCoreAsync(response), null);
 858            return dispatch!;
 859        }
 860
 861        /// <inheritdoc />
 862        public override Task DispatchRawJsonResponseAsync(RawJsonResponse response)
 863            => DispatchSerialAsync(
 864                response,
 865                static (subscription, state) => ((Subscription<T>)subscription).DispatchRawJsonResponseUnserializedAsync
 866
 867        private Task DispatchRawJsonResponseUnserializedAsync(RawJsonResponse response)
 868        {
 869            if (CleanupStarted)
 870                return Task.CompletedTask;
 871
 872            if (_capturedContext is null)
 873                return DispatchRawJsonResponseCoreAsync(response);
 874
 875            Task? dispatch = null;
 876            ExecutionContext.Run(_capturedContext, _ => dispatch = DispatchRawJsonResponseCoreAsync(response), null);
 877            return dispatch!;
 878        }
 879
 880        private Task DispatchResponseCoreAsync(object? response)
 881        {
 882            try
 883            {
 884                var payload = response is T typed
 885                    ? typed
 886                    : response.As<T>();
 887
 888                var completion = _completionPredicate(payload);
 889                if (!completion.IsCompletedSuccessfully)
 890                    return AwaitCompletionPredicateAsync(completion, payload);
 891
 892                var finished = completion.Result;
 893                if (!finished || !TryBeginTerminal())
 894                    return Task.CompletedTask;
 895
 896                _tcs.TrySetResult(payload);
 897                return CleanupOnceAsTask();
 898            }
 899            catch (Exception ex)
 900            {
 901                if (!TryBeginTerminal())
 902                    return Task.CompletedTask;
 903
 904                AsyncResponseDiagnostics.SetError(WaitActivity, ex);
 905                _tcs.TrySetException(ex);
 906                return CleanupOnceAsTask();
 907            }
 908        }
 909
 910        private Task DispatchRawJsonResponseCoreAsync(RawJsonResponse response)
 911        {
 912            try
 913            {
 914                return DispatchPayloadAsync(response.Deserialize<T>()!);
 915            }
 916            catch (Exception ex)
 917            {
 918                return FaultAsync(ex);
 919            }
 920        }
 921
 922        // Raw ingress has to materialize JSON before it can run the same completion semantics as
 923        // the typed path. Keep this separate so typed publishers stay on the shorter inline path.
 924        private Task DispatchPayloadAsync(T payload)
 925        {
 926            try
 927            {
 928                var completion = _completionPredicate(payload);
 929                if (!completion.IsCompletedSuccessfully)
 930                    return AwaitCompletionPredicateAsync(completion, payload);
 931
 932                var finished = completion.Result;
 933                if (!finished || !TryBeginTerminal())
 934                    return Task.CompletedTask;
 935
 936                _tcs.TrySetResult(payload);
 937                return CleanupOnceAsTask();
 938            }
 939            catch (Exception ex)
 940            {
 941                return FaultAsync(ex);
 942            }
 943        }
 944
 945        private Task FaultAsync(Exception exception)
 946        {
 947            if (!TryBeginTerminal())
 948                return Task.CompletedTask;
 949
 950            AsyncResponseDiagnostics.SetError(WaitActivity, exception);
 951            _tcs.TrySetException(exception);
 952            return CleanupOnceAsTask();
 953        }
 954
 955        private async Task AwaitCompletionPredicateAsync(ValueTask<bool> completion, T payload)
 956        {
 957            try
 958            {
 959                var finished = await completion.ConfigureAwait(false);
 960                if (!finished || !TryBeginTerminal())
 961                    return;
 962
 963                _tcs.TrySetResult(payload);
 964                await CleanupOnceAsync().ConfigureAwait(false);
 965            }
 966            catch (Exception ex)
 967            {
 968                if (!TryBeginTerminal())
 969                    return;
 970
 971                AsyncResponseDiagnostics.SetError(WaitActivity, ex);
 972                _tcs.TrySetException(ex);
 973                await CleanupOnceAsync().ConfigureAwait(false);
 974            }
 975        }
 976
 977        /// <inheritdoc />
 978        protected override void SetTimeoutException(Exception exception)
 979            => _tcs.TrySetException(exception);
 980
 981        /// <inheritdoc />
 982        public override void TrySetException(Exception exception)
 983            => _tcs.TrySetException(exception);
 984
 985        /// <inheritdoc />
 986        public override void TrySetCanceled()
 987            => _tcs.TrySetCanceled();
 988    }
 989}
 990
 991internal sealed class InMemoryAsyncResponseWaiter<T>(
 992    Task<T> _responseTask,
 993    Func<ValueTask> _cleanupAsync) : IAsyncResponseWaiter<T> where T : IAsyncResponsePayload
 994{
 995    public Task<T> ResponseTask => _responseTask;
 996
 997    /// <inheritdoc />
 998    public ValueTask DisposeAsync()
 999        => _cleanupAsync();
 1000}