-
Notifications
You must be signed in to change notification settings - Fork 4.2k
Description
Version Used:
dotnet 10.0.101
Steps to Reproduce:
bool first = true;
async IAsyncEnumerable<int> Create([EnumeratorCancellation] CancellationToken ct = default)
{
if (!first)
await Task.Delay(TimeSpan.FromDays(1), ct);
first = false;
yield break;
}
var stream = Create();
var en1 = stream.GetAsyncEnumerator(CancellationToken.None);
await en1.MoveNextAsync();
var en2 = stream.GetAsyncEnumerator(CancellationToken.None);
_ = en2.MoveNextAsync().AsTask();
await en1.DisposeAsync();
Expected Behavior:
Would expect this code to run through without errors
Actual Behavior:
Last line throws a NotSupportedExcetion
Possible cause:
From looking at the IL - bit hard to read so take with a big grain of salt.. but it looks like the generated state machine implements both IAsyncEnumerable and IAsyncEnumerator.
In most casese, GetAsyncEnumerator() will "clone" the state machine and return that as an IAsyncEnumerator.
In some cases, however, it returns itself as the IAsyncEnumerator - specifically, when the calling thread is the same as the thread that created this state machine instance, and if the state machine is in a resettable state.
It looks though that on some cases (eg synchronous completion of the sequence with synchronous disposal), the state machine can reach this resettable state while external callers are still permitted to call methods on it afterwards. So basically,
bool first = true;
async IAsyncEnumerable<int> Create([EnumeratorCancellation] CancellationToken ct = default)
{
if (!first)
await Task.Delay(TimeSpan.FromDays(1), ct);
first = false;
yield break;
}
// Our current managed thread ID is captured
var stream = Create();
// Still on the same thread, the state machine itself is returned as the enumerator
var en1 = stream.GetAsyncEnumerator(CancellationToken.None);
// Everything completes synchronously here. So afterwards, the state machine is now in principle eligible to be recycled for new GetAsyncEnumerator calls
await en1.MoveNextAsync();
// Still on the same thread, state machine is seen as resettable, hence this call gets an enumerator that is instance-equal to en1
var en2 = stream.GetAsyncEnumerator(CancellationToken.None);
// Now we transition the state machine into Running state
_ = en2.MoveNextAsync().AsTask();
// .. But wait, we are still allowed to dispose the "old" enumerator. But the old enumerator is the same as the new enumerator. Hence we are actually trying to dispose a state machine that by now is in running state, so things explode
await en1.DisposeAsync();
Possible fix
Not sure if that's the best way, but possibly a state machine should only be eligible to be recycled after DisposeAsync has been called explicitly (even if its implementation is de facto a no-op).
Then again, even that would only really work if callers are only ever allowed to call DisposeAsync once - which I'm not sure about. Would seem reasonable to assume that, but I've not seen any mention that multiple calls are against the contract?
Metadata
Metadata
Assignees
Labels
Type
Projects
Status