Skip to content

Fix CapacityLimiter over-granting tokens on asyncio - #1172

Merged
agronholm merged 9 commits into
agronholm:masterfrom
binggao1230:fix-1170-capacitylimiter-overgrant
Jul 12, 2026
Merged

agronholm merged 9 commits into
agronholm:masterfrom
binggao1230:fix-1170-capacitylimiter-overgrant

Conversation

@binggao1230

Copy link
Copy Markdown
Contributor

Changes

Fixes #1170.

On the asyncio backend, CapacityLimiter could over-grant tokens: a non-blocking acquire issued in the window between a token being released (which notifies the next waiter) and that waiter actually resuming would slip through, leaving borrowed_tokens > total_tokens and available_tokens negative — two tasks holding a single-token limiter at once.

The cause: release_on_behalf_of popped the next waiter out of _wait_queue and set its event, but the waiter only added itself to _borrowers after it resumed. In between, the wait queue was empty and the borrower count was below the limit, so acquire_on_behalf_of_nowait's guard admitted a second borrower for the already-reserved token.

The fix reserves the freed token for the woken waiter the moment its event is set — the borrower is added to _borrowers in _notify_next_waiter (and when total_tokens is increased) rather than after the waiter resumes — mirroring the hand-off that already makes Semaphore/Lock immune. Consequently:

  • the woken waiter no longer re-adds itself to _borrowers on resume, and
  • a waiter cancelled after being granted the token now releases it and passes it on to the next waiter.

Only the asyncio backend is affected; the trio backend wraps trio's own CapacityLimiter.

Checklist

If this is a user-facing code change, like a bugfix or a new feature, please ensure that
you've fulfilled the following conditions (where applicable):

  • You've added tests (in tests/) which would fail without your patch
  • You've updated the documentation (in docs/), in case of behavior changes or new
    features
  • You've added a new changelog entry (in docs/versionhistory.rst).

If this is a trivial change, like a typo fix or a code reformatting, then you can ignore
these instructions.


This change was prepared by an AI agent under my direction; I reviewed and verified it.

On the asyncio backend, releasing a token notified the next waiter but did
not reserve the token for it until that waiter resumed. A non-blocking
acquire issued in that window saw an empty wait queue and a borrower count
below the limit, so it slipped through, leaving borrowed_tokens greater than
total_tokens and available_tokens negative.

Reserve the freed token for the woken waiter the moment its event is set
(in _notify_next_waiter and when increasing total_tokens), mirroring the
Semaphore/Lock hand-off, so the non-blocking acquire correctly raises
WouldBlock. The woken waiter no longer re-adds itself on resume, and a
waiter cancelled after being granted the token now releases it to the next
in line.

Fixes agronholm#1170.
@agronholm agronholm added this to the 4.14.2 milestone Jun 25, 2026
Comment thread tests/test_synchronization.py Outdated
@binggao1230

Copy link
Copy Markdown
Contributor Author

The red test (ubuntu-latest, 3.14, [trio]) job isn't from this PR — it's tests/test_from_thread.py::TestBlockingPortal::test_asyncio_run_sync_called[asyncio+uvloop], where assert not caplog.text trips on asyncio's slow-callback warning (... _call_in_runner_task() ... took 0.136 seconds, against the 0.1 s threshold).

The same job fails the same way on master — e.g. run 28965447285 and run 28957305269. It's timing-sensitive: the recorded durations across the last three runs here were 0.103 s, 0.118 s and 0.136 s. (The warning's _asyncio.py line number differs from master's only because this branch shifts that file.)

3959 tests pass otherwise. I can't re-run the job from a fork, so leaving it as-is.

agronholm and others added 3 commits July 11, 2026 23:29
Now that agronholm#1218 makes the trio backend raise anyio.WouldBlock, the
regression test no longer needs to be scoped to asyncio; the invariant
holds on both backends.
@binggao1230

Copy link
Copy Markdown
Contributor Author

Done in 84e5382 — removed the asyncio scoping so the test runs on all backends. With #1218 merged in, it now passes on asyncio, asyncio+eager, and trio.

@agronholm agronholm left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

CapacityLimiter can over-grant tokens (borrowed_tokens > total_tokens, available_tokens goes negative)

2 participants