Fix processor correctness: lazy re-enumeration, broken cancellation on dispose, hung awaiters#332
Merged
Merged
Conversation
- Materialize task wrappers once at construction. Input enumerables were lazily re-enumerated up to 5 times (validation Any(), Process, WhenAll, CancelAll, dispose), breaking one-shot sources and re-running side effects. Removes the ConcurrentDictionary identity workaround and the eager .Any() probe of lazy enumerables. - Fix disposal never cancelling: _disposed was set before CancelAll, whose guard then made it a no-op, so dispose waited up to 30s for tasks to finish naturally. - Result processors registered sync-over-async Dispose as the cancellation callback, blocking the cancelling thread up to 30s. Both bases now register CancelAll, and only at Start() so a token cancelled during construction cannot fire on a partially constructed instance (NRE). - Route Process() faults into unfinished completion sources instead of discarding them; awaiters previously hung forever if item enumeration or cancellation plumbing threw. - Create TaskCompletionSources with RunContinuationsAsynchronously to stop user continuations running inline on the completing thread. - Preserve all task exceptions via InnerExceptions instead of GetBaseException, which dropped all but the first fault. - Sync Dispose no longer blocks via Task.Run().Wait(30s); it cancels and releases immediately. - Consistent cancellation token flow in rate-limited and timed processors (was a mix of None and missing tokens).
- Remove GetPerformanceWarning: computed and immediately discarded in four constructors, never observable. - Remove arbitrary validation caps (10,000 tasks, 10,000 batch size, 24h timespan) that threw on legitimate large workloads. Negative/zero checks remain. - Add missing parallelism/batch/timespan validation to the result processor variants so all processors behave alike. - Strip unused equality ceremony, Deconstruct and AggressiveInlining (a no-op on async methods) from the TaskWrapper structs. - Remove the completed-task fast paths in ParallelExtensions: rethrowing GetBaseException destroyed stack traces and dropped sibling exceptions; awaiting is equivalent and correct. - LangVersion preview -> latest now that no preview features are used. - Update README disposal notes to match the non-blocking synchronous Dispose.
thomhurst
had a problem deploying
to
Pull Requests
July 21, 2026 16:42 — with
GitHub Actions
Failure
Greptile SummaryThis PR fixes processor lifecycle and execution correctness across the library. The main changes are:
Confidence Score: 5/5This looks safe to merge.
Important Files Changed
Reviews (9): Last reviewed commit: "chore: remove accidentally committed too..." | Re-trigger Greptile |
- Extract shared internal ProcessorLifecycle owning start/ cancel/dispose. The void and result bases cannot share an ancestor (differently typed completion sources), and the previous mirrored ~90 lines of concurrency-sensitive code had already drifted once (one base registered CancelAll, the other Dispose). Each base now supplies only its two typed fan-out loops. - Collapse the StartProcessing extension trampoline into internal instance methods on the bases; call sites keep identical syntax. - Collapse the four identical TaskWrapper catch blocks into two TrySetFromFault overloads so the fault-classification policy exists once per completion-source type. - Reuse GetEnumerableTasks() for OverallTask/Results instead of restating the projection. - Remove ValidateCount/ValidateBatchSize/ValidateParallelism/ ValidateTimeSpan forwarders that only renamed ThrowIfNegative/ThrowIfNegativeOrZero; call the primitives directly. CallerArgumentExpression keeps messages identical. - Build the wrapper/completion-source arrays the same way in all four abstract processor constructors.
thomhurst
had a problem deploying
to
Pull Requests
July 21, 2026 16:51 — with
GitHub Actions
Failure
Measured at 100k items with completed-task selectors (pure overhead, net9): - unbounded parallel: 61ms -> 16ms, 444 -> 162 B/item - maxConcurrency 64: 57ms -> 10ms, 431 -> 164 B/item - rate-limited 64: 163ms -> 9ms, 1062 -> 156 B/item Changes: - Rate-limited, timed and throttled parallel processors now run on a fixed pool of P worker loops (WorkerPool) instead of queueing one task per item: P Task.Run calls and one Interlocked increment per item replace N Task.Run tasks, N closures and N semaphore waits. The internal ITaskWrapper interface lets one generic helper serve all four wrapper structs via constrained calls without boxing. - TaskWrappers fields are typed as arrays so iteration uses struct enumerators and LINQ array fast paths instead of interface-dispatched enumerators. - ToIAsyncEnumerable pre-net9 fallback replaced the O(N^2) WhenAny loop (rebuilding the task list every completion) with completion-order buckets: O(N), verified on net8 - 100k results stream in 73ms. - Throttled ProcessInParallel now validates maxConcurrency at build time; previously 0 surfaced as a semaphore ArgumentOutOfRangeException at process time.
thomhurst
had a problem deploying
to
Pull Requests
July 21, 2026 17:03 — with
GitHub Actions
Failure
30 new tests across five suites, each pinned to a previously shipped bug: - InputEnumerationRegressionTests: input enumerated exactly once across process/await/CancelAll/dispose; one-shot enumerables work; iterator exceptions throw at build time instead of hanging awaiters. - DisposalRegressionTests: DisposeAsync actually cancels pending tasks and completes promptly; synchronous Dispose no longer blocks on in-flight work; CancelAll on result processors returns immediately (previously blocked up to 30s via the Dispose cancellation callback); disposal is idempotent in any order. - ExceptionFidelityTests: multi-fault tasks preserve every inner exception (GetBaseException previously kept one); one failing item cannot stop the rest; completion sources run continuations asynchronously. - ValidationRegressionTests: zero/negative parallelism, maxConcurrency, batch size and negative timespans throw at build time on void AND result variants (result variants previously skipped validation; maxConcurrency 0 previously failed mid-processing); the removed 10k caps stay removed; already-cancelled tokens fail cleanly, never NRE. - WorkerPoolBehaviourTests: concurrency limit holds on the worker-pool path, every item processed exactly once, oversized limits fine, cancellation drains promptly, result order preserved regardless of completion order. - StreamingResultsTests: results stream in completion order, exactly once, and honour cancellation. Test project now targets net9.0 and net8.0 so the pre-net9 completion-order bucket implementation of ToIAsyncEnumerable is exercised in CI rather than only compiled.
thomhurst
had a problem deploying
to
Pull Requests
July 21, 2026 17:12 — with
GitHub Actions
Failure
TUnit 0.53.0 -> 1.61.15: no source changes needed; 537 tests pass on net9.0 and net8.0. ModularPipelines 3.1.90 -> 3.2.8 (packages are net10.0-only, so the pipeline project targets net10.0; main was failing to restore since the 3.x bump). Migrated to the v3 API: - Pipeline.CreateBuilder(args) + Build()/RunAsync() replaces PipelineHostBuilder. - Module overrides take IModuleContext; non-generic Module implements ExecuteModuleAsync; GetModule moved onto the context; ModuleResult.Value -> ValueOrDefault. - ShouldSkip/ShouldIgnoreFailures/OnAfterExecute property overrides replaced by Configure() with WithSkipWhen/ WithIgnoreFailuresWhen and OnBeforeExecuteAsync/ OnAfterExecuteAsync hooks. - Options records rebuilt: constructor args -> property initializers (DotNetNugetPushOptions.Path, DotNetNugetAddSourceOptions.Packagesourcepath), Pack Configuration is now a string, DotNetTestOptions targets the new dotnet test via Project. - context.FileSystem -> context.Files. global.json opts dotnet test into the Microsoft.Testing .Platform runner: the new DotNetTestOptions drives "dotnet test --project", which the classic VSTest driver rejects (verified locally: 1074 tests across both TFMs run through the exact pipeline command). CI installs 8.0.x/9.0.x/10.0.x SDKs: 10 for the pipeline, 8 and 9 as runtimes for the multi-targeted test project.
thomhurst
had a problem deploying
to
Pull Requests
July 21, 2026 17:25 — with
GitHub Actions
Failure
Pipeline.CreateBuilder resolves configuration files relative to the output directory, not the working directory the old generic host used, so CI failed with FileNotFoundException before any module ran.
thomhurst
had a problem deploying
to
Pull Requests
July 21, 2026 17:28 — with
GitHub Actions
Failure
ModularPipelines writes a "strategies: [Mainline]" GitVersion config when the repo has none, and that strategy crashes GitVersion 6 with InvalidOperationException on GitHub PR merge commits (reproduced locally against refs/pull/332/merge; the default strategies succeed on the same commit). A committed config takes precedence. Default strategies plus ContinuousDeployment with an empty label on main keep publish versions stable: main resolves to 3.7.0, PR refs to pre-release versions.
This PR ships breaking changes (eager input materialization, non-blocking sync Dispose, removed caps, trimmed wrapper-struct surface), so it releases as a major version. Verified locally: main resolves to 4.0.0, PR refs to 4.0.0 pre-releases. Also collapse the double blank line in ResultBatchAsyncProcessor_1.cs flagged by CodeFactor.
This was referenced Jul 21, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Two commits: correctness fixes to the processor core, then removal of dead code and arbitrary limits. Found during a full review of the library core.
Commit 1 - Correctness fixes
Input enumerated up to 5 times (now exactly once).
TaskWrapperswas a lazyitems.Select(...)query, re-enumerated by validation (.Any()),Process(),Task.WhenAll,CancelAll()and disposal. One-shot sources (DB readers,File.ReadLines) broke, and side-effecting iterators re-ran their side effects. Wrappers are now materialized once in the constructor, which also removes theConcurrentDictionarythat existed only to keep completion-source identity stable across re-enumerations (and allocated a fresh TCS per index on every enumeration via theGetOrAddvalue overload).Disposal never cancelled anything.
DisposeAsyncset_disposed = truebefore callingCancelAll(), whose_disposedguard then made it a no-op - so disposal waited up to 30s for tasks to finish naturally. Cancellation now runs unconditionally during disposal via a private core method.CancelAll()on result processors blocked the calling thread up to 30s. The result base registered sync-over-asyncDisposeas the cancellation callback. Both bases now registerCancelAll, and registration happens atStart()rather than in the constructor - so a token cancelled during construction can no longer fire a callback on a partially constructed instance (NRE via the unassigned_cancellationTokenSource/TaskWrappers).Awaiters could hang forever.
StartProcessingdiscarded theProcess()task. A fault outside the per-item wrappers (cancellation plumbing, previously also enumeration) left completion sources permanently unfinished.Process()faults are now routed into all unfinished completion sources.Inline continuations. Completion sources are now created with
TaskCreationOptions.RunContinuationsAsynchronously, so user continuations no longer run inline on the completing thread.Exception fidelity. The completed-task fast path used
GetBaseException(), which dropped all but one exception on multi-fault tasks and diverged from the awaited path. Faulted tasks now propagateException.InnerExceptionsin full.Non-blocking sync
Dispose(). PreviouslyTask.Run(...).Wait(30s). It now cancels and releases immediately;DisposeAsynckeeps the bounded 30s graceful wait for in-flight work (now waiting on the actual process task rather than the completion sources, which complete instantly once cancelled).Consistent cancellation flow in the rate-limited/timed processors (was a mix of
CancellationToken.None, missingTask.Runtokens, and stale comments).Commit 2 - Cleanup
GetPerformanceWarning(computed then discarded in four constructors) and the never-enforcedMAX_PARALLELISMconstant.IEquatable/operators/Deconstruct/GetHashCodeandAggressiveInlining(a no-op on async methods) from theTaskWrapperstructs.ParallelExtensions- rethrowingGetBaseException()destroyed stack traces; awaiting is equivalent and correct.LangVersionpreview->latest(no preview features remain).Behaviour changes to be aware of
Dispose()no longer blocks up to 30s.TaskWrappermembers (equality operators,Deconstruct) were removed - technically binary-breaking, happy to drop that commit if you want to keep them.Testing
EnumerableAsyncProcessor.ExampleandEnumerableAsyncProcessor.Pipelinealready fail to build onmain(pre-existing: example references a non-existentToAsyncEnumerable; pipeline targets net9.0 with net10-only ModularPipelines v3 packages) - untouched here.