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
- 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;
}
}
- Set environment variable:
- Hit the following endpoints:
/product_1 → OK
/product_2 → FAILS
/product_3 → OK
/product_4 → OK
- 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
Description
When returning an object that contains
IAsyncEnumerable<T>properties,System.Text.Jsondoes not invokeDisposeAsync()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:
IAsyncEnumerable<T>IAsyncEnumerable<T>(usingawait foreach)ToArrayAsync)The problem is specific to property-based
IAsyncEnumerable<T>.Reproduction Steps
/product_1→ OK/product_2→ FAILS/product_3→ OK/product_4→ OK/dispose_1,/dispose_2,/dispose_3to inspectDisposeAsync()timing.Expected behavior
For property-based
IAsyncEnumerable<T>:DisposeAsync()should be invoked.This would match the behavior of:
IAsyncEnumerable<T>await foreach)Actual behavior
For property-based
IAsyncEnumerable<T>:DisposeAsync()is never invoked.When
MAX_RETRY_COUNT >= 1, EF Core’s retry logic closes the connection once, which masks the issue.DisposeAsync timing evidence
/dispose_1— top-levelIAsyncEnumerable<T>/dispose_2— property-basedIAsyncEnumerable<T>sample project
https://github.com/juner/AspireDbAndAspNetCore_Error_20260409