RateLimitingMiddleware never disposes the endpoint PartitionedRateLimiter it creates, so every disposed app stays reachable
- Dominant language
- C#
- Stars
- 38.4k
- Forks
- 10.9k
- Avg merge
- 2d 5h
- Merged PRs (30d)
- 276
Description
### Description
`RateLimitingMiddleware` creates its endpoint limiter in `CreateEndpointLimiter()` with `PartitionedRateLimiter.Create(...)`. 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
```csharp
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();
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 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` 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
Contributor guide
Research direction
Start at RateLimitingMiddleware.CreateEndpointLimiter() and trace how UseRateLimiter() participates in application stop and disposal. Reproduce the retention with the supplied WebApplication and TestServer sample, then verify that disposing the application also releases the created limiter and disposed service providers are no longer retained.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- csharp
- Domain
- backend
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 52/100