From f2a09759247f3cb4008622d5fc883d9e3a71d71a Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Wed, 22 Jul 2026 21:19:14 -0400 Subject: [PATCH] test: concurrency / race-condition stress testing with Coyote (#207) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds tests/Wolfgang.Etl.Abstractions.Tests.Concurrency + .github/workflows/coyote.yaml. Microsoft Coyote rewrites the assemblies to control the task scheduler and replays each test under thousands of distinct interleavings, surfacing races/deadlocks a normal single-schedule run never hits. `coyote test` exits non-zero on a found bug, so it is an enforced gate. Two systematic tests over the base-class concurrency: - concurrent per-item counter increments never lose an update (the counter is Interlocked — a non-atomic ++ would surface as a lost update on some schedule); - DisposeAsync racing an in-flight enumeration never deadlocks. Both explore ~1600 unique execution paths locally with 0 bugs found. The project targets net8.0 because the Coyote 1.7.x CLI cannot load net10.0 assemblies (it predates that runtime); the concurrency explored is runtime-agnostic. coyote.yaml gates PRs touching src/** (1000 iterations/test) and runs the deep exploration weekly + on dispatch (10000 iterations/test). The optional 24h soak (leak hunting via dotnet-counters) is NOT implemented — it needs a persistent self-hosted runner this project lacks; documented in the workflow header for when one exists. Closes #207 Co-Authored-By: Claude Opus 4.8 --- .github/workflows/coyote.yaml | 87 ++++++++++++++ .../ConcurrencyTests.cs | 110 ++++++++++++++++++ ....Etl.Abstractions.Tests.Concurrency.csproj | 28 +++++ .../coyote.json | 7 ++ 4 files changed, 232 insertions(+) create mode 100644 .github/workflows/coyote.yaml create mode 100644 tests/Wolfgang.Etl.Abstractions.Tests.Concurrency/ConcurrencyTests.cs create mode 100644 tests/Wolfgang.Etl.Abstractions.Tests.Concurrency/Wolfgang.Etl.Abstractions.Tests.Concurrency.csproj create mode 100644 tests/Wolfgang.Etl.Abstractions.Tests.Concurrency/coyote.json diff --git a/.github/workflows/coyote.yaml b/.github/workflows/coyote.yaml new file mode 100644 index 00000000..351a2408 --- /dev/null +++ b/.github/workflows/coyote.yaml @@ -0,0 +1,87 @@ +name: Concurrency (Coyote) + +# Systematic concurrency / race-condition testing (#207) with Microsoft Coyote. +# Coyote rewrites the assemblies to control the task scheduler, then replays each +# test under thousands of distinct interleavings — surfacing races/deadlocks that a +# normal single-schedule run never hits. `coyote test` exits non-zero on a found +# bug, so this is an enforced gate. +# +# The Coyote project targets net8.0: the Coyote 1.7.x CLI cannot load net10.0 +# assemblies (it predates that runtime), and the concurrency being explored is +# runtime-agnostic. Triggers: +# - pull_request touching src/** or the concurrency project — a lighter run +# (1000 iterations/test) gates races before merge. +# - schedule (weekly) + workflow_dispatch — the deep run (10000 iterations/test). +# +# NOT implemented: the optional 24h soak (handle/thread/memory-leak hunting via +# dotnet-counters). It requires a persistent self-hosted runner, which this project +# does not have; add it here behind a `runs-on: [self-hosted]` job when one exists. + +on: + pull_request: + branches: + - main + - vNext + paths: + - 'src/**/*.cs' + - 'tests/Wolfgang.Etl.Abstractions.Tests.Concurrency/**' + - '.github/workflows/coyote.yaml' + schedule: + - cron: '0 5 * * 1' # weekly Monday 05:00 UTC + workflow_dispatch: + +permissions: + contents: read + +jobs: + coyote: + name: Coyote systematic exploration + runs-on: ubuntu-latest + timeout-minutes: 30 + env: + PROJECT: tests/Wolfgang.Etl.Abstractions.Tests.Concurrency + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: Setup .NET + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6 + with: + dotnet-version: | + 8.0.x + 10.0.x + + - name: Build the concurrency project (Release) + run: dotnet build "$PROJECT/Wolfgang.Etl.Abstractions.Tests.Concurrency.csproj" -c Release + + - name: Install Coyote CLI + run: | + dotnet tool install --global Microsoft.Coyote.CLI --version 1.7.11 + echo "$HOME/.dotnet/tools" >> "$GITHUB_PATH" + + - name: Rewrite assemblies for the controlled scheduler + working-directory: ${{ env.PROJECT }} + run: coyote rewrite coyote.json + + - name: Run Coyote systematic tests + working-directory: ${{ env.PROJECT }} + run: | + set -euo pipefail + # Deep run on schedule/dispatch; a lighter gate on PRs to keep them fast. + if [ "${{ github.event_name }}" = "pull_request" ]; then + iterations=1000 + else + iterations=10000 + fi + dll="bin/Release/net8.0/Wolfgang.Etl.Abstractions.Tests.Concurrency.dll" + methods=( + Concurrent_item_count_increments_never_lose_an_update + Dispose_racing_enumeration_never_deadlocks + ) + for m in "${methods[@]}"; do + echo "::group::coyote test $m ($iterations iterations)" + coyote test "$dll" --method "$m" --iterations "$iterations" + echo "::endgroup::" + done diff --git a/tests/Wolfgang.Etl.Abstractions.Tests.Concurrency/ConcurrencyTests.cs b/tests/Wolfgang.Etl.Abstractions.Tests.Concurrency/ConcurrencyTests.cs new file mode 100644 index 00000000..5df5a400 --- /dev/null +++ b/tests/Wolfgang.Etl.Abstractions.Tests.Concurrency/ConcurrencyTests.cs @@ -0,0 +1,110 @@ +using System.Runtime.CompilerServices; +using Microsoft.Coyote.Specifications; +using Microsoft.Coyote.SystematicTesting; + +namespace Wolfgang.Etl.Abstractions.Tests.Concurrency; + +/// +/// Concurrency / race-condition stress tests (#207) driven by Microsoft Coyote's +/// systematic scheduler. Each [Test] method is replayed under thousands of +/// distinct task interleavings by coyote test (see +/// .github/workflows/coyote.yaml); a race, deadlock, or assertion violation +/// on any explored schedule fails the run. +/// +public static class ConcurrencyTests +{ + /// + /// Two tasks concurrently drive the base class's per-item counter. Under every + /// interleaving Coyote explores, no increment may be lost — the counter is an + /// operation. A non-atomic ++ + /// would surface here as a lost update on some schedule. + /// + [Microsoft.Coyote.SystematicTesting.Test] + public static async Task Concurrent_item_count_increments_never_lose_an_update() + { + var harness = new CounterHarness(); + const int perTask = 5; + + var first = Task.Run(() => + { + for (var i = 0; i < perTask; i++) + { + harness.Bump(); + } + }); + + var second = Task.Run(() => + { + for (var i = 0; i < perTask; i++) + { + harness.Bump(); + } + }); + + await Task.WhenAll(first, second); + + Specification.Assert( + harness.Count == 2 * perTask, + $"Lost update under a concurrent schedule: expected {2 * perTask}, got {harness.Count}."); + } + + + /// + /// Races against an in-flight + /// enumeration of the same extractor. Under every interleaving the run must + /// terminate — no deadlock, and no unobserved exception other than the expected + /// cancellation/disposal signals. + /// + [Microsoft.Coyote.SystematicTesting.Test] + public static async Task Dispose_racing_enumeration_never_deadlocks() + { + var extractor = new CounterHarness(); + + var consume = Task.Run(async () => + { + try + { + await foreach (var _ in extractor.ExtractAsync()) + { + // drain + } + } + catch (ObjectDisposedException) + { + // acceptable outcome of racing disposal + } + catch (OperationCanceledException) + { + // acceptable outcome of racing disposal + } + }); + + var dispose = Task.Run(async () => await extractor.DisposeAsync()); + + await Task.WhenAll(consume, dispose); + // Reaching here means neither task deadlocked on any explored schedule. + } + + + // Surfaces the protected per-item counter for the increment race, and provides a + // trivial async source for the dispose race. + private sealed class CounterHarness : ExtractorBase + { + public void Bump() => IncrementCurrentItemCount(); + + public int Count => CurrentItemCount; + + protected override async IAsyncEnumerable ExtractWorkerAsync( + [EnumeratorCancellation] CancellationToken token) + { + for (var i = 0; i < 4; i++) + { + token.ThrowIfCancellationRequested(); + yield return i; + await Task.Yield(); + } + } + + protected override Report CreateProgressReport() => new(CurrentItemCount); + } +} diff --git a/tests/Wolfgang.Etl.Abstractions.Tests.Concurrency/Wolfgang.Etl.Abstractions.Tests.Concurrency.csproj b/tests/Wolfgang.Etl.Abstractions.Tests.Concurrency/Wolfgang.Etl.Abstractions.Tests.Concurrency.csproj new file mode 100644 index 00000000..6deee838 --- /dev/null +++ b/tests/Wolfgang.Etl.Abstractions.Tests.Concurrency/Wolfgang.Etl.Abstractions.Tests.Concurrency.csproj @@ -0,0 +1,28 @@ + + + + + net8.0 + latest + enable + enable + false + Library + + + + + + + + + + + + diff --git a/tests/Wolfgang.Etl.Abstractions.Tests.Concurrency/coyote.json b/tests/Wolfgang.Etl.Abstractions.Tests.Concurrency/coyote.json new file mode 100644 index 00000000..7423a5df --- /dev/null +++ b/tests/Wolfgang.Etl.Abstractions.Tests.Concurrency/coyote.json @@ -0,0 +1,7 @@ +{ + "AssembliesPath": "bin/Release/net8.0", + "Assemblies": [ + "Wolfgang.Etl.Abstractions.Tests.Concurrency.dll", + "Wolfgang.Etl.Abstractions.dll" + ] +}