Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ [EnumeratorCancellation] CancellationToken token
{
await foreach (var item in stream.ConfigureAwait(false))
{
// Stryker disable once Statement: equivalent in every reachable path — the pipeline head
// (EtlPipelineImpl.CountExtracted) always runs the same ThrowIfCancellationRequested one
// layer up (it is the sole source of this sink's stream), so removing this defence-in-depth
// check cannot change observable cancellation behaviour. The head guard itself IS tested
// directly — see EtlPipelineTests.AsAsyncEnumerable_honours_cancellation_at_the_head.
token.ThrowIfCancellationRequested();
state.RecordsLoaded++;
progress?.Report(state.Snapshot());
Expand Down
8 changes: 8 additions & 0 deletions src/Wolfgang.Etl.Abstractions/ExtractorBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -409,10 +409,14 @@ protected void IncrementCurrentSkippedItemCount()
// win the CompareExchange records the start; later calls are a cheap volatile read.
private void EnsureStarted()
{
// Stryker disable all: equivalent mutant — the CompareExchange below is the real guard, so
// whether this fast-path early-out (or its whole block) executes, a re-entrant caller that
// has already started changes nothing: the assignment only happens on the winning exchange.
if (Volatile.Read(ref _startTimestamp) != 0)
{
return;
}
// Stryker restore all

var now = DateTimeOffset.UtcNow;
var timestamp = Stopwatch.GetTimestamp();
Expand Down Expand Up @@ -461,6 +465,9 @@ public void Dispose()
/// <see langword="true"/> when called from <see cref="Dispose()"/> or <see cref="DisposeAsync"/>
/// (dispose managed resources); <see langword="false"/> when called from a finalizer.
/// </param>
// 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)
Expand All @@ -470,4 +477,5 @@ protected virtual void Dispose(bool disposing)

_disposed = true;
}
// Stryker restore all
}
8 changes: 8 additions & 0 deletions src/Wolfgang.Etl.Abstractions/LoaderBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -407,10 +407,14 @@ protected void IncrementCurrentSkippedItemCount()
// win the CompareExchange records the start; later calls are a cheap volatile read.
private void EnsureStarted()
{
// Stryker disable all: equivalent mutant — the CompareExchange below is the real guard, so
// whether this fast-path early-out (or its whole block) executes, a re-entrant caller that
// has already started changes nothing: the assignment only happens on the winning exchange.
if (Volatile.Read(ref _startTimestamp) != 0)
{
return;
}
// Stryker restore all

var now = DateTimeOffset.UtcNow;
var timestamp = Stopwatch.GetTimestamp();
Expand Down Expand Up @@ -459,6 +463,9 @@ public void Dispose()
/// <see langword="true"/> when called from <see cref="Dispose()"/> or <see cref="DisposeAsync"/>
/// (dispose managed resources); <see langword="false"/> when called from a finalizer.
/// </param>
// 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)
Expand All @@ -468,4 +475,5 @@ protected virtual void Dispose(bool disposing)

_disposed = true;
}
// Stryker restore all
}
4 changes: 4 additions & 0 deletions src/Wolfgang.Etl.Abstractions/Report.cs
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,10 @@ public TimeSpan? EstimatedRemaining
// Guard against TimeSpan.FromSeconds overflowing for a pathologically low
// rate (e.g. a single item after a very long elapsed time); clamp to TimeSpan.MaxValue.
var seconds = remaining / rate;
// Stryker disable once Equality: equivalent mutant — >= versus > differs only when
// seconds exactly equals TimeSpan.MaxValue.TotalSeconds. That is a computed double from
// (int remaining / positive rate); no clean test input lands on that exact boundary, and
// both branches clamp identically for every reachable value.
return seconds >= TimeSpan.MaxValue.TotalSeconds
? TimeSpan.MaxValue
: TimeSpan.FromSeconds(seconds);
Expand Down
8 changes: 8 additions & 0 deletions src/Wolfgang.Etl.Abstractions/TransformerBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -417,10 +417,14 @@ protected void IncrementCurrentSkippedItemCount()
// win the CompareExchange records the start; later calls are a cheap volatile read.
private void EnsureStarted()
{
// Stryker disable all: equivalent mutant — the CompareExchange below is the real guard, so
// whether this fast-path early-out (or its whole block) executes, a re-entrant caller that
// has already started changes nothing: the assignment only happens on the winning exchange.
if (Volatile.Read(ref _startTimestamp) != 0)
{
return;
}
// Stryker restore all

var now = DateTimeOffset.UtcNow;
var timestamp = Stopwatch.GetTimestamp();
Expand Down Expand Up @@ -468,6 +472,9 @@ public void Dispose()
/// <see langword="true"/> when called from <see cref="Dispose()"/> or <see cref="DisposeAsync"/>
/// (dispose managed resources); <see langword="false"/> when called from a finalizer.
/// </param>
// 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)
Expand All @@ -477,4 +484,5 @@ protected virtual void Dispose(bool disposing)

_disposed = true;
}
// Stryker restore all
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
using Wolfgang.Etl.Abstractions.Tests.Unit.Models;

namespace Wolfgang.Etl.Abstractions.Tests.Unit;

/// <summary>
/// Verifies that the library never marshals a continuation back onto the caller's
/// <see cref="SynchronizationContext"/> — i.e. every internal await uses
/// <c>ConfigureAwait(false)</c>. This is the behaviour that keeps the library safe from the classic
/// sync-over-async deadlock and from needless context-hopping for WPF / WinForms / legacy-ASP.NET
/// consumers, and it is invisible to a normal headless test (which has no context), so it needs an
/// explicit context installed to exercise it.
///
/// <para>Each test installs a counting context, starts the operation while it is current (so the
/// awaits that suspend on that synchronous stack capture it under a <c>ConfigureAwait(true)</c>
/// mutation), then awaits the result off-context and asserts nothing was posted back. The test
/// doubles are all context-agnostic (their own awaits use <c>ConfigureAwait(false)</c>), so the only
/// thing that can post to the context is the library code under test.</para>
/// </summary>
public sealed class ConfigureAwaitContextTests
{
[Fact]
public Task EtlPipeline_streaming_run_does_not_capture_the_context()
=> AssertDoesNotCaptureContext(() => EtlPipeline
.Create()
.From(SuspendingSource(3))
.To(new ContextAgnosticLoader())
.RunAsync());


[Fact]
public Task DisposingOwned_streaming_run_does_not_capture_the_context()
=> AssertDoesNotCaptureContext(() => EtlPipeline
.Create()
.From(SuspendingSource(3))
.To(new ContextAgnosticLoader())
.DisposingOwned(new SuspendingAsyncDisposable())
.RunAsync());


[Fact]
public Task DisposingOwned_resource_cleanup_does_not_capture_the_context()
// Synchronous run keeps execution on the context thread so the suspending owned-resource
// disposal is the await that would capture it.
=> AssertDoesNotCaptureContext(() => EtlPipeline
.Create()
.From(SynchronousSource(2))
.To(new ContextAgnosticLoader())
.DisposingOwned(new SuspendingAsyncDisposable())
.RunAsync());


[Fact]
public Task LoaderBase_progress_run_does_not_capture_the_context()
=> AssertDoesNotCaptureContext(() => new ContextAgnosticLoader()
.LoadAsync(SuspendingSource(3), new NoOpProgress<EtlProgress>()));


[Fact]
public Task Fluent_pipeline_streaming_run_does_not_capture_the_context()
=> AssertDoesNotCaptureContext(() => Pipeline
.Extract(new SuspendingExtractor(3))
.Load(new ContextAgnosticListLoader())
.DisposeStagesOnCompletion()
.RunAsync());


[Fact]
public Task Fluent_pipeline_stage_disposal_does_not_capture_the_context()
// Synchronous run + a suspending IAsyncDisposable stage → the disposal await is the one that
// would capture the context.
=> AssertDoesNotCaptureContext(() => Pipeline
.Extract(new SuspendingAsyncDisposableExtractor(2))
.Load(new ContextAgnosticListLoader())
.DisposeStagesOnCompletion()
.RunAsync());


// Starts the operation while a counting context is current on this thread, restores the previous
// context, then awaits off-context and asserts the library posted nothing back. A ConfigureAwait
// flip to (true) on any await that suspended while the context was current posts a continuation
// to it and trips the assertion.
private static async Task AssertDoesNotCaptureContext(Func<Task> start)
{
var context = new CountingSynchronizationContext();
var previous = SynchronizationContext.Current;
SynchronizationContext.SetSynchronizationContext(context);

Task run;
try
{
run = start();
}
finally
{
SynchronizationContext.SetSynchronizationContext(previous);
}

await run.ConfigureAwait(false);

Assert.Equal(0, context.Posts);
}


// Suspends between items (so the library's awaits configure real continuations) but never
// captures the ambient context itself.
private static async IAsyncEnumerable<int> SuspendingSource(int count)
{
for (var i = 0; i < count; i++)
{
await Task.Delay(1).ConfigureAwait(false);
yield return i;
}
}


// Completes synchronously — keeps the run on the context thread so a suspending disposal await
// is the one under test.
private static async IAsyncEnumerable<int> SynchronousSource(int count)
{
for (var i = 0; i < count; i++)
{
yield return i;
}

await Task.CompletedTask;
}


[ExcludeFromCodeCoverage]
private sealed class CountingSynchronizationContext : SynchronizationContext
{
private int _posts;

public int Posts => Volatile.Read(ref _posts);

public override void Post(SendOrPostCallback d, object? state)
{
Interlocked.Increment(ref _posts);
ThreadPool.QueueUserWorkItem(_ => d(state));
}

public override void Send(SendOrPostCallback d, object? state)
{
Interlocked.Increment(ref _posts);
d(state);
}
}


[ExcludeFromCodeCoverage]
private sealed class NoOpProgress<T> : IProgress<T>
{
public void Report(T value)
{
}
}


[ExcludeFromCodeCoverage]
private sealed class ContextAgnosticLoader : LoaderBase<int, EtlProgress>
{
protected override async Task LoadWorkerAsync(IAsyncEnumerable<int> items, CancellationToken token)
{
await foreach (var item in items.WithCancellation(token).ConfigureAwait(false))
{
_ = item;
IncrementCurrentItemCount();
}
}

protected override EtlProgress CreateProgressReport() => new(CurrentItemCount);
}


[ExcludeFromCodeCoverage]
private sealed class ContextAgnosticListLoader : ILoadAsync<int>
{
public async Task LoadAsync(IAsyncEnumerable<int> items)
{
await foreach (var item in items.ConfigureAwait(false))
{
_ = item;
}
}
}


[ExcludeFromCodeCoverage]
private sealed class SuspendingExtractor : IExtractAsync<int>
{
private readonly int _count;

public SuspendingExtractor(int count) => _count = count;

public async IAsyncEnumerable<int> ExtractAsync()
{
for (var i = 0; i < _count; i++)
{
await Task.Delay(1).ConfigureAwait(false);
yield return i;
}
}
}


[ExcludeFromCodeCoverage]
private sealed class SuspendingAsyncDisposableExtractor : IExtractAsync<int>, IAsyncDisposable
{
private readonly int _count;

public SuspendingAsyncDisposableExtractor(int count) => _count = count;

public async IAsyncEnumerable<int> ExtractAsync()
{
for (var i = 0; i < _count; i++)
{
yield return i;
}

await Task.CompletedTask;
}

public async ValueTask DisposeAsync() => await Task.Delay(1).ConfigureAwait(false);
}


[ExcludeFromCodeCoverage]
private sealed class SuspendingAsyncDisposable : IAsyncDisposable
{
public async ValueTask DisposeAsync() => await Task.Delay(1).ConfigureAwait(false);
}
}
Loading
Loading