Skip to content

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

@juner

Description

@juner

Description

When returning an object that contains IAsyncEnumerable<T> properties,
System.Text.Json does not invoke DisposeAsync() after enumeration completes.

This leaves EF Core’s PostgreSQL connection open, and the next enumeration fails due to the still-open connection.

This issue does not occur for:

  • top-level IAsyncEnumerable<T>
  • async-iterator–wrapped IAsyncEnumerable<T> (using await foreach)
  • buffered materialization (ToArrayAsync)

The problem is specific to property-based IAsyncEnumerable<T>.

Reproduction Steps

  1. Run the following minimal Aspire app (full reproducible sample):
using Data;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Runtime.CompilerServices;

var builder = WebApplication.CreateBuilder(args);

builder.AddServiceDefaults();

// Add services to the container.
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
builder.Services.AddOpenApi();

// max retry count 
var maxRetryCount = builder.Configuration.GetValue("MAX_RETRY_COUNT", 1);
builder.AddApplicationDbContext(maxRetryCount);

var app = builder.Build();

app.MapDefaultEndpoints();

if (app.Environment.IsDevelopment())
{
    // Configure the HTTP request pipeline.
    app.MapOpenApi("/openapi/{documentName}.json");
    app.UseSwaggerUI(o => o.SwaggerEndpoint("/openapi/v1.json", "v1"));
}


app.UseHttpsRedirection();

app
    .MapGet("/product_1", ([FromKeyedServices] ApplicationDbContext dbContext) => TypedResults.Ok(dbContext.Products.AsAsyncEnumerable()))
    .WithSummary("Top-level IAsyncEnumerable with immediate DisposeAsync");
app
    .MapGet("/product_2", ([FromKeyedServices] ApplicationDbContext dbContext) =>
    {
        var odd = dbContext.Products.Where(v => v.Id % 2 == 0).AsAsyncEnumerable();
        var even = dbContext.Products.Where(v => v.Id % 2 != 0).AsAsyncEnumerable();
        return TypedResults.Ok(new
        {
            Odd = odd,
            Even = even,
        });
    })
    .WithSummary("Property-based IAsyncEnumerable where DisposeAsync is not invoked");

app
    .MapGet("/product_3", ([FromKeyedServices] ApplicationDbContext dbContext, CancellationToken cancellationToken) =>
    {
        var odd = ToAsyncEnumerable(dbContext.Products.Where(v => v.Id % 2 == 0).AsAsyncEnumerable(), cancellationToken);
        var even = ToAsyncEnumerable(dbContext.Products.Where(v => v.Id % 2 != 0).AsAsyncEnumerable(), cancellationToken);
        return TypedResults.Ok(new
        {
            Odd = odd,
            Even = even,
        });
        async static IAsyncEnumerable<T> ToAsyncEnumerable<T>(IAsyncEnumerable<T> ite, [EnumeratorCancellation] CancellationToken cancellationToken)
        {
            await foreach (var item in ite.WithCancellation(cancellationToken))
            {
                yield return item;
            }
        }
    })
    .WithSummary("Async-iterator wrapper that ensures immediate DisposeAsync via await foreach");

app
    .MapGet("/product_4", async ([FromKeyedServices] ApplicationDbContext dbContext, CancellationToken cancellationToken) =>
    {
        var odd = await dbContext.Products.Where(v => v.Id % 2 == 0).ToArrayAsync(cancellationToken);
        var even = await dbContext.Products.Where(v => v.Id % 2 != 0).ToArrayAsync(cancellationToken);
        return TypedResults.Ok(new
        {
            Odd = odd,
            Even = even,
        });
    })
    .WithSummary("Buffered materialization (ToArrayAsync) with no streaming or DisposeAsync behavior"
);


app
    .MapGet("/dispose_1", async ([FromKeyedServices] ApplicationDbContext dbContext, [FromServices] ILoggerFactory loggerFactory, CancellationToken cancellationToken) =>
    {
        var logger1 = loggerFactory.CreateLogger("Api.Dispose_1");
        var count = 1000;
        var delay = TimeSpan.FromMicroseconds(200);
        return TypedResults.Ok(new LoggingAsyncEnumerable(logger1, count, delay));
    })
    .WithSummary("DisposeAsync timing for top-level IAsyncEnumerable"
);

app
    .MapGet("/dispose_2", async ([FromKeyedServices] ApplicationDbContext dbContext,[FromServices] ILoggerFactory loggerFactory, CancellationToken cancellationToken) =>
    {
        var logger1 = loggerFactory.CreateLogger("Api.Dispose_2 (1)");
        var logger2 = loggerFactory.CreateLogger("Api.Dispose_2 (2)");
        var count = 500;
        var delay = TimeSpan.FromMicroseconds(200);
        var num1 = new LoggingAsyncEnumerable(logger1, count, delay);
        var num2 = new LoggingAsyncEnumerable(logger2, count, delay);
        return TypedResults.Ok(new
        {
            Num1 = num1,
            Num2 = num2,
        });
    })
    .WithSummary("DisposeAsync timing for property-based IAsyncEnumerable (delayed disposal)"
);

app
    .MapGet("/dispose_3", async ([FromKeyedServices] ApplicationDbContext dbContext, [FromServices] ILoggerFactory loggerFactory, CancellationToken cancellationToken) =>
    {
        var logger1 = loggerFactory.CreateLogger("Api.Dispose_3 (1)");
        var logger2 = loggerFactory.CreateLogger("Api.Dispose_3 (2)");
        var count = 500;
        var delay = TimeSpan.FromMicroseconds(200);
        var num1 = ToAsyncEnumerable(new LoggingAsyncEnumerable(logger1, count, delay), cancellationToken);
        var num2 = ToAsyncEnumerable(new LoggingAsyncEnumerable(logger2, count, delay), cancellationToken);
        return TypedResults.Ok(new
        {
            Num1 = num1,
            Num2 = num2,
        });
        async static IAsyncEnumerable<T> ToAsyncEnumerable<T>(IAsyncEnumerable<T> ite, [EnumeratorCancellation] CancellationToken cancellationToken)
        {
            await foreach (var item in ite.WithCancellation(cancellationToken))
            {
                yield return item;
            }
        }
    })
    .WithSummary("DisposeAsync timing for async-iterator wrapper (immediate disposal)"
);

await app.RunAsync();

public class LoggingAsyncEnumerable(ILogger logger, int max, TimeSpan span) : IAsyncEnumerable<int>
{
    public IAsyncEnumerator<int> GetAsyncEnumerator(CancellationToken cancellationToken = default)
        => new LoggingAsyncEnumerator(logger, max, span);
}

public class LoggingAsyncEnumerator(ILogger logger, int max, TimeSpan span) : IAsyncEnumerator<int>
{
    private int _current = 0;

    public int Current => _current;

    public ValueTask DisposeAsync()
    {
        logger.LogInformation("[DISPOSE] DisposeAsync called");
        return ValueTask.CompletedTask;
    }

    public async ValueTask<bool> MoveNextAsync()
    {
        if (_current >= max)
            return false;

        await Task.Delay(span);
        _current++;
        logger.LogInformation($"[MOVE] {_current}");
        return true;
    }
}
  1. Set environment variable:
MAX_RETRY_COUNT=0
  1. Hit the following endpoints:
  • /product_1 → OK
  • /product_2FAILS
  • /product_3 → OK
  • /product_4 → OK
  1. Observe logs from /dispose_1, /dispose_2, /dispose_3 to inspect DisposeAsync() timing.

Expected behavior

For property-based IAsyncEnumerable<T>:

  • After enumeration completes, DisposeAsync() should be invoked.
  • EF Core should release the PostgreSQL connection.
  • The next enumeration should succeed.

This would match the behavior of:

  • top-level IAsyncEnumerable<T>
  • async iterator wrappers (await foreach)

Actual behavior

For property-based IAsyncEnumerable<T>:

  • Enumeration completes normally.
  • DisposeAsync() is never invoked.
  • The PostgreSQL connection remains open.
  • The next enumeration attempt fails due to the still-open connection.

When MAX_RETRY_COUNT >= 1, EF Core’s retry logic closes the connection once, which masks the issue.


DisposeAsync timing evidence

/dispose_1 — top-level IAsyncEnumerable<T>

[MOVE] ...
[DISPOSE]   ← immediately after enumeration

/dispose_2 — property-based IAsyncEnumerable<T>

Num1: [MOVE] ...
Num2: [MOVE] ...
Num1: [DISPOSE]
Num2: [DISPOSE]   ← only after JSON serialization completes

sample project

https://github.com/juner/AspireDbAndAspNetCore_Error_20260409

Metadata

Metadata

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions