Skip to content

Never block the event loop acquiring a connection permit - #2226

Merged
hyperxpro merged 4 commits into
AsyncHttpClient:mainfrom
maygemdev:fix/no-blocking-acquire-on-event-loop
Jul 18, 2026
Merged

Never block the event loop acquiring a connection permit#2226
hyperxpro merged 4 commits into
AsyncHttpClient:mainfrom
maygemdev:fix/no-blocking-acquire-on-event-loop

Conversation

@pavel-ptashyts

Copy link
Copy Markdown
Contributor

On a redirect / 401 / 407 / retry replay, sendNextRequest re-enters NettyRequestSender.sendRequestWithNewChannel from AsyncHttpClientHandler.channelRead — on the Netty event loop. acquirePartitionLockLazily then called the connection semaphore's blocking acquireChannelLock (Semaphore.tryAcquire(acquireFreeChannelTimeout)), parking the event-loop thread for up to the configured timeout when a finite maxConnections / maxConnectionsPerHost limit is saturated. Blocking the loop stalls every other connection it serves, and the permit may only be released by a task queued on that same loop. The sibling waitForHttp2Connection already guards this with isOnEventLoop(); this path did not.

Acquire the permit non-blocking when on the event loop: fail fast (the request still gets its single non-blocking HTTP/2-reuse poll before aborting) instead of parking the loop. Off the loop — the initial execute() on the caller thread — the configured blocking wait is unchanged.

  • ConnectionSemaphore: add a default acquireChannelLock(key, nonBlocking) overload (default delegates to the blocking form, so custom implementations are unaffected). - Max/PerHost/Combined limiters override it with a non-blocking tryAcquire(). - NettyResponseFuture.acquirePartitionLockLazily(boolean) threads the flag through; the call site passes isOnEventLoop().

Gated behind non-default config (a positive cap AND a positive acquireFreeChannelTimeout); inert under defaults. Adds SemaphoreTest coverage that the non-blocking acquire fails fast instead of waiting and that the default overload delegates.

On a redirect / 401 / 407 / retry replay, sendNextRequest re-enters NettyRequestSender.sendRequestWithNewChannel from AsyncHttpClientHandler.channelRead — on the Netty event loop. acquirePartitionLockLazily then called the connection semaphore's blocking acquireChannelLock (Semaphore.tryAcquire(acquireFreeChannelTimeout)), parking the event-loop thread for up to the configured timeout when a finite maxConnections / maxConnectionsPerHost limit is saturated. Blocking the loop stalls every other connection it serves, and the permit may only be released by a task queued on that same loop. The sibling waitForHttp2Connection already guards this with isOnEventLoop(); this path did not.
Acquire the permit non-blocking when on the event loop: fail fast (the request still gets its single non-blocking HTTP/2-reuse poll before aborting) instead of parking the loop. Off the loop — the initial execute() on the caller thread — the configured blocking wait is unchanged.
- ConnectionSemaphore: add a default acquireChannelLock(key, nonBlocking) overload (default delegates to the blocking form, so custom implementations are unaffected). - Max/PerHost/Combined limiters override it with a non-blocking tryAcquire(). - NettyResponseFuture.acquirePartitionLockLazily(boolean) threads the flag through; the call site passes isOnEventLoop().
Gated behind non-default config (a positive cap AND a positive acquireFreeChannelTimeout); inert under defaults. Adds SemaphoreTest coverage that the non-blocking acquire fails fast instead of waiting and that the default overload delegates.
@hyperxpro
hyperxpro merged commit 2ce2a25 into AsyncHttpClient:mainJul 18, 2026
13 checks passed
hyperxpro added a commit that referenced this pull request Jul 18, 2026
Motivation:
#2226 made the connection-permit acquire non-blocking on the event loop.
CombinedConnectionSemaphore's non-blocking path takes the global permit first
and then the per-host permit, releasing the global one if the per-host permit is
unavailable. That releaseGlobal branch is the single place the non-blocking path
can leak the global permit, yet it had no coverage: the existing
combinedNonBlockingFailsFastWhenExhausted uses equal global and per-host limits
(1, 1), so the acquire is rejected at the global gate and never reaches the
per-host rejection where releaseGlobal runs.
Modification:
Add combinedNonBlockingReleasesGlobalPermitWhenPerHostExhausted using a wider
global limit (2) than per-host (1). The non-blocking acquire passes the global
gate, is rejected by the per-host limit (asserted as
TooManyConnectionsPerHostException), and a follow-up non-blocking acquire for a
different host must succeed — proving the global permit taken during the failed
attempt was released rather than leaked.
Result:
The global-permit release path of the non-blocking combined acquire is now
covered; a regression that leaked the global permit on per-host rejection would
starve other hosts of the global permit and fail this test.
hyperxpro added a commit that referenced this pull request Jul 18, 2026
Motivation:
#2226 made the connection-permit acquire non-blocking on the event loop.
CombinedConnectionSemaphore's non-blocking path takes the global permit first
and then the per-host permit, releasing the global one if the per-host permit is
unavailable. That releaseGlobal branch is the single place the non-blocking path
can leak the global permit, yet it had no coverage: the existing
combinedNonBlockingFailsFastWhenExhausted uses equal global and per-host limits
(1, 1), so the acquire is rejected at the global gate and never reaches the
per-host rejection where releaseGlobal runs.
Modification:
Add combinedNonBlockingReleasesGlobalPermitWhenPerHostExhausted using a wider
global limit (2) than per-host (1). The non-blocking acquire passes the global
gate, is rejected by the per-host limit (asserted as
TooManyConnectionsPerHostException), and a follow-up non-blocking acquire for a
different host must succeed; proving the global permit taken during the failed
attempt was released rather than leaked.
Result:
The global-permit release path of the non-blocking combined acquire is now
covered; a regression that leaked the global permit on per-host rejection would
starve other hosts of the global permit and fail this test.
hyperxpro added a commit that referenced this pull request Jul 18, 2026
Motivation:
#2226 made the connection-permit acquire non-blocking on the event loop.
CombinedConnectionSemaphore's non-blocking path takes the global permit
first and then the per-host permit, releasing the global one if the
per-host permit is unavailable. That releaseGlobal branch is the single
place the non-blocking path can leak the global permit, yet it had no
coverage: the existing combinedNonBlockingFailsFastWhenExhausted uses
equal global and per-host limits (1, 1), so the acquire is rejected at
the global gate and never reaches the per-host rejection where
releaseGlobal runs.
Modification:
Add combinedNonBlockingReleasesGlobalPermitWhenPerHostExhausted using a
wider global limit (2) than per-host (1). The non-blocking acquire
passes the global gate, is rejected by the per-host limit (asserted as
TooManyConnectionsPerHostException), and a follow-up non-blocking
acquire for a different host must succeed; proving the global permit
taken during the failed attempt was released rather than leaked.
Result:
The global-permit release path of the non-blocking combined acquire is
now covered; a regression that leaked the global permit on per-host
rejection would starve other hosts of the global permit and fail this
test.
@pavel-ptashyts
pavel-ptashyts deleted the fix/no-blocking-acquire-on-event-loop branch July 18, 2026 20:34
Sign up for freeto 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.

2 participants

@pavel-ptashyts@hyperxpro