Dispose IAsyncEnumerable enumerators inline during property-based JSON serialization#126712
Dispose IAsyncEnumerable enumerators inline during property-based JSON serialization#126712
Conversation
|
Tagging subscribers to this area: @dotnet/area-system-numerics |
…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
...son/src/System/Text/Json/Serialization/Converters/Collection/IAsyncEnumerableOfTConverter.cs
Outdated
Show resolved
Hide resolved
src/libraries/System.Text.Json/src/System/Text/Json/Serialization/WriteStackFrame.cs
Outdated
Show resolved
Hide resolved
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 |
The field always stores an IAsyncEnumerator<T>, so rename and widen the type to object? to make its utility more self-evident. The DisposeFrame helper in WriteStack now pattern-matches on IAsyncDisposable for exception cleanup. Agent-Logs-Url: https://github.com/dotnet/runtime/sessions/9c262aea-5dbb-468f-8220-3f33fd23e811 Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com>
| // ensure the enumerator already is stored | ||
| // in the WriteStack for proper disposal. | ||
| moveNextTask = enumerator.MoveNextAsync(); | ||
| case AsyncEnumeratorState.PendingDisposal: |
There was a problem hiding this comment.
@copilot please order the case handling to follow the natural order of enumeration. Disposal should be last accordingly.
There was a problem hiding this comment.
Reordered in 6c755f5. Cases now follow the natural lifecycle: None → PendingMoveNext → PendingDisposal → default (Enumerating).
There was a problem hiding this comment.
Pull request overview
Fixes async JSON serialization of property-based IAsyncEnumerable<T> so enumerators are disposed promptly (and awaited when disposal is asynchronous), preventing resource leaks (e.g., DB connections) and avoiding sibling properties enumerating before prior enumerators are disposed.
Changes:
- Dispose
IAsyncEnumerator<T>inline inIAsyncEnumerableOfTConverter.OnWriteResume, including supporting pendingDisposeAsync()viaWriteStack.PendingTask. - Replace deferred
CompletedAsyncDisposablesdisposal with per-frameAsyncEnumeratorStatetracking (WriteStackFrame,WriteStack, root serialization loop). - Add/extend tests for disposal ordering and exception propagation.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| src/libraries/System.Text.Json/tests/Common/CollectionTests/CollectionTests.AsyncEnumerable.cs | Adds tests covering disposal ordering, async disposal, empty enumerables, and disposal exceptions. |
| src/libraries/System.Text.Json/src/System/Text/Json/Serialization/WriteStackFrame.cs | Replaces async-disposable tracking fields with AsyncEnumerator + AsyncEnumeratorState. |
| src/libraries/System.Text.Json/src/System/Text/Json/Serialization/WriteStack.cs | Removes deferred disposal list; updates exception cleanup to dispose the per-frame async enumerator when present. |
| src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonTypeInfoOfT.WriteHelpers.cs | Stops disposing CompletedAsyncDisposables; keeps awaiting resumable pending tasks (now includes DisposeAsync()). |
| src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Collection/IAsyncEnumerableOfTConverter.cs | Implements inline enumerator disposal and handles pending DisposeAsync() with resumable state. |
| src/libraries/System.Text.Json/src/System/Text/Json/Serialization/AsyncEnumeratorState.cs | Introduces enum for async enumerator lifecycle tracking within a WriteStackFrame. |
| src/libraries/System.Text.Json/src/System.Text.Json.csproj | Adds new AsyncEnumeratorState.cs to compilation. |
| events.Add($"{id}:Disposed"); | ||
| if (asyncDisposal) | ||
| { | ||
| return new ValueTask(Task.Delay(1)); | ||
| } | ||
| return default; |
There was a problem hiding this comment.
The asyncDisposal path in DisposalTrackingAsyncEnumerable.Enumerator.DisposeAsync() records "{id}:Disposed" before returning a pending Task.Delay(1). That means the ordering assertions in WriteSequentialNestedAsyncEnumerables_… / WriteEmptyNestedAsyncEnumerables_… don’t actually verify that serialization waits for async disposal to complete before starting the next enumerator—only that DisposeAsync() was invoked. To cover the real scenario (e.g., DB connection released when the disposal task completes), consider logging Disposed only after the async delay (or add separate DisposeStarted/Disposed events and assert Disposed precedes the next enumerator’s first MoveNext).
| events.Add($"{id}:Disposed"); | |
| if (asyncDisposal) | |
| { | |
| return new ValueTask(Task.Delay(1)); | |
| } | |
| return default; | |
| if (asyncDisposal) | |
| { | |
| return CompleteAsyncDispose(); | |
| } | |
| events.Add($"{id}:Disposed"); | |
| return default; | |
| async ValueTask CompleteAsyncDispose() | |
| { | |
| await Task.Delay(1); | |
| events.Add($"{id}:Disposed"); | |
| } |
None → PendingMoveNext → PendingDisposal → default (Enumerating) Agent-Logs-Url: https://github.com/dotnet/runtime/sessions/7417b369-e732-4d7b-9daa-ebf6af1d1762 Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.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.DisposeAsyncexception propagation.