From 716b84c47a71a5b82b1dedf387005545f5e4efbb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 9 Apr 2026 14:58:13 +0000 Subject: [PATCH 1/8] Initial plan From 287f2c50d1ab7aba50d768087161fd16628041ae Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 9 Apr 2026 15:38:44 +0000 Subject: [PATCH 2/8] Fix IAsyncEnumerable DisposeAsync not being invoked for property-based 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 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> --- .../IAsyncEnumerableOfTConverter.cs | 27 +++++++++++++-- .../Metadata/JsonTypeInfoOfT.WriteHelpers.cs | 8 +---- .../Text/Json/Serialization/WriteStack.cs | 34 ------------------- .../Json/Serialization/WriteStackFrame.cs | 6 ++++ 4 files changed, 31 insertions(+), 44 deletions(-) diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Collection/IAsyncEnumerableOfTConverter.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Collection/IAsyncEnumerableOfTConverter.cs index 9c55998ef85c00..902dd279de0ee5 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Collection/IAsyncEnumerableOfTConverter.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Collection/IAsyncEnumerableOfTConverter.cs @@ -49,6 +49,17 @@ protected override bool OnWriteResume(Utf8JsonWriter writer, TAsyncEnumerable va IAsyncEnumerator enumerator; ValueTask moveNextTask; + if (state.Current.AsyncEnumeratorIsPendingDisposal) + { + // Converter was previously suspended due to a pending DisposeAsync() task. + Debug.Assert(state.Current.AsyncDisposable is null); + Debug.Assert(state.PendingTask is not null && state.PendingTask.IsCompleted); + state.PendingTask.GetAwaiter().GetResult(); + state.Current.AsyncEnumeratorIsPendingDisposal = false; + state.PendingTask = null; + return true; + } + if (state.Current.AsyncDisposable is null) { enumerator = value.GetAsyncEnumerator(state.CancellationToken); @@ -100,10 +111,20 @@ protected override bool OnWriteResume(Utf8JsonWriter writer, TAsyncEnumerable va { if (!moveNextTask.Result) { - // we have completed serialization for the enumerator, - // clear from the stack and schedule for async disposal. + // Enumeration complete, dispose the enumerator inline. + // Clear from the stack first to prevent double disposal on exception. state.Current.AsyncDisposable = null; - state.AddCompletedAsyncDisposable(enumerator); + ValueTask disposeTask = enumerator.DisposeAsync(); + if (!disposeTask.IsCompleted) + { + // DisposeAsync is pending; store as a pending task + // and yield control to the root-level async serialization loop. + state.PendingTask = disposeTask.AsTask(); + state.Current.AsyncEnumeratorIsPendingDisposal = true; + return false; + } + + disposeTask.GetAwaiter().GetResult(); return true; } diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonTypeInfoOfT.WriteHelpers.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonTypeInfoOfT.WriteHelpers.cs index 6dcbdb5746fa4e..6cae18c524a3f9 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonTypeInfoOfT.WriteHelpers.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonTypeInfoOfT.WriteHelpers.cs @@ -195,7 +195,7 @@ rootValue is not null && } finally { - // Await any pending resumable converter tasks (currently these can only be IAsyncEnumerator.MoveNextAsync() tasks). + // Await any pending resumable converter tasks (currently these can only be IAsyncEnumerator.MoveNextAsync() or DisposeAsync() tasks). // Note that pending tasks are always awaited, even if an exception has been thrown or the cancellation token has fired. if (state.PendingTask is not null) { @@ -210,12 +210,6 @@ rootValue is not null && catch { } #endif } - - // Dispose any pending async disposables (currently these can only be completed IAsyncEnumerators). - if (state.CompletedAsyncDisposables?.Count > 0) - { - await state.DisposeCompletedAsyncDisposables().ConfigureAwait(false); - } } } while (!isFinalBlock); diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/WriteStack.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/WriteStack.cs index 4941b6e3420d0a..b945a9b6bc6f4d 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/WriteStack.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/WriteStack.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Collections; -using System.Collections.Generic; using System.Diagnostics; using System.IO.Pipelines; using System.Runtime.ExceptionServices; @@ -78,10 +77,6 @@ public readonly ref WriteStackFrame Parent /// public Task? PendingTask; - /// - /// List of completed IAsyncDisposables that have been scheduled for disposal by converters. - /// - public List? CompletedAsyncDisposables; /// /// The amount of bytes to write before the underlying Stream should be flushed and the @@ -277,35 +272,6 @@ public void Pop(bool success) } } - public void AddCompletedAsyncDisposable(IAsyncDisposable asyncDisposable) - => (CompletedAsyncDisposables ??= new List()).Add(asyncDisposable); - - // Asynchronously dispose of any AsyncDisposables that have been scheduled for disposal - public readonly async ValueTask DisposeCompletedAsyncDisposables() - { - Debug.Assert(CompletedAsyncDisposables?.Count > 0); - Exception? exception = null; - - foreach (IAsyncDisposable asyncDisposable in CompletedAsyncDisposables) - { - try - { - await asyncDisposable.DisposeAsync().ConfigureAwait(false); - } - catch (Exception e) - { - exception = e; - } - } - - if (exception is not null) - { - ExceptionDispatchInfo.Capture(exception).Throw(); - } - - CompletedAsyncDisposables.Clear(); - } - /// /// Walks the stack cleaning up any leftover IDisposables /// in the event of an exception on serialization diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/WriteStackFrame.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/WriteStackFrame.cs index a2b6a787c6ac47..baab70e953ad46 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/WriteStackFrame.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/WriteStackFrame.cs @@ -29,6 +29,12 @@ internal struct WriteStackFrame /// public bool AsyncEnumeratorIsPendingCompletion; + /// + /// The current stackframe has suspended serialization due to a pending DisposeAsync() task, + /// stored in the property. + /// + public bool AsyncEnumeratorIsPendingDisposal; + /// /// The original JsonPropertyInfo that is not changed. It contains all properties. /// From 437860982b9656d4f6ce7d0916d069769695223c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 9 Apr 2026 15:50:21 +0000 Subject: [PATCH 3/8] Add comprehensive unit tests for prompt IAsyncEnumerable DisposeAsync 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> --- .../CollectionTests.AsyncEnumerable.cs | 176 ++++++++++++++++++ 1 file changed, 176 insertions(+) diff --git a/src/libraries/System.Text.Json/tests/Common/CollectionTests/CollectionTests.AsyncEnumerable.cs b/src/libraries/System.Text.Json/tests/Common/CollectionTests/CollectionTests.AsyncEnumerable.cs index e525d988769fde..d12ce05709bfd1 100644 --- a/src/libraries/System.Text.Json/tests/Common/CollectionTests/CollectionTests.AsyncEnumerable.cs +++ b/src/libraries/System.Text.Json/tests/Common/CollectionTests/CollectionTests.AsyncEnumerable.cs @@ -367,6 +367,182 @@ static async IAsyncEnumerable GetFailingAsyncEnumerable() } } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task WriteSequentialNestedAsyncEnumerables_EachEnumeratorDisposedBeforeNextStarts(bool asyncDisposal) + { + if (StreamingSerializer?.IsAsyncSerializer != true) + { + return; + } + + var events = new List(); + var enumerable1 = new DisposalTrackingAsyncEnumerable( + new[] { 1, 2, 3 }, "A", events, asyncDisposal); + var enumerable2 = new DisposalTrackingAsyncEnumerable( + new[] { 4, 5, 6 }, "B", events, asyncDisposal); + + using var stream = new Utf8MemoryStream(); + await StreamingSerializer.SerializeWrapper(stream, new AsyncEnumerableDtoWithTwoProperties + { + Data1 = enumerable1, + Data2 = enumerable2, + }); + + JsonTestHelper.AssertJsonEqual("""{"Data1":[1,2,3],"Data2":[4,5,6]}""", stream.AsString()); + + int disposeAIndex = events.IndexOf("A:Disposed"); + int startBIndex = events.IndexOf("B:MoveNext"); + Assert.True(disposeAIndex >= 0, "A should have been disposed"); + Assert.True(startBIndex >= 0, "B should have started enumeration"); + Assert.True(disposeAIndex < startBIndex, + $"A should be disposed before B starts enumeration. Events: [{string.Join(", ", events)}]"); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task WriteNestedAsyncEnumerableInsideAsyncEnumerable_InnerEnumeratorsDisposedPromptly(bool asyncDisposal) + { + if (StreamingSerializer?.IsAsyncSerializer != true) + { + return; + } + + // This test requires reflection to serialize custom IAsyncEnumerable types; + // skip in source-gen contexts where reflection is disabled. + if (!JsonSerializer.IsReflectionEnabledByDefault) + { + return; + } + + var events = new List(); + var inner1 = new DisposalTrackingAsyncEnumerable( + new[] { 1, 2 }, "Inner1", events, asyncDisposal); + var inner2 = new DisposalTrackingAsyncEnumerable( + new[] { 3, 4 }, "Inner2", events, asyncDisposal); + + var outer = new DisposalTrackingAsyncEnumerable>( + new IAsyncEnumerable[] { inner1, inner2 }, "Outer", events, asyncDisposal); + + using var stream = new Utf8MemoryStream(); + await JsonSerializer.SerializeAsync>>(stream, outer); + + JsonTestHelper.AssertJsonEqual("[[1,2],[3,4]]", stream.AsString()); + + Assert.Contains("Inner1:Disposed", events); + Assert.Contains("Inner2:Disposed", events); + Assert.Contains("Outer:Disposed", events); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task WriteEmptyNestedAsyncEnumerables_AllDisposed(bool asyncDisposal) + { + if (StreamingSerializer?.IsAsyncSerializer != true) + { + return; + } + + var events = new List(); + var enumerable1 = new DisposalTrackingAsyncEnumerable( + Array.Empty(), "A", events, asyncDisposal); + var enumerable2 = new DisposalTrackingAsyncEnumerable( + Array.Empty(), "B", events, asyncDisposal); + + using var stream = new Utf8MemoryStream(); + await StreamingSerializer.SerializeWrapper(stream, new AsyncEnumerableDtoWithTwoProperties + { + Data1 = enumerable1, + Data2 = enumerable2, + }); + + JsonTestHelper.AssertJsonEqual("""{"Data1":[],"Data2":[]}""", stream.AsString()); + + int disposeAIndex = events.IndexOf("A:Disposed"); + int startBIndex = events.IndexOf("B:MoveNext"); + Assert.True(disposeAIndex >= 0, "A should have been disposed"); + Assert.True(startBIndex >= 0, "B should have started enumeration"); + Assert.True(disposeAIndex < startBIndex, + $"A should be disposed before B starts enumeration. Events: [{string.Join(", ", events)}]"); + } + + [Fact] + public async Task WriteAsyncEnumerable_DisposeAsyncThrows_ExceptionPropagated() + { + if (StreamingSerializer?.IsAsyncSerializer != true) + { + return; + } + + using var stream = new Utf8MemoryStream(); + var enumerable = new ThrowingDisposeAsyncEnumerable(new[] { 1, 2 }); + + await Assert.ThrowsAsync(async () => + await StreamingSerializer.SerializeWrapper(stream, new AsyncEnumerableDto { Data = enumerable })); + } + + private sealed class DisposalTrackingAsyncEnumerable( + IEnumerable source, + string id, + List events, + bool asyncDisposal) : IAsyncEnumerable + { + public IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) + => new Enumerator(source.GetEnumerator(), id, events, asyncDisposal); + + private sealed class Enumerator( + IEnumerator inner, + string id, + List events, + bool asyncDisposal) : IAsyncEnumerator + { + public TElement Current => inner.Current; + + public ValueTask MoveNextAsync() + { + bool result = inner.MoveNext(); + events.Add($"{id}:MoveNext"); + return new ValueTask(result); + } + + public ValueTask DisposeAsync() + { + inner.Dispose(); + events.Add($"{id}:Disposed"); + if (asyncDisposal) + { + return new ValueTask(Task.CompletedTask); + } + return default; + } + } + } + + private sealed class ThrowingDisposeAsyncEnumerable(IEnumerable source) : IAsyncEnumerable + { + public IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) + => new Enumerator(source.GetEnumerator()); + + private sealed class Enumerator(IEnumerator inner) : IAsyncEnumerator + { + public TElement Current => inner.Current; + public ValueTask MoveNextAsync() + { + bool result = inner.MoveNext(); + return new ValueTask(result); + } + + public ValueTask DisposeAsync() + { + inner.Dispose(); + throw new InvalidOperationException("DisposeAsync failed"); + } + } + } + public class MockedAsyncEnumerable : IAsyncEnumerable, IEnumerable { private readonly IEnumerable _source; From 9b96b9367ef637e1962c4df21e667a65007fe61a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 9 Apr 2026 15:53:56 +0000 Subject: [PATCH 4/8] Fix double blank line in WriteStack.cs and make test async disposal truly 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> --- .../src/System/Text/Json/Serialization/WriteStack.cs | 1 - .../Common/CollectionTests/CollectionTests.AsyncEnumerable.cs | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/WriteStack.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/WriteStack.cs index b945a9b6bc6f4d..553bcf1df571cb 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/WriteStack.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/WriteStack.cs @@ -77,7 +77,6 @@ public readonly ref WriteStackFrame Parent /// public Task? PendingTask; - /// /// The amount of bytes to write before the underlying Stream should be flushed and the /// current buffer adjusted to remove the processed bytes. diff --git a/src/libraries/System.Text.Json/tests/Common/CollectionTests/CollectionTests.AsyncEnumerable.cs b/src/libraries/System.Text.Json/tests/Common/CollectionTests/CollectionTests.AsyncEnumerable.cs index d12ce05709bfd1..2522b511516aa0 100644 --- a/src/libraries/System.Text.Json/tests/Common/CollectionTests/CollectionTests.AsyncEnumerable.cs +++ b/src/libraries/System.Text.Json/tests/Common/CollectionTests/CollectionTests.AsyncEnumerable.cs @@ -514,7 +514,7 @@ public ValueTask DisposeAsync() events.Add($"{id}:Disposed"); if (asyncDisposal) { - return new ValueTask(Task.CompletedTask); + return new ValueTask(Task.Delay(1)); } return default; } From d4dc714959067c45ae56b1babc9e3b05b85ab79a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 9 Apr 2026 15:57:04 +0000 Subject: [PATCH 5/8] Improve test error message for clarity Agent-Logs-Url: https://github.com/dotnet/runtime/sessions/789acec8-2f71-4980-adac-0635a3f61a35 Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com> --- .../Common/CollectionTests/CollectionTests.AsyncEnumerable.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libraries/System.Text.Json/tests/Common/CollectionTests/CollectionTests.AsyncEnumerable.cs b/src/libraries/System.Text.Json/tests/Common/CollectionTests/CollectionTests.AsyncEnumerable.cs index 2522b511516aa0..7baaa72488c458 100644 --- a/src/libraries/System.Text.Json/tests/Common/CollectionTests/CollectionTests.AsyncEnumerable.cs +++ b/src/libraries/System.Text.Json/tests/Common/CollectionTests/CollectionTests.AsyncEnumerable.cs @@ -538,7 +538,7 @@ public ValueTask MoveNextAsync() public ValueTask DisposeAsync() { inner.Dispose(); - throw new InvalidOperationException("DisposeAsync failed"); + throw new InvalidOperationException("Simulated DisposeAsync failure for testing"); } } } From 404bc3fa1cf087e964942e190e8056dabc045aa3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 9 Apr 2026 18:16:53 +0000 Subject: [PATCH 6/8] Replace boolean flags with AsyncEnumeratorState enum per review feedback 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> --- .../src/System.Text.Json.csproj | 1 + .../Serialization/AsyncEnumeratorState.cs | 31 +++++++ .../IAsyncEnumerableOfTConverter.cs | 88 ++++++++++--------- .../Json/Serialization/WriteStackFrame.cs | 11 +-- 4 files changed, 79 insertions(+), 52 deletions(-) create mode 100644 src/libraries/System.Text.Json/src/System/Text/Json/Serialization/AsyncEnumeratorState.cs diff --git a/src/libraries/System.Text.Json/src/System.Text.Json.csproj b/src/libraries/System.Text.Json/src/System.Text.Json.csproj index 687d263b4a3197..ef2714d1c81642 100644 --- a/src/libraries/System.Text.Json/src/System.Text.Json.csproj +++ b/src/libraries/System.Text.Json/src/System.Text.Json.csproj @@ -107,6 +107,7 @@ The System.Text.Json library is built-in as part of the shared framework in .NET + diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/AsyncEnumeratorState.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/AsyncEnumeratorState.cs new file mode 100644 index 00000000000000..1dca59f265b9d3 --- /dev/null +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/AsyncEnumeratorState.cs @@ -0,0 +1,31 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.Text.Json +{ + /// + /// Tracks the state of an async enumerator within a . + /// + internal enum AsyncEnumeratorState : byte + { + /// + /// No async enumerator is active; the enumerator has not been created yet. + /// + None, + + /// + /// The async enumerator has been created and is actively being iterated. + /// + Enumerating, + + /// + /// The converter has been suspended due to a pending MoveNextAsync() task. + /// + PendingMoveNext, + + /// + /// The converter has been suspended due to a pending DisposeAsync() task. + /// + PendingDisposal, + } +} diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Collection/IAsyncEnumerableOfTConverter.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Collection/IAsyncEnumerableOfTConverter.cs index 902dd279de0ee5..77e6795fa3c116 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Collection/IAsyncEnumerableOfTConverter.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Collection/IAsyncEnumerableOfTConverter.cs @@ -49,58 +49,59 @@ protected override bool OnWriteResume(Utf8JsonWriter writer, TAsyncEnumerable va IAsyncEnumerator enumerator; ValueTask moveNextTask; - if (state.Current.AsyncEnumeratorIsPendingDisposal) + switch (state.Current.AsyncEnumeratorState) { - // Converter was previously suspended due to a pending DisposeAsync() task. - Debug.Assert(state.Current.AsyncDisposable is null); - Debug.Assert(state.PendingTask is not null && state.PendingTask.IsCompleted); - state.PendingTask.GetAwaiter().GetResult(); - state.Current.AsyncEnumeratorIsPendingDisposal = false; - state.PendingTask = null; - return true; - } + case AsyncEnumeratorState.PendingDisposal: + // Converter was previously suspended due to a pending DisposeAsync() task. + Debug.Assert(state.Current.AsyncDisposable is null); + Debug.Assert(state.PendingTask is not null && state.PendingTask.IsCompleted); + state.PendingTask.GetAwaiter().GetResult(); + state.Current.AsyncEnumeratorState = AsyncEnumeratorState.None; + state.PendingTask = null; + return true; - if (state.Current.AsyncDisposable is null) - { - enumerator = value.GetAsyncEnumerator(state.CancellationToken); - // async enumerators can only be disposed asynchronously; - // store in the WriteStack for future disposal - // by the root async serialization context. - state.Current.AsyncDisposable = enumerator; - // enumerator.MoveNextAsync() calls can throw, - // ensure the enumerator already is stored - // in the WriteStack for proper disposal. - moveNextTask = enumerator.MoveNextAsync(); + case AsyncEnumeratorState.None: + enumerator = value.GetAsyncEnumerator(state.CancellationToken); + // async enumerators can only be disposed asynchronously; + // store in the WriteStack for disposal on exception. + state.Current.AsyncDisposable = enumerator; + state.Current.AsyncEnumeratorState = AsyncEnumeratorState.Enumerating; + // enumerator.MoveNextAsync() calls can throw, + // ensure the enumerator already is stored + // in the WriteStack for proper disposal. + moveNextTask = enumerator.MoveNextAsync(); + + if (!moveNextTask.IsCompleted) + { + // It is common for first-time MoveNextAsync() calls to return pending tasks, + // since typically that is when underlying network connections are being established. + // For this case only, suppress flushing the current buffer contents (e.g. the leading '[' token of the written array) + // to give the stream owner the ability to recover in case of a connection error. + state.SuppressFlush = true; + goto SuspendDueToPendingTask; + } + break; - if (!moveNextTask.IsCompleted) - { - // It is common for first-time MoveNextAsync() calls to return pending tasks, - // since typically that is when underlying network connections are being established. - // For this case only, suppress flushing the current buffer contents (e.g. the leading '[' token of the written array) - // to give the stream owner the ability to recover in case of a connection error. - state.SuppressFlush = true; - goto SuspendDueToPendingTask; - } - } - else - { - Debug.Assert(state.Current.AsyncDisposable is IAsyncEnumerator); - enumerator = (IAsyncEnumerator)state.Current.AsyncDisposable; + case AsyncEnumeratorState.PendingMoveNext: + Debug.Assert(state.Current.AsyncDisposable is IAsyncEnumerator); + enumerator = (IAsyncEnumerator)state.Current.AsyncDisposable; - if (state.Current.AsyncEnumeratorIsPendingCompletion) - { // converter was previously suspended due to a pending MoveNextAsync() task Debug.Assert(state.PendingTask is Task && state.PendingTask.IsCompleted); moveNextTask = new ValueTask((Task)state.PendingTask); - state.Current.AsyncEnumeratorIsPendingCompletion = false; + state.Current.AsyncEnumeratorState = AsyncEnumeratorState.Enumerating; state.PendingTask = null; - } - else - { + break; + + default: + Debug.Assert(state.Current.AsyncEnumeratorState == AsyncEnumeratorState.Enumerating); + Debug.Assert(state.Current.AsyncDisposable is IAsyncEnumerator); + enumerator = (IAsyncEnumerator)state.Current.AsyncDisposable; + // converter was suspended for a different reason; // the last MoveNextAsync() call can only have completed with 'true'. moveNextTask = new ValueTask(true); - } + break; } Debug.Assert(moveNextTask.IsCompleted); @@ -114,13 +115,14 @@ protected override bool OnWriteResume(Utf8JsonWriter writer, TAsyncEnumerable va // Enumeration complete, dispose the enumerator inline. // Clear from the stack first to prevent double disposal on exception. state.Current.AsyncDisposable = null; + state.Current.AsyncEnumeratorState = AsyncEnumeratorState.None; ValueTask disposeTask = enumerator.DisposeAsync(); if (!disposeTask.IsCompleted) { // DisposeAsync is pending; store as a pending task // and yield control to the root-level async serialization loop. state.PendingTask = disposeTask.AsTask(); - state.Current.AsyncEnumeratorIsPendingDisposal = true; + state.Current.AsyncEnumeratorState = AsyncEnumeratorState.PendingDisposal; return false; } @@ -149,7 +151,7 @@ protected override bool OnWriteResume(Utf8JsonWriter writer, TAsyncEnumerable va // mark the current stackframe as pending completion. Debug.Assert(state.PendingTask is null); state.PendingTask = moveNextTask.AsTask(); - state.Current.AsyncEnumeratorIsPendingCompletion = true; + state.Current.AsyncEnumeratorState = AsyncEnumeratorState.PendingMoveNext; return false; } diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/WriteStackFrame.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/WriteStackFrame.cs index baab70e953ad46..623f3ee2eeabc2 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/WriteStackFrame.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/WriteStackFrame.cs @@ -24,16 +24,9 @@ internal struct WriteStackFrame public IAsyncDisposable? AsyncDisposable; /// - /// The current stackframe has suspended serialization due to a pending task, - /// stored in the property. + /// The state of the async enumerator for the current stack frame. /// - public bool AsyncEnumeratorIsPendingCompletion; - - /// - /// The current stackframe has suspended serialization due to a pending DisposeAsync() task, - /// stored in the property. - /// - public bool AsyncEnumeratorIsPendingDisposal; + public AsyncEnumeratorState AsyncEnumeratorState; /// /// The original JsonPropertyInfo that is not changed. It contains all properties. From bfba15352c6e6bcc5eff7c5b62181936671ee522 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 9 Apr 2026 18:46:33 +0000 Subject: [PATCH 7/8] Rename AsyncDisposable to AsyncEnumerator (object?) per review feedback The field always stores an IAsyncEnumerator, so rename and widen the type to object? to make its utility more self-evident. The DisposeFrame helper in WriteStack now pattern-matches on IAsyncDisposable for exception cleanup. Agent-Logs-Url: https://github.com/dotnet/runtime/sessions/9c262aea-5dbb-468f-8220-3f33fd23e811 Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com> --- .../Collection/IAsyncEnumerableOfTConverter.cs | 14 +++++++------- .../System/Text/Json/Serialization/WriteStack.cs | 16 ++++++++-------- .../Text/Json/Serialization/WriteStackFrame.cs | 4 ++-- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Collection/IAsyncEnumerableOfTConverter.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Collection/IAsyncEnumerableOfTConverter.cs index 77e6795fa3c116..7f27bb6f3f2613 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Collection/IAsyncEnumerableOfTConverter.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Collection/IAsyncEnumerableOfTConverter.cs @@ -53,7 +53,7 @@ protected override bool OnWriteResume(Utf8JsonWriter writer, TAsyncEnumerable va { case AsyncEnumeratorState.PendingDisposal: // Converter was previously suspended due to a pending DisposeAsync() task. - Debug.Assert(state.Current.AsyncDisposable is null); + Debug.Assert(state.Current.AsyncEnumerator is null); Debug.Assert(state.PendingTask is not null && state.PendingTask.IsCompleted); state.PendingTask.GetAwaiter().GetResult(); state.Current.AsyncEnumeratorState = AsyncEnumeratorState.None; @@ -64,7 +64,7 @@ protected override bool OnWriteResume(Utf8JsonWriter writer, TAsyncEnumerable va enumerator = value.GetAsyncEnumerator(state.CancellationToken); // async enumerators can only be disposed asynchronously; // store in the WriteStack for disposal on exception. - state.Current.AsyncDisposable = enumerator; + state.Current.AsyncEnumerator = enumerator; state.Current.AsyncEnumeratorState = AsyncEnumeratorState.Enumerating; // enumerator.MoveNextAsync() calls can throw, // ensure the enumerator already is stored @@ -83,8 +83,8 @@ protected override bool OnWriteResume(Utf8JsonWriter writer, TAsyncEnumerable va break; case AsyncEnumeratorState.PendingMoveNext: - Debug.Assert(state.Current.AsyncDisposable is IAsyncEnumerator); - enumerator = (IAsyncEnumerator)state.Current.AsyncDisposable; + Debug.Assert(state.Current.AsyncEnumerator is IAsyncEnumerator); + enumerator = (IAsyncEnumerator)state.Current.AsyncEnumerator; // converter was previously suspended due to a pending MoveNextAsync() task Debug.Assert(state.PendingTask is Task && state.PendingTask.IsCompleted); @@ -95,8 +95,8 @@ protected override bool OnWriteResume(Utf8JsonWriter writer, TAsyncEnumerable va default: Debug.Assert(state.Current.AsyncEnumeratorState == AsyncEnumeratorState.Enumerating); - Debug.Assert(state.Current.AsyncDisposable is IAsyncEnumerator); - enumerator = (IAsyncEnumerator)state.Current.AsyncDisposable; + Debug.Assert(state.Current.AsyncEnumerator is IAsyncEnumerator); + enumerator = (IAsyncEnumerator)state.Current.AsyncEnumerator; // converter was suspended for a different reason; // the last MoveNextAsync() call can only have completed with 'true'. @@ -114,7 +114,7 @@ protected override bool OnWriteResume(Utf8JsonWriter writer, TAsyncEnumerable va { // Enumeration complete, dispose the enumerator inline. // Clear from the stack first to prevent double disposal on exception. - state.Current.AsyncDisposable = null; + state.Current.AsyncEnumerator = null; state.Current.AsyncEnumeratorState = AsyncEnumeratorState.None; ValueTask disposeTask = enumerator.DisposeAsync(); if (!disposeTask.IsCompleted) diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/WriteStack.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/WriteStack.cs index 553bcf1df571cb..568f70b8e2f1fa 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/WriteStack.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/WriteStack.cs @@ -279,7 +279,7 @@ public readonly void DisposePendingDisposablesOnException() { Exception? exception = null; - Debug.Assert(Current.AsyncDisposable is null); + Debug.Assert(Current.AsyncEnumerator is null); DisposeFrame(Current.CollectionEnumerator, ref exception); if (_stack is not null) @@ -288,7 +288,7 @@ public readonly void DisposePendingDisposablesOnException() int stackSize = Math.Max(currentIndex, _continuationCount); for (int i = 0; i < stackSize; i++) { - Debug.Assert(_stack[i].AsyncDisposable is null); + Debug.Assert(_stack[i].AsyncEnumerator is null); if (i == currentIndex) { @@ -330,7 +330,7 @@ public readonly async ValueTask DisposePendingDisposablesOnExceptionAsync() { Exception? exception = null; - exception = await DisposeFrame(Current.CollectionEnumerator, Current.AsyncDisposable, exception).ConfigureAwait(false); + exception = await DisposeFrame(Current.CollectionEnumerator, Current.AsyncEnumerator, exception).ConfigureAwait(false); if (_stack is not null) { @@ -343,11 +343,11 @@ public readonly async ValueTask DisposePendingDisposablesOnExceptionAsync() { // Matches the entry in Current, skip to avoid double disposal. Debug.Assert(_stack[i].CollectionEnumerator is null || ReferenceEquals(Current.CollectionEnumerator, _stack[i].CollectionEnumerator)); - Debug.Assert(_stack[i].AsyncDisposable is null || ReferenceEquals(Current.AsyncDisposable, _stack[i].AsyncDisposable)); + Debug.Assert(_stack[i].AsyncEnumerator is null || ReferenceEquals(Current.AsyncEnumerator, _stack[i].AsyncEnumerator)); continue; } - exception = await DisposeFrame(_stack[i].CollectionEnumerator, _stack[i].AsyncDisposable, exception).ConfigureAwait(false); + exception = await DisposeFrame(_stack[i].CollectionEnumerator, _stack[i].AsyncEnumerator, exception).ConfigureAwait(false); } } @@ -356,9 +356,9 @@ public readonly async ValueTask DisposePendingDisposablesOnExceptionAsync() ExceptionDispatchInfo.Capture(exception).Throw(); } - static async ValueTask DisposeFrame(IEnumerator? collectionEnumerator, IAsyncDisposable? asyncDisposable, Exception? exception) + static async ValueTask DisposeFrame(IEnumerator? collectionEnumerator, object? asyncEnumerator, Exception? exception) { - Debug.Assert(!(collectionEnumerator is not null && asyncDisposable is not null)); + Debug.Assert(!(collectionEnumerator is not null && asyncEnumerator is not null)); try { @@ -366,7 +366,7 @@ public readonly async ValueTask DisposePendingDisposablesOnExceptionAsync() { disposable.Dispose(); } - else if (asyncDisposable is not null) + else if (asyncEnumerator is IAsyncDisposable asyncDisposable) { await asyncDisposable.DisposeAsync().ConfigureAwait(false); } diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/WriteStackFrame.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/WriteStackFrame.cs index 623f3ee2eeabc2..93a1af9f8d6dbf 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/WriteStackFrame.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/WriteStackFrame.cs @@ -19,9 +19,9 @@ internal struct WriteStackFrame public IEnumerator? CollectionEnumerator; /// - /// The enumerator for resumable async disposables. + /// The async enumerator for resumable async enumerable collections. /// - public IAsyncDisposable? AsyncDisposable; + public object? AsyncEnumerator; /// /// The state of the async enumerator for the current stack frame. From 6c755f581155e4099bad6461027641fd86458b19 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 9 Apr 2026 19:35:30 +0000 Subject: [PATCH 8/8] Reorder switch cases to follow natural enumeration lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit None → PendingMoveNext → PendingDisposal → default (Enumerating) Agent-Logs-Url: https://github.com/dotnet/runtime/sessions/7417b369-e732-4d7b-9daa-ebf6af1d1762 Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com> --- .../Collection/IAsyncEnumerableOfTConverter.cs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Collection/IAsyncEnumerableOfTConverter.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Collection/IAsyncEnumerableOfTConverter.cs index 7f27bb6f3f2613..906cbdf76c23a8 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Collection/IAsyncEnumerableOfTConverter.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Collection/IAsyncEnumerableOfTConverter.cs @@ -51,15 +51,6 @@ protected override bool OnWriteResume(Utf8JsonWriter writer, TAsyncEnumerable va switch (state.Current.AsyncEnumeratorState) { - case AsyncEnumeratorState.PendingDisposal: - // Converter was previously suspended due to a pending DisposeAsync() task. - Debug.Assert(state.Current.AsyncEnumerator is null); - Debug.Assert(state.PendingTask is not null && state.PendingTask.IsCompleted); - state.PendingTask.GetAwaiter().GetResult(); - state.Current.AsyncEnumeratorState = AsyncEnumeratorState.None; - state.PendingTask = null; - return true; - case AsyncEnumeratorState.None: enumerator = value.GetAsyncEnumerator(state.CancellationToken); // async enumerators can only be disposed asynchronously; @@ -93,6 +84,15 @@ protected override bool OnWriteResume(Utf8JsonWriter writer, TAsyncEnumerable va state.PendingTask = null; break; + case AsyncEnumeratorState.PendingDisposal: + // Converter was previously suspended due to a pending DisposeAsync() task. + Debug.Assert(state.Current.AsyncEnumerator is null); + Debug.Assert(state.PendingTask is not null && state.PendingTask.IsCompleted); + state.PendingTask.GetAwaiter().GetResult(); + state.Current.AsyncEnumeratorState = AsyncEnumeratorState.None; + state.PendingTask = null; + return true; + default: Debug.Assert(state.Current.AsyncEnumeratorState == AsyncEnumeratorState.Enumerating); Debug.Assert(state.Current.AsyncEnumerator is IAsyncEnumerator);