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

Merged
eiriktsarpalis merged 14 commits into
mainfrom
copilot/fix-disposeasync-issue
Jun 9, 2026
Merged

Dispose IAsyncEnumerable enumerators inline during property-based JSON serialization#126712
eiriktsarpalis merged 14 commits into
mainfrom
copilot/fix-disposeasync-issue

Conversation

CopilotAI commented Apr 9, 2026

Copy link
Copy Markdown
Contributor

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

…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>
CopilotAI requested review from Copilot and removed request for CopilotApril 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>
CopilotAI requested review from Copilot and removed request for CopilotApril 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>
CopilotAI requested review from Copilot and removed request for CopilotApril 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>
CopilotAI requested review from Copilot and removed request for CopilotApril 9, 2026 15:57
CopilotAI changed the title [WIP] Fix DisposeAsync not invoked for property-based IAsyncEnumerable during JSON serializationDispose IAsyncEnumerable enumerators inline during property-based JSON serializationApr 9, 2026
CopilotAI requested a review from eiriktsarpalisApril 9, 2026 15:58

@eiriktsarpaliseiriktsarpalis left a comment

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 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>
CopilotAI requested review from Copilot and removed request for CopilotApril 9, 2026 18:16

CopilotAI commented Apr 9, 2026

Copy link
Copy Markdown
ContributorAuthor

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

Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com>
CopilotAI requested review from Copilot and removed request for CopilotJune 9, 2026 14:27
CopilotAI review requested due to automatic review settings June 9, 2026 15:10

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.

Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com>
@eiriktsarpalis
eiriktsarpalis enabled auto-merge (squash) June 9, 2026 18:14
@eiriktsarpalis
eiriktsarpalis merged commit 89cd18a into mainJun 9, 2026
94 of 96 checks passed
@eiriktsarpalis
eiriktsarpalis deleted the copilot/fix-disposeasync-issue branch June 9, 2026 18:15
eiriktsarpalis added a commit that referenced this pull request Jul 15, 2026
…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>
@github-actionsgithub-actionsBot locked and limited conversation to collaborators Jul 18, 2026
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

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

5 participants

@tarekgh@eiriktsarpalis@PranavSenthilnathan
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

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

Merged
eiriktsarpalis merged 14 commits into
mainfrom
copilot/fix-disposeasync-issue
Jun 9, 2026
Merged

Dispose IAsyncEnumerable enumerators inline during property-based JSON serialization#126712
eiriktsarpalis merged 14 commits into
mainfrom
copilot/fix-disposeasync-issue

Conversation

CopilotAI commented Apr 9, 2026

Copy link
Copy Markdown
Contributor

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

…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>
CopilotAI requested review from Copilot and removed request for CopilotApril 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>
CopilotAI requested review from Copilot and removed request for CopilotApril 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>
CopilotAI requested review from Copilot and removed request for CopilotApril 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>
CopilotAI requested review from Copilot and removed request for CopilotApril 9, 2026 15:57
CopilotAI changed the title [WIP] Fix DisposeAsync not invoked for property-based IAsyncEnumerable during JSON serializationDispose IAsyncEnumerable enumerators inline during property-based JSON serializationApr 9, 2026
CopilotAI requested a review from eiriktsarpalisApril 9, 2026 15:58

@eiriktsarpaliseiriktsarpalis left a comment

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 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>
CopilotAI requested review from Copilot and removed request for CopilotApril 9, 2026 18:16

CopilotAI commented Apr 9, 2026

Copy link
Copy Markdown
ContributorAuthor

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

Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com>
CopilotAI requested review from Copilot and removed request for CopilotJune 9, 2026 14:27
CopilotAI review requested due to automatic review settings June 9, 2026 15:10

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.

Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com>
@eiriktsarpalis
eiriktsarpalis enabled auto-merge (squash) June 9, 2026 18:14
@eiriktsarpalis
eiriktsarpalis merged commit 89cd18a into mainJun 9, 2026
94 of 96 checks passed
@eiriktsarpalis
eiriktsarpalis deleted the copilot/fix-disposeasync-issue branch June 9, 2026 18:15
eiriktsarpalis added a commit that referenced this pull request Jul 15, 2026
…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>
@github-actionsgithub-actionsBot locked and limited conversation to collaborators Jul 18, 2026
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

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

5 participants

@tarekgh@eiriktsarpalis@PranavSenthilnathan
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

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

Merged
eiriktsarpalis merged 14 commits into
mainfrom
copilot/fix-disposeasync-issue
Jun 9, 2026
Merged

Dispose IAsyncEnumerable enumerators inline during property-based JSON serialization#126712
eiriktsarpalis merged 14 commits into
mainfrom
copilot/fix-disposeasync-issue

Conversation

CopilotAI commented Apr 9, 2026

Copy link
Copy Markdown
Contributor

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

…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>
CopilotAI requested review from Copilot and removed request for CopilotApril 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>
CopilotAI requested review from Copilot and removed request for CopilotApril 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>
CopilotAI requested review from Copilot and removed request for CopilotApril 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>
CopilotAI requested review from Copilot and removed request for CopilotApril 9, 2026 15:57
CopilotAI changed the title [WIP] Fix DisposeAsync not invoked for property-based IAsyncEnumerable during JSON serializationDispose IAsyncEnumerable enumerators inline during property-based JSON serializationApr 9, 2026
CopilotAI requested a review from eiriktsarpalisApril 9, 2026 15:58

@eiriktsarpaliseiriktsarpalis left a comment

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 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>
CopilotAI requested review from Copilot and removed request for CopilotApril 9, 2026 18:16

CopilotAI commented Apr 9, 2026

Copy link
Copy Markdown
ContributorAuthor

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

Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com>
CopilotAI requested review from Copilot and removed request for CopilotJune 9, 2026 14:27
CopilotAI review requested due to automatic review settings June 9, 2026 15:10

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.

Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com>
@eiriktsarpalis
eiriktsarpalis enabled auto-merge (squash) June 9, 2026 18:14
@eiriktsarpalis
eiriktsarpalis merged commit 89cd18a into mainJun 9, 2026
94 of 96 checks passed
@eiriktsarpalis
eiriktsarpalis deleted the copilot/fix-disposeasync-issue branch June 9, 2026 18:15
eiriktsarpalis added a commit that referenced this pull request Jul 15, 2026
…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>
@github-actionsgithub-actionsBot locked and limited conversation to collaborators Jul 18, 2026
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

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

5 participants

@tarekgh@eiriktsarpalis@PranavSenthilnathan
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

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

Merged
eiriktsarpalis merged 14 commits into
mainfrom
copilot/fix-disposeasync-issue
Jun 9, 2026
Merged

Dispose IAsyncEnumerable enumerators inline during property-based JSON serialization#126712
eiriktsarpalis merged 14 commits into
mainfrom
copilot/fix-disposeasync-issue

Conversation

CopilotAI commented Apr 9, 2026

Copy link
Copy Markdown
Contributor

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

…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>
CopilotAI requested review from Copilot and removed request for CopilotApril 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>
CopilotAI requested review from Copilot and removed request for CopilotApril 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>
CopilotAI requested review from Copilot and removed request for CopilotApril 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>
CopilotAI requested review from Copilot and removed request for CopilotApril 9, 2026 15:57
CopilotAI changed the title [WIP] Fix DisposeAsync not invoked for property-based IAsyncEnumerable during JSON serializationDispose IAsyncEnumerable enumerators inline during property-based JSON serializationApr 9, 2026
CopilotAI requested a review from eiriktsarpalisApril 9, 2026 15:58

@eiriktsarpaliseiriktsarpalis left a comment

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 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>
CopilotAI requested review from Copilot and removed request for CopilotApril 9, 2026 18:16

CopilotAI commented Apr 9, 2026

Copy link
Copy Markdown
ContributorAuthor

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

Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com>
CopilotAI requested review from Copilot and removed request for CopilotJune 9, 2026 14:27
CopilotAI review requested due to automatic review settings June 9, 2026 15:10

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.

Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com>
@eiriktsarpalis
eiriktsarpalis enabled auto-merge (squash) June 9, 2026 18:14
@eiriktsarpalis
eiriktsarpalis merged commit 89cd18a into mainJun 9, 2026
94 of 96 checks passed
@eiriktsarpalis
eiriktsarpalis deleted the copilot/fix-disposeasync-issue branch June 9, 2026 18:15
eiriktsarpalis added a commit that referenced this pull request Jul 15, 2026
…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>
@github-actionsgithub-actionsBot locked and limited conversation to collaborators Jul 18, 2026
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

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

5 participants

@tarekgh@eiriktsarpalis@PranavSenthilnathan
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

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

Merged
eiriktsarpalis merged 14 commits into
mainfrom
copilot/fix-disposeasync-issue
Jun 9, 2026
Merged

Dispose IAsyncEnumerable enumerators inline during property-based JSON serialization#126712
eiriktsarpalis merged 14 commits into
mainfrom
copilot/fix-disposeasync-issue

Conversation

CopilotAI commented Apr 9, 2026

Copy link
Copy Markdown
Contributor

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

…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>
CopilotAI requested review from Copilot and removed request for CopilotApril 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>
CopilotAI requested review from Copilot and removed request for CopilotApril 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>
CopilotAI requested review from Copilot and removed request for CopilotApril 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>
CopilotAI requested review from Copilot and removed request for CopilotApril 9, 2026 15:57
CopilotAI changed the title [WIP] Fix DisposeAsync not invoked for property-based IAsyncEnumerable during JSON serializationDispose IAsyncEnumerable enumerators inline during property-based JSON serializationApr 9, 2026
CopilotAI requested a review from eiriktsarpalisApril 9, 2026 15:58

@eiriktsarpaliseiriktsarpalis left a comment

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 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>
CopilotAI requested review from Copilot and removed request for CopilotApril 9, 2026 18:16

CopilotAI commented Apr 9, 2026

Copy link
Copy Markdown
ContributorAuthor

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

Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com>
CopilotAI requested review from Copilot and removed request for CopilotJune 9, 2026 14:27
CopilotAI review requested due to automatic review settings June 9, 2026 15:10

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.

Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com>
@eiriktsarpalis
eiriktsarpalis enabled auto-merge (squash) June 9, 2026 18:14
@eiriktsarpalis
eiriktsarpalis merged commit 89cd18a into mainJun 9, 2026
94 of 96 checks passed
@eiriktsarpalis
eiriktsarpalis deleted the copilot/fix-disposeasync-issue branch June 9, 2026 18:15
eiriktsarpalis added a commit that referenced this pull request Jul 15, 2026
…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>
@github-actionsgithub-actionsBot locked and limited conversation to collaborators Jul 18, 2026
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

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

5 participants

@tarekgh@eiriktsarpalis@PranavSenthilnathan
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

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

Merged
eiriktsarpalis merged 14 commits into
mainfrom
copilot/fix-disposeasync-issue
Jun 9, 2026
Merged

Dispose IAsyncEnumerable enumerators inline during property-based JSON serialization#126712
eiriktsarpalis merged 14 commits into
mainfrom
copilot/fix-disposeasync-issue

Conversation

CopilotAI commented Apr 9, 2026

Copy link
Copy Markdown
Contributor

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

…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>
CopilotAI requested review from Copilot and removed request for CopilotApril 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>
CopilotAI requested review from Copilot and removed request for CopilotApril 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>
CopilotAI requested review from Copilot and removed request for CopilotApril 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>
CopilotAI requested review from Copilot and removed request for CopilotApril 9, 2026 15:57
CopilotAI changed the title [WIP] Fix DisposeAsync not invoked for property-based IAsyncEnumerable during JSON serializationDispose IAsyncEnumerable enumerators inline during property-based JSON serializationApr 9, 2026
CopilotAI requested a review from eiriktsarpalisApril 9, 2026 15:58

@eiriktsarpaliseiriktsarpalis left a comment

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 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>
CopilotAI requested review from Copilot and removed request for CopilotApril 9, 2026 18:16

CopilotAI commented Apr 9, 2026

Copy link
Copy Markdown
ContributorAuthor

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

Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com>
CopilotAI requested review from Copilot and removed request for CopilotJune 9, 2026 14:27
CopilotAI review requested due to automatic review settings June 9, 2026 15:10

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.

Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com>
@eiriktsarpalis
eiriktsarpalis enabled auto-merge (squash) June 9, 2026 18:14
@eiriktsarpalis
eiriktsarpalis merged commit 89cd18a into mainJun 9, 2026
94 of 96 checks passed
@eiriktsarpalis
eiriktsarpalis deleted the copilot/fix-disposeasync-issue branch June 9, 2026 18:15
eiriktsarpalis added a commit that referenced this pull request Jul 15, 2026
…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>
@github-actionsgithub-actionsBot locked and limited conversation to collaborators Jul 18, 2026
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

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

5 participants

@tarekgh@eiriktsarpalis@PranavSenthilnathan
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

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

Merged
eiriktsarpalis merged 14 commits into
mainfrom
copilot/fix-disposeasync-issue
Jun 9, 2026
Merged

Dispose IAsyncEnumerable enumerators inline during property-based JSON serialization#126712
eiriktsarpalis merged 14 commits into
mainfrom
copilot/fix-disposeasync-issue

Conversation

CopilotAI commented Apr 9, 2026

Copy link
Copy Markdown
Contributor

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

…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>
CopilotAI requested review from Copilot and removed request for CopilotApril 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>
CopilotAI requested review from Copilot and removed request for CopilotApril 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>
CopilotAI requested review from Copilot and removed request for CopilotApril 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>
CopilotAI requested review from Copilot and removed request for CopilotApril 9, 2026 15:57
CopilotAI changed the title [WIP] Fix DisposeAsync not invoked for property-based IAsyncEnumerable during JSON serializationDispose IAsyncEnumerable enumerators inline during property-based JSON serializationApr 9, 2026
CopilotAI requested a review from eiriktsarpalisApril 9, 2026 15:58

@eiriktsarpaliseiriktsarpalis left a comment

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 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>
CopilotAI requested review from Copilot and removed request for CopilotApril 9, 2026 18:16

CopilotAI commented Apr 9, 2026

Copy link
Copy Markdown
ContributorAuthor

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

Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com>
CopilotAI requested review from Copilot and removed request for CopilotJune 9, 2026 14:27
CopilotAI review requested due to automatic review settings June 9, 2026 15:10

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.

Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com>
@eiriktsarpalis
eiriktsarpalis enabled auto-merge (squash) June 9, 2026 18:14
@eiriktsarpalis
eiriktsarpalis merged commit 89cd18a into mainJun 9, 2026
94 of 96 checks passed
@eiriktsarpalis
eiriktsarpalis deleted the copilot/fix-disposeasync-issue branch June 9, 2026 18:15
eiriktsarpalis added a commit that referenced this pull request Jul 15, 2026
…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>
@github-actionsgithub-actionsBot locked and limited conversation to collaborators Jul 18, 2026
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

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

5 participants

@tarekgh@eiriktsarpalis@PranavSenthilnathan
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

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

Merged
eiriktsarpalis merged 14 commits into
mainfrom
copilot/fix-disposeasync-issue
Jun 9, 2026
Merged

Dispose IAsyncEnumerable enumerators inline during property-based JSON serialization#126712
eiriktsarpalis merged 14 commits into
mainfrom
copilot/fix-disposeasync-issue

Conversation

CopilotAI commented Apr 9, 2026

Copy link
Copy Markdown
Contributor

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

…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>
CopilotAI requested review from Copilot and removed request for CopilotApril 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>
CopilotAI requested review from Copilot and removed request for CopilotApril 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>
CopilotAI requested review from Copilot and removed request for CopilotApril 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>
CopilotAI requested review from Copilot and removed request for CopilotApril 9, 2026 15:57
CopilotAI changed the title [WIP] Fix DisposeAsync not invoked for property-based IAsyncEnumerable during JSON serializationDispose IAsyncEnumerable enumerators inline during property-based JSON serializationApr 9, 2026
CopilotAI requested a review from eiriktsarpalisApril 9, 2026 15:58

@eiriktsarpaliseiriktsarpalis left a comment

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 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>
CopilotAI requested review from Copilot and removed request for CopilotApril 9, 2026 18:16

CopilotAI commented Apr 9, 2026

Copy link
Copy Markdown
ContributorAuthor

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

Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com>
CopilotAI requested review from Copilot and removed request for CopilotJune 9, 2026 14:27
CopilotAI review requested due to automatic review settings June 9, 2026 15:10

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.

Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com>
@eiriktsarpalis
eiriktsarpalis enabled auto-merge (squash) June 9, 2026 18:14
@eiriktsarpalis
eiriktsarpalis merged commit 89cd18a into mainJun 9, 2026
94 of 96 checks passed
@eiriktsarpalis
eiriktsarpalis deleted the copilot/fix-disposeasync-issue branch June 9, 2026 18:15
eiriktsarpalis added a commit that referenced this pull request Jul 15, 2026
…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>
@github-actionsgithub-actionsBot locked and limited conversation to collaborators Jul 18, 2026
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

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

5 participants

@tarekgh@eiriktsarpalis@PranavSenthilnathan