Skip to content

JsonSerializer.SerializeAsync fails to dispose IAsyncEnumerator<T> on cancellation during nested serialization. #120010

@baal2000

Description

@baal2000

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.

Metadata

Metadata

Type

No type

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions