Skip to content

Fix processor correctness: lazy re-enumeration, broken cancellation on dispose, hung awaiters#332

Merged
thomhurst merged 11 commits into
mainfrom
fix/async-processor-correctness
Jul 21, 2026
Merged

Fix processor correctness: lazy re-enumeration, broken cancellation on dispose, hung awaiters#332
thomhurst merged 11 commits into
mainfrom
fix/async-processor-correctness

Conversation

@thomhurst

Copy link
Copy Markdown
Owner

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). TaskWrappers was a lazy items.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 the ConcurrentDictionary that existed only to keep completion-source identity stable across re-enumerations (and allocated a fresh TCS per index on every enumeration via the GetOrAdd value overload).

Disposal never cancelled anything. DisposeAsync set _disposed = true before calling CancelAll(), whose _disposed guard 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-async Dispose as the cancellation callback. Both bases now register CancelAll, and registration happens at Start() 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. StartProcessing discarded the Process() 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 propagate Exception.InnerExceptions in full.

Non-blocking sync Dispose(). Previously Task.Run(...).Wait(30s). It now cancels and releases immediately; DisposeAsync keeps 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, missing Task.Run tokens, and stale comments).

Commit 2 - Cleanup

  • Removed GetPerformanceWarning (computed then discarded in four constructors) and the never-enforced MAX_PARALLELISM constant.
  • Removed the arbitrary caps: 10,000 max tasks, 10,000 max batch size, 24h max timespan. These threw on legitimate large workloads. Negative/zero validation remains.
  • Added the missing parallelism/batch/timespan validation to the result processor variants so all processors validate alike.
  • Stripped unused IEquatable/operators/Deconstruct/GetHashCode and AggressiveInlining (a no-op on async methods) from the TaskWrapper structs.
  • Removed the completed-task fast paths in ParallelExtensions - rethrowing GetBaseException() destroyed stack traces; awaiting is equivalent and correct.
  • LangVersion preview -> latest (no preview features remain).
  • README disposal notes updated to match.

Behaviour changes to be aware of

  • Constructors now enumerate the input eagerly, so iterator exceptions surface at builder-call time instead of hanging awaiters.
  • Sync Dispose() no longer blocks up to 30s.
  • Counts/batch sizes over 10,000 and timespans over 24h are now accepted.
  • Public but incidental TaskWrapper members (equality operators, Deconstruct) were removed - technically binary-breaking, happy to drop that commit if you want to keep them.

Testing

  • Library builds clean on all TFMs (net6.0/net8.0/net9.0/netstandard2.0), 0 warnings.
  • Full TUnit suite: 507/507 passing (one test removed with the equality ceremony it exercised).
  • Note: EnumerableAsyncProcessor.Example and EnumerableAsyncProcessor.Pipeline already fail to build on main (pre-existing: example references a non-existent ToAsyncEnumerable; pipeline targets net9.0 with net10-only ModularPipelines v3 packages) - untouched here.

- 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.
@greptile-apps

greptile-apps Bot commented Jul 21, 2026

Copy link
Copy Markdown

Greptile Summary

This PR fixes processor lifecycle and execution correctness across the library. The main changes are:

  • Materializes input sequences once to prevent lazy re-enumeration.
  • Corrects cancellation, disposal, and process-fault propagation.
  • Preserves complete task exceptions and runs continuations asynchronously.
  • Reworks rate-limited execution around a worker pool.
  • Aligns validation and result streaming across processor variants.
  • Updates the build pipeline and prepares the package for v4.0.0.

Confidence Score: 5/5

This looks safe to merge.

  • The public wrapper API removal is consistent with the planned v4.0.0 release.
  • No separate compatibility break or incomplete fix was found.

Important Files Changed

Filename Overview
EnumerableAsyncProcessor/TaskWrapper.cs Simplifies the public wrapper structs while retaining their construction and processing behavior.
EnumerableAsyncProcessor/ProcessorLifecycle.cs Centralizes processor startup, cancellation, fault propagation, and disposal.
EnumerableAsyncProcessor/WorkerPool.cs Introduces shared worker-pool execution for bounded and rate-limited processing.
GitVersion.yml Sets the next package version to the v4.0.0 major release.

Reviews (9): Last reviewed commit: "chore: remove accidentally committed too..." | Re-trigger Greptile

Comment thread EnumerableAsyncProcessor/TaskWrapper.cs Outdated
- 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.
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.
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.
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.
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.
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant