From 9bcc26f641d204ab6b784be7bb61464afd63404f Mon Sep 17 00:00:00 2001 From: Stephen Cleary Date: Thu, 30 Dec 2021 10:26:37 -0500 Subject: [PATCH] Reference counted disposables (#15) --- CHANGELOG.md | 9 +- .../IReferenceCountedAsyncDisposable.cs | 43 +++ .../IReferenceCountedDisposable.cs | 41 +++ .../IWeakReferenceCountedAsyncDisposable.cs | 23 ++ .../IWeakReferenceCountedDisposable.cs | 21 ++ src/Nito.Disposables/Internals/BoundAction.cs | 2 +- .../Internals/BoundAsyncAction.cs | 2 +- .../Internals/IReferenceCounter.cs | 25 ++ .../ReferenceCountedAsyncDisposable.cs | 56 +++ .../Internals/ReferenceCountedDisposable.cs | 53 +++ .../Internals/ReferenceCounter.cs | 56 +++ .../Internals/ReferenceCounterEphemerons.cs | 32 ++ .../WeakReferenceCountedAsyncDisposable.cs | 46 +++ .../WeakReferenceCountedDisposable.cs | 44 +++ src/Nito.Disposables/Nito.Disposables.csproj | 1 + .../ReferenceCountedAsyncDisposable.cs | 48 +++ .../ReferenceCountedDisposable.cs | 46 +++ src/project.props | 3 +- ...eferenceCountedAsyncDisposableUnitTests.cs | 327 ++++++++++++++++++ .../ReferenceCountedDisposableUnitTests.cs | 277 +++++++++++++++ 20 files changed, 1149 insertions(+), 6 deletions(-) create mode 100644 src/Nito.Disposables/IReferenceCountedAsyncDisposable.cs create mode 100644 src/Nito.Disposables/IReferenceCountedDisposable.cs create mode 100644 src/Nito.Disposables/IWeakReferenceCountedAsyncDisposable.cs create mode 100644 src/Nito.Disposables/IWeakReferenceCountedDisposable.cs create mode 100644 src/Nito.Disposables/Internals/IReferenceCounter.cs create mode 100644 src/Nito.Disposables/Internals/ReferenceCountedAsyncDisposable.cs create mode 100644 src/Nito.Disposables/Internals/ReferenceCountedDisposable.cs create mode 100644 src/Nito.Disposables/Internals/ReferenceCounter.cs create mode 100644 src/Nito.Disposables/Internals/ReferenceCounterEphemerons.cs create mode 100644 src/Nito.Disposables/Internals/WeakReferenceCountedAsyncDisposable.cs create mode 100644 src/Nito.Disposables/Internals/WeakReferenceCountedDisposable.cs create mode 100644 src/Nito.Disposables/ReferenceCountedAsyncDisposable.cs create mode 100644 src/Nito.Disposables/ReferenceCountedDisposable.cs create mode 100644 test/UnitTests/ReferenceCountedAsyncDisposableUnitTests.cs create mode 100644 test/UnitTests/ReferenceCountedDisposableUnitTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 172e251..f1a1564 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,11 +3,14 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +- [Feature] Added `ReferenceCountedDisposable`, `ReferenceCountedAsyncDisposable`, and associated types. #12 +- [Fix] Fixed race condition bug (never observed). #14 + ## [2.2.1] - 2021-09-25 - [Fix] Bumped `System.Collections.Immutable` dependency version from `1.4.0` to `1.7.1`. This fixes the shim dlls issue on .NET Framework targets. ## [2.2.0] - 2020-10-02 -- [Feature] Added support for `null` disposables and delegates (which are ignored). +- [Feature] Added support for `null` disposables and delegates (which are ignored). #13 - [Feature] Added `Disposable` and `AsyncDisposable`. ## [2.1.0] - 2020-06-08 @@ -21,10 +24,10 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [2.0.1] - 2019-07-20 - [Fix] Published NuGet symbol packages. -- [Fix] Added explicit `net461` target. +- [Fix] Added explicit `net461` target. #4 ## [2.0.0] - 2018-06-02 -- [Breaking] Fixed typo: `SingleDisposable.IsDispoed` is now `SingleDisposable.IsDisposed`. +- [Breaking] Fixed typo: `SingleDisposable.IsDispoed` is now `SingleDisposable.IsDisposed`. #3 - [Feature] Added source linking. ## [1.2.3] - 2017-09-09 diff --git a/src/Nito.Disposables/IReferenceCountedAsyncDisposable.cs b/src/Nito.Disposables/IReferenceCountedAsyncDisposable.cs new file mode 100644 index 0000000..734f764 --- /dev/null +++ b/src/Nito.Disposables/IReferenceCountedAsyncDisposable.cs @@ -0,0 +1,43 @@ +#if NETSTANDARD2_1 +using System; + +namespace Nito.Disposables +{ + /// + /// An instance that represents a reference count. All members are threadsafe. + /// + public interface IReferenceCountedAsyncDisposable : IAsyncDisposable + where T : class, IAsyncDisposable + { + /// + /// Adds a weak reference to this reference counted disposable. Throws if this instance is disposed. + /// + IWeakReferenceCountedAsyncDisposable AddWeakReference(); + + /// + /// Returns a new reference to this reference counted disposable, incrementing the reference counter. Throws if this instance is disposed. + /// + IReferenceCountedAsyncDisposable AddReference(); + + /// + /// Gets the target object. Throws if this instance is disposed. + /// + T? Target { get; } + + /// + /// Whether this instance is currently disposing or has been disposed. + /// + public bool IsDisposeStarted { get; } + + /// + /// Whether this instance is disposed (finished disposing). + /// + public bool IsDisposed { get; } + + /// + /// Whether this instance is currently disposing, but not finished yet. + /// + public bool IsDisposing { get; } + } +} +#endif \ No newline at end of file diff --git a/src/Nito.Disposables/IReferenceCountedDisposable.cs b/src/Nito.Disposables/IReferenceCountedDisposable.cs new file mode 100644 index 0000000..f39559f --- /dev/null +++ b/src/Nito.Disposables/IReferenceCountedDisposable.cs @@ -0,0 +1,41 @@ +using System; + +namespace Nito.Disposables +{ + /// + /// An instance that represents a reference count. All members are threadsafe. + /// + public interface IReferenceCountedDisposable : IDisposable + where T : class, IDisposable + { + /// + /// Adds a weak reference to this reference counted disposable. Throws if this instance is disposed. + /// + IWeakReferenceCountedDisposable AddWeakReference(); + + /// + /// Returns a new reference to this reference counted disposable, incrementing the reference counter. Throws if this instance is disposed. + /// + IReferenceCountedDisposable AddReference(); + + /// + /// Gets the target object. Throws if this instance is disposed. + /// + T? Target { get; } + + /// + /// Whether this instance is currently disposing or has been disposed. + /// + public bool IsDisposeStarted { get; } + + /// + /// Whether this instance is disposed (finished disposing). + /// + public bool IsDisposed { get; } + + /// + /// Whether this instance is currently disposing, but not finished yet. + /// + public bool IsDisposing { get; } + } +} diff --git a/src/Nito.Disposables/IWeakReferenceCountedAsyncDisposable.cs b/src/Nito.Disposables/IWeakReferenceCountedAsyncDisposable.cs new file mode 100644 index 0000000..cd1f5b2 --- /dev/null +++ b/src/Nito.Disposables/IWeakReferenceCountedAsyncDisposable.cs @@ -0,0 +1,23 @@ +#if NETSTANDARD2_1 +using System; + +namespace Nito.Disposables +{ + /// + /// An instance that represents an uncounted weak reference. All members are threadsafe. + /// + public interface IWeakReferenceCountedAsyncDisposable + where T : class, IAsyncDisposable + { + /// + /// Adds a reference to this reference counted disposable. Returns null if the underlying disposable has already been disposed or garbage collected. + /// + IReferenceCountedAsyncDisposable? TryAddReference(); + + /// + /// Attempts to get the target object. Returns null if the underlying disposable has already been disposed or garbage collected. + /// + T? TryGetTarget(); + } +} +#endif \ No newline at end of file diff --git a/src/Nito.Disposables/IWeakReferenceCountedDisposable.cs b/src/Nito.Disposables/IWeakReferenceCountedDisposable.cs new file mode 100644 index 0000000..db4a773 --- /dev/null +++ b/src/Nito.Disposables/IWeakReferenceCountedDisposable.cs @@ -0,0 +1,21 @@ +using System; + +namespace Nito.Disposables +{ + /// + /// An instance that represents an uncounted weak reference. All members are threadsafe. + /// + public interface IWeakReferenceCountedDisposable + where T : class, IDisposable + { + /// + /// Adds a reference to this reference counted disposable. Returns null if the underlying disposable has already been disposed or garbage collected. + /// + IReferenceCountedDisposable? TryAddReference(); + + /// + /// Attempts to get the target object. Returns null if the underlying disposable has already been disposed or garbage collected. + /// + T? TryGetTarget(); + } +} diff --git a/src/Nito.Disposables/Internals/BoundAction.cs b/src/Nito.Disposables/Internals/BoundAction.cs index 3e6b469..39e5071 100644 --- a/src/Nito.Disposables/Internals/BoundAction.cs +++ b/src/Nito.Disposables/Internals/BoundAction.cs @@ -45,7 +45,7 @@ public bool TryUpdateContext(Func contextUpdater) _ = contextUpdater ?? throw new ArgumentNullException(nameof(contextUpdater)); while (true) { - var original = Interlocked.CompareExchange(ref _field, _field, _field); + var original = Interlocked.CompareExchange(ref _field, null, null); if (original == null) return false; var updatedContext = new BoundAction(original, contextUpdater); diff --git a/src/Nito.Disposables/Internals/BoundAsyncAction.cs b/src/Nito.Disposables/Internals/BoundAsyncAction.cs index 35a1af9..4e5c504 100644 --- a/src/Nito.Disposables/Internals/BoundAsyncAction.cs +++ b/src/Nito.Disposables/Internals/BoundAsyncAction.cs @@ -48,7 +48,7 @@ public bool TryUpdateContext(Func contextUpdater) _ = contextUpdater ?? throw new ArgumentNullException(nameof(contextUpdater)); while (true) { - var original = Interlocked.CompareExchange(ref _field, _field, _field); + var original = Interlocked.CompareExchange(ref _field, null, null); if (original == null) return false; var updatedContext = new BoundAction(original, contextUpdater); diff --git a/src/Nito.Disposables/Internals/IReferenceCounter.cs b/src/Nito.Disposables/Internals/IReferenceCounter.cs new file mode 100644 index 0000000..7896dc6 --- /dev/null +++ b/src/Nito.Disposables/Internals/IReferenceCounter.cs @@ -0,0 +1,25 @@ +using System; + +namespace Nito.Disposables.Internals +{ + /// + /// A reference count for an underlying target. + /// + public interface IReferenceCounter + { + /// + /// Increments the reference count and returns true. If the reference count has already reached zero, returns false. + /// + bool TryIncrementCount(); + + /// + /// Decrements the reference count and returns null. If this call causes the reference count to reach zero, returns the underlying target. + /// + object? TryDecrementCount(); + + /// + /// Returns the underlying target. Returns null if the reference count has already reached zero. + /// + object? TryGetTarget(); + } +} diff --git a/src/Nito.Disposables/Internals/ReferenceCountedAsyncDisposable.cs b/src/Nito.Disposables/Internals/ReferenceCountedAsyncDisposable.cs new file mode 100644 index 0000000..a0beb82 --- /dev/null +++ b/src/Nito.Disposables/Internals/ReferenceCountedAsyncDisposable.cs @@ -0,0 +1,56 @@ +#if NETSTANDARD2_1 +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading.Tasks; + +namespace Nito.Disposables.Internals +{ + /// + /// An instance that represents a reference count. + /// + public sealed class ReferenceCountedAsyncDisposable : SingleAsyncDisposable, IReferenceCountedAsyncDisposable + where T : class, IAsyncDisposable + { + /// + /// Initializes a reference counted disposable that refers to the specified reference count. The specified reference count must have already been incremented for this instance. + /// + public ReferenceCountedAsyncDisposable(IReferenceCounter referenceCounter) + : base(referenceCounter) + { + _ = referenceCounter ?? throw new ArgumentNullException(nameof(referenceCounter)); + + // Ensure we can cast from the stored IDisposable to T. + _ = ((IReferenceCountedAsyncDisposable) this).Target; + } + + /// + protected override ValueTask DisposeAsync(IReferenceCounter referenceCounter) => (referenceCounter.TryDecrementCount() as IAsyncDisposable)?.DisposeAsync() ?? new ValueTask(); + + T? IReferenceCountedAsyncDisposable.Target => (T?) ReferenceCounter.TryGetTarget(); + + IReferenceCountedAsyncDisposable IReferenceCountedAsyncDisposable.AddReference() + { + var referenceCounter = ReferenceCounter; + if (!referenceCounter.TryIncrementCount()) + throw new ObjectDisposedException(nameof(ReferenceCountedAsyncDisposable)); // cannot actually happen + return new ReferenceCountedAsyncDisposable(referenceCounter); + } + + IWeakReferenceCountedAsyncDisposable IReferenceCountedAsyncDisposable.AddWeakReference() => new WeakReferenceCountedAsyncDisposable(ReferenceCounter); + + private IReferenceCounter ReferenceCounter + { + get + { + IReferenceCounter referenceCounter = null!; + // Implementation note: this always "succeeds" in updating the context since it always returns the same instance. + // So, we know that this will be called at most once. It may also be called zero times if this instance is disposed. + if (!TryUpdateContext(x => referenceCounter = x)) + throw new ObjectDisposedException(nameof(ReferenceCountedAsyncDisposable)); + return referenceCounter; + } + } + } +} +#endif \ No newline at end of file diff --git a/src/Nito.Disposables/Internals/ReferenceCountedDisposable.cs b/src/Nito.Disposables/Internals/ReferenceCountedDisposable.cs new file mode 100644 index 0000000..e123a28 --- /dev/null +++ b/src/Nito.Disposables/Internals/ReferenceCountedDisposable.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace Nito.Disposables.Internals +{ + /// + /// An instance that represents a reference count. + /// + public sealed class ReferenceCountedDisposable : SingleDisposable, IReferenceCountedDisposable + where T : class, IDisposable + { + /// + /// Initializes a reference counted disposable that refers to the specified reference count. The specified reference count must have already been incremented for this instance. + /// + public ReferenceCountedDisposable(IReferenceCounter referenceCounter) + : base(referenceCounter) + { + _ = referenceCounter ?? throw new ArgumentNullException(nameof(referenceCounter)); + + // Ensure we can cast from the stored IDisposable to T. + _ = ((IReferenceCountedDisposable) this).Target; + } + + /// + protected override void Dispose(IReferenceCounter referenceCounter) => (referenceCounter.TryDecrementCount() as IDisposable)?.Dispose(); + + T? IReferenceCountedDisposable.Target => (T?) ReferenceCounter.TryGetTarget(); + + IReferenceCountedDisposable IReferenceCountedDisposable.AddReference() + { + var referenceCounter = ReferenceCounter; + if (!referenceCounter.TryIncrementCount()) + throw new ObjectDisposedException(nameof(ReferenceCountedDisposable)); // cannot actually happen + return new ReferenceCountedDisposable(referenceCounter); + } + + IWeakReferenceCountedDisposable IReferenceCountedDisposable.AddWeakReference() => new WeakReferenceCountedDisposable(ReferenceCounter); + + private IReferenceCounter ReferenceCounter + { + get + { + IReferenceCounter referenceCounter = null!; + // Implementation note: this always "succeeds" in updating the context since it always returns the same instance. + // So, we know that this will be called at most once. It may also be called zero times if this instance is disposed. + if (!TryUpdateContext(x => referenceCounter = x)) + throw new ObjectDisposedException(nameof(ReferenceCountedDisposable)); + return referenceCounter; + } + } + } +} diff --git a/src/Nito.Disposables/Internals/ReferenceCounter.cs b/src/Nito.Disposables/Internals/ReferenceCounter.cs new file mode 100644 index 0000000..50495ff --- /dev/null +++ b/src/Nito.Disposables/Internals/ReferenceCounter.cs @@ -0,0 +1,56 @@ +using System; +using System.Threading; + +namespace Nito.Disposables.Internals +{ + /// + /// A reference count for an underlying target. + /// + public sealed class ReferenceCounter : IReferenceCounter + { + private object? _target; + private int _count; + + /// + /// Creates a new reference counter with a reference count of 1 referencing the specified target. + /// + public ReferenceCounter(object? target) + { + _target = target; + _count = 1; + } + + bool IReferenceCounter.TryIncrementCount() => TryUpdate(x => x + 1) != null; + + object? IReferenceCounter.TryDecrementCount() + { + var updateResult = TryUpdate(x => x - 1); + if (updateResult != 0) + return null; + return Interlocked.Exchange(ref _target, null); + } + + object? IReferenceCounter.TryGetTarget() + { + var result = Interlocked.CompareExchange(ref _target, null, null); + var count = Interlocked.CompareExchange(ref _count, 0, 0); + if (count == 0) + return null; + return result; + } + + private int? TryUpdate(Func func) + { + while (true) + { + var original = Interlocked.CompareExchange(ref _count, 0, 0); + if (original == 0) + return null; + var updatedCount = func(original); + var result = Interlocked.CompareExchange(ref _count, updatedCount, original); + if (original == result) + return updatedCount; + } + } + } +} diff --git a/src/Nito.Disposables/Internals/ReferenceCounterEphemerons.cs b/src/Nito.Disposables/Internals/ReferenceCounterEphemerons.cs new file mode 100644 index 0000000..a74f9a8 --- /dev/null +++ b/src/Nito.Disposables/Internals/ReferenceCounterEphemerons.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Text; + +namespace Nito.Disposables.Internals +{ + /// + /// The collection of reference counters, stored as ephemerons. + /// + public static class ReferenceCounterEphemerons + { + /// + /// Increments and returns the reference counter for the specified target, creating it if necessary. + /// Returns null if the reference counter has already reached 0. + /// + public static IReferenceCounter? TryGetAndIncrementOrCreate(object target) + { + ReferenceCounter? createdReferenceCounter = null; + var referenceCounter = Ephemerons.GetValue(target, t => createdReferenceCounter = new ReferenceCounter(t)); + if (referenceCounter != createdReferenceCounter) + { + if (!referenceCounter.TryIncrementCount()) + return null; + } + + return referenceCounter; + } + + private static readonly ConditionalWeakTable Ephemerons = new(); + } +} diff --git a/src/Nito.Disposables/Internals/WeakReferenceCountedAsyncDisposable.cs b/src/Nito.Disposables/Internals/WeakReferenceCountedAsyncDisposable.cs new file mode 100644 index 0000000..987f7ee --- /dev/null +++ b/src/Nito.Disposables/Internals/WeakReferenceCountedAsyncDisposable.cs @@ -0,0 +1,46 @@ +#if NETSTANDARD2_1 +using System; +using System.Collections.Generic; +using System.Text; + +namespace Nito.Disposables.Internals +{ + /// + /// An instance that represents an uncounted weak reference. + /// + public sealed class WeakReferenceCountedAsyncDisposable : IWeakReferenceCountedAsyncDisposable + where T : class, IAsyncDisposable + { + private readonly WeakReference _weakReference; + + /// + /// Creates an instance that weakly references the specified reference counter. The specified reference counter should not be incremented. + /// + public WeakReferenceCountedAsyncDisposable(IReferenceCounter referenceCounter) + { + _ = referenceCounter ?? throw new ArgumentNullException(nameof(referenceCounter)); + + _weakReference = new(referenceCounter); + + // Ensure we can cast from the stored disposable to T. + _ = (T?) referenceCounter.TryGetTarget()!; + } + + IReferenceCountedAsyncDisposable? IWeakReferenceCountedAsyncDisposable.TryAddReference() + { + if (!_weakReference.TryGetTarget(out var referenceCounter)) + return null; + if (!referenceCounter.TryIncrementCount()) + return null; + return new ReferenceCountedAsyncDisposable(referenceCounter); + } + + T? IWeakReferenceCountedAsyncDisposable.TryGetTarget() + { + if (!_weakReference.TryGetTarget(out var referenceCounter)) + return null; + return (T?) referenceCounter.TryGetTarget(); + } + } +} +#endif \ No newline at end of file diff --git a/src/Nito.Disposables/Internals/WeakReferenceCountedDisposable.cs b/src/Nito.Disposables/Internals/WeakReferenceCountedDisposable.cs new file mode 100644 index 0000000..d873dca --- /dev/null +++ b/src/Nito.Disposables/Internals/WeakReferenceCountedDisposable.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace Nito.Disposables.Internals +{ + /// + /// An instance that represents an uncounted weak reference. + /// + public sealed class WeakReferenceCountedDisposable : IWeakReferenceCountedDisposable + where T : class, IDisposable + { + private readonly WeakReference _weakReference; + + /// + /// Creates an instance that weakly references the specified reference counter. The specified reference counter should not be incremented. + /// + public WeakReferenceCountedDisposable(IReferenceCounter referenceCounter) + { + _ = referenceCounter ?? throw new ArgumentNullException(nameof(referenceCounter)); + + _weakReference = new(referenceCounter); + + // Ensure we can cast from the stored disposable to T. + _ = (T?) referenceCounter.TryGetTarget()!; + } + + IReferenceCountedDisposable? IWeakReferenceCountedDisposable.TryAddReference() + { + if (!_weakReference.TryGetTarget(out var referenceCounter)) + return null; + if (!referenceCounter.TryIncrementCount()) + return null; + return new ReferenceCountedDisposable(referenceCounter); + } + + T? IWeakReferenceCountedDisposable.TryGetTarget() + { + if (!_weakReference.TryGetTarget(out var referenceCounter)) + return null; + return (T?) referenceCounter.TryGetTarget(); + } + } +} diff --git a/src/Nito.Disposables/Nito.Disposables.csproj b/src/Nito.Disposables/Nito.Disposables.csproj index 3c6cfce..be5b0dd 100644 --- a/src/Nito.Disposables/Nito.Disposables.csproj +++ b/src/Nito.Disposables/Nito.Disposables.csproj @@ -8,6 +8,7 @@ + diff --git a/src/Nito.Disposables/ReferenceCountedAsyncDisposable.cs b/src/Nito.Disposables/ReferenceCountedAsyncDisposable.cs new file mode 100644 index 0000000..613e608 --- /dev/null +++ b/src/Nito.Disposables/ReferenceCountedAsyncDisposable.cs @@ -0,0 +1,48 @@ +#if NETSTANDARD2_1 +using System; +using System.Runtime.CompilerServices; +using Nito.Disposables.Internals; + +namespace Nito.Disposables +{ + /// + /// Creation methods for reference counted disposables. + /// + public static class ReferenceCountedAsyncDisposable + { + /// + /// Creates a new disposable that disposes when all reference counts have been disposed. This method uses attached (ephemeron) reference counters. + /// + /// The disposable to dispose when all references have been disposed. If this is null, then the returned instance does nothing when it is disposed. + public static IReferenceCountedAsyncDisposable Create(T? disposable) + where T : class, IAsyncDisposable => + TryCreate(disposable) ?? throw new ObjectDisposedException(nameof(T)); + + /// + /// Creates a new disposable that disposes when all reference counts have been disposed. This method uses attached (ephemeron) reference counters. + /// + /// The disposable to dispose when all references have been disposed. If this is null, then the returned instance does nothing when it is disposed. + public static IReferenceCountedAsyncDisposable? TryCreate(T? disposable) + where T : class, IAsyncDisposable + { + // We can't attach reference counters to null, so we use a sort of null object pattern here. + if (disposable == null) + return CreateWithNewReferenceCounter(disposable); + + var referenceCounter = ReferenceCounterEphemerons.TryGetAndIncrementOrCreate(disposable); + if (referenceCounter == null) + return null; + + return new ReferenceCountedAsyncDisposable(referenceCounter); + } + + /// + /// Creates a new disposable that disposes when all reference counts have been disposed. This method creates a new reference counter to keep track of the reference counts. + /// + /// The disposable to dispose when all references have been disposed. If this is null, then the returned instance does nothing when it is disposed. + public static IReferenceCountedAsyncDisposable CreateWithNewReferenceCounter(T? disposable) + where T : class, IAsyncDisposable + => new ReferenceCountedAsyncDisposable(new ReferenceCounter(disposable)); + } +} +#endif \ No newline at end of file diff --git a/src/Nito.Disposables/ReferenceCountedDisposable.cs b/src/Nito.Disposables/ReferenceCountedDisposable.cs new file mode 100644 index 0000000..115eec9 --- /dev/null +++ b/src/Nito.Disposables/ReferenceCountedDisposable.cs @@ -0,0 +1,46 @@ +using System; +using System.Runtime.CompilerServices; +using Nito.Disposables.Internals; + +namespace Nito.Disposables +{ + /// + /// Creation methods for reference counted disposables. + /// + public static class ReferenceCountedDisposable + { + /// + /// Creates a new disposable that disposes when all reference counts have been disposed. This method uses attached (ephemeron) reference counters. + /// + /// The disposable to dispose when all references have been disposed. If this is null, then the returned instance does nothing when it is disposed. + public static IReferenceCountedDisposable Create(T? disposable) + where T : class, IDisposable => + TryCreate(disposable) ?? throw new ObjectDisposedException(nameof(T)); + + /// + /// Creates a new disposable that disposes when all reference counts have been disposed. This method uses attached (ephemeron) reference counters. + /// + /// The disposable to dispose when all references have been disposed. If this is null, then the returned instance does nothing when it is disposed. + public static IReferenceCountedDisposable? TryCreate(T? disposable) + where T : class, IDisposable + { + // We can't attach reference counters to null, so we use a sort of null object pattern here. + if (disposable == null) + return CreateWithNewReferenceCounter(disposable); + + var referenceCounter = ReferenceCounterEphemerons.TryGetAndIncrementOrCreate(disposable); + if (referenceCounter == null) + return null; + + return new ReferenceCountedDisposable(referenceCounter); + } + + /// + /// Creates a new disposable that disposes when all reference counts have been disposed. This method creates a new reference counter to keep track of the reference counts. + /// + /// The disposable to dispose when all references have been disposed. If this is null, then the returned instance does nothing when it is disposed. + public static IReferenceCountedDisposable CreateWithNewReferenceCounter(T? disposable) + where T : class, IDisposable + => new ReferenceCountedDisposable(new ReferenceCounter(disposable)); + } +} diff --git a/src/project.props b/src/project.props index ef8d22c..6348c76 100644 --- a/src/project.props +++ b/src/project.props @@ -1,6 +1,7 @@ - 2.2.1 + 2.3.0 + pre02 Stephen Cleary \ No newline at end of file diff --git a/test/UnitTests/ReferenceCountedAsyncDisposableUnitTests.cs b/test/UnitTests/ReferenceCountedAsyncDisposableUnitTests.cs new file mode 100644 index 0000000..5201ce9 --- /dev/null +++ b/test/UnitTests/ReferenceCountedAsyncDisposableUnitTests.cs @@ -0,0 +1,327 @@ +using System; +using System.Threading.Tasks; +using Nito.Disposables; +using System.Linq; +using System.Threading; +using Xunit; + +namespace UnitTests +{ + public class ReferenceCountedAsyncDisposableUnitTests + { + [Fact] + public async Task Create_NullDisposable_DoesNotThrow() + { + var disposable = ReferenceCountedAsyncDisposable.Create(null); + await disposable.DisposeAsync(); + } + + [Fact] + public async Task AdvancedCreate_NullDisposable_DoesNotThrow() + { + var disposable = ReferenceCountedAsyncDisposable.CreateWithNewReferenceCounter(null); + await disposable.DisposeAsync(); + } + + [Fact] + public void Target_ReturnsTarget() + { + var target = AsyncDisposable.Create(null); + var disposable = ReferenceCountedAsyncDisposable.Create(target); + Assert.Equal(target, disposable.Target); + } + + [Fact] + public void Target_WhenNull_ReturnsTarget() + { + var disposable = ReferenceCountedAsyncDisposable.Create(null); + Assert.Null(disposable.Target); + } + + [Fact] + public async Task Target_AfterDispose_Throws() + { + var disposable = ReferenceCountedAsyncDisposable.Create(null); + await disposable.DisposeAsync(); + Assert.Throws(() => disposable.Target); + } + + [Fact] + public async Task Target_WhenNull_AfterDispose_Throws() + { + var disposable = ReferenceCountedAsyncDisposable.Create(null); + await disposable.DisposeAsync(); + Assert.Throws(() => disposable.Target); + } + + [Fact] + public async Task Dispose_DisposesTarget() + { + var target = AsyncDisposable.Create(null); + var disposable = ReferenceCountedAsyncDisposable.Create(target); + Assert.False(target.IsDisposed); + await disposable.DisposeAsync(); + Assert.True(target.IsDisposed); + } + + [Fact] + public async Task MultiDispose_DisposesTargetOnceAsync() + { + var targetDisposeCount = 0; +#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously + var target = new UnsafeDisposable(async () => ++targetDisposeCount); +#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously + var disposable = ReferenceCountedAsyncDisposable.Create(target); + Assert.Equal(0, targetDisposeCount); + await disposable.DisposeAsync(); + Assert.Equal(1, targetDisposeCount); + await disposable.DisposeAsync(); + Assert.Equal(1, targetDisposeCount); + } + + [Fact] + public async Task AddReference_AfterDispose_ThrowsAsync() + { + var target = AsyncDisposable.Create(null); + var disposable = ReferenceCountedAsyncDisposable.Create(target); + await disposable.DisposeAsync(); + Assert.Throws(() => disposable.AddReference()); + } + + [Fact] + public async Task AddReference_AfterDispose_WhenAnotherReferenceExists_ThrowsAsync() + { + var target = AsyncDisposable.Create(null); + var disposable = ReferenceCountedAsyncDisposable.Create(target); + var secondDisposable = disposable.AddReference(); + await disposable.DisposeAsync(); + Assert.Throws(() => disposable.AddReference()); + Assert.False(target.IsDisposed); + } + + [Fact] + public async Task Dispose_WhenAnotherReferenceExists_DoesNotDisposeTarget_UntilOtherReferenceIsDisposedAsync() + { + var target = AsyncDisposable.Create(null); + var disposable = ReferenceCountedAsyncDisposable.Create(target); + var secondDisposable = disposable.AddReference(); + Assert.False(target.IsDisposed); + await disposable.DisposeAsync(); + Assert.False(target.IsDisposed); + await secondDisposable.DisposeAsync(); + Assert.True(target.IsDisposed); + } + + [Fact] + public async Task MultiDispose_OnlyDecrementsReferenceCountOnceAsync() + { + var target = AsyncDisposable.Create(null); + var disposable = ReferenceCountedAsyncDisposable.Create(target); + _ = disposable.AddReference(); + Assert.False(target.IsDisposed); + await disposable.DisposeAsync(); + Assert.False(target.IsDisposed); + await disposable.DisposeAsync(); + Assert.False(target.IsDisposed); + } + + [Fact] + public async Task MultiCreate_SameTarget_SharesReferenceCountAsync() + { + var target = AsyncDisposable.Create(null); + var disposable = ReferenceCountedAsyncDisposable.Create(target); + var secondDisposable = ReferenceCountedAsyncDisposable.Create(target); + await disposable.DisposeAsync(); + Assert.False(target.IsDisposed); + await secondDisposable.DisposeAsync(); + Assert.True(target.IsDisposed); + } + + [Fact] + public async Task MultiTryCreate_SameTarget_AfterDisposal_ReturnsNullAsync() + { + var target = AsyncDisposable.Create(null); + var disposable = ReferenceCountedAsyncDisposable.Create(target); + await disposable.DisposeAsync(); + var secondDisposable = ReferenceCountedAsyncDisposable.TryCreate(target); + Assert.Null(secondDisposable); + } + + [Fact] + public async Task MultiCreate_SameTarget_AfterDisposal_ThrowsAsync() + { + var target = AsyncDisposable.Create(null); + var disposable = ReferenceCountedAsyncDisposable.Create(target); + await disposable.DisposeAsync(); + Assert.Throws(() => ReferenceCountedAsyncDisposable.Create(target)); + } + + [Fact] + public async Task AddWeakReference_AfterDispose_ThrowsAsync() + { + var target = AsyncDisposable.Create(null); + var disposable = ReferenceCountedAsyncDisposable.Create(target); + await disposable.DisposeAsync(); + Assert.Throws(() => disposable.AddWeakReference()); + } + + [Fact] + public void WeakReferenceTarget_ReturnsTarget() + { + var target = AsyncDisposable.Create(null); + var disposable = ReferenceCountedAsyncDisposable.Create(target); + var weakDisposable = disposable.AddWeakReference(); + Assert.Equal(target, weakDisposable.TryGetTarget()); + GC.KeepAlive(disposable); + } + + [Fact] + public async Task WeakReference_IsNotCountedAsync() + { + var target = AsyncDisposable.Create(null); + var disposable = ReferenceCountedAsyncDisposable.Create(target); + var weakDisposable = disposable.AddWeakReference(); + await disposable.DisposeAsync(); + Assert.Null(weakDisposable.TryGetTarget()); + Assert.Null(weakDisposable.TryAddReference()); + GC.KeepAlive(disposable); + GC.KeepAlive(target); + } + + [Fact] + public async Task WeakReference_NotDisposed_CanIncrementCountAsync() + { + var target = AsyncDisposable.Create(null); + var disposable = ReferenceCountedAsyncDisposable.Create(target); + var weakDisposable = disposable.AddWeakReference(); + var secondDisposable = weakDisposable.TryAddReference(); + Assert.NotNull(secondDisposable); + await disposable.DisposeAsync(); + Assert.NotNull(weakDisposable.TryGetTarget()); + Assert.False(target.IsDisposed); + await secondDisposable.DisposeAsync(); + Assert.Null(weakDisposable.TryGetTarget()); + Assert.Null(weakDisposable.TryAddReference()); + GC.KeepAlive(secondDisposable); + GC.KeepAlive(disposable); + GC.KeepAlive(target); + } + + [Fact] + public void CreateDerived_AfterBase_RefersToSameTarget() + { + var target = new DerivedDisposable(); + var baseTarget = target as BaseDisposable; + var baseDisposable = ReferenceCountedAsyncDisposable.Create(baseTarget); + var derivedDisposable = ReferenceCountedAsyncDisposable.Create(target); + Assert.Equal(baseDisposable.Target, derivedDisposable.Target); + } + + [Fact] + public void CreateBase_AfterDerived_RefersToSameTarget() + { + var target = new DerivedDisposable(); + var baseTarget = target as BaseDisposable; + var derivedDisposable = ReferenceCountedAsyncDisposable.Create(target); + var baseDisposable = ReferenceCountedAsyncDisposable.Create(baseTarget); + Assert.Equal(baseDisposable.Target, derivedDisposable.Target); + } + + [Fact] + public void GenericVariance_RefersToSameTarget() + { + var target = new DerivedDisposable(); + var derivedDisposable = ReferenceCountedAsyncDisposable.Create(target); + var baseDisposable = derivedDisposable as IReferenceCountedAsyncDisposable; + Assert.NotNull(baseDisposable); + Assert.Equal(baseDisposable.Target, derivedDisposable.Target); + } + + [Fact] + public void CastReferenceFromBaseToDerived_Fails() + { + var target = new DerivedDisposable(); + var baseTarget = target as BaseDisposable; + var baseDisposable = ReferenceCountedAsyncDisposable.Create(baseTarget); + var derivedDisposable = baseDisposable as IReferenceCountedAsyncDisposable; + Assert.Null(derivedDisposable); + } + + [Fact] + public void CastTargetFromBaseToDerived_Succeeds() + { + var target = new DerivedDisposable(); + var baseTarget = target as BaseDisposable; + var baseDisposable = ReferenceCountedAsyncDisposable.Create(baseTarget); + var derivedTarget = baseDisposable.Target as DerivedDisposable; + Assert.NotNull(derivedTarget); + Assert.Equal(derivedTarget, target); + } + + [Fact] + public void Create_FromBothSynchronousAndAysnchronous_ReferencesSameTarget() + { + var target = new UnsafeSyncAsyncDisposable(() => { }); + var syncDisposable = ReferenceCountedDisposable.Create(target); + var asyncDisposable = ReferenceCountedAsyncDisposable.Create(target); + Assert.Equal(syncDisposable.Target, asyncDisposable.Target); + } + + [Fact] + public async Task BothSyncAndAysnc_SyncDisposedFirst_OnlyDisposesWhenBothAreDisposed() + { + var disposeCount = 0; + var target = new UnsafeSyncAsyncDisposable(() => ++disposeCount); + var syncDisposable = ReferenceCountedDisposable.Create(target); + var asyncDisposable = ReferenceCountedAsyncDisposable.Create(target); + syncDisposable.Dispose(); + Assert.Equal(0, disposeCount); + await asyncDisposable.DisposeAsync(); + Assert.Equal(1, disposeCount); + } + + [Fact] + public async Task BothSyncAndAysnc_AsyncDisposedFirst_OnlyDisposesWhenBothAreDisposed() + { + var disposeCount = 0; + var target = new UnsafeSyncAsyncDisposable(() => ++disposeCount); + var syncDisposable = ReferenceCountedDisposable.Create(target); + var asyncDisposable = ReferenceCountedAsyncDisposable.Create(target); + await asyncDisposable.DisposeAsync(); + Assert.Equal(0, disposeCount); + syncDisposable.Dispose(); + Assert.Equal(1, disposeCount); + } + + private sealed class UnsafeDisposable : IAsyncDisposable + { + public UnsafeDisposable(Func action) => _action = action; + + public async ValueTask DisposeAsync() => await _action(); + + private readonly Func _action; + } + + private class BaseDisposable : IAsyncDisposable + { + public ValueTask DisposeAsync() => new(); + } + + private class DerivedDisposable : BaseDisposable + { + } + + private sealed class UnsafeSyncAsyncDisposable: IDisposable, IAsyncDisposable + { + private readonly Action _action; + + public UnsafeSyncAsyncDisposable(Action action) => _action = action; + public void Dispose() => _action(); + public ValueTask DisposeAsync() + { + _action(); + return new(); + } + } + } +} diff --git a/test/UnitTests/ReferenceCountedDisposableUnitTests.cs b/test/UnitTests/ReferenceCountedDisposableUnitTests.cs new file mode 100644 index 0000000..18c19c7 --- /dev/null +++ b/test/UnitTests/ReferenceCountedDisposableUnitTests.cs @@ -0,0 +1,277 @@ +using System; +using System.Threading.Tasks; +using Nito.Disposables; +using System.Linq; +using System.Threading; +using Xunit; + +namespace UnitTests +{ + public class ReferenceCountedDisposableUnitTests + { + [Fact] + public void Create_NullDisposable_DoesNotThrow() + { + var disposable = ReferenceCountedDisposable.Create(null); + disposable.Dispose(); + } + + [Fact] + public void AdvancedCreate_NullDisposable_DoesNotThrow() + { + var disposable = ReferenceCountedDisposable.CreateWithNewReferenceCounter(null); + disposable.Dispose(); + } + + [Fact] + public void Target_ReturnsTarget() + { + var target = Disposable.Create(null); + var disposable = ReferenceCountedDisposable.Create(target); + Assert.Equal(target, disposable.Target); + } + + [Fact] + public void Target_WhenNull_ReturnsTarget() + { + var disposable = ReferenceCountedDisposable.Create(null); + Assert.Null(disposable.Target); + } + + [Fact] + public void Target_AfterDispose_Throws() + { + var disposable = ReferenceCountedDisposable.Create(null); + disposable.Dispose(); + Assert.Throws(() => disposable.Target); + } + + [Fact] + public void Target_WhenNull_AfterDispose_Throws() + { + var disposable = ReferenceCountedDisposable.Create(null); + disposable.Dispose(); + Assert.Throws(() => disposable.Target); + } + + [Fact] + public void Dispose_DisposesTarget() + { + var target = Disposable.Create(null); + var disposable = ReferenceCountedDisposable.Create(target); + Assert.False(target.IsDisposed); + disposable.Dispose(); + Assert.True(target.IsDisposed); + } + + [Fact] + public void MultiDispose_DisposesTargetOnce() + { + var targetDisposeCount = 0; + var target = new UnsafeDisposable(() => ++targetDisposeCount); + var disposable = ReferenceCountedDisposable.Create(target); + Assert.Equal(0, targetDisposeCount); + disposable.Dispose(); + Assert.Equal(1, targetDisposeCount); + disposable.Dispose(); + Assert.Equal(1, targetDisposeCount); + } + + [Fact] + public void AddReference_AfterDispose_Throws() + { + var target = Disposable.Create(null); + var disposable = ReferenceCountedDisposable.Create(target); + disposable.Dispose(); + Assert.Throws(() => disposable.AddReference()); + } + + [Fact] + public void AddReference_AfterDispose_WhenAnotherReferenceExists_Throws() + { + var target = Disposable.Create(null); + var disposable = ReferenceCountedDisposable.Create(target); + var secondDisposable = disposable.AddReference(); + disposable.Dispose(); + Assert.Throws(() => disposable.AddReference()); + Assert.False(target.IsDisposed); + } + + [Fact] + public void Dispose_WhenAnotherReferenceExists_DoesNotDisposeTarget_UntilOtherReferenceIsDisposed() + { + var target = Disposable.Create(null); + var disposable = ReferenceCountedDisposable.Create(target); + var secondDisposable = disposable.AddReference(); + Assert.False(target.IsDisposed); + disposable.Dispose(); + Assert.False(target.IsDisposed); + secondDisposable.Dispose(); + Assert.True(target.IsDisposed); + } + + [Fact] + public void MultiDispose_OnlyDecrementsReferenceCountOnce() + { + var target = Disposable.Create(null); + var disposable = ReferenceCountedDisposable.Create(target); + _ = disposable.AddReference(); + Assert.False(target.IsDisposed); + disposable.Dispose(); + Assert.False(target.IsDisposed); + disposable.Dispose(); + Assert.False(target.IsDisposed); + } + + [Fact] + public void MultiCreate_SameTarget_SharesReferenceCount() + { + var target = Disposable.Create(null); + var disposable = ReferenceCountedDisposable.Create(target); + var secondDisposable = ReferenceCountedDisposable.Create(target); + disposable.Dispose(); + Assert.False(target.IsDisposed); + secondDisposable.Dispose(); + Assert.True(target.IsDisposed); + } + + [Fact] + public void MultiTryCreate_SameTarget_AfterDisposal_ReturnsNull() + { + var target = Disposable.Create(null); + var disposable = ReferenceCountedDisposable.Create(target); + disposable.Dispose(); + var secondDisposable = ReferenceCountedDisposable.TryCreate(target); + Assert.Null(secondDisposable); + } + + [Fact] + public void MultiCreate_SameTarget_AfterDisposal_Throws() + { + var target = Disposable.Create(null); + var disposable = ReferenceCountedDisposable.Create(target); + disposable.Dispose(); + Assert.Throws(() => ReferenceCountedDisposable.Create(target)); + } + + [Fact] + public void AddWeakReference_AfterDispose_Throws() + { + var target = Disposable.Create(null); + var disposable = ReferenceCountedDisposable.Create(target); + disposable.Dispose(); + Assert.Throws(() => disposable.AddWeakReference()); + } + + [Fact] + public void WeakReferenceTarget_ReturnsTarget() + { + var target = Disposable.Create(null); + var disposable = ReferenceCountedDisposable.Create(target); + var weakDisposable = disposable.AddWeakReference(); + Assert.Equal(target, weakDisposable.TryGetTarget()); + GC.KeepAlive(disposable); + } + + [Fact] + public void WeakReference_IsNotCounted() + { + var target = Disposable.Create(null); + var disposable = ReferenceCountedDisposable.Create(target); + var weakDisposable = disposable.AddWeakReference(); + disposable.Dispose(); + Assert.Null(weakDisposable.TryGetTarget()); + Assert.Null(weakDisposable.TryAddReference()); + GC.KeepAlive(disposable); + GC.KeepAlive(target); + } + + [Fact] + public void WeakReference_NotDisposed_CanIncrementCount() + { + var target = Disposable.Create(null); + var disposable = ReferenceCountedDisposable.Create(target); + var weakDisposable = disposable.AddWeakReference(); + var secondDisposable = weakDisposable.TryAddReference(); + Assert.NotNull(secondDisposable); + disposable.Dispose(); + Assert.NotNull(weakDisposable.TryGetTarget()); + Assert.False(target.IsDisposed); + secondDisposable.Dispose(); + Assert.Null(weakDisposable.TryGetTarget()); + Assert.Null(weakDisposable.TryAddReference()); + GC.KeepAlive(secondDisposable); + GC.KeepAlive(disposable); + GC.KeepAlive(target); + } + + [Fact] + public void CreateDerived_AfterBase_RefersToSameTarget() + { + var target = new DerivedDisposable(); + var baseTarget = target as BaseDisposable; + var baseDisposable = ReferenceCountedDisposable.Create(baseTarget); + var derivedDisposable = ReferenceCountedDisposable.Create(target); + Assert.Equal(baseDisposable.Target, derivedDisposable.Target); + } + + [Fact] + public void CreateBase_AfterDerived_RefersToSameTarget() + { + var target = new DerivedDisposable(); + var baseTarget = target as BaseDisposable; + var derivedDisposable = ReferenceCountedDisposable.Create(target); + var baseDisposable = ReferenceCountedDisposable.Create(baseTarget); + Assert.Equal(baseDisposable.Target, derivedDisposable.Target); + } + + [Fact] + public void GenericVariance_RefersToSameTarget() + { + var target = new DerivedDisposable(); + var derivedDisposable = ReferenceCountedDisposable.Create(target); + var baseDisposable = derivedDisposable as IReferenceCountedDisposable; + Assert.NotNull(baseDisposable); + Assert.Equal(baseDisposable.Target, derivedDisposable.Target); + } + + [Fact] + public void CastReferenceFromBaseToDerived_Fails() + { + var target = new DerivedDisposable(); + var baseTarget = target as BaseDisposable; + var baseDisposable = ReferenceCountedDisposable.Create(baseTarget); + var derivedDisposable = baseDisposable as IReferenceCountedDisposable; + Assert.Null(derivedDisposable); + } + + [Fact] + public void CastTargetFromBaseToDerived_Succeeds() + { + var target = new DerivedDisposable(); + var baseTarget = target as BaseDisposable; + var baseDisposable = ReferenceCountedDisposable.Create(baseTarget); + var derivedTarget = baseDisposable.Target as DerivedDisposable; + Assert.NotNull(derivedTarget); + Assert.Equal(derivedTarget, target); + } + + private sealed class UnsafeDisposable : IDisposable + { + public UnsafeDisposable(Action action) => _action = action; + + public void Dispose() => _action(); + + private readonly Action _action; + } + + private class BaseDisposable : IDisposable + { + public void Dispose() { } + } + + private class DerivedDisposable : BaseDisposable + { + } + } +}