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