Description
RateLimitingMiddleware creates its endpoint limiter in CreateEndpointLimiter() with PartitionedRateLimiter.Create<HttpContext, DefaultKeyType>(...). The partitioner closure captures the middleware itself (this, to reach the policy map). The middleware implements neither IDisposable nor IAsyncDisposable, and nothing disposes that limiter.
A partitioned limiter keeps a periodic timer (RunTimer) running. The process-wide timer queue therefore keeps the limiter alive, and through it the partitioner closure, the middleware, its _next delegate, the whole request pipeline and the application's IServiceProvider. Every application that called UseRateLimiter() stays reachable after StopAsync() and DisposeAsync().
Production is unaffected when there is one application per process, because the limiter lives as long as the app. The leak appears when many applications are created and disposed in one process, which is what integration test suites using WebApplicationFactory or TestServer do. In our suite, with roughly 1,000 test hosts per run, it retained about 11 MB per test. The test host was killed by the OOM killer around the thousandth test, with either 2 or 4 parallel threads.
Minimal repro
using System.Runtime.CompilerServices;
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.AspNetCore.TestHost;
// Starts and disposes N applications, with and without UseRateLimiter(), and counts how many
// of their service providers are still reachable after a full GC.
const int N = 50;
foreach (var useRateLimiter in new[] { false, true })
{
var refs = new List<WeakReference>();
for (var i = 0; i < N; i++)
{
refs.Add(await RunOnceAsync(useRateLimiter));
}
for (var g = 0; g < 3; g++)
{
GC.Collect();
GC.WaitForPendingFinalizers();
await Task.Delay(200);
}
Console.WriteLine($"UseRateLimiter={useRateLimiter}: {refs.Count(r => r.IsAlive)}/{N} disposed apps still alive");
}
[MethodImpl(MethodImplOptions.NoInlining)]
static async Task<WeakReference> RunOnceAsync(bool useRateLimiter)
{
var builder = WebApplication.CreateBuilder();
builder.WebHost.UseTestServer();
builder.Logging.ClearProviders();
builder.Services.AddRateLimiter(o => o.AddPolicy("fixed", _ =>
RateLimitPartition.GetFixedWindowLimiter("k", _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 10,
Window = TimeSpan.FromMinutes(1),
})));
var app = builder.Build();
if (useRateLimiter)
{
app.UseRateLimiter();
}
app.MapGet("/", () => "ok").RequireRateLimiting("fixed");
await app.StartAsync();
using (var client = app.GetTestClient())
{
(await client.GetAsync("/")).EnsureSuccessStatusCode();
}
var weak = new WeakReference(app.Services);
await app.StopAsync();
await app.DisposeAsync();
return weak;
}
Project: Microsoft.NET.Sdk.Web, net10.0, plus Microsoft.AspNetCore.TestHost 10.0.*. Output:
UseRateLimiter=False: 0/50 disposed apps still alive
UseRateLimiter=True: 50/50 disposed apps still alive
Heap evidence from our suite
A dotnet-gcdump taken about 60 s into a 345-test run showed:
- 89
RateLimitingMiddleware instances;
- 89
DefaultPartitionedRateLimiter<HttpContext, DefaultKeyType> instances, each with an active RunTimer state machine;
- about 92 retained hosts.
Skipping UseRateLimiter() changed:
|
With UseRateLimiter() |
Without |
| Retained hosts at that point |
~110 |
21 |
| Test-host RSS at the end of the run |
~3.0 GB |
~1.3 GB |
Before reaching this we ruled out, with confirmed removals, the LoggingEventSource change-token registrations, configuration file watching (both polling and FileSystemWatcher) and Npgsql connection pools.
Expected behaviour
Disposing the application releases the limiters the middleware created. For example, the middleware, or a DI-owned holder, could dispose the endpoint limiter when the application stops.
Workaround
We are gating UseRateLimiter() behind a configuration flag that test hosts turn off. The app refuses to start in Production with the flag off unless that is explicitly confirmed.
Environment
- ASP.NET Core 10.0.11
- .NET SDK 10.0.400
- Linux arm64, dev container
Description
RateLimitingMiddlewarecreates its endpoint limiter inCreateEndpointLimiter()withPartitionedRateLimiter.Create<HttpContext, DefaultKeyType>(...). The partitioner closure captures the middleware itself (this, to reach the policy map). The middleware implements neitherIDisposablenorIAsyncDisposable, and nothing disposes that limiter.A partitioned limiter keeps a periodic timer (
RunTimer) running. The process-wide timer queue therefore keeps the limiter alive, and through it the partitioner closure, the middleware, its_nextdelegate, the whole request pipeline and the application'sIServiceProvider. Every application that calledUseRateLimiter()stays reachable afterStopAsync()andDisposeAsync().Production is unaffected when there is one application per process, because the limiter lives as long as the app. The leak appears when many applications are created and disposed in one process, which is what integration test suites using
WebApplicationFactoryorTestServerdo. In our suite, with roughly 1,000 test hosts per run, it retained about 11 MB per test. The test host was killed by the OOM killer around the thousandth test, with either 2 or 4 parallel threads.Minimal repro
Project:
Microsoft.NET.Sdk.Web,net10.0, plusMicrosoft.AspNetCore.TestHost10.0.*. Output:Heap evidence from our suite
A
dotnet-gcdumptaken about 60 s into a 345-test run showed:RateLimitingMiddlewareinstances;DefaultPartitionedRateLimiter<HttpContext, DefaultKeyType>instances, each with an activeRunTimerstate machine;Skipping
UseRateLimiter()changed:UseRateLimiter()Before reaching this we ruled out, with confirmed removals, the
LoggingEventSourcechange-token registrations, configuration file watching (both polling andFileSystemWatcher) and Npgsql connection pools.Expected behaviour
Disposing the application releases the limiters the middleware created. For example, the middleware, or a DI-owned holder, could dispose the endpoint limiter when the application stops.
Workaround
We are gating
UseRateLimiter()behind a configuration flag that test hosts turn off. The app refuses to start in Production with the flag off unless that is explicitly confirmed.Environment