Skip to content

RateLimitingMiddleware never disposes the endpoint PartitionedRateLimiter it creates, so every disposed app stays reachable #69259

Description

@joaofonsecasynget

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area-middlewareIncludes: URL rewrite, redirect, response cache/compression, session, and other general middlewares

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions