< Summary

Information
Class: AmbientServices.ResponsibilityNotDisposedEventArgs
Assembly: AmbientServices
File(s): /home/runner/work/AmbientServices/AmbientServices/AmbientServices/Helpers/DisposeResponsibility.cs
Tag: 332_35464845198
Line coverage
100%
Covered lines: 4
Uncovered lines: 0
Coverable lines: 4
Total lines: 678
Line coverage: 100%
Branch coverage
N/A
Covered branches: 0
Total branches: 0
Branch coverage: N/A
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

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

File(s)

/home/runner/work/AmbientServices/AmbientServices/AmbientServices/Helpers/DisposeResponsibility.cs

#LineLine coverage
 1using System;
 2using System.Collections.Concurrent;
 3using System.Collections.Generic;
 4using System.Linq;
 5using System.Text;
 6#if NETSTANDARD2_1_OR_GREATER || NET5_0_OR_GREATER
 7using System.Threading.Tasks;
 8#endif
 9#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP || NET5_0_OR_GREATER
 10using System.Runtime.CompilerServices;
 11#endif
 12
 13namespace AmbientServices;
 14
 15/// <summary>
 16/// An interface that abstracts an object that contains an <see cref="IDisposable"/> and allows transfer of the disposal
 17/// Instances should ALWAYS be disposed.
 18/// </summary>
 19/// <remarks>
 20/// <pitch>Makes ownership of a disposable explicit and transferable: instead of comments and conventions about who disp
 21/// <pledge>
 22/// At any moment at most one responsibility instance owns a given disposable; a transfer empties the source (its <see c
 23/// Disposing a responsibility disposes its contained object if it still owns one, and is safe to call regardless; every
 24/// <see cref="StackOnCreation"/> identifies where the responsibility originated so leaks can be attributed to their cre
 25/// </pledge>
 26/// </remarks>
 27/// <typeparam name="T">The disposable type being wrapped.</typeparam>
 28public interface IDisposeResponsibility<out T> : IDisposable
 29#if NETSTANDARD2_1_OR_GREATER || NET5_0_OR_GREATER
 30    , IAsyncDisposable
 31#endif
 32{
 33    /// <summary>
 34    /// The contained disposable object.  Throws an <see cref="ObjectDisposedException"/> if the object is no longer con
 35    /// </summary>
 36    public T Contained { get; }
 37    /// <summary>
 38    /// The contained disposable object, or null if no disposable is contained.
 39    /// </summary>
 40    public T? NullableContained { get; }
 41    /// <summary>
 42    /// Returns whether or not this instance contains a disposable and therefore still has responsibility for disposing 
 43    /// </summary>
 44    public bool ContainsDisposable { get; }
 45    /// <summary>
 46    /// Gets a string containing the stack at the time the responsibility was created, or the creation-site string the c
 47    /// </summary>
 48    /// <remarks>
 49    /// When the creation site was captured as a stack (see <see cref="DisposeResponsibility.CollectLeakDetails"/>), the
 50    /// </remarks>
 51    public string StackOnCreation { get; }
 52}
 53
 54internal interface IShirkResponsibility
 55{
 56    /// <summary>
 57    /// Intended for internal use.  Takes responsibility from the instance (presumably to transfer it to another respons
 58    /// </summary>
 59    internal void ShirkResponsibility();
 60    /// <summary>
 61    /// Intended for internal use.  Gets the creation-site string the creator supplied, or null if none was supplied.
 62    /// Transferring this (instead of <see cref="IDisposeResponsibility{T}.StackOnCreation"/>) is what keeps a transfer 
 63    /// </summary>
 64    internal string? ExplicitCreationSite { get; }
 65    /// <summary>
 66    /// Intended for internal use.  Gets the (unrendered) stack captured at construction, or null when no stack was capt
 67    /// </summary>
 68    internal System.Diagnostics.StackTrace? CapturedCreationStack { get; }
 69    /// <summary>
 70    /// Intended for internal use.  Gets the identifier of this instance's entry in the DEBUG-build pending-disposal cen
 71    /// </summary>
 72    internal long PendingDisposalCensusId { get; }
 73}
 74
 75/// <summary>
 76/// A class that wraps a contained <see cref="IDisposable"/> and allows transfer of the disposal responsibility between 
 77/// Failure to dispose of this object will result in a finalizer that will notify you that the contained object was not 
 78/// The DEBUG version of the code also has a property in <see cref="DisposeResponsibility"/> that tracks all tracked und
 79/// Instances of this class contained in another instance should only be contained in objects that are disposable and sh
 80/// DO NOT use this for static objects or objects that are not disposable.
 81/// Instances of this class on the stack should ALWAYS be in a using statement.
 82/// The responsibility to dispose may be transferred out to another instance using <see cref="TransferResponsibilityToCa
 83/// </summary>
 84/// <remarks>
 85/// <pitch>The standard realization of <see cref="IDisposeResponsibility{T}"/>, with leak detection built in: an instanc
 86/// <pledge><see cref="IDisposeResponsibility{T}"/></pledge>
 87/// <pledge>
 88/// Beyond the transfer contract: <see cref="AssumeResponsibility"/> disposes any current contents before taking the new
 89/// An instance that is finalized while still owning responsibility reports a leak: through the <see cref="DisposeRespon
 90/// Leak reports always identify the leak (the contained type, and the creation-site string when the creator supplied on
 91/// Individual instances are not thread-safe; concurrent transfer and dispose require caller coordination.
 92/// </pledge>
 93/// <plan>
 94/// Three fields — the contained object, the creation site, and a still-responsible flag — plus a finalizer that fires o
 95/// The creation site is either the string the creator supplied (no capture cost at all — what hot paths should pass) or
 96/// The stack is captured without file information: that keeps capture (which resolves file/line eagerly when asked for 
 97/// Deferral has a history worth keeping: the string used to be rendered eagerly at construction because .NET Framework 
 98/// In DEBUG builds each still-undisposed instance also has an entry in <c>PendingDispose</c> (a creation-site object pe
 99/// </plan>
 100/// <priority>
 101/// <see cref="IDisposeResponsibility{T}"/>
 102/// 1. A degraded leak report over no leak report: every failure path here still reports something.  Creation-site colle
 103/// 2. Zero cost on the happy path over cheap diagnostics: the finalizer is suppressed on correct disposal and re-regist
 104/// 3. Capture speed over report precision: stacks are captured without file information, keeping construction close to 
 105/// </priority>
 106/// </remarks>
 107/// <typeparam name="T">The disposable type being wrapped.</typeparam>
 108public sealed class DisposeResponsibility<T> : IDisposeResponsibility<T>, IShirkResponsibility
 109{
 110    private static readonly AmbientLogger<DisposeResponsibility<T>> Logger = new();
 111
 112    private string? _explicitStackOnCreation;                           // the creation-site string the creator supplied
 113    private System.Diagnostics.StackTrace? _capturedStackOnCreation;    // the creation stack, captured but NOT rendered
 114    private T? _contained;
 115    private bool _stillResponsible;                                     // whether this instance still owes a disposal, 
 116#if DEBUG
 117    private long _pendingDisposalCensusId;                              // this instance's entry in the DEBUG-only pendi
 118#endif
 119
 120    /// <summary>
 121    /// The contained disposable object.  Throws an <see cref="ObjectDisposedException"/> if the object is no longer con
 122    /// </summary>
 123    public T Contained
 124    {
 125        get
 126        {
 127#if NET7_0_OR_GREATER
 128            ObjectDisposedException.ThrowIf(_contained == null, this);
 129#else
 130            if (_contained == null) throw new ObjectDisposedException("The contained disposable object is no longer owne
 131#endif
 132            return _contained;
 133        }
 134    }
 135    /// <summary>
 136    /// The contained disposable object, or null if no disposable is contained.
 137    /// </summary>
 138    public T? NullableContained
 139    {
 140        get
 141        {
 142            return _contained;
 143        }
 144    }
 145    /// <summary>
 146    /// Returns whether or not this instance contains a disposable and therefore still has responsibility for disposing 
 147    /// </summary>
 148    public bool ContainsDisposable => _contained != null;
 149    /// <summary>
 150    /// Gets a string containing the stack at the time the responsibility was created, or the creation-site string that 
 151    /// </summary>
 152    /// <remarks>
 153    /// A captured stack is rendered here, on demand, rather than at construction, so each read of this property on a ca
 154    /// </remarks>
 155    public string StackOnCreation
 156    {
 157        get
 158        {
 159            string? explicitSite = _explicitStackOnCreation;
 160            if (explicitSite != null) return explicitSite;
 161            System.Diagnostics.StackTrace? capturedStack = _capturedStackOnCreation;
 162            if (capturedStack == null) return "";
 163            try
 164            {
 165                return capturedStack.ToString();
 166            }
 167#pragma warning disable CA1031 // Do not catch general exception types--losing the leak report because the frames could 
 168            catch (Exception ex)
 169#pragma warning restore CA1031 // Do not catch general exception types
 170            {
 171                return $"<the creation stack could not be rendered: {ex.GetType().Name}: {ex.Message}>";
 172            }
 173        }
 174    }
 175
 176    /// <summary>
 177    /// Does the logic as if the finalizer was called (for testing).
 178    /// There is no reason to call this except for testing.
 179    /// </summary>
 180    /// <remarks>
 181    /// Unlike the real finalizer, this does not enqueue a deferred leak for <see cref="DisposeResponsibility.AssertNoUn
 182    /// so unit tests can simulate finalization without failing assembly-level verification.
 183    /// </remarks>
 184    public void FinalizeLogic()
 185    {
 186        NotifyUndisposedResponsibilityLeak(recordForDeferredAssemblyVerification: false);
 187    }
 188
 189    /// <summary>
 190    /// Finalizes the object and ensures that the contained object was disposed as expected.
 191    /// Note that when used properly, this finalizer is never used, so there isn't a significant performance hit from ha
 192    /// Proper disposal short circuits this.
 193    /// </summary>
 194    ~DisposeResponsibility()
 195    {
 196        NotifyUndisposedResponsibilityLeak(recordForDeferredAssemblyVerification: true);
 197    }
 198
 199    private void NotifyUndisposedResponsibilityLeak(bool recordForDeferredAssemblyVerification)
 200    {
 201        if (!_stillResponsible) return;  // responsibility has been shirked or fulfilled; no leak to report
 202        string stackOnCreation = StackOnCreation;  // this is where a captured stack gets rendered--at report time, not 
 203        if (DisposeResponsibility.NotifyEvent(this, new ResponsibilityNotDisposedEventArgs(_contained, stackOnCreation))
 204
 205        // note that the leak is always reported, even when no creation-site detail was collected--the occurrence and th
 206        string notice = string.IsNullOrEmpty(stackOnCreation)
 207            ? $"A DisposeResponsibility<{typeof(T).FullName}> was not disposed.  No creation site was collected: enable 
 208            : $"A DisposeResponsibility<{typeof(T).FullName}> was not disposed.  It was constructed at {stackOnCreation}
 209        Logger.Filter(AmbientLogLevel.Warning)?.Log(new { Action = "UndisposedDisposeResponsibility", Message = notice }
 210        // stop/notify *if we can*, but if not, queue up a record of this that someone can query later, presumably durin
 211    if (System.Diagnostics.Debugger.IsAttached) System.Diagnostics.Debugger.Break();
 212    else System.Diagnostics.Trace.WriteLine(notice);
 213    if (recordForDeferredAssemblyVerification) DisposeResponsibility.RecordDeferredUndisposedLeak(notice);
 214  }
 215
 216  /// <summary>
 217  /// Constructs an empty dispose responsibility object which can later take responsibility for disposing a specified di
 218  /// </summary>
 219  public DisposeResponsibility()
 220    {
 221        // note that the stack is captured but NOT rendered here--rendering is deferred to report time (see the Plan)
 222        _capturedStackOnCreation = DisposeResponsibility.CollectLeakDetails ? new System.Diagnostics.StackTrace(1) : nul
 223        _stillResponsible = true;
 224#if DEBUG
 225        _pendingDisposalCensusId = PendingDispose.OnConstruct(_capturedStackOnCreation);
 226#endif
 227        // Note that the rules are that this MUST be disposed, so we want to enforce that even it the contents are null!
 228    }
 229    /// <summary>
 230    /// Constructs a dispose responsibility object which takes responsibility for disposing the specified disposable obj
 231    /// </summary>
 232    /// <param name="contained">An optional disposable object that will be owned and disposed by the instance.</param>
 233    /// <param name="stackOnCreation">The creation stack to associate with <paramref name="contained"/>.  When this is s
 234    public DisposeResponsibility(T? contained, string? stackOnCreation = null)
 235    {
 236        _contained = contained;
 237        _explicitStackOnCreation = stackOnCreation;
 238        // only consult the setting when we would otherwise walk the stack, so callers that pass their own creation site
 239        _capturedStackOnCreation = (stackOnCreation == null && DisposeResponsibility.CollectLeakDetails) ? new System.Di
 240        _stillResponsible = true;
 241#if DEBUG
 242        _pendingDisposalCensusId = PendingDispose.OnConstruct(_explicitStackOnCreation ?? (object?)_capturedStackOnCreat
 243#endif
 244        // Note that the rules are that this MUST be disposed, so we want to enforce that even it the contents are null!
 245    }
 246    /// <summary>
 247    /// Constructs a dispose responsibility object that takes responsibility from the specified responsibility object.
 248    /// </summary>
 249    /// <param name="other">Another dispose responsibility object to take responsibility from.</param>
 250    public DisposeResponsibility(IDisposeResponsibility<T> other)
 251    {
 252#if NET5_0_OR_GREATER
 253        ArgumentNullException.ThrowIfNull(other);
 254#else
 255        if (other == null) throw new ArgumentNullException(nameof(other));
 256#endif
 257        if (other is not IShirkResponsibility isr) throw new NotImplementedException("Unable to transfer responsibility 
 258        // transfer the creation site as-is (an unrendered capture stays unrendered)
 259        _explicitStackOnCreation = isr.ExplicitCreationSite;
 260        _capturedStackOnCreation = isr.CapturedCreationStack;
 261        _contained = other.Contained;
 262        _stillResponsible = true;
 263#if DEBUG
 264        _pendingDisposalCensusId = isr.PendingDisposalCensusId;
 265#endif
 266        isr.ShirkResponsibility();
 267        // Note that the rules are that this MUST be disposed, so we want to enforce that even it the contents are null!
 268    }
 269
 270    private static void DisposeContained(T contained)
 271    {
 272        if (contained == null) return;
 273
 274        if (contained is IDisposable disposable)
 275        {
 276            disposable.Dispose();
 277        }
 278        else if (contained is IAsyncDisposable asyncDisposable)
 279        {
 280            // since we've been called synchronously but the contained object only has an async disposer, we have to wai
 281            asyncDisposable.DisposeAsync().AsTask().Wait();
 282        }
 283#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP || NET5_0_OR_GREATER
 284        else if (contained is ITuple tuple)
 285        {
 286            for (int i = 0; i < tuple.Length; i++)
 287            {
 288                if (tuple[i] is IDisposable d) d.Dispose();
 289            }
 290        }
 291#endif
 292    }
 293
 294#if NETSTANDARD2_1_OR_GREATER || NET5_0_OR_GREATER  // this is just to exclude this function when it is not used--the co
 295    private static async ValueTask DisposeContainedAsync(T contained)
 296    {
 297        if (contained == null) return;
 298
 299        if (contained is IAsyncDisposable asyncDisposable)
 300        {
 301            await asyncDisposable.DisposeAsync();
 302        }
 303        else if (contained is IDisposable disposable)
 304        {
 305            disposable.Dispose();
 306        }
 307#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP || NET5_0_OR_GREATER
 308        else if (contained is ITuple tuple)
 309        {
 310            for (int i = 0; i < tuple.Length; i++)
 311            {
 312                if (tuple[i] is IAsyncDisposable ad) await ad.DisposeAsync();
 313                else if (tuple[i] is IDisposable d) d.Dispose();
 314            }
 315        }
 316#endif
 317    }
 318#endif
 319
 320    /// <summary>
 321    /// Disposes of this instance by disposing of the contained instance.
 322    /// </summary>
 323    public void Dispose()
 324    {
 325        // dispose the contained object (if any) first, so that if disposal throws, the instance stays reportable and (i
 326        if (_contained is not null)
 327        {
 328            DisposeContained(_contained);
 329            _contained = default;
 330        }
 331        // clear the responsibility bookkeeping even when there was no contained object: a responsibility must be dispos
 332#if DEBUG
 333        PendingDispose.OnDispose(_pendingDisposalCensusId);
 334        _pendingDisposalCensusId = 0;
 335#endif
 336        _explicitStackOnCreation = null;
 337        _capturedStackOnCreation = null;
 338        _stillResponsible = false;
 339        GC.SuppressFinalize(this);
 340    }
 341#if NETSTANDARD2_1_OR_GREATER || NET5_0_OR_GREATER
 342    /// <summary>
 343    /// Asynchronously disposes of this instance by disposing of the contained instance.
 344    /// </summary>
 345    public async ValueTask DisposeAsync()
 346    {
 347        // dispose the contained object (if any) first, so that if disposal throws, the instance stays reportable and (i
 348        if (_contained is not null)
 349        {
 350            await DisposeContainedAsync(_contained);
 351            _contained = default;
 352        }
 353        // clear the responsibility bookkeeping even when there was no contained object, mirroring Dispose so an async-d
 354#if DEBUG
 355        PendingDispose.OnDispose(_pendingDisposalCensusId);
 356        _pendingDisposalCensusId = 0;
 357#endif
 358        _explicitStackOnCreation = null;
 359        _capturedStackOnCreation = null;
 360        _stillResponsible = false;
 361        GC.SuppressFinalize(this);
 362    }
 363#endif
 364    /// <summary>
 365    /// Disposes of any existing disposable and assumes responsibility for the newly specified disposable.
 366    /// </summary>
 367    /// <param name="newDisposable">The new disposable to take responsibility for.</param>
 368    /// <param name="stackOnCreation">The creation stack to associate with <paramref name="newDisposable"/>.  When this 
 369    public void AssumeResponsibility(T? newDisposable, string? stackOnCreation = null)
 370    {
 371        Dispose();
 372        _contained = newDisposable;
 373        _explicitStackOnCreation = stackOnCreation;
 374        _capturedStackOnCreation = (stackOnCreation == null && DisposeResponsibility.CollectLeakDetails) ? new System.Di
 375        _stillResponsible = true;
 376#if DEBUG
 377        _pendingDisposalCensusId = PendingDispose.OnConstruct(_explicitStackOnCreation ?? (object?)_capturedStackOnCreat
 378#endif
 379        // Note that the rules are that this MUST be disposed, so we want to enforce that even it the contents are null!
 380        GC.ReRegisterForFinalize(this);
 381    }
 382    /// <summary>
 383    /// Transfers the responsibility from a specified instance into this instance.
 384    /// </summary>
 385    /// <param name="sourceOwnership">The <see cref="IDisposeResponsibility{T}"/> instance whose contained disposable wi
 386    public void TransferResponsibilityFrom(IDisposeResponsibility<T> sourceOwnership)
 387    {
 388#if NET5_0_OR_GREATER
 389        ArgumentNullException.ThrowIfNull(sourceOwnership);
 390#else
 391        if (sourceOwnership == null) throw new ArgumentNullException(nameof(sourceOwnership));
 392#endif
 393        if (sourceOwnership is not IShirkResponsibility isr) throw new NotImplementedException("Unable to transfer respo
 394        Dispose();
 395        _contained = sourceOwnership.NullableContained;
 396        // transfer the creation site as-is (an unrendered capture stays unrendered)
 397        _explicitStackOnCreation = isr.ExplicitCreationSite;
 398        _capturedStackOnCreation = isr.CapturedCreationStack;
 399        _stillResponsible = true;
 400#if DEBUG
 401        _pendingDisposalCensusId = isr.PendingDisposalCensusId;
 402#endif
 403        // Note that the rules are that this MUST be disposed, so we want to enforce that even it the contents are null!
 404        GC.ReRegisterForFinalize(this);
 405        isr.ShirkResponsibility();
 406    }
 407    /// <summary>
 408    /// Intended for internal use.  Takes responsibility from the instance (presumably to transfer it to another respons
 409    /// </summary>
 410    void IShirkResponsibility.ShirkResponsibility()
 411    {
 412        _contained = default;
 413        _explicitStackOnCreation = null;
 414        _capturedStackOnCreation = null;
 415        _stillResponsible = false;  // clear so FinalizeLogic won't report a false leak
 416#if DEBUG
 417        _pendingDisposalCensusId = 0;   // the census entry (if there was one) now belongs to whoever took responsibilit
 418#endif
 419#pragma warning disable CA1816     // intentional: shirking transfers ownership away, so no finalizer needed
 420        GC.SuppressFinalize(this);
 421#pragma warning restore CA1816
 422    }
 423    /// <summary>
 424    /// Intended for internal use.  Gets the creation-site string the creator supplied, or null if none was supplied.
 425    /// </summary>
 426    string? IShirkResponsibility.ExplicitCreationSite => _explicitStackOnCreation;
 427    /// <summary>
 428    /// Intended for internal use.  Gets the unrendered stack captured at construction, or null if none was captured.
 429    /// </summary>
 430    System.Diagnostics.StackTrace? IShirkResponsibility.CapturedCreationStack => _capturedStackOnCreation;
 431    /// <summary>
 432    /// Intended for internal use.  Gets this instance's entry in the DEBUG-only pending-disposal census, or zero if it 
 433    /// </summary>
 434    long IShirkResponsibility.PendingDisposalCensusId =>
 435#if DEBUG
 436        _pendingDisposalCensusId;
 437#else
 438        0;  // there is no census in non-DEBUG builds
 439#endif
 440    /// <summary>
 441    /// Returns a new instance to be returned from the containing function, with dispose responsibility transferred from
 442    /// </summary>
 443    /// <returns>A new <see cref="DisposeResponsibility{T}"/> with disposal responsibility.</returns>
 444    public DisposeResponsibility<T> TransferResponsibilityToCaller()
 445    {
 446        DisposeResponsibility<T> newInstance = new();
 447        newInstance.TransferResponsibilityFrom(this);
 448        return newInstance;
 449    }
 450    /// <summary>
 451    /// Gets a string representation of the contained disposable (if any).
 452    /// </summary>
 453    /// <returns>A string representation of the contained disposable (if any).</returns>
 454    public override string ToString()
 455    {
 456        return _contained?.ToString() ?? "";
 457    }
 458}
 459/// <summary>
 460/// A class containing the arguments for the <see cref="DisposeResponsibility.ResponsibilityNotDisposed"/> event.
 461/// </summary>
 462public class ResponsibilityNotDisposedEventArgs : EventArgs
 463{
 464    /// <summary>
 465    /// Constructs a new <see cref="ResponsibilityNotDisposedEventArgs"/> with the specified stack on creation.
 466    /// </summary>
 467    /// <param name="contained">The instance contained in the <see cref="DisposeResponsibility{T}"/>.</param>
 468    /// <param name="stackOnCreation">The stack trace captured when the instance was created.</param>
 2469    public ResponsibilityNotDisposedEventArgs(object? contained, string stackOnCreation)
 470    {
 2471        Contained = contained;
 2472        StackOnCreation = stackOnCreation;
 2473    }
 474    /// <summary>
 475    /// The object contained within the <see cref="DisposeResponsibility{T}"/>.
 476    /// </summary>
 477    public object? Contained { get; }
 478    /// <summary>
 479    /// A string containing the stack trace captured when the disposable instance was created.
 480    /// </summary>
 481    public string StackOnCreation { get; }
 482}
 483
 484/// <summary>
 485/// A static class that contains utility functions applicable across all <see cref="DisposeResponsibility{T}"/> types.
 486/// For example, it allows you to query <see cref="DisposeResponsibility{T}"/> instances to see how many outstanding dis
 487/// </summary>
 488/// <remarks>
 489/// <pitch>The cross-type leak-reporting surface for <see cref="DisposeResponsibility{T}"/>: subscribe to hear about und
 490/// <pledge>
 491/// <see cref="ResponsibilityNotDisposed"/> is raised from finalizer threads when a leaked instance is detected; while a
 492/// <see cref="AssertNoUndisposedDisposeResponsibilityLeaksAfterFullGc"/> forces full collections and finalizer drains, 
 493/// Detail gathering is off by default, and asking for an explicit leak report while it is off is a setup error, not a s
 494/// <see cref="CollectLeakDetails"/> follows the ambient settings, so enabling it is a per-process configuration choice 
 495/// </pledge>
 496/// <plan>Leak notices queue in a static <see cref="ConcurrentQueue{T}"/> as finalizers run; the assertion performs repe
 497/// The toggle is a declared <see cref="IAmbientSetting{T}"/> (off by default) rather than a static flag, so that it is 
 498/// </remarks>
 499public static class DisposeResponsibility
 500{
 501    private static readonly AmbientService<IAmbientSettingsSet> _SettingsSet = Ambient.GetService<IAmbientSettingsSet>()
 502    private static readonly IAmbientSetting<bool> _CollectLeakDetailsSetting = AmbientSettings.GetAmbientSetting<bool>(n
 503
 504    /// <summary>
 505    /// Gets the ambient settings key that controls <see cref="CollectLeakDetails"/>, so callers can override the settin
 506    /// </summary>
 507    public static string CollectLeakDetailsSettingKey => _CollectLeakDetailsSetting.Key;
 508    /// <summary>
 509    /// Gets whether or not <see cref="DisposeResponsibility{T}"/> instances constructed in this call context should gat
 510    /// </summary>
 511    /// <remarks>
 512    /// <para>
 513    /// This is <b>off by default</b>, because capturing a stack on every construction is expensive.
 514    /// Turn it on (with the <see cref="CollectLeakDetailsSettingKey"/> ambient setting, or for one call context with <s
 515    /// typically test assemblies and diagnostic runs, which are the same places that call <see cref="AssertNoUndisposed
 516    /// </para>
 517    /// <para>
 518    /// Callers that pass an explicit creation-site string when constructing a <see cref="DisposeResponsibility{T}"/> ge
 519    /// </para>
 520    /// </remarks>
 521    public static bool CollectLeakDetails => _CollectLeakDetailsSetting.Value;
 522    /// <summary>
 523    /// Turns creation-site detail gathering on (or off) for the current call context until the returned object is dispo
 524    /// </summary>
 525    /// <param name="collect">Whether detail gathering should be on (the default) or off within the scope.</param>
 526    /// <returns>An <see cref="IDisposable"/> that restores the previous settings for the call context when it is dispos
 527    /// <remarks>
 528    /// This is the convenient way for a test or a diagnostic scenario to opt in: instances constructed within the scope
 529    /// Note that instances capture according to the value in effect where they are <em>constructed</em>, so the scope h
 530    /// </remarks>
 531    public static IDisposable ScopedLeakDetailCollection(bool collect = true)
 532    {
 533        BasicAmbientSettingsSet scopedSettings = new(nameof(ScopedLeakDetailCollection));
 534        scopedSettings.ChangeSetting(_CollectLeakDetailsSetting.Key, collect ? "true" : "false");
 535        // layer the one setting over whatever settings set the context already had so that overriding this setting does
 536        return new ScopedLocalServiceOverride<IAmbientSettingsSet>(new AmbientSettingsLayers(_SettingsSet.Local, scopedS
 537    }
 538#if DEBUG
 539    /// <summary>
 540    /// Gets an enumeration of all pending disposals tracked by instances of <see cref="DisposeResponsibility{T}"/>,
 541    /// with the path that created them and the number of instances created through that path that have not yet been dis
 542    /// Entries are returned in descending order of the number of pending disposals.
 543    /// </summary>
 544    public static IEnumerable<(string Stack, int Count)> AllPendingDisposals => PendingDispose.AllPendingDisposals;
 545#endif
 546    private static readonly ConcurrentQueue<string> DeferredUndisposedLeaks = new();
 547
 548    internal static void RecordDeferredUndisposedLeak(string notice)
 549    {
 550        DeferredUndisposedLeaks.Enqueue(notice);
 551    }
 552
 553    /// <summary>
 554    /// Runs a full garbage collection and waits for pending finalizers (repeatedly) so that unreachable
 555    /// <see cref="DisposeResponsibility{T}"/> instances run their finalizers, then fails if any undisposed wrappers
 556    /// were detected on those finalization paths without a <see cref="ResponsibilityNotDisposed"/> handler.
 557    /// </summary>
 558    /// <remarks>
 559    /// <para>
 560    /// Test hosts such as Microsoft Testing Platform can behave poorly when finalizers assert or block.
 561    /// Undisposed instances are instead recorded when finalized and
 562    /// reported here so tests can call this from assembly cleanup (after a full GC and finalizer drain).
 563    /// </para>
 564    /// <para>
 565    /// Subscribe to <see cref="ResponsibilityNotDisposed"/> (and optionally call <see cref="System.Diagnostics.Debugger
 566    /// if you need immediate notification during interactive debugging.
 567    /// </para>
 568    /// <para>
 569    /// Calling this while <see cref="CollectLeakDetails"/> is off (its default) is treated as a setup mistake and throw
 570    /// because leaks found that way could not name their creation sites and would be nearly impossible to track down.
 571    /// Enable detail collection in the project or scenario that verifies leaks (see <see cref="CollectLeakDetailsSettin
 572    /// </para>
 573    /// </remarks>
 574    /// <exception cref="InvalidOperationException">Thrown when deferred leaks were recorded from finalization without a
 575    public static void AssertNoUndisposedDisposeResponsibilityLeaksAfterFullGc()
 576    {
 577        // fail loudly instead of "verifying" leaks that could never name where they came from
 578        if (!CollectLeakDetails) throw new InvalidOperationException($"{nameof(AssertNoUndisposedDisposeResponsibilityLe
 579        for (int pass = 0; pass < 3; pass++)
 580        {
 581            GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced);
 582            GC.WaitForPendingFinalizers();
 583        }
 584        GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced);
 585
 586        List<string> leaksCopy = DrainDeferredUndisposedLeaks();
 587
 588        if (leaksCopy.Count == 0)
 589            return;
 590
 591        StringBuilder message = new();
 592        message.Append("Undisposed DisposeResponsibility leak(s) detected after full GC (no ResponsibilityNotDisposed ha
 593        for (int i = 0; i < leaksCopy.Count; i++)
 594            message.AppendLine().Append('[').Append(i).Append("] ").Append(leaksCopy[i]);
 595        throw new InvalidOperationException(message.ToString());
 596    }
 597
 598    private static List<string> DrainDeferredUndisposedLeaks()
 599    {
 600        List<string> drained = [];
 601        while (DeferredUndisposedLeaks.TryDequeue(out string? notice))
 602            drained.Add(notice);
 603        return drained;
 604    }
 605
 606    internal static bool NotifyEvent(object? sender, ResponsibilityNotDisposedEventArgs args)
 607    {
 608        if (ResponsibilityNotDisposed == null) return false;
 609        ResponsibilityNotDisposed.Invoke(sender, args);
 610        return true;
 611    }
 612    /// <summary>
 613    /// An event that notifies subscribers that a <see cref="DisposeResponsibility{T}"/> was not properly disposed.
 614    /// </summary>
 615    public static event EventHandler<ResponsibilityNotDisposedEventArgs>? ResponsibilityNotDisposed;
 616}
 617
 618#if DEBUG
 619/// <summary>
 620/// A DEBUG-build census of the creation sites of <see cref="DisposeResponsibility{T}"/> instances that have not been di
 621/// </summary>
 622/// <remarks>
 623/// <pitch>Answers "what is piling up undisposed right now, and where was it created?" while chasing a leak, without nee
 624/// <pledge>An instance is counted from the moment it takes responsibility until it is disposed (a transfer moves the en
 625/// <plan>A <see cref="ConcurrentDictionary{TKey,TValue}"/> from a monotonically increasing id (handed back to the insta
 626/// </remarks>
 627class PendingDispose
 628{
 629    private const int StackTraceCharLimit = 1024;
 630
 631    private static readonly ConcurrentDictionary<long, object> _PendingDisposals = new();   // id -> the creation site (
 632    private static long _NextId;                                                            // interlocked
 633
 634    /// <summary>
 635    /// Adds a census entry for an instance that has just taken responsibility.
 636    /// </summary>
 637    /// <param name="creationSite">The instance's creation site: the caller-supplied string, or the unrendered <see cref
 638    /// <returns>The id of the new entry, or zero if no entry was added.</returns>
 639    public static long OnConstruct(object? creationSite)
 640    {
 641        if (creationSite == null) return 0;     // nothing to attribute this instance to, so don't census it
 642        long id = System.Threading.Interlocked.Increment(ref _NextId);
 643        _PendingDisposals[id] = creationSite;
 644        return id;
 645    }
 646    /// <summary>
 647    /// Removes the census entry with the specified id (if any), because that instance's disposal responsibility has bee
 648    /// </summary>
 649    /// <param name="id">The id returned by <see cref="OnConstruct"/>, or zero if the instance has no entry.</param>
 650    public static void OnDispose(long id)
 651    {
 652        if (id != 0) _PendingDisposals.TryRemove(id, out _);
 653    }
 654    public static IEnumerable<(string Stack, int Count)> AllPendingDisposals => _PendingDisposals.Values.GroupBy(RenderC
 655    private static string RenderCreationSite(object creationSite)
 656    {
 657        string site;
 658        if (creationSite is string explicitSite)
 659        {
 660            site = explicitSite;
 661        }
 662        else
 663        {
 664            try
 665            {
 666                site = creationSite.ToString() ?? "";
 667            }
 668#pragma warning disable CA1031 // Do not catch general exception types--one unrenderable entry must not break the whole 
 669            catch (Exception ex)
 670#pragma warning restore CA1031 // Do not catch general exception types
 671            {
 672                site = $"<the creation stack could not be rendered: {ex.GetType().Name}>";
 673            }
 674        }
 675        return (site.Length > StackTraceCharLimit) ? site.Substring(0, StackTraceCharLimit) : site;
 676    }
 677}
 678#endif

Methods/Properties

.ctor(object, string)