RateLimitingMiddleware never disposes the limiter it creates, keeping disposed hosts alive
- 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
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