Skip to content

Commit d18f2c8

Browse files
authored
fix: unify streaming exception fidelity and prevent cancellation hang (#360)
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.
1 parent 96e1c26 commit d18f2c8

4 files changed

Lines changed: 169 additions & 12 deletions

File tree

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Threading;
4+
using System.Threading.Tasks;
5+
using EnumerableAsyncProcessor.Extensions;
6+
7+
namespace EnumerableAsyncProcessor.UnitTests;
8+
9+
/// <summary>
10+
/// Guards against cancellation hanging the bounded async-enumerable result pipeline:
11+
/// a worker that observed cancellation between an item being written to the channel
12+
/// and that item being claimed used to abandon the item's completion source, leaving
13+
/// the consumer awaiting a task that never completes.
14+
/// </summary>
15+
public class CancellationRegressionTests
16+
{
17+
[Test, Timeout(120_000)]
18+
public async Task Cancelling_Bounded_Result_Streaming_Never_Hangs(CancellationToken cancellationToken)
19+
{
20+
for (var iteration = 0; iteration < 100; iteration++)
21+
{
22+
using var cts = new CancellationTokenSource();
23+
var cancelAfter = 1 + iteration % 10;
24+
var consumed = 0;
25+
26+
var stream = InfiniteSource()
27+
.SelectAsync(async item =>
28+
{
29+
await Task.Yield();
30+
return item;
31+
}, cts.Token)
32+
.ProcessInParallel(maxConcurrency: 2);
33+
34+
async Task ConsumeAsync()
35+
{
36+
await foreach (var _ in stream.ExecuteAsync())
37+
{
38+
if (Interlocked.Increment(ref consumed) == cancelAfter)
39+
{
40+
cts.Cancel();
41+
}
42+
}
43+
}
44+
45+
Exception? caught = null;
46+
try
47+
{
48+
await ConsumeAsync().WaitAsync(TimeSpan.FromSeconds(10), cancellationToken);
49+
}
50+
catch (Exception exception)
51+
{
52+
caught = exception;
53+
}
54+
55+
// A TimeoutException here means the pipeline hung instead of observing cancellation.
56+
await Assert.That(caught is OperationCanceledException).IsTrue();
57+
}
58+
}
59+
60+
// Deliberately ignores cancellation so the pipeline's own guards are what stop it.
61+
private static async IAsyncEnumerable<int> InfiniteSource()
62+
{
63+
var i = 0;
64+
while (true)
65+
{
66+
yield return i++;
67+
await Task.Yield();
68+
}
69+
}
70+
}

EnumerableAsyncProcessor.UnitTests/ExceptionFidelityTests.cs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,57 @@ public async Task Failed_Items_Do_Not_Prevent_Remaining_Items_From_Processing()
100100
await Assert.That(processor.GetEnumerableTasks().Count(x => x.IsFaulted)).IsEqualTo(10);
101101
}
102102

103+
[Test]
104+
public async Task Streaming_Results_Surface_The_Original_Exception_Unwrapped()
105+
{
106+
await using var processor = Enumerable.Range(0, 5).ToList()
107+
.SelectAsync(i => i == 2
108+
? Task.FromException<int>(new InvalidOperationException("item 2 failed"))
109+
: Task.FromResult(i))
110+
.ProcessInParallel(2);
111+
112+
Exception? caught = null;
113+
try
114+
{
115+
await foreach (var _ in processor.GetResultsAsyncEnumerable())
116+
{
117+
}
118+
}
119+
catch (Exception exception)
120+
{
121+
caught = exception;
122+
}
123+
124+
await Assert.That(caught).IsNotNull();
125+
await Assert.That(caught!.GetType()).IsEqualTo(typeof(InvalidOperationException));
126+
await Assert.That(caught.Message).IsEqualTo("item 2 failed");
127+
}
128+
129+
[Test]
130+
public async Task Streaming_Results_Surface_Cancellation_As_OperationCanceledException_Not_AggregateException()
131+
{
132+
using var itemCancellation = new CancellationTokenSource();
133+
itemCancellation.Cancel();
134+
135+
await using var processor = new[] { 1 }
136+
.SelectAsync(_ => Task.FromCanceled<int>(itemCancellation.Token))
137+
.ProcessInParallel(2);
138+
139+
Exception? caught = null;
140+
try
141+
{
142+
await foreach (var _ in processor.GetResultsAsyncEnumerable())
143+
{
144+
}
145+
}
146+
catch (Exception exception)
147+
{
148+
caught = exception;
149+
}
150+
151+
await Assert.That(caught is OperationCanceledException).IsTrue();
152+
}
153+
103154
[Test]
104155
public async Task Item_Task_Continuations_Are_Not_Forced_Inline()
105156
{

EnumerableAsyncProcessor/AsyncEnumerableWorkerPool.cs

Lines changed: 44 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -79,15 +79,23 @@ internal static async IAsyncEnumerable<TOutput> ProcessResultsAsync<TInput, TOut
7979

8080
if (pendingResults.Count == workerCount)
8181
{
82-
yield return await pendingResults.Dequeue().ConfigureAwait(false);
82+
// WaitAsync guards against a worker observing cancellation and exiting
83+
// between this item being written and it being claimed - the item's
84+
// completion source would otherwise never complete and this await
85+
// would hang forever.
86+
var value = await pendingResults.Peek().WaitAsync(pipelineToken).ConfigureAwait(false);
87+
pendingResults.Dequeue();
88+
yield return value;
8389
}
8490
}
8591

8692
channel.Writer.TryComplete();
8793

88-
while (pendingResults.TryDequeue(out var resultTask))
94+
while (pendingResults.Count > 0)
8995
{
90-
yield return await resultTask.ConfigureAwait(false);
96+
var value = await pendingResults.Peek().WaitAsync(pipelineToken).ConfigureAwait(false);
97+
pendingResults.Dequeue();
98+
yield return value;
9199
}
92100

93101
await Task.WhenAll(workers).ConfigureAwait(false);
@@ -105,6 +113,17 @@ internal static async IAsyncEnumerable<TOutput> ProcessResultsAsync<TInput, TOut
105113
{
106114
// Expected when enumeration is canceled or the consumer stops early.
107115
}
116+
117+
// Results abandoned by cancellation or an earlier failure may still fault;
118+
// observe them so they cannot surface as UnobservedTaskException.
119+
while (pendingResults.TryDequeue(out var abandonedResult))
120+
{
121+
_ = abandonedResult.ContinueWith(
122+
static t => _ = t.Exception,
123+
CancellationToken.None,
124+
TaskContinuationOptions.ExecuteSynchronously,
125+
TaskScheduler.Default);
126+
}
108127
}
109128
}
110129

@@ -169,19 +188,33 @@ private static Task[] StartResultWorkers<TInput, TOutput>(
169188
{
170189
workers[i] = Task.Run(async () =>
171190
{
172-
await foreach (var workItem in reader.ReadAllAsync(cancellationToken).ConfigureAwait(false))
191+
try
173192
{
174-
Task<TOutput>? task = null;
175-
176-
try
193+
await foreach (var workItem in reader.ReadAllAsync(cancellationToken).ConfigureAwait(false))
177194
{
178-
task = taskSelector(workItem.Input);
179-
workItem.CompletionSource.TrySetResult(await task.ConfigureAwait(false));
195+
Task<TOutput>? task = null;
196+
197+
try
198+
{
199+
task = taskSelector(workItem.Input);
200+
workItem.CompletionSource.TrySetResult(await task.ConfigureAwait(false));
201+
}
202+
catch (Exception exception)
203+
{
204+
workItem.CompletionSource.TrySetFromFault(task, exception, cancellationToken);
205+
}
180206
}
181-
catch (Exception exception)
207+
}
208+
catch (OperationCanceledException)
209+
{
210+
// Cancellation can strand items that were written but never claimed;
211+
// complete them so nothing awaiting their results hangs.
212+
while (reader.TryRead(out var abandonedItem))
182213
{
183-
workItem.CompletionSource.TrySetFromFault(task, exception, cancellationToken);
214+
abandonedItem.CompletionSource.TrySetCanceled(cancellationToken);
184215
}
216+
217+
throw;
185218
}
186219
}, cancellationToken);
187220
}

EnumerableAsyncProcessor/Extensions/EnumerableExtensions.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,10 @@ internal static async IAsyncEnumerable<T> ToIAsyncEnumerable<T>(this IEnumerable
155155
#if NET9_0_OR_GREATER
156156
await foreach (var task in Task.WhenEach(tasks).ConfigureAwait(false).WithCancellation(cancellationToken))
157157
{
158-
yield return task.Result;
158+
// Await rather than .Result: the task is already complete, but .Result wraps
159+
// failures in AggregateException while the net8 fallback path rethrows the
160+
// original exception. Both paths must surface identical exceptions.
161+
yield return await task.ConfigureAwait(false);
159162
}
160163
#else
161164
// Interleaving via completion-order buckets: each task's continuation claims the next

0 commit comments

Comments
 (0)