Skip to content

refactor!: freeze a clean v4 public API surface#361

Merged
thomhurst merged 4 commits into
mainfrom
refactor/v4-api-surface-cleanup
Jul 21, 2026
Merged

refactor!: freeze a clean v4 public API surface#361
thomhurst merged 4 commits into
mainfrom
refactor/v4-api-surface-cleanup

Conversation

@thomhurst

Copy link
Copy Markdown
Owner

Now-or-never API cleanup before 4.0.0 freezes the contract, from the pre-release audit.

API surface (breaking)

  • TaskWrapper structs internalized (ActionTaskWrapper, ItemTaskWrapper × arities). Both base hierarchies declare internal abstract Task Process(), so no external subclass was ever possible — every protected member on the abstract bases was unreachable noise about to be frozen. Those members (TaskWrappers, EnumerableTaskCompletionSources, CancellationToken, constructors, DisposeAsyncCore) are now private protected. Tests keep access via InternalsVisibleTo.
  • All ~24 leaf processor and builder classes are sealed — zero-risk today (internal ctors/members already prevented subclassing), binary-breaking to do later.
  • IAsyncEnumerableProcessor moved to EnumerableAsyncProcessor.Interfaces (was declared in the Extensions namespace despite living in Interfaces/) and both variants now extend IAsyncDisposable, IDisposable. IAsyncEnumerableProcessor<TOutput> is also covariant now.
  • Parameterized no-selector IAsyncEnumerable<T>.ProcessInParallel(maxConcurrency, …) overloads removed — they buffered the stream into a list and silently ignored maxConcurrency/scheduleOnThreadPool.

Behavior fixes

  • Linked CancellationTokenSource leak fixed: the async-enumerable builder path created a linked CTS nobody ever disposed, pinning a registration on the caller's token for its lifetime. The six async-enumerable processors now dispose it when ExecuteAsync completes and support explicit Dispose/DisposeAsync (idempotent; await using without execution is safe).
  • scheduleOnThreadPool is honored on bounded paths — previously ignored whenever maxConcurrency was set (both processors and the extension overload).

TUnit binary compatibility (important)

TUnit.Engine is compiled against EnumerableAsyncProcessor 3.8.4, and any locally-referenced v4 build shadows it (same assembly identity). Two members TUnit binds to had been removed: ItemActionAsyncProcessorBuilder<,>.ProcessInParallel(int) (removed in the v4 consolidation — this had already silently broken --treenode-filter discovery on main) and the parameterless ProcessInParallel<T>(IAsyncEnumerable<T>, CancellationToken). Without them, TUnit test discovery dies with MissingMethodException — in this repo and for any TUnit user who adds EAP 4.0 to a test project.

This PR keeps two functional, documented, transitional compat members: ProcessInParallel(int) forwarders on the four enumerable builders, and the parameterless collect overload (docs state plainly that it parallelizes nothing). V3BinaryCompatibilityTests pins the exact signatures with an explanation. They can be dropped once TUnit ships a build compiled against v4 — or better, internalizes EAP into TUnit.Engine (ILRepack), which ends the diamond dependency permanently. Side effect: --treenode-filter works again for the first time since the consolidation.

Packaging

  • GenerateDocumentationFile — the package previously shipped no IntelliSense XML despite extensive /// docs.
  • IsAotCompatible (library uses zero reflection).
  • Merged duplicate metadata PropertyGroups; removed dead private ToAsyncEnumerable helper.
  • PublicAPI.Shipped.txt regenerated (176→181 entries reflecting all of the above).

Docs

  • Migration guide: new bullets for the namespace move, sealing, wrapper internalization, no-selector removal; corrected the levelOfParallelism rename claim (InParallelAsync keeps that parameter name).
  • README: disposal claims now accurate for the async-enumerable family.
  • CLAUDE.md: removed stale RateLimitedParallel strategy and minimumIterationTime slot-dwell description (now token bucket); documented the TUnit dogfooding constraint.

Tests

1659 passing across net8.0/net9.0/net10.0 (removed 2 tests for deleted overloads; added 2 async-enumerable disposal regression tests + the TUnit compat guard). Filtered discovery verified working.

Now-or-never cleanup before the 4.0.0 contract freezes:

- Internalize the ActionTaskWrapper/ItemTaskWrapper structs and demote the
  abstract processor bases' protected plumbing (TaskWrappers,
  EnumerableTaskCompletionSources, CancellationToken, constructors,
  DisposeAsyncCore) to private protected - none of it was usable outside
  the assembly because Process() is internal abstract.
- Seal every leaf processor and builder class.
- Move IAsyncEnumerableProcessor to EnumerableAsyncProcessor.Interfaces
  and make both variants IAsyncDisposable/IDisposable. The six
  async-enumerable processors now dispose their linked
  CancellationTokenSource when ExecuteAsync completes (previously leaked
  a registration on the caller's token) and support explicit disposal.
- Honor scheduleOnThreadPool on the bounded parallel paths (it was
  silently ignored whenever maxConcurrency was set).
- Remove the parameterized no-selector IAsyncEnumerable ProcessInParallel
  overloads whose maxConcurrency/scheduleOnThreadPool did nothing.
- Keep binary compatibility with assemblies compiled against v3, notably
  TUnit.Engine, which this repo's own test runner depends on: restore the
  parameterless ProcessInParallel(items, ct) collect overload and add
  ProcessInParallel(int) forwarders on the four enumerable builders.
  V3BinaryCompatibilityTests pins the exact signatures; this also fixes
  --treenode-filter discovery, broken since the v4 API consolidation.
- Packaging: generate XML docs (IntelliSense was missing from the
  package), mark AOT-compatible, merge duplicate metadata groups.
- Docs: correct stale CLAUDE.md/README claims (RateLimitedParallel
  strategy, minimumIterationTime model, disposal contract) and expand the
  migration guide.
Mark the no-selector ProcessInParallel collect overload [Obsolete] so
source consumers migrate off it, and hide the builder ProcessInParallel(int)
forwarders with [EditorBrowsable(Never)]. The forwarders cannot be
[Obsolete]: exact-int overload resolution prefers them for the idiomatic
ProcessInParallel(5) call, which would warn on every normal bounded use.
@greptile-apps

greptile-apps Bot commented Jul 21, 2026

Copy link
Copy Markdown

Greptile Summary

This PR freezes the public API for version 4 and updates processor lifecycle behavior. The main changes are:

  • Internalized task-wrapper implementation details and sealed processor and builder classes.
  • Moved and expanded the async-enumerable processor interfaces.
  • Removed misleading no-selector parallel overloads while retaining required binary-compatibility members.
  • Added linked-token disposal and bounded asynchronous-disposal waiting.
  • Honored thread-pool scheduling on bounded parallel paths.
  • Updated package metadata, public API declarations, documentation, examples, and tests.

Confidence Score: 5/5

This looks safe to merge.

  • Active async-enumerable executions are cancelled before disposal waits for completion.
  • Void processors track the execution task, and result processors signal completion from iterator cleanup.
  • No blocking issues remain in the updated disposal paths.

Important Files Changed

Filename Overview
EnumerableAsyncProcessor/RunnableProcessors/AsyncEnumerable/AsyncEnumerableParallelProcessor.cs Tracks active execution and waits for it during asynchronous disposal.
EnumerableAsyncProcessor/RunnableProcessors/AsyncEnumerable/ResultProcessors/ResultAsyncEnumerableParallelProcessor.cs Tracks active result enumeration and signals completion from iterator cleanup.
EnumerableAsyncProcessor.UnitTests/DisposalRegressionTests.cs Adds coverage for cancellation, idempotent disposal, and waiting for active selectors.
EnumerableAsyncProcessor/Interfaces/IAsyncEnumerableProcessor.cs Moves the interfaces to their public namespace and adds synchronous and asynchronous disposal contracts.
EnumerableAsyncProcessor/Extensions/AsyncEnumerableExtensions.cs Removes misleading overloads and preserves the compatibility entry point required by existing consumers.

Reviews (3): Last reviewed commit: "fix: DisposeAsync awaits the in-flight r..." | Re-trigger Greptile

…osed

Explicit Dispose/DisposeAsync on the six IAsyncEnumerableProcessor
implementations previously disposed the linked CancellationTokenSource
without cancelling it, so an active ExecuteAsync kept running and later
caller-token cancellation no longer reached the pipeline (disposal had
severed the link). Disposal now cancels first; the completion-path
self-dispose skips the cancel since nothing is left in flight. Both
paths race-safely share an Interlocked guard. Builders capture the
processor token up front so late selector invocations never touch a
disposed source. Regression test: disposing mid-run terminates the
pipeline with OperationCanceledException.
Async disposal previously cancelled the linked source and returned
immediately; a selector already running could still produce side effects
after 'await processor.DisposeAsync()'. Mirror ProcessorLifecycle: void
processors track their execution task, streaming processors complete a
signal when enumeration finishes, and DisposeAsync cancels then waits up
to the shared 30-second disposal window before releasing the source.
Synchronous Dispose still cancels without blocking.
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