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);
+ }
+}