Uh oh!
There was an error while loading. Please reload this page.
Dispose IAsyncEnumerable enumerators inline during property-based JSON serialization - #126712
Conversation
…d serialization Dispose async enumerators inline when enumeration completes instead of deferring to a CompletedAsyncDisposables list. When DisposeAsync returns a pending task, it is stored as PendingTask and yielded to the root serialization loop for proper awaiting, matching MoveNextAsync behavior. This ensures property-based IAsyncEnumerable<T> enumerators are disposed promptly after enumeration, preventing resource leaks such as EF Core connections remaining open. Agent-Logs-Url: https://github.com/dotnet/runtime/sessions/789acec8-2f71-4980-adac-0635a3f61a35 Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com>
… behavior Tests cover: - Sequential nested async enumerables verify each is disposed before the next starts - Nested async enumerable inside async enumerable (inner disposal tracking) - Empty async enumerables are disposed promptly - DisposeAsync exception propagation - Both sync and async disposal paths (ValueTask vs Task) Agent-Logs-Url: https://github.com/dotnet/runtime/sessions/789acec8-2f71-4980-adac-0635a3f61a35 Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com>
…ruly async Use Task.Delay(1) instead of Task.CompletedTask to exercise the pending DisposeAsync task path in tests. Agent-Logs-Url: https://github.com/dotnet/runtime/sessions/789acec8-2f71-4980-adac-0635a3f61a35 Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com>
Agent-Logs-Url: https://github.com/dotnet/runtime/sessions/789acec8-2f71-4980-adac-0635a3f61a35 Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com>
eiriktsarpalis
left a comment
There was a problem hiding this comment.
@copilot do we need to get rid of
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Consolidate AsyncEnumeratorIsPendingCompletion and AsyncEnumeratorIsPendingDisposal into an AsyncEnumeratorState enum with values: None, Enumerating, PendingMoveNext, PendingDisposal. Refactor OnWriteResume to use a switch expression over the enum for clearer state transitions. The AsyncDisposable field is retained as it is still needed for storing the enumerator reference between re-entries and for exception cleanup in DisposePendingDisposablesOnExceptionAsync. Agent-Logs-Url: https://github.com/dotnet/runtime/sessions/2e84e8f8-01b8-4b95-86b8-6dc8b310efaf Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com>
If this is about |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com>
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com>
Uh oh!
There was an error while loading. Please reload this page.
…N serialization (#126712) ## Description `DisposeAsync()` was never invoked for property-based `IAsyncEnumerable<T>` during async JSON serialization. Enumerators were queued into a `CompletedAsyncDisposables` list and only disposed after the entire object finished serializing — meaning sibling `IAsyncEnumerable<T>` properties would start enumeration before prior enumerators were disposed. This caused resource leaks, most visibly with EF Core where PostgreSQL connections remained open. ```csharp // Second property's enumeration starts before first's enumerator is disposed await JsonSerializer.SerializeAsync(stream, new { A = dbContext.Products.Where(p => p.Id % 2 == 0).AsAsyncEnumerable(), B = dbContext.Products.Where(p => p.Id % 2 != 0).AsAsyncEnumerable(), // fails: connection still open from A }); ``` ### Changes - **`IAsyncEnumerableOfTConverter.OnWriteResume`**: Call `DisposeAsync()` inline when enumeration completes. If the resulting `ValueTask` is pending, store it as `PendingTask` and yield to the root serialization loop — mirroring how `MoveNextAsync()` pending tasks are already handled. Refactored to use a `switch` over the new `AsyncEnumeratorState` enum for clearer state transitions. - **`WriteStackFrame`**: Replace `AsyncEnumeratorIsPendingCompletion` and `AsyncEnumeratorIsPendingDisposal` boolean fields with a single `AsyncEnumeratorState` enum field. Rename `AsyncDisposable` to `AsyncEnumerator` (typed as `object?`) to better reflect that it always stores an `IAsyncEnumerator<T>` instance, used for enumerator retrieval between re-entries and for exception cleanup in `DisposePendingDisposablesOnExceptionAsync`. - **`AsyncEnumeratorState`**: New enum with values `None`, `Enumerating`, `PendingMoveNext`, and `PendingDisposal` to track the async enumerator's lifecycle within a stack frame. - **`WriteStack`**: Remove `CompletedAsyncDisposables` field, `AddCompletedAsyncDisposable()`, and `DisposeCompletedAsyncDisposables()` — no longer needed. Update `DisposePendingDisposablesOnExceptionAsync` to pattern-match `AsyncEnumerator` on `IAsyncDisposable` for exception cleanup. - **`JsonTypeInfoOfT.WriteHelpers.cs`**: Remove deferred `CompletedAsyncDisposables` disposal from the root serialization loop `finally` block. - **Build compatibility fix**: Replace `UnreachableException` usage in `IAsyncEnumerableOfTConverter` with `InvalidOperationException("Invalid async enumerator state.")` to avoid build breaks on target frameworks where `UnreachableException` is unavailable. - **Build break follow-up fix**: Replace `_ = disposeTask.Result` with `disposeTask.GetAwaiter().GetResult()` in the synchronous disposal path, since non-generic `ValueTask` has no `Result` property. - **Tests**: Add tests covering prompt disposal ordering of sibling properties, nested async enumerables, empty enumerables, truly async disposal paths, and `DisposeAsync` exception propagation. - **Test synchronization hardening**: Update disposal-ordering tests to use explicit state-based synchronization callbacks between enumerators (instead of timing/order assertions on event list writes), reducing flakiness risk. - **Pending-then-faulted disposal coverage**: Extend `ThrowingDisposeAsyncEnumerable` tests to also cover the async fault path (`await Task.Yield(); throw ...`) so the `PendingDisposal` suspend/resume exception propagation path is validated. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com> Co-authored-by: Eirik Tsarpalis <eirik.tsarpalis@gmail.com>
Description
DisposeAsync()was never invoked for property-basedIAsyncEnumerable<T>during async JSON serialization. Enumerators were queued into aCompletedAsyncDisposableslist and only disposed after the entire object finished serializing — meaning siblingIAsyncEnumerable<T>properties would start enumeration before prior enumerators were disposed. This caused resource leaks, most visibly with EF Core where PostgreSQL connections remained open.Changes
IAsyncEnumerableOfTConverter.OnWriteResume: CallDisposeAsync()inline when enumeration completes. If the resultingValueTaskis pending, store it asPendingTaskand yield to the root serialization loop — mirroring howMoveNextAsync()pending tasks are already handled. Refactored to use aswitchover the newAsyncEnumeratorStateenum for clearer state transitions.WriteStackFrame: ReplaceAsyncEnumeratorIsPendingCompletionandAsyncEnumeratorIsPendingDisposalboolean fields with a singleAsyncEnumeratorStateenum field. RenameAsyncDisposabletoAsyncEnumerator(typed asobject?) to better reflect that it always stores anIAsyncEnumerator<T>instance, used for enumerator retrieval between re-entries and for exception cleanup inDisposePendingDisposablesOnExceptionAsync.AsyncEnumeratorState: New enum with valuesNone,Enumerating,PendingMoveNext, andPendingDisposalto track the async enumerator's lifecycle within a stack frame.WriteStack: RemoveCompletedAsyncDisposablesfield,AddCompletedAsyncDisposable(), andDisposeCompletedAsyncDisposables()— no longer needed. UpdateDisposePendingDisposablesOnExceptionAsyncto pattern-matchAsyncEnumeratoronIAsyncDisposablefor exception cleanup.JsonTypeInfoOfT.WriteHelpers.cs: Remove deferredCompletedAsyncDisposablesdisposal from the root serialization loopfinallyblock.UnreachableExceptionusage inIAsyncEnumerableOfTConverterwithInvalidOperationException("Invalid async enumerator state.")to avoid build breaks on target frameworks whereUnreachableExceptionis unavailable._ = disposeTask.ResultwithdisposeTask.GetAwaiter().GetResult()in the synchronous disposal path, since non-genericValueTaskhas noResultproperty.DisposeAsyncexception propagation.ThrowingDisposeAsyncEnumerabletests to also cover the async fault path (await Task.Yield(); throw ...) so thePendingDisposalsuspend/resume exception propagation path is validated.