-
Notifications
You must be signed in to change notification settings - Fork 5.2k
Description
Description
When JsonSerializer.SerializeAsync
is cancelled while serializing an object, it fails to dispose the IAsyncEnumerator<T>
of nested IAsyncEnumerable<T>
properties. This can lead to resource leaks if the enumerator holds unmanaged resources.
If IAsyncEnumerable<T>
is serialized directly (not nested), disposal works as expected.
This issue affects both explicit IAsyncEnumerator<T>
implementations and implicit async iterators (i.e., methods using async yield
).
Reproduction Steps
The following unit test demonstrates the bug.
An IAsyncEnumerable<T>
is nested inside a TestValue
object. The serialization task is cancelled midway through enumeration. The test correctly throws an OperationCanceledException
but fails the final assertion, Assert.True(testAsyncEnumerator.IsDisposed)
.
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task SystemTextJson_CancelsExplicitAsyncEnumeratorMidwayAsync(bool cancelMidway)
{
using var cts = new CancellationTokenSource();
var token = cts.Token;
var testAsyncEnumerator = new TestAsyncEnumeratorThatCancelsMidway<T>(cancelMidway ? cts : null);
var testAsyncEnumerable = new TestAsyncEnumerableFromEnumerator<T>(testAsyncEnumerator);
await using var stream = new MemoryStream();
var options = new JsonSerializerOptions();
var serializeTask = JsonSerializer.SerializeAsync(stream, new TestValue { Value = testAsyncEnumerable }, options, token);
if (cancelMidway)
{
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => serializeTask);
}
else
{
await serializeTask;
}
Assert.True(testAsyncEnumerator.IsDisposed);
}
public class TestValue
{
public object Value { get; set; } = null!;
}
sealed class TestAsyncEnumerableFromEnumerator(IAsyncEnumerator<int> enumerator) : IAsyncEnumerable<int>
{
public IAsyncEnumerator<int> GetAsyncEnumerator(CancellationToken cancellationToken = default)
{
return enumerator;
}
}
sealed class TestAsyncEnumeratorThatCancelsMidway(CancellationTokenSource? cts) : IAsyncEnumerator<int>
{
readonly int upperBound = cts is null ? 1_000 : 1_000_000; // Higher upper bound when cancelling to ensure reliable test.
const int CancelAt = 10;
int current;
public bool IsDisposed { get; private set; }
public int Current => current;
public async ValueTask<bool> MoveNextAsync()
{
await Task.Yield(); // Enhances the race condition in serializer when cancelling.
if (cts is not null && current == CancelAt)
{
await cts.CancelAsync();
}
if (current++ < upperBound)
{
current++;
return true;
}
return false;
}
public ValueTask DisposeAsync()
{
IsDisposed = true;
current = upperBound;
return ValueTask.CompletedTask;
}
}
Expected behavior
The IAsyncEnumerator<T>
should always be disposed when the serialization is cancelled.
- For explicit enumerators, DisposeAsync() should be called.
- For implicit async iterators, the finally block of the iterator method should be executed.
Actual behavior
When the IAsyncEnumerable<T>
is a nested property, its enumerator is not disposed upon cancellation. DisposeAsync()
is never called, and the finally block of an async iterator is not reached.
Known Workarounds
A possible workaround is to implement a custom IAsyncEnumerable<T>
wrapper. This wrapper would manage the lifecycle of the underlying enumerator, ensuring it is disposed correctly after being consumed, even if the serializer fails to do so upon cancellation.
Configuration
.NET Version: .NET 8, .NET 9
Operating Systems: Ubuntu, macOS (and likely Windows)
Note on the Test Design
The provided TestAsyncEnumeratorThatCancelsMidway
intentionally does not check a CancellationToken
. Instead, it triggers the cancellation itself via the injected CancellationTokenSource
.
This is a deliberate design choice to eliminate race conditions and create a 100% reliable reproduction of the bug. It cleanly isolates and exposes the serializer's failure to handle the external cancellation and dispose of the enumerator.
In real-world scenarios where an IAsyncEnumerator
does monitor a token, this bug can appear intermittently depending on timing, which makes it much harder to diagnose.