dotnet / dotnet/aspnetcore

RateLimitingMiddleware never disposes the limiter it creates, keeping disposed hosts alive

Open
#69,061 2 comments 0 reactions 0 assignees View on GitHub
area-middleware
Dominant language
C#
Stars
38.4k
Forks
10.9k
Avg merge
2d 10h
Merged PRs (30d)
281

Description

### Is there an existing issue for this?

- [x] I have searched the existing issues

### Describe the bug

`RateLimitingMiddleware` creates a `PartitionedRateLimiter` in its constructor and implements no disposal interface, so nothing ever disposes it.

The chain, on v10.0.10:

1. The middleware builds a limiter unconditionally in its constructor ([`RateLimitingMiddleware.cs#L56`](https://github.com/dotnet/aspnetcore/blob/v10.0.10/src/Middleware/RateLimiting/src/RateLimitingMiddleware.cs#L56)): `_endpointLimiter = CreateEndpointLimiter();`
2. That limiter starts a timer in its own constructor ([`DefaultPartitionedRateLimiter.cs#L48-L49`](https://github.com/dotnet/runtime/blob/v10.0.10/src/libraries/System.Threading.RateLimiting/src/System/Threading/RateLimiting/DefaultPartitionedRateLimiter.cs#L48-L49)). The only thing that stops it is `_timer.Stop()` in `CommonDispose` ([`#L194`](https://github.com/dotnet/runtime/blob/v10.0.10/src/libraries/System.Threading.RateLimiting/src/System/Threading/RateLimiting/DefaultPartitionedRateLimiter.cs#L194)), reachable only from `Dispose`/`DisposeAsync`. `TimerAwaitable` wraps a real `System.Threading.Timer` ([`TimerAwaitable.cs#L55`](https://github.com/dotnet/runtime/blob/v10.0.10/src/libraries/System.Threading.RateLimiting/src/System/Threading/RateLimiting/TimerAwaitable.cs#L55)), so while it runs it is a GC root.
3. The partitioner passed to `PartitionedRateLimiter.Create` reads `_policyMap` and `_defaultPolicyKey` ([`#L252`](https://github.com/dotnet/aspnetcore/blob/v10.0.10/src/Middleware/RateLimiting/src/RateLimitingMiddleware.cs#L252)), so it captures the middleware instance, and the middleware holds `_next`, which is the rest of the pipeline. In an app with routing and endpoints, endpoint execution captures the service provider, so the chain reaches the whole DI container and a disposed `IHost` stays fully reachable.
4. Nothing can break that chain. [`internal sealed partial class RateLimitingMiddleware`](https://github.com/dotnet/aspnetcore/blob/v10.0.10/src/Middleware/RateLimiting/src/RateLimitingMiddleware.cs#L15) implements neither `IDisposable` nor `IAsyncDisposable`, and the limiter it creates is registered nowhere.

### Expected Behavior

Disposing the host should release the host, its pipeline and its service provider. `RateLimitingMiddleware` should implement `IAsyncDisposable` and dispose the endpoint limiter it created, so the host disposes it along with the rest of the pipeline.

### Steps To Reproduce

Minimal repro: https://github.com/themidnightgospel/RateLimiterHostLeak

dotnet test RateLimiterHostLeak.slnx -c Release

Three tests, all passing on 10.0.10:

- `DisposedHost_WithRateLimiter_IsStillReachable` — a `WeakReference` to the host survives a blocking gen-2 collection after `StopAsync()` and `Dispose()`.
- `DisposedHost_WithoutRateLimiter_IsCollected` — the identical host without `UseRateLimiter()` is collected, so the retention is the middleware.
- `DisposedHost_WithRateLimitersDisposed_IsCollected` — disposing `_globalLimiter` / `_endpointLimiter` by reflection releases the host, confirming the limiter is the root.

The repro configures no policies at all (`services.AddRateLimiter(_ => { })`) and still leaks.

### Exceptions (if any)

_No response_

### .NET Version

10.0.300

### Anything else?

- ASP.NET Core version: Microsoft.AspNetCore.App 10.0.10
- OS: Windows 11

Contributor guide

Open the contributing guide

Research direction

Start with src/Middleware/RateLimiting/src/RateLimitingMiddleware.cs, especially the constructor and class declaration, then run dotnet test RateLimiterHostLeak.slnx -c Release from the linked reproduction. Compare the middleware lifecycle with the three named host-collection tests. Done means the middleware releases its created limiter during host disposal and the disposed host is collectible.

Written by the indexing model from the issue text.

Assessment

Tech stack
csharp
Domain
backend
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
72/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.