Skip to content

Dispose IAsyncEnumerable enumerators inline during property-based JSON serialization#126712

Open
Copilot wants to merge 8 commits intomainfrom
copilot/fix-disposeasync-issue
Open

Dispose IAsyncEnumerable enumerators inline during property-based JSON serialization#126712
Copilot wants to merge 8 commits intomainfrom
copilot/fix-disposeasync-issue

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented Apr 9, 2026

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.

// 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.
  • Tests: Add tests covering prompt disposal ordering of sibling properties, nested async enumerables, empty enumerables, truly async disposal paths, and DisposeAsync exception propagation.

@dotnet-policy-service
Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @dotnet/area-system-numerics
See info in area-owners.md if you want to be subscribed.

…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>
Copilot AI requested review from Copilot and removed request for Copilot April 9, 2026 15:38
… 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>
Copilot AI requested review from Copilot and removed request for Copilot April 9, 2026 15:50
…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>
Copilot AI requested review from Copilot and removed request for Copilot April 9, 2026 15:53
Agent-Logs-Url: https://github.com/dotnet/runtime/sessions/789acec8-2f71-4980-adac-0635a3f61a35

Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com>
Copilot AI requested review from Copilot and removed request for Copilot April 9, 2026 15:57
Copilot AI changed the title [WIP] Fix DisposeAsync not invoked for property-based IAsyncEnumerable during JSON serialization Dispose IAsyncEnumerable enumerators inline during property-based JSON serialization Apr 9, 2026
Copilot AI requested a review from eiriktsarpalis April 9, 2026 15:58
Copy link
Copy Markdown
Member

@eiriktsarpalis eiriktsarpalis left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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>
Copilot AI requested review from Copilot and removed request for Copilot April 9, 2026 18:16
Copy link
Copy Markdown
Contributor Author

Copilot AI commented Apr 9, 2026

@copilot do we need to get rid of

If this is about CompletedAsyncDisposables — yes, it was already removed in the initial commit (287f2c5). If about AsyncDisposable — see my reply on the other thread; it's still needed for enumerator storage between re-entries and for exception cleanup in DisposePendingDisposablesOnExceptionAsync.

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>
Copilot AI requested review from Copilot and removed request for Copilot April 9, 2026 18:46
// ensure the enumerator already is stored
// in the WriteStack for proper disposal.
moveNextTask = enumerator.MoveNextAsync();
case AsyncEnumeratorState.PendingDisposal:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot please order the case handling to follow the natural order of enumeration. Disposal should be last accordingly.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reordered in 6c755f5. Cases now follow the natural lifecycle: NonePendingMoveNextPendingDisposaldefault (Enumerating).

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 in IAsyncEnumerableOfTConverter.OnWriteResume, including supporting pending DisposeAsync() via WriteStack.PendingTask.
  • Replace deferred CompletedAsyncDisposables disposal with per-frame AsyncEnumeratorState tracking (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.

Comment on lines +514 to +519
events.Add($"{id}:Disposed");
if (asyncDisposal)
{
return new ValueTask(Task.Delay(1));
}
return default;
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
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");
}

Copilot uses AI. Check for mistakes.
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

DisposeAsync() is not invoked for property-based IAsyncEnumerable<T> during JSON serialization, causing EF Core (PostgreSQL) connections to remain open

3 participants