Dispose IAsyncEnumerable enumerators inline during property-based JSON serialization#126712
Dispose IAsyncEnumerable enumerators inline during property-based JSON serialization#126712Copilot wants to merge 11 commits into
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
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 |
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. |
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>
| public ValueTask DisposeAsync() | ||
| { | ||
| inner.Dispose(); | ||
| events.Add($"{id}:Disposed"); | ||
| if (asyncDisposal) | ||
| { | ||
| return new ValueTask(Task.Delay(1)); | ||
| } | ||
| return default; | ||
| } |
There was a problem hiding this comment.
@copilot making tests depend on timing is recipe for flakiness. Rather than doing this, we want to make sure some degree of syncrhonization is performed (e.g. by having bespoke async enumerators updating state).
Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com>
|
Tagging subscribers to this area: @dotnet/area-system-text-json |
|
I went through this change in depth (production code + the suspend/resume flow + the new tests) and it looks correct. The inline disposal in Two test-only items remain before this is ready: 1. The most recent review feedback on test synchronization is still open. 2. The pending-then-faulted ValueTask disposeTask = enumerator.DisposeAsync();
if (!disposeTask.IsCompleted)
{
state.PendingTask = disposeTask.AsTask();
state.Current.AsyncEnumeratorState = AsyncEnumeratorState.PendingDisposal;
return false;
}followed on resume by: case AsyncEnumeratorState.PendingDisposal:
state.PendingTask.GetAwaiter().GetResult();This is the trickiest path, because the exception has to survive the root loop's A Minor: the switch cases read |
tarekgh
left a comment
There was a problem hiding this comment.
Add a comment, LGTM otherwise.
|
@copilot please fix the build errors and address #126712 (comment) |
Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com>
Addressed in 173cff5.
|
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.DisposeAsyncexception propagation.ThrowingDisposeAsyncEnumerabletests to also cover the async fault path (await Task.Yield(); throw ...) so thePendingDisposalsuspend/resume exception propagation path is validated.