diff --git a/src/Wolfgang.Etl.Abstractions/ExtractorBase.cs b/src/Wolfgang.Etl.Abstractions/ExtractorBase.cs index 652112c0..b176d6e7 100644 --- a/src/Wolfgang.Etl.Abstractions/ExtractorBase.cs +++ b/src/Wolfgang.Etl.Abstractions/ExtractorBase.cs @@ -215,6 +215,7 @@ public int SkipItemCount /// public virtual IAsyncEnumerable ExtractAsync() { + ThrowIfDisposed(); return ExtractWithResetAsync(CancellationToken.None); } @@ -223,6 +224,7 @@ public virtual IAsyncEnumerable ExtractAsync() /// public virtual IAsyncEnumerable ExtractAsync(CancellationToken token) { + ThrowIfDisposed(); return ExtractWithResetAsync(token); } @@ -231,6 +233,7 @@ public virtual IAsyncEnumerable ExtractAsync(CancellationToken token) /// public virtual IAsyncEnumerable ExtractAsync(IProgress progress) { + ThrowIfDisposed(); #if NET6_0_OR_GREATER ArgumentNullException.ThrowIfNull(progress); #else @@ -250,6 +253,7 @@ public virtual IAsyncEnumerable ExtractAsync(IProgress progr /// public virtual IAsyncEnumerable ExtractAsync(IProgress progress, CancellationToken token) { + ThrowIfDisposed(); #if NET6_0_OR_GREATER ArgumentNullException.ThrowIfNull(progress); #else @@ -465,17 +469,30 @@ public void Dispose() /// when called from or /// (dispose managed resources); when called from a finalizer. /// - // Stryker disable all: equivalent mutant — Dispose(bool) has an inert base body: _disposed has - // no other reader (nothing throws ObjectDisposedException). Removing the whole body, negating the - // guard, or dropping the assignment is all unobservable; derived overrides supply real behaviour. protected virtual void Dispose(bool disposing) { if (_disposed) + // Stryker disable once all: equivalent — dropping this guard block only skips a redundant, + // idempotent re-assignment of _disposed. The guard's negation and the assignment below are + // real and killable (covered by the use-after-dispose tests). { + // Stryker disable once all: equivalent — same reasoning; skipping the early return just + // re-runs the idempotent `_disposed = true`. return; } _disposed = true; } - // Stryker restore all + + + // Throws if this extractor has already been disposed. Reads _disposed, so the public entry + // points reject use-after-dispose (and give the Dispose(bool) idempotency guard an observable + // effect). + private void ThrowIfDisposed() + { + if (_disposed) + { + throw new ObjectDisposedException(GetType().FullName); + } + } } diff --git a/src/Wolfgang.Etl.Abstractions/LoaderBase.cs b/src/Wolfgang.Etl.Abstractions/LoaderBase.cs index 0655245d..ed037e7c 100644 --- a/src/Wolfgang.Etl.Abstractions/LoaderBase.cs +++ b/src/Wolfgang.Etl.Abstractions/LoaderBase.cs @@ -186,6 +186,7 @@ public int SkipItemCount /// public virtual Task LoadAsync(IAsyncEnumerable items) { + ThrowIfDisposed(); #if NET6_0_OR_GREATER ArgumentNullException.ThrowIfNull(items); #else @@ -204,6 +205,7 @@ public virtual Task LoadAsync(IAsyncEnumerable items) /// public virtual Task LoadAsync(IAsyncEnumerable items, CancellationToken token) { + ThrowIfDisposed(); #if NET6_0_OR_GREATER ArgumentNullException.ThrowIfNull(items); #else @@ -222,6 +224,7 @@ public virtual Task LoadAsync(IAsyncEnumerable items, Cancellation /// public virtual Task LoadAsync(IAsyncEnumerable items, IProgress progress) { + ThrowIfDisposed(); #if NET6_0_OR_GREATER ArgumentNullException.ThrowIfNull(items); ArgumentNullException.ThrowIfNull(progress); @@ -246,6 +249,7 @@ public virtual Task LoadAsync(IAsyncEnumerable items, IProgress public virtual Task LoadAsync(IAsyncEnumerable items, IProgress progress, CancellationToken token) { + ThrowIfDisposed(); #if NET6_0_OR_GREATER ArgumentNullException.ThrowIfNull(items); ArgumentNullException.ThrowIfNull(progress); @@ -463,17 +467,29 @@ public void Dispose() /// when called from or /// (dispose managed resources); when called from a finalizer. /// - // Stryker disable all: equivalent mutant — Dispose(bool) has an inert base body: _disposed has - // no other reader (nothing throws ObjectDisposedException). Removing the whole body, negating the - // guard, or dropping the assignment is all unobservable; derived overrides supply real behaviour. protected virtual void Dispose(bool disposing) { if (_disposed) + // Stryker disable once all: equivalent — dropping this guard block only skips a redundant, + // idempotent re-assignment of _disposed. The guard's negation and the assignment below are + // real and killable (covered by the use-after-dispose tests). { + // Stryker disable once all: equivalent — same reasoning; skipping the early return just + // re-runs the idempotent `_disposed = true`. return; } _disposed = true; } - // Stryker restore all + + + // Throws if this loader has already been disposed. Reads _disposed, so the public entry points + // reject use-after-dispose (and give the Dispose(bool) idempotency guard an observable effect). + private void ThrowIfDisposed() + { + if (_disposed) + { + throw new ObjectDisposedException(GetType().FullName); + } + } } diff --git a/src/Wolfgang.Etl.Abstractions/TransformerBase.cs b/src/Wolfgang.Etl.Abstractions/TransformerBase.cs index d78cc6b7..c6979007 100644 --- a/src/Wolfgang.Etl.Abstractions/TransformerBase.cs +++ b/src/Wolfgang.Etl.Abstractions/TransformerBase.cs @@ -191,6 +191,7 @@ public int SkipItemCount /// public virtual IAsyncEnumerable TransformAsync(IAsyncEnumerable items) { + ThrowIfDisposed(); #if NET6_0_OR_GREATER ArgumentNullException.ThrowIfNull(items); #else @@ -209,6 +210,7 @@ public virtual IAsyncEnumerable TransformAsync(IAsyncEnumerable public virtual IAsyncEnumerable TransformAsync(IAsyncEnumerable items, CancellationToken token) { + ThrowIfDisposed(); #if NET6_0_OR_GREATER ArgumentNullException.ThrowIfNull(items); #else @@ -227,6 +229,7 @@ public virtual IAsyncEnumerable TransformAsync(IAsyncEnumerable public virtual IAsyncEnumerable TransformAsync(IAsyncEnumerable items, IProgress progress) { + ThrowIfDisposed(); #if NET6_0_OR_GREATER ArgumentNullException.ThrowIfNull(items); ArgumentNullException.ThrowIfNull(progress); @@ -251,6 +254,7 @@ public virtual IAsyncEnumerable TransformAsync(IAsyncEnumerable public virtual IAsyncEnumerable TransformAsync(IAsyncEnumerable items, IProgress progress, CancellationToken token) { + ThrowIfDisposed(); #if NET6_0_OR_GREATER ArgumentNullException.ThrowIfNull(items); ArgumentNullException.ThrowIfNull(progress); @@ -472,17 +476,30 @@ public void Dispose() /// when called from or /// (dispose managed resources); when called from a finalizer. /// - // Stryker disable all: equivalent mutant — Dispose(bool) has an inert base body: _disposed has - // no other reader (nothing throws ObjectDisposedException). Removing the whole body, negating the - // guard, or dropping the assignment is all unobservable; derived overrides supply real behaviour. protected virtual void Dispose(bool disposing) { if (_disposed) + // Stryker disable once all: equivalent — dropping this guard block only skips a redundant, + // idempotent re-assignment of _disposed. The guard's negation and the assignment below are + // real and killable (covered by the use-after-dispose tests). { + // Stryker disable once all: equivalent — same reasoning; skipping the early return just + // re-runs the idempotent `_disposed = true`. return; } _disposed = true; } - // Stryker restore all + + + // Throws if this transformer has already been disposed. Reads _disposed, so the public entry + // points reject use-after-dispose (and give the Dispose(bool) idempotency guard an observable + // effect). + private void ThrowIfDisposed() + { + if (_disposed) + { + throw new ObjectDisposedException(GetType().FullName); + } + } } diff --git a/tests/Wolfgang.Etl.Abstractions.Tests.Unit/DisposedGuardTests.cs b/tests/Wolfgang.Etl.Abstractions.Tests.Unit/DisposedGuardTests.cs new file mode 100644 index 00000000..556dd6ce --- /dev/null +++ b/tests/Wolfgang.Etl.Abstractions.Tests.Unit/DisposedGuardTests.cs @@ -0,0 +1,172 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Wolfgang.Etl.Abstractions.Tests.Unit.Models; + +namespace Wolfgang.Etl.Abstractions.Tests.Unit; + +/// +/// Covers the dispose contract added to the base classes: once disposed, the public entry points +/// throw , and Dispose is idempotent. This gives the +/// Dispose(bool) idempotency guard (if (_disposed) …; _disposed = true;) an observable +/// effect — a mutation that negates the guard, drops its block, or skips the assignment leaves the +/// instance "not disposed" and the throw never happens. +/// +public sealed class DisposedGuardTests +{ + private static async IAsyncEnumerable Empty() + { + await Task.CompletedTask; + yield break; + } + + + private static readonly IProgress Progress = new NoOpProgress(); + + + [Fact] + public void LoaderBase_every_LoadAsync_overload_throws_after_Dispose() + { + var loader = new NoOpLoader(); + loader.Dispose(); + + Assert.Throws(() => { _ = loader.LoadAsync(Empty()); }); + Assert.Throws(() => { _ = loader.LoadAsync(Empty(), CancellationToken.None); }); + Assert.Throws(() => { _ = loader.LoadAsync(Empty(), Progress); }); + Assert.Throws(() => { _ = loader.LoadAsync(Empty(), Progress, CancellationToken.None); }); + } + + + [Fact] + public async Task LoaderBase_LoadAsync_throws_after_DisposeAsync() + { + var loader = new NoOpLoader(); + await loader.DisposeAsync(); + + Assert.Throws(() => { _ = loader.LoadAsync(Empty()); }); + } + + + [Fact] + public void ExtractorBase_every_ExtractAsync_overload_throws_after_Dispose() + { + var extractor = new NoOpExtractor(); + extractor.Dispose(); + + Assert.Throws(() => extractor.ExtractAsync()); + Assert.Throws(() => extractor.ExtractAsync(CancellationToken.None)); + Assert.Throws(() => extractor.ExtractAsync(Progress)); + Assert.Throws(() => extractor.ExtractAsync(Progress, CancellationToken.None)); + } + + + [Fact] + public async Task ExtractorBase_ExtractAsync_throws_after_DisposeAsync() + { + var extractor = new NoOpExtractor(); + await extractor.DisposeAsync(); + + Assert.Throws(() => extractor.ExtractAsync()); + } + + + [Fact] + public void TransformerBase_every_TransformAsync_overload_throws_after_Dispose() + { + var transformer = new NoOpTransformer(); + transformer.Dispose(); + + Assert.Throws(() => transformer.TransformAsync(Empty())); + Assert.Throws(() => transformer.TransformAsync(Empty(), CancellationToken.None)); + Assert.Throws(() => transformer.TransformAsync(Empty(), Progress)); + Assert.Throws(() => transformer.TransformAsync(Empty(), Progress, CancellationToken.None)); + } + + + [Fact] + public async Task TransformerBase_TransformAsync_throws_after_DisposeAsync() + { + var transformer = new NoOpTransformer(); + await transformer.DisposeAsync(); + + Assert.Throws(() => transformer.TransformAsync(Empty())); + } + + + [Fact] + public void Dispose_is_idempotent() + { + var loader = new NoOpLoader(); + + loader.Dispose(); + var second = Record.Exception(() => loader.Dispose()); + + Assert.Null(second); + } + + + [Fact] + public void A_live_component_does_not_throw() + { + // Guards against a mutant that makes ThrowIfDisposed always throw (or the guard being + // inverted): a freshly-constructed, undisposed loader must accept a call. + var loader = new NoOpLoader(); + + var exception = Record.Exception(() => { _ = loader.LoadAsync(Empty()); }); + + Assert.Null(exception); + } + + + [ExcludeFromCodeCoverage] + private sealed class NoOpProgress : IProgress + { + public void Report(EtlProgress value) + { + } + } + + + [ExcludeFromCodeCoverage] + private sealed class NoOpLoader : LoaderBase + { + protected override Task LoadWorkerAsync(IAsyncEnumerable items, CancellationToken token) + => Task.CompletedTask; + + protected override EtlProgress CreateProgressReport() => new(CurrentItemCount); + } + + + [ExcludeFromCodeCoverage] + private sealed class NoOpExtractor : ExtractorBase + { +#pragma warning disable CS1998 // async iterator with no yielded items is intentional + protected override async IAsyncEnumerable ExtractWorkerAsync( + [EnumeratorCancellation] CancellationToken token) + { + yield break; + } +#pragma warning restore CS1998 + + protected override EtlProgress CreateProgressReport() => new(CurrentItemCount); + } + + + [ExcludeFromCodeCoverage] + private sealed class NoOpTransformer : TransformerBase + { +#pragma warning disable CS1998 // async iterator with no yielded items is intentional + protected override async IAsyncEnumerable TransformWorkerAsync( + IAsyncEnumerable items, + [EnumeratorCancellation] CancellationToken token) + { + yield break; + } +#pragma warning restore CS1998 + + protected override EtlProgress CreateProgressReport() => new(CurrentItemCount); + } +}