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

Information
Class: AsyncResponse.AsyncResponseContextPropagation
Assembly: AsyncResponse.Core
File(s): /_/src/AsyncResponse.Core/AsyncResponseContextPropagation.cs
Line coverage
100%
Covered lines: 111
Uncovered lines: 0
Coverable lines: 111
Total lines: 321
Line coverage: 100%
Branch coverage
93%
Covered branches: 67
Total branches: 72
Branch coverage: 93%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%22100%
Capture()100%44100%
Restore(...)95.83%2424100%
DisposeInReverse(...)87.5%88100%
SafeDispose(...)50%22100%
.cctor()100%11100%
Dispose()100%11100%
.ctor(...)100%11100%
Dispose()100%22100%
.ctor(...)100%11100%
Dispose()100%22100%
.ctor(...)100%11100%
Dispose()100%44100%
.cctor()100%11100%
.ctor(...)100%11100%
Rent(...)100%22100%
Detach()100%44100%
EnsureWritable()100%22100%
get_ReadView()100%22100%
get_Item(...)100%11100%
set_Item(...)100%11100%
get_Keys()100%11100%
get_Values()100%11100%
get_Count()100%22100%
get_IsReadOnly()100%11100%
Add(...)100%11100%
Add(...)100%11100%
ContainsKey(...)100%22100%
Contains(...)50%22100%
TryGetValue(...)100%22100%
Remove(...)100%22100%
Remove(...)100%22100%
Clear()50%22100%
CopyTo(...)100%11100%
GetEnumerator()100%11100%
System.Collections.IEnumerable.GetEnumerator()100%11100%

File(s)

/_/src/AsyncResponse.Core/AsyncResponseContextPropagation.cs

#LineLine coverage
 1using Microsoft.Extensions.Logging;
 2using System.Collections;
 3
 4namespace AsyncResponse;
 5
 6/// <summary>
 7/// Aggregates the registered <see cref="IAsyncResponseContextPropagator"/>s into a single
 8/// capture/restore step used by the worker and lost-subscriber paths. With no propagators
 9/// registered it is a zero-overhead no-op, so the feature has no effect on apps that don't use it.
 10/// </summary>
 11internal sealed class AsyncResponseContextPropagation
 12{
 13    private readonly IReadOnlyList<IAsyncResponseContextPropagator> _propagators;
 14    private readonly ILogger? _logger;
 15
 16    /// <summary>Runs the AsyncResponseContextPropagation operation.</summary>
 398117    public AsyncResponseContextPropagation(
 398118        IEnumerable<IAsyncResponseContextPropagator> propagators,
 398119        ILogger<AsyncResponseContextPropagation>? logger = null)
 20    {
 398121        _propagators = propagators as IReadOnlyList<IAsyncResponseContextPropagator> ?? propagators.ToArray();
 398122        _logger = logger;
 398123    }
 24
 25    /// <summary>
 26    /// Captures the current ambient context from every propagator into a serializable carrier.
 27    /// Returns <c>null</c> when there are no propagators or none wrote anything, so the carrier is
 28    /// left off the wire payload entirely.
 29    /// </summary>
 30    public Dictionary<string, string>? Capture()
 31    {
 1380332        var propagators = _propagators;
 1380333        var count = propagators.Count;
 1380334        if (count == 0)
 1317135            return null;
 36
 37        // Hand each propagator a lazy carrier instead of a fully-formed Dictionary. The backing
 38        // dictionary is allocated only when a propagator actually writes a value, so the very
 39        // common "nothing to capture" path (no ambient trace/tenant/etc. set) allocates nothing.
 40        // The carrier wrapper itself is pooled per thread and never escapes this synchronous call,
 41        // so even when a propagator does write, the only surviving allocation is the dictionary
 42        // that gets returned — no wrapper and no enumerator. Indexing the list avoids the
 43        // IEnumerator<T> boxing that a foreach over an interface-typed list would incur.
 63244        var carrier = LazyCapturingCarrier.Rent(count);
 262245        for (var i = 0; i < count; i++)
 67946            propagators[i].Capture(carrier);
 47
 63248        return carrier.Detach();
 49    }
 50
 51    /// <summary>
 52    /// Restores ambient context from <paramref name="carrier"/> for the lifetime of the returned
 53    /// scope. A no-op when there are no propagators or the carrier is null/empty.
 54    /// </summary>
 55    public IDisposable Restore(IReadOnlyDictionary<string, string>? carrier)
 56    {
 644057        var propagators = _propagators;
 644058        var count = propagators.Count;
 644059        if (count == 0 || carrier is null || carrier.Count == 0)
 639160            return NullScope.Instance;
 61
 62        // Collect the real scopes without allocating until we genuinely need a composite. The 0/1/2
 63        // active-scope cases — by far the most common — return either the shared no-op, a single
 64        // guarded scope, or a fixed two-field CompositeScope2. Only 3+ active propagators fall back
 65        // to the List-backed CompositeScope. Indexing the list avoids the foreach enumerator alloc.
 4966        IDisposable? first = null;
 4967        IDisposable? second = null;
 4968        List<IDisposable>? more = null;
 69
 25070        for (var i = 0; i < count; i++)
 71        {
 72            IDisposable? scope;
 73            try
 74            {
 8475                scope = propagators[i].Restore(carrier);
 7676            }
 877            catch
 78            {
 79                // Propagator i threw part-way through the set. Everything 0..i-1 already mutated
 80                // ambient state (principal, tenant, logging scope) and only its scope can put that
 81                // back — and no caller can dispose what was never returned. Unwind here, in reverse,
 82                // then let the fault out: an aborted dispatch must not leave a half-restored
 83                // identity attached to the thread for whatever the pool runs next.
 884                DisposeInReverse(first, second, more);
 885                throw;
 86            }
 87
 7688            if (scope is null || ReferenceEquals(scope, NullScope.Instance))
 89                continue;
 90
 7491            if (more is not null)
 292                more.Add(scope);
 7293            else if (first is null)
 4794                first = scope;
 2595            else if (second is null)
 1996                second = scope;
 97            else
 698                more = [first, second, scope];
 99        }
 100
 41101        if (more is not null)
 4102            return new CompositeScope(more, _logger);
 103
 37104        if (second is not null)
 9105            return new CompositeScope2(first!, second, _logger);
 106
 107        // Wrapped even though it is a single scope: returning it raw made disposal behavior depend
 108        // on how many propagators happen to be registered — with one, a throwing Dispose escaped
 109        // the caller's `using` and failed a dispatch whose work had already completed (at the
 110        // worker ingress, that redelivers an executed job); with two or more it vanished silently.
 111        // One rule for every count: cleanup never fails the dispatch, and never fails invisibly.
 28112        return first is null ? NullScope.Instance : new GuardedScope(first, _logger);
 113    }
 114
 115    private void DisposeInReverse(IDisposable? first, IDisposable? second, List<IDisposable>? more)
 116    {
 8117        if (more is not null)
 118        {
 16119            for (var i = more.Count - 1; i >= 0; i--)
 6120                SafeDispose(more[i], _logger);
 121
 2122            return;
 123        }
 124
 6125        if (second is not null)
 4126            SafeDispose(second, _logger);
 6127        if (first is not null)
 6128            SafeDispose(first, _logger);
 6129    }
 130
 131    /// <summary>
 132    /// Disposes one scope without letting its failure mask the others or the dispatch. Logged
 133    /// rather than swallowed: a propagator that cannot detach its ambient state is leaking it into
 134    /// whatever the thread runs next, which is precisely the kind of fault that has to be visible.
 135    /// </summary>
 136    private static void SafeDispose(IDisposable scope, ILogger? logger)
 137    {
 138        try
 139        {
 74140            scope.Dispose();
 62141        }
 12142        catch (Exception ex)
 143        {
 12144            logger?.LogError(
 12145                ex,
 12146                "An AsyncResponse context propagator scope ({ScopeType}) failed to dispose; ambient context it restored 
 12147                scope.GetType().FullName);
 12148        }
 74149    }
 150
 151    private sealed class NullScope : IDisposable
 152    {
 16153        public static readonly NullScope Instance = new();
 154        /// <summary>Releases resources held by this instance.</summary>
 6379155        public void Dispose() { }
 156    }
 157
 158    /// <summary>
 159    /// Guards a single propagator scope so its disposal obeys the same rule as the multi-scope
 160    /// cases: a failure is reported, never thrown at the dispatch that was already finishing.
 161    /// </summary>
 26162    private sealed class GuardedScope(IDisposable _scope, ILogger? _logger) : IDisposable
 163    {
 164        private int _disposed;
 165
 166        /// <summary>Releases resources held by this instance.</summary>
 167        public void Dispose()
 168        {
 28169            if (Interlocked.Exchange(ref _disposed, 1) != 0)
 2170                return;
 171
 26172            SafeDispose(_scope, _logger);
 26173        }
 174    }
 175
 176    /// <summary>
 177    /// Disposes exactly two scopes in reverse order, mirroring nested <c>using</c> semantics without
 178    /// the <see cref="List{T}"/> + general <see cref="CompositeScope"/> allocation that the 1–2
 179    /// propagator case (the overwhelmingly common one) would otherwise pay.
 180    /// </summary>
 9181    private sealed class CompositeScope2(IDisposable _first, IDisposable _second, ILogger? _logger) : IDisposable
 182    {
 183        private int _disposed;
 184
 185        /// <summary>Releases resources held by this instance.</summary>
 186        public void Dispose()
 187        {
 11188            if (Interlocked.Exchange(ref _disposed, 1) != 0)
 2189                return;
 190
 191            // A misbehaving propagator scope must not mask the other — but it must not vanish
 192            // either, so SafeDispose logs what it absorbs.
 9193            SafeDispose(_second, _logger);
 9194            SafeDispose(_first, _logger);
 9195        }
 196    }
 197
 4198    private sealed class CompositeScope(List<IDisposable> _scopes, ILogger? _logger) : IDisposable
 199    {
 200        private int _disposed;
 201
 202        /// <summary>Releases resources held by this instance.</summary>
 203        public void Dispose()
 204        {
 8205            if (Interlocked.Exchange(ref _disposed, 1) != 0)
 4206                return;
 207
 208            // Dispose in reverse order, mirroring nested using semantics.
 36209            for (int i = _scopes.Count - 1; i >= 0; i--)
 14210                SafeDispose(_scopes[i], _logger);
 4211        }
 212    }
 213
 214    /// <summary>
 215    /// An <see cref="IDictionary{TKey,TValue}"/> facade that defers creating its backing
 216    /// <see cref="Dictionary{TKey,TValue}"/> until the first mutation. Reads before any write see an
 217    /// empty map; the first write allocates a right-sized dictionary. A single instance is reused per
 218    /// thread (the carrier never escapes the synchronous <see cref="Capture"/> call), so capturing
 219    /// context adds no wrapper allocation on the hot path — only the backing dictionary, and only
 220    /// when a propagator writes.
 221    /// </summary>
 222    private sealed class LazyCapturingCarrier : IDictionary<string, string>
 223    {
 224        [ThreadStatic] private static LazyCapturingCarrier? _cached;
 225
 226        // Shared, never-mutated empty map backing every read taken before the first write.
 8227        private static readonly Dictionary<string, string> EmptyView = new(0, StringComparer.Ordinal);
 228
 229        private Dictionary<string, string>? _inner;
 230        private int _capacity;
 231
 120232        private LazyCapturingCarrier(int capacity) => _capacity = capacity;
 233
 234        /// <summary>
 235        /// Borrows the thread's pooled carrier (or allocates one on first use / under re-entrancy).
 236        /// The slot is cleared while borrowed so a re-entrant capture gets its own instance rather
 237        /// than corrupting the borrowed one.
 238        /// </summary>
 239        public static LazyCapturingCarrier Rent(int capacity)
 240        {
 632241            var carrier = _cached;
 632242            if (carrier is null)
 60243                return new LazyCapturingCarrier(capacity);
 244
 572245            _cached = null;
 572246            carrier._capacity = capacity;
 572247            return carrier;
 248        }
 249
 250        /// <summary>
 251        /// Returns the captured dictionary (or <c>null</c> when nothing was written), resets the
 252        /// carrier, and returns it to the thread pool for the next capture.
 253        /// </summary>
 254        public Dictionary<string, string>? Detach()
 255        {
 632256            var inner = _inner;
 632257            _inner = null;
 632258            _cached = this;
 632259            return inner is { Count: > 0 } ? inner : null;
 260        }
 261
 262        private Dictionary<string, string> EnsureWritable()
 100263            => _inner ??= new Dictionary<string, string>(_capacity, StringComparer.Ordinal);
 264
 62265        private Dictionary<string, string> ReadView => _inner ?? EmptyView;
 266
 267        public string this[string key]
 268        {
 6269            get => ReadView[key];
 84270            set => EnsureWritable()[key] = value;
 271        }
 272
 14273        public ICollection<string> Keys => ReadView.Keys;
 14274        public ICollection<string> Values => ReadView.Values;
 16275        public int Count => _inner?.Count ?? 0;
 4276        public bool IsReadOnly => false;
 277
 278        /// <summary>Runs the Add operation.</summary>
 10279        public void Add(string key, string value) => EnsureWritable().Add(key, value);
 280
 281        /// <summary>Runs the Add operation.</summary>
 282        public void Add(KeyValuePair<string, string> item)
 6283            => ((ICollection<KeyValuePair<string, string>>)EnsureWritable()).Add(item);
 284
 285        /// <summary>Runs the ContainsKey operation.</summary>
 14286        public bool ContainsKey(string key) => _inner?.ContainsKey(key) ?? false;
 287
 288        /// <summary>Runs the Contains operation.</summary>
 289        public bool Contains(KeyValuePair<string, string> item)
 10290            => _inner is { } inner && ((ICollection<KeyValuePair<string, string>>)inner).Contains(item);
 291
 292        /// <summary>Runs the TryGetValue operation.</summary>
 293        public bool TryGetValue(string key, out string value)
 294        {
 10295            if (_inner is { } inner)
 4296                return inner.TryGetValue(key, out value!);
 297
 6298            value = null!;
 6299            return false;
 300        }
 301
 302        /// <summary>Runs the Remove operation.</summary>
 10303        public bool Remove(string key) => _inner?.Remove(key) ?? false;
 304
 305        /// <summary>Runs the Remove operation.</summary>
 306        public bool Remove(KeyValuePair<string, string> item)
 14307            => _inner is { } inner && ((ICollection<KeyValuePair<string, string>>)inner).Remove(item);
 308
 309        /// <summary>Runs the Clear operation.</summary>
 6310        public void Clear() => _inner?.Clear();
 311
 312        /// <summary>Runs the CopyTo operation.</summary>
 313        public void CopyTo(KeyValuePair<string, string>[] array, int arrayIndex)
 10314            => ((ICollection<KeyValuePair<string, string>>)ReadView).CopyTo(array, arrayIndex);
 315
 316        /// <summary>Runs the GetEnumerator operation.</summary>
 18317        public IEnumerator<KeyValuePair<string, string>> GetEnumerator() => ReadView.GetEnumerator();
 318
 4319        IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
 320    }
 321}