Description
_tokenCount is stored as double and may hold fractional values after replenishment. AttemptAcquire(0) returns a successful lease whenever _tokenCount > 0, yet GetStatistics().CurrentAvailablePermits truncates the same value to long, so it reports 0.
This means that AttemptAcquire(0) will always grants a lease, even when none should be given.
https://github.com/dotnet/runtime/blob/b6e34b8c14fc531b6997795024374302447372d7/src/libraries/System.Threading.RateLimiting/src/System/Threading/RateLimiting/TokenBucketRateLimiter.cs#L115C13-L119C18
Reproduction
[Test]
public async Task FractionalTokenBug()
{
var limiter = new TokenBucketRateLimiter(new TokenBucketRateLimiterOptions
{
TokenLimit = 3,
TokensPerPeriod = 1,
ReplenishmentPeriod = TimeSpan.FromSeconds(0.5),
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
AutoReplenishment = false
});
// Drain bucket
limiter.AttemptAcquire(3).Dispose();
await Task.Delay(500); // enough to add ~1.0 token
limiter.TryReplenish(); // _tokenCount ≈ 1.0
limiter.AttemptAcquire(1).Dispose(); // succeeds as expected
Assert.That(limiter.GetStatistics()?.CurrentAvailablePermits, Is.EqualTo(0), "Tokens after acquiring 1 permit");
var lease = limiter.AttemptAcquire(0); // **unexpected success**
Assert.That(!lease.IsAcquired, "Acquired a lease, when none should be available");
lease.Dispose();
}
Resutls
Assert.That(!lease.IsAcquired, "Acquired a lease, when none should be available"); is failing.
Setting a break point, showed the value of _tokenCount to be 0.025640600000000013 in one example test run.
Root cause
GetStatistics() truncates with (long)_tokenCount, so shows as 0.
AttemptAcquireCore considers any _tokenCount > 0 a success, even when _tokenCount sits in (0,1)
The cast hides the available fraction from statistics but the acquisition path still sees it, there are multiple places in the class that should be updated to _tokenCount >= 1, or where conditions are checking for _tokenCount == 0, that will never happen once the first TryReplenish triggers.
Description
_tokenCountis stored asdoubleand may hold fractional values after replenishment.AttemptAcquire(0)returns a successful lease whenever_tokenCount > 0, yetGetStatistics().CurrentAvailablePermitstruncates the same value tolong, so it reports 0.This means that
AttemptAcquire(0)will always grants a lease, even when none should be given.https://github.com/dotnet/runtime/blob/b6e34b8c14fc531b6997795024374302447372d7/src/libraries/System.Threading.RateLimiting/src/System/Threading/RateLimiting/TokenBucketRateLimiter.cs#L115C13-L119C18
Reproduction
Resutls
Assert.That(!lease.IsAcquired, "Acquired a lease, when none should be available");is failing.Setting a break point, showed the value of
_tokenCountto be0.025640600000000013in one example test run.Root cause
GetStatistics()truncates with(long)_tokenCount, so shows as0.AttemptAcquireCoreconsiders any_tokenCount > 0a success, even when_tokenCountsits in(0,1)The cast hides the available fraction from statistics but the acquisition path still sees it, there are multiple places in the class that should be updated to
_tokenCount >= 1, or where conditions are checking for_tokenCount == 0, that will never happen once the firstTryReplenishtriggers.