fix: unify streaming exception fidelity and prevent cancellation hang#360
Merged
Merged
Conversation
Streaming results via GetResultsAsyncEnumerable used task.Result on the net9+ Task.WhenEach path, wrapping failures in AggregateException while the net8 fallback rethrew the original exception. Both paths now await the completed task so every TFM surfaces identical exceptions. In the bounded async-enumerable result pipeline, a worker observing cancellation between an item being written and claimed abandoned that item's completion source, hanging the consumer forever. The producer now awaits pending results with WaitAsync(pipelineToken), workers cancel any unclaimed items they strand on exit, and abandoned faulted results are observed so they cannot raise UnobservedTaskException.
This was referenced Jul 21, 2026
Closed
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.
Two release blockers found in a pre-v4.0 audit.
1. Streaming exception fidelity differs per TFM
GetResultsAsyncEnumerable()routes throughToIAsyncEnumerable, whose net9+/net10 path usedtask.Resultinside theTask.WhenEachloop. A faulted item surfaced asAggregateException(and a canceled item asAggregateException(TaskCanceledException)), while the net8 completion-order-bucket fallback rethrew the original exception unwrapped. Same code, different exception type per TFM — and the net9+ streaming path also disagreed withGetResultsAsync()in the same TFM.Fix: await the (already completed) task instead of touching
.Result.2. Cancellation could hang the bounded async-enumerable result pipeline forever
In
AsyncEnumerableWorkerPool.ProcessResultsAsync, the producer awaited per-itemTaskCompletionSourcetasks with no cancellation linkage. A worker that observed cancellation between an item being written to the channel and that item being claimed (channelWaitToReadAsyncchecks the token before checking for data) exited without completing the item's TCS — leaving the consumer'sawait foreachhanging forever, precisely when the user cancelled to regain control.Fix (belt and braces):
WaitAsync(pipelineToken)— airtight no-hang guarantee.finally, so they can never surface asUnobservedTaskException.Tests
ExceptionFidelityTests: two new streaming tests asserting the original exception type (notAggregateException) surfaces fromGetResultsAsyncEnumerable()for faulted and canceled items. Verified they fail on net10 without the fix (exactly 2 failures) and pass with it.CancellationRegressionTests(new): 100-iteration stress test cancelling bounded result streaming at varying points against a cancellation-ignoring infinite source; each iteration must observeOperationCanceledExceptionwithin a bounded wait instead of hanging.Full suite: 1665 passing across net8.0/net9.0/net10.0.
Note (not addressed here): TUnit
--treenode-filterdiscovery currently fails on this repo with aMissingMethodExceptionreferencing the removed v3ProcessInParallel(Int32)overload — pre-existing on main, unrelated to this change; filter-free runs work.