diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml
index ba4f3d7..f70240b 100644
--- a/.github/workflows/dotnet.yml
+++ b/.github/workflows/dotnet.yml
@@ -28,7 +28,11 @@ jobs:
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
- dotnet-version: 9.0.x
+ # 10.0.x runs the pipeline; 8.0.x and 9.0.x provide runtimes for the multi-targeted test project
+ dotnet-version: |
+ 8.0.x
+ 9.0.x
+ 10.0.x
- name: Run Pipeline
run: dotnet run -c Release
working-directory: "EnumerableAsyncProcessor.Pipeline"
diff --git a/.gitignore b/.gitignore
index 377bb11..5313adb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -351,3 +351,5 @@ MigrationBackup/
# Claude local settings
.claude/settings.local.json
+
+.playwright-mcp/
diff --git a/EnumerableAsyncProcessor.Pipeline/EnumerableAsyncProcessor.Pipeline.csproj b/EnumerableAsyncProcessor.Pipeline/EnumerableAsyncProcessor.Pipeline.csproj
index 4e79a78..7686c41 100644
--- a/EnumerableAsyncProcessor.Pipeline/EnumerableAsyncProcessor.Pipeline.csproj
+++ b/EnumerableAsyncProcessor.Pipeline/EnumerableAsyncProcessor.Pipeline.csproj
@@ -2,15 +2,20 @@
Exe
- net9.0
+ net10.0
enable
enable
false
-
-
+
+
+
+
+
+
+
diff --git a/EnumerableAsyncProcessor.Pipeline/Modules/LocalMachine/AddLocalNugetSourceModule.cs b/EnumerableAsyncProcessor.Pipeline/Modules/LocalMachine/AddLocalNugetSourceModule.cs
index 0630a59..4dd5110 100644
--- a/EnumerableAsyncProcessor.Pipeline/Modules/LocalMachine/AddLocalNugetSourceModule.cs
+++ b/EnumerableAsyncProcessor.Pipeline/Modules/LocalMachine/AddLocalNugetSourceModule.cs
@@ -1,4 +1,5 @@
-using ModularPipelines.Attributes;
+using ModularPipelines.Attributes;
+using ModularPipelines.Configuration;
using ModularPipelines.Context;
using ModularPipelines.DotNet.Extensions;
using ModularPipelines.DotNet.Options;
@@ -11,21 +12,24 @@ namespace EnumerableAsyncProcessor.Pipeline.Modules.LocalMachine;
[DependsOn]
public class AddLocalNugetSourceModule : Module
{
- protected override async Task ShouldIgnoreFailures(IPipelineContext context, Exception exception)
+ protected override ModuleConfiguration Configure()
{
- await Task.Yield();
- return exception is CommandException commandException &&
- commandException.StandardOutput.Contains("The name specified has already been added to the list of available package sources");
+ return ModuleConfiguration.Create()
+ .WithIgnoreFailuresWhen((_, exception) =>
+ exception is CommandException commandException &&
+ commandException.StandardOutput.Contains("The name specified has already been added to the list of available package sources"))
+ .Build();
}
- protected override async Task ExecuteAsync(IPipelineContext context, CancellationToken cancellationToken)
+ protected override async Task ExecuteAsync(IModuleContext context, CancellationToken cancellationToken)
{
- var localNugetPathResult = await GetModule();
+ var localNugetPathResult = await context.GetModule();
return await context.DotNet().Nuget.Add
- .Source(new DotNetNugetAddSourceOptions(localNugetPathResult.Value!)
+ .Source(new DotNetNugetAddSourceOptions
{
+ Packagesourcepath = localNugetPathResult.ValueOrDefault!.Path,
Name = "ModularPipelinesLocalNuGet"
- }, cancellationToken);
+ }, cancellationToken: cancellationToken);
}
}
diff --git a/EnumerableAsyncProcessor.Pipeline/Modules/LocalMachine/CreateLocalNugetFolderModule.cs b/EnumerableAsyncProcessor.Pipeline/Modules/LocalMachine/CreateLocalNugetFolderModule.cs
index 38ce560..7fde8cc 100644
--- a/EnumerableAsyncProcessor.Pipeline/Modules/LocalMachine/CreateLocalNugetFolderModule.cs
+++ b/EnumerableAsyncProcessor.Pipeline/Modules/LocalMachine/CreateLocalNugetFolderModule.cs
@@ -1,4 +1,4 @@
-using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging;
using ModularPipelines.Attributes;
using ModularPipelines.Context;
using ModularPipelines.FileSystem;
@@ -10,17 +10,16 @@ namespace EnumerableAsyncProcessor.Pipeline.Modules.LocalMachine;
[DependsOn]
public class CreateLocalNugetFolderModule : Module
{
- protected override async Task ExecuteAsync(IPipelineContext context, CancellationToken cancellationToken)
+ protected override Task ExecuteAsync(IModuleContext context, CancellationToken cancellationToken)
{
- var localNugetRepositoryFolder = context.FileSystem.GetFolder(Environment.SpecialFolder.ApplicationData)
+ var localNugetRepositoryFolder = context.Files
+ .GetFolder(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData))
.GetFolder("ModularPipelines")
.GetFolder("LocalNuget")
.Create();
-
- await Task.Yield();
context.Logger.LogInformation("Local NuGet Repository Path: {Path}", localNugetRepositoryFolder.Path);
- return localNugetRepositoryFolder;
+ return Task.FromResult(localNugetRepositoryFolder);
}
}
diff --git a/EnumerableAsyncProcessor.Pipeline/Modules/LocalMachine/UploadPackagesToLocalNuGetModule.cs b/EnumerableAsyncProcessor.Pipeline/Modules/LocalMachine/UploadPackagesToLocalNuGetModule.cs
index 03c0bee..9cd638b 100644
--- a/EnumerableAsyncProcessor.Pipeline/Modules/LocalMachine/UploadPackagesToLocalNuGetModule.cs
+++ b/EnumerableAsyncProcessor.Pipeline/Modules/LocalMachine/UploadPackagesToLocalNuGetModule.cs
@@ -14,26 +14,31 @@ namespace EnumerableAsyncProcessor.Pipeline.Modules.LocalMachine;
[DependsOn]
public class UploadPackagesToLocalNuGetModule : Module
{
- protected override async Task OnBeforeExecute(IPipelineContext context)
+ protected override async Task OnBeforeExecuteAsync(IModuleContext context, CancellationToken cancellationToken)
{
- var packagePaths = await GetModule();
- foreach (var packagePath in packagePaths.Value!)
+ var packagePaths = await context.GetModule();
+
+ foreach (var packagePath in packagePaths.ValueOrDefault ?? [])
{
context.Logger.LogInformation("[Local Directory] Uploading {File}", packagePath);
}
- await base.OnBeforeExecute(context);
+ await base.OnBeforeExecuteAsync(context, cancellationToken);
}
- protected override async Task ExecuteAsync(IPipelineContext context, CancellationToken cancellationToken)
+ protected override async Task ExecuteAsync(IModuleContext context, CancellationToken cancellationToken)
{
- var localRepoLocation = await GetModule();
- var packagePaths = await GetModule();
- return await packagePaths.Value!.SelectAsync(async file => await context.DotNet()
- .Nuget
- .Push(new DotNetNugetPushOptions(file)
- {
- Source = localRepoLocation.Value!,
- }, cancellationToken), cancellationToken: cancellationToken).ProcessOneAtATime();
+ var localRepoLocation = await context.GetModule();
+ var packagePaths = await context.GetModule();
+
+ return await packagePaths.ValueOrDefault!
+ .SelectAsync(file => context.DotNet()
+ .Nuget
+ .Push(new DotNetNugetPushOptions
+ {
+ Path = file.Path,
+ Source = localRepoLocation.ValueOrDefault!.Path,
+ }, cancellationToken: cancellationToken), cancellationToken: cancellationToken)
+ .ProcessOneAtATime();
}
-}
\ No newline at end of file
+}
diff --git a/EnumerableAsyncProcessor.Pipeline/Modules/NugetVersionGeneratorModule.cs b/EnumerableAsyncProcessor.Pipeline/Modules/NugetVersionGeneratorModule.cs
index 1539469..c2c8078 100644
--- a/EnumerableAsyncProcessor.Pipeline/Modules/NugetVersionGeneratorModule.cs
+++ b/EnumerableAsyncProcessor.Pipeline/Modules/NugetVersionGeneratorModule.cs
@@ -1,30 +1,28 @@
-using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging;
using ModularPipelines.Context;
using ModularPipelines.Git.Extensions;
+using ModularPipelines.Models;
using ModularPipelines.Modules;
-// ReSharper disable HeuristicUnreachableCode
-#pragma warning disable CS0162
-
namespace EnumerableAsyncProcessor.Pipeline.Modules;
public class NugetVersionGeneratorModule : Module
{
- protected override async Task ExecuteAsync(IPipelineContext context, CancellationToken cancellationToken)
+ protected override async Task ExecuteAsync(IModuleContext context, CancellationToken cancellationToken)
{
var gitVersionInformation = await context.Git().Versioning.GetGitVersioningInformation();
-
+
if (gitVersionInformation.BranchName == "main")
{
return gitVersionInformation.SemVer!;
}
-
+
return $"{gitVersionInformation.Major}.{gitVersionInformation.Minor}.{gitVersionInformation.Patch}-{gitVersionInformation.PreReleaseLabel}-{gitVersionInformation.CommitsSinceVersionSource}";
}
- protected override async Task OnAfterExecute(IPipelineContext context)
+ protected override Task?> OnAfterExecuteAsync(IModuleContext context, ModuleResult result, CancellationToken cancellationToken)
{
- var moduleResult = await this;
- context.Logger.LogInformation("NuGet Version to Package: {Version}", moduleResult.Value);
+ context.Logger.LogInformation("NuGet Version to Package: {Version}", result.ValueOrDefault);
+ return base.OnAfterExecuteAsync(context, result, cancellationToken);
}
}
diff --git a/EnumerableAsyncProcessor.Pipeline/Modules/PackProjectsModule.cs b/EnumerableAsyncProcessor.Pipeline/Modules/PackProjectsModule.cs
index 587e09c..a6bf5fe 100644
--- a/EnumerableAsyncProcessor.Pipeline/Modules/PackProjectsModule.cs
+++ b/EnumerableAsyncProcessor.Pipeline/Modules/PackProjectsModule.cs
@@ -15,29 +15,31 @@ namespace EnumerableAsyncProcessor.Pipeline.Modules;
[DependsOn]
public class PackProjectsModule : Module>
{
- protected override async Task?> ExecuteAsync(IPipelineContext context, CancellationToken cancellationToken)
+ protected override async Task?> ExecuteAsync(IModuleContext context, CancellationToken cancellationToken)
{
var results = new List();
- var packageVersion = await GetModule();
- var projectFiles = context.Git().RootDirectory!.GetFiles(f => GetProjectsPredicate(f, context));
+ var packageVersion = await context.GetModule();
+ var projectFiles = context.Git().RootDirectory.GetFiles(f => GetProjectsPredicate(f, context));
+
foreach (var projectFile in projectFiles)
{
- results.Add(await context.DotNet().Pack(new DotNetPackOptions {
- ProjectSolution = projectFile.Path,
- Configuration = Configuration.Release,
+ results.Add(await context.DotNet().Pack(new DotNetPackOptions
+ {
+ ProjectSolution = projectFile.Path,
+ Configuration = "Release",
Properties =
[
- ("PackageVersion", packageVersion.Value)!,
- ("Version", packageVersion.Value)!
+ ("PackageVersion", packageVersion.ValueOrDefault)!,
+ ("Version", packageVersion.ValueOrDefault)!
],
IncludeSource = true
- }, cancellationToken));
+ }, cancellationToken: cancellationToken));
}
return results;
}
- private bool GetProjectsPredicate(File file, IPipelineContext context)
+ private bool GetProjectsPredicate(File file, IModuleContext context)
{
var path = file.Path;
if (!path.EndsWith(".csproj", StringComparison.OrdinalIgnoreCase))
@@ -55,4 +57,4 @@ private bool GetProjectsPredicate(File file, IPipelineContext context)
context.Logger.LogInformation("Found File: {File}", path);
return true;
}
-}
\ No newline at end of file
+}
diff --git a/EnumerableAsyncProcessor.Pipeline/Modules/PackageFilesRemovalModule.cs b/EnumerableAsyncProcessor.Pipeline/Modules/PackageFilesRemovalModule.cs
index 8cda2dd..76764ac 100644
--- a/EnumerableAsyncProcessor.Pipeline/Modules/PackageFilesRemovalModule.cs
+++ b/EnumerableAsyncProcessor.Pipeline/Modules/PackageFilesRemovalModule.cs
@@ -6,15 +6,15 @@ namespace EnumerableAsyncProcessor.Pipeline.Modules;
public class PackageFilesRemovalModule : Module
{
- protected override async Task?> ExecuteAsync(IPipelineContext context, CancellationToken cancellationToken)
+ protected override Task ExecuteModuleAsync(IModuleContext context, CancellationToken cancellationToken)
{
- var packageFiles = context.Git().RootDirectory.GetFiles(path => path.Extension is ".nupkg");
+ var packageFiles = context.Git().RootDirectory.GetFiles(file => file.Extension is ".nupkg");
foreach (var packageFile in packageFiles)
{
packageFile.Delete();
}
- return await NothingAsync();
+ return Task.CompletedTask;
}
}
diff --git a/EnumerableAsyncProcessor.Pipeline/Modules/PackagePathsParserModule.cs b/EnumerableAsyncProcessor.Pipeline/Modules/PackagePathsParserModule.cs
index ff07081..78ee866 100644
--- a/EnumerableAsyncProcessor.Pipeline/Modules/PackagePathsParserModule.cs
+++ b/EnumerableAsyncProcessor.Pipeline/Modules/PackagePathsParserModule.cs
@@ -8,11 +8,11 @@ namespace EnumerableAsyncProcessor.Pipeline.Modules;
[DependsOn]
public class PackagePathsParserModule : Module>
{
- protected override async Task?> ExecuteAsync(IPipelineContext context, CancellationToken cancellationToken)
+ protected override async Task?> ExecuteAsync(IModuleContext context, CancellationToken cancellationToken)
{
- var packPackagesModuleResult = await GetModule();
+ var packPackagesModuleResult = await context.GetModule();
- return packPackagesModuleResult.Value!
+ return packPackagesModuleResult.ValueOrDefault!
.Select(x => x.StandardOutput)
.Select(x => x.Split("Successfully created package '")[1])
.Select(x => x.Split("'.")[0])
diff --git a/EnumerableAsyncProcessor.Pipeline/Modules/RunUnitTestsModule.cs b/EnumerableAsyncProcessor.Pipeline/Modules/RunUnitTestsModule.cs
index aced2e7..34f1bfc 100644
--- a/EnumerableAsyncProcessor.Pipeline/Modules/RunUnitTestsModule.cs
+++ b/EnumerableAsyncProcessor.Pipeline/Modules/RunUnitTestsModule.cs
@@ -2,7 +2,6 @@
using ModularPipelines.DotNet.Extensions;
using ModularPipelines.DotNet.Options;
using ModularPipelines.Git.Extensions;
-using ModularPipelines.Enums;
using ModularPipelines.Models;
using ModularPipelines.Modules;
@@ -10,20 +9,19 @@ namespace EnumerableAsyncProcessor.Pipeline.Modules;
public class RunUnitTestsModule : Module>
{
- protected override async Task?> ExecuteAsync(IPipelineContext context, CancellationToken cancellationToken)
+ protected override async Task?> ExecuteAsync(IModuleContext context, CancellationToken cancellationToken)
{
var results = new List();
foreach (var unitTestProjectFile in context
- .Git().RootDirectory!
+ .Git().RootDirectory
.GetFiles(file => file.Path.EndsWith(".csproj", StringComparison.OrdinalIgnoreCase)
&& file.Path.Contains("UnitTests", StringComparison.OrdinalIgnoreCase)))
{
results.Add(await context.DotNet().Test(new DotNetTestOptions
{
- ProjectSolutionDirectoryDllExe = unitTestProjectFile.Path,
- CommandLogging = CommandLogging.Input | CommandLogging.Error,
- }, cancellationToken));
+ Project = unitTestProjectFile.Path,
+ }, cancellationToken: cancellationToken));
}
return results;
diff --git a/EnumerableAsyncProcessor.Pipeline/Modules/UploadPackagesToNugetModule.cs b/EnumerableAsyncProcessor.Pipeline/Modules/UploadPackagesToNugetModule.cs
index 230050d..86179af 100644
--- a/EnumerableAsyncProcessor.Pipeline/Modules/UploadPackagesToNugetModule.cs
+++ b/EnumerableAsyncProcessor.Pipeline/Modules/UploadPackagesToNugetModule.cs
@@ -3,6 +3,7 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ModularPipelines.Attributes;
+using ModularPipelines.Configuration;
using ModularPipelines.Context;
using ModularPipelines.DotNet.Extensions;
using ModularPipelines.DotNet.Options;
@@ -23,57 +24,57 @@ public UploadPackagesToNugetModule(IOptions options)
_options = options;
}
- protected override async Task OnBeforeExecute(IPipelineContext context)
+ protected override ModuleConfiguration Configure()
{
- var packagePaths = await GetModule();
+ return ModuleConfiguration.Create()
+ .WithSkipWhen(async context =>
+ {
+ var gitVersionInfo = await context.Git().Versioning.GetGitVersioningInformation();
- foreach (var packagePath in packagePaths.Value!)
- {
- context.Logger.LogInformation("Uploading {File}", packagePath);
- }
+ if (gitVersionInfo.BranchName != "main")
+ {
+ return SkipDecision.Skip("Not on the main branch");
+ }
- await base.OnBeforeExecute(context);
+ var publishPackages = Environment.GetEnvironmentVariable("PUBLISH_PACKAGES");
+
+ if (!bool.TryParse(publishPackages, out var shouldPublishPackages) || !shouldPublishPackages)
+ {
+ return SkipDecision.Skip("PUBLISH_PACKAGES is not set to true");
+ }
+
+ return SkipDecision.Of(false, "Publishing packages");
+ })
+ .Build();
}
- protected override async Task ShouldSkip(IPipelineContext context)
+ protected override async Task OnBeforeExecuteAsync(IModuleContext context, CancellationToken cancellationToken)
{
- var gitVersionInfo = await context.Git().Versioning.GetGitVersioningInformation();
+ var packagePaths = await context.GetModule();
- if (gitVersionInfo.BranchName != "main")
+ foreach (var packagePath in packagePaths.ValueOrDefault ?? [])
{
- return true;
- }
-
- var publishPackages =
- context.Environment.EnvironmentVariables.GetEnvironmentVariable("PUBLISH_PACKAGES")!;
-
- if (!bool.TryParse(publishPackages, out var shouldPublishPackages) || !shouldPublishPackages)
- {
- return true;
+ context.Logger.LogInformation("Uploading {File}", packagePath);
}
- return false;
+ await base.OnBeforeExecuteAsync(context, cancellationToken);
}
- protected override async Task ExecuteAsync(IPipelineContext context, CancellationToken cancellationToken)
+ protected override async Task ExecuteAsync(IModuleContext context, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(_options.Value.ApiKey);
- var gitVersionInformation = await context.Git().Versioning.GetGitVersioningInformation();
+ var packagePaths = await context.GetModule();
- if (gitVersionInformation.BranchName != "main")
- {
- return await NothingAsync();
- }
-
- var packagePaths = await GetModule();
-
- return await packagePaths.Value!.SelectAsync(async file => await context.DotNet()
- .Nuget
- .Push(new DotNetNugetPushOptions(file)
- {
- Source = "https://api.nuget.org/v3/index.json",
- ApiKey = _options.Value.ApiKey!
- }, cancellationToken), cancellationToken: cancellationToken).ProcessOneAtATime();
+ return await packagePaths.ValueOrDefault!
+ .SelectAsync(file => context.DotNet()
+ .Nuget
+ .Push(new DotNetNugetPushOptions
+ {
+ Path = file.Path,
+ Source = "https://api.nuget.org/v3/index.json",
+ ApiKey = _options.Value.ApiKey!
+ }, cancellationToken: cancellationToken), cancellationToken: cancellationToken)
+ .ProcessOneAtATime();
}
}
diff --git a/EnumerableAsyncProcessor.Pipeline/Program.cs b/EnumerableAsyncProcessor.Pipeline/Program.cs
index 7b20956..dfe0c87 100644
--- a/EnumerableAsyncProcessor.Pipeline/Program.cs
+++ b/EnumerableAsyncProcessor.Pipeline/Program.cs
@@ -2,36 +2,35 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using ModularPipelines.Extensions;
-using ModularPipelines.Host;
using EnumerableAsyncProcessor.Pipeline.Modules;
using EnumerableAsyncProcessor.Pipeline.Modules.LocalMachine;
using EnumerableAsyncProcessor.Pipeline.Settings;
-await PipelineHostBuilder.Create()
- .ConfigureAppConfiguration((_, builder) =>
- {
- builder.AddJsonFile("appsettings.json")
- .AddUserSecrets()
- .AddEnvironmentVariables();
- })
- .ConfigureServices((context, collection) =>
- {
- collection.Configure(context.Configuration.GetSection("NuGet"));
+var builder = ModularPipelines.Pipeline.CreateBuilder(args);
- if (context.HostingEnvironment.IsDevelopment())
- {
- collection.AddModule()
- .AddModule()
- .AddModule();
- }
- else
- {
- collection.AddModule();
- }
- })
- .AddModule()
+builder.Configuration
+ .AddJsonFile("appsettings.json")
+ .AddUserSecrets()
+ .AddEnvironmentVariables();
+
+builder.Services.Configure(builder.Configuration.GetSection("NuGet"));
+
+if (builder.Environment.IsDevelopment())
+{
+ builder.AddModule()
+ .AddModule()
+ .AddModule();
+}
+else
+{
+ builder.AddModule();
+}
+
+builder.AddModule()
.AddModule()
.AddModule()
.AddModule()
- .AddModule()
- .ExecutePipelineAsync();
+ .AddModule();
+
+await using var pipeline = builder.Build();
+await pipeline.RunAsync();
diff --git a/EnumerableAsyncProcessor.UnitTests/DisposalRegressionTests.cs b/EnumerableAsyncProcessor.UnitTests/DisposalRegressionTests.cs
new file mode 100644
index 0000000..1683625
--- /dev/null
+++ b/EnumerableAsyncProcessor.UnitTests/DisposalRegressionTests.cs
@@ -0,0 +1,130 @@
+using System;
+using System.Diagnostics;
+using System.Linq;
+using System.Threading.Tasks;
+using EnumerableAsyncProcessor.Extensions;
+
+namespace EnumerableAsyncProcessor.UnitTests;
+
+///
+/// Guards the disposal and cancellation semantics:
+/// - disposal must actually cancel pending work (a guard-ordering bug previously made it a no-op),
+/// - synchronous Dispose must not block (it previously ran sync-over-async with a 30s wait),
+/// - CancelAll on a result processor must not block the calling thread (it previously invoked
+/// blocking Dispose via the cancellation callback).
+///
+public class DisposalRegressionTests
+{
+ [Test]
+ public async Task DisposeAsync_Cancels_Unstarted_Tasks_And_Completes_Promptly()
+ {
+ var blocker = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var firstItemStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ var processor = Enumerable.Range(0, 10).ToList()
+ .ForEachAsync(async _ =>
+ {
+ firstItemStarted.TrySetResult();
+ await blocker.Task;
+ })
+ .ProcessInParallel(1);
+
+ await firstItemStarted.Task;
+
+ var stopwatch = Stopwatch.StartNew();
+ var disposeTask = processor.DisposeAsync();
+
+ // Cancellation happens synchronously inside DisposeAsync, before it waits for in-flight work.
+ // Previously nothing was cancelled and disposal just waited for tasks to finish naturally.
+ await Assert.That(processor.GetEnumerableTasks().Count(x => x.IsCanceled)).IsEqualTo(10);
+
+ blocker.TrySetResult();
+ await disposeTask;
+ stopwatch.Stop();
+
+ await Assert.That(stopwatch.Elapsed).IsLessThan(TimeSpan.FromSeconds(5));
+ }
+
+ [Test]
+ public async Task Synchronous_Dispose_Returns_Without_Blocking_On_InFlight_Work()
+ {
+ var blocker = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var firstItemStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ var processor = Enumerable.Range(0, 10).ToList()
+ .ForEachAsync(async _ =>
+ {
+ firstItemStarted.TrySetResult();
+ await blocker.Task;
+ })
+ .ProcessInParallel(1);
+
+ await firstItemStarted.Task;
+
+ try
+ {
+ var stopwatch = Stopwatch.StartNew();
+ processor.Dispose();
+ stopwatch.Stop();
+
+ await Assert.That(stopwatch.Elapsed).IsLessThan(TimeSpan.FromSeconds(2));
+ await Assert.That(processor.GetEnumerableTasks().Count(x => x.IsCanceled)).IsEqualTo(10);
+ }
+ finally
+ {
+ blocker.TrySetResult();
+ }
+ }
+
+ [Test]
+ public async Task CancelAll_On_Result_Processor_Does_Not_Block_And_Cancels_Pending_Tasks()
+ {
+ var blocker = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var firstItemStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ var processor = Enumerable.Range(0, 10).ToList()
+ .SelectAsync(async i =>
+ {
+ firstItemStarted.TrySetResult();
+ await blocker.Task;
+ return i;
+ })
+ .ProcessInParallel(1);
+
+ await firstItemStarted.Task;
+
+ try
+ {
+ var stopwatch = Stopwatch.StartNew();
+ processor.CancelAll();
+ stopwatch.Stop();
+
+ await Assert.That(stopwatch.Elapsed).IsLessThan(TimeSpan.FromSeconds(2));
+ await Assert.That(processor.GetEnumerableTasks().Count(x => x.IsCanceled)).IsEqualTo(10);
+ await Assert.ThrowsAsync(() => processor.GetResultsAsync());
+ }
+ finally
+ {
+ blocker.TrySetResult();
+ }
+
+ await processor.DisposeAsync();
+ }
+
+ [Test]
+ public async Task Disposal_Is_Idempotent_And_Safe_In_Any_Order()
+ {
+ var processor = Enumerable.Range(0, 5).ToList()
+ .ForEachAsync(_ => Task.CompletedTask)
+ .ProcessInParallel();
+
+ await processor.WaitAsync();
+
+ await processor.DisposeAsync();
+ await processor.DisposeAsync();
+ processor.Dispose();
+ processor.CancelAll();
+
+ await Assert.That(processor.GetEnumerableTasks().Count(x => x.IsCompletedSuccessfully)).IsEqualTo(5);
+ }
+}
diff --git a/EnumerableAsyncProcessor.UnitTests/EnumerableAsyncProcessor.UnitTests.csproj b/EnumerableAsyncProcessor.UnitTests/EnumerableAsyncProcessor.UnitTests.csproj
index ddd2fda..71bfe6f 100644
--- a/EnumerableAsyncProcessor.UnitTests/EnumerableAsyncProcessor.UnitTests.csproj
+++ b/EnumerableAsyncProcessor.UnitTests/EnumerableAsyncProcessor.UnitTests.csproj
@@ -1,7 +1,8 @@
- net9.0
+
+ net9.0;net8.0
enable
false