| | | 1 | | using System; |
| | | 2 | | using System.Collections.Concurrent; |
| | | 3 | | using System.Collections.Generic; |
| | | 4 | | using System.Linq; |
| | | 5 | | using System.Text; |
| | | 6 | | #if NETSTANDARD2_1_OR_GREATER || NET5_0_OR_GREATER |
| | | 7 | | using System.Threading.Tasks; |
| | | 8 | | #endif |
| | | 9 | | #if NETSTANDARD2_1_OR_GREATER || NETCOREAPP || NET5_0_OR_GREATER |
| | | 10 | | using System.Runtime.CompilerServices; |
| | | 11 | | #endif |
| | | 12 | | |
| | | 13 | | namespace 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> |
| | | 28 | | public 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 | | |
| | | 54 | | internal 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> |
| | | 108 | | public 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 | | { |
| | 2 | 297 | | if (contained == null) return; |
| | | 298 | | |
| | 2 | 299 | | if (contained is IAsyncDisposable asyncDisposable) |
| | | 300 | | { |
| | 2 | 301 | | await asyncDisposable.DisposeAsync(); |
| | | 302 | | } |
| | 2 | 303 | | else if (contained is IDisposable disposable) |
| | | 304 | | { |
| | 2 | 305 | | disposable.Dispose(); |
| | | 306 | | } |
| | | 307 | | #if NETSTANDARD2_1_OR_GREATER || NETCOREAPP || NET5_0_OR_GREATER |
| | 2 | 308 | | else if (contained is ITuple tuple) |
| | | 309 | | { |
| | 2 | 310 | | for (int i = 0; i < tuple.Length; i++) |
| | | 311 | | { |
| | 2 | 312 | | if (tuple[i] is IAsyncDisposable ad) await ad.DisposeAsync(); |
| | 2 | 313 | | else if (tuple[i] is IDisposable d) d.Dispose(); |
| | | 314 | | } |
| | | 315 | | } |
| | | 316 | | #endif |
| | 2 | 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 |
| | 2 | 348 | | if (_contained is not null) |
| | | 349 | | { |
| | 2 | 350 | | await DisposeContainedAsync(_contained); |
| | 2 | 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 |
| | 2 | 358 | | _explicitStackOnCreation = null; |
| | 2 | 359 | | _capturedStackOnCreation = null; |
| | 2 | 360 | | _stillResponsible = false; |
| | 2 | 361 | | GC.SuppressFinalize(this); |
| | 2 | 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> |
| | | 462 | | public 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> |
| | | 469 | | public ResponsibilityNotDisposedEventArgs(object? contained, string stackOnCreation) |
| | | 470 | | { |
| | | 471 | | Contained = contained; |
| | | 472 | | StackOnCreation = stackOnCreation; |
| | | 473 | | } |
| | | 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> |
| | | 499 | | public static class DisposeResponsibility |
| | | 500 | | { |
| | 3 | 501 | | private static readonly AmbientService<IAmbientSettingsSet> _SettingsSet = Ambient.GetService<IAmbientSettingsSet>() |
| | 3 | 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> |
| | 2 | 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> |
| | 3 | 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 | | { |
| | 3 | 533 | | BasicAmbientSettingsSet scopedSettings = new(nameof(ScopedLeakDetailCollection)); |
| | 3 | 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 |
| | 3 | 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 |
| | 3 | 546 | | private static readonly ConcurrentQueue<string> DeferredUndisposedLeaks = new(); |
| | | 547 | | |
| | | 548 | | internal static void RecordDeferredUndisposedLeak(string notice) |
| | | 549 | | { |
| | 2 | 550 | | DeferredUndisposedLeaks.Enqueue(notice); |
| | 2 | 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 |
| | 3 | 578 | | if (!CollectLeakDetails) throw new InvalidOperationException($"{nameof(AssertNoUndisposedDisposeResponsibilityLe |
| | 3 | 579 | | for (int pass = 0; pass < 3; pass++) |
| | | 580 | | { |
| | 3 | 581 | | GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced); |
| | 3 | 582 | | GC.WaitForPendingFinalizers(); |
| | | 583 | | } |
| | 3 | 584 | | GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced); |
| | | 585 | | |
| | 3 | 586 | | List<string> leaksCopy = DrainDeferredUndisposedLeaks(); |
| | | 587 | | |
| | 3 | 588 | | if (leaksCopy.Count == 0) |
| | 3 | 589 | | return; |
| | | 590 | | |
| | 2 | 591 | | StringBuilder message = new(); |
| | 2 | 592 | | message.Append("Undisposed DisposeResponsibility leak(s) detected after full GC (no ResponsibilityNotDisposed ha |
| | 2 | 593 | | for (int i = 0; i < leaksCopy.Count; i++) |
| | 2 | 594 | | message.AppendLine().Append('[').Append(i).Append("] ").Append(leaksCopy[i]); |
| | 2 | 595 | | throw new InvalidOperationException(message.ToString()); |
| | | 596 | | } |
| | | 597 | | |
| | | 598 | | private static List<string> DrainDeferredUndisposedLeaks() |
| | | 599 | | { |
| | 3 | 600 | | List<string> drained = []; |
| | 3 | 601 | | while (DeferredUndisposedLeaks.TryDequeue(out string? notice)) |
| | 2 | 602 | | drained.Add(notice); |
| | 3 | 603 | | return drained; |
| | | 604 | | } |
| | | 605 | | |
| | | 606 | | internal static bool NotifyEvent(object? sender, ResponsibilityNotDisposedEventArgs args) |
| | | 607 | | { |
| | 2 | 608 | | if (ResponsibilityNotDisposed == null) return false; |
| | 2 | 609 | | ResponsibilityNotDisposed.Invoke(sender, args); |
| | 2 | 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> |
| | | 627 | | class 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 |