From 6e89a8da667a406812c5068e3e629ac4310b6582 Mon Sep 17 00:00:00 2001 From: Aayush Atharva Date: Sat, 18 Jul 2026 20:00:45 +0000 Subject: [PATCH] Test combined semaphore releases global permit on per-host rejection 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. --- .../netty/channel/SemaphoreTest.java | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/client/src/test/java/org/asynchttpclient/netty/channel/SemaphoreTest.java b/client/src/test/java/org/asynchttpclient/netty/channel/SemaphoreTest.java index 962ee4fd0..32faeb43c 100644 --- a/client/src/test/java/org/asynchttpclient/netty/channel/SemaphoreTest.java +++ b/client/src/test/java/org/asynchttpclient/netty/channel/SemaphoreTest.java @@ -215,6 +215,35 @@ private void nonBlockingFailsFast(ConnectionSemaphore semaphore) throws IOExcept semaphore.acquireChannelLock(PK, true); // must not throw } + @Test + @Timeout(unit = TimeUnit.MILLISECONDS, value = 1000) + public void combinedNonBlockingReleasesGlobalPermitWhenPerHostExhausted() throws IOException { + // global(2) > per-host(1): the non-blocking acquire takes a global permit, then fails on the + // per-host limit. It must release the global permit it just took (releaseGlobal); otherwise a + // request to a DIFFERENT host would be wrongly starved of the still-held global permit. With + // global == per-host (as in the fail-fast tests above) the acquire fails on the global limit first + // and this release branch is never exercised, so use a wider global limit to reach it. + CombinedConnectionSemaphore semaphore = new CombinedConnectionSemaphore(2, 1, NON_BLOCKING__LONG_TIMEOUT); + Object hostA = new Object(); + Object hostB = new Object(); + + semaphore.acquireChannelLock(hostA); // global -> 1 free, per-host(A) -> 0 + + // The non-blocking acquire for hostA must be rejected by the PER-HOST limit (a global permit was + // still free), confirming it got past the global gate and into the releaseGlobal branch. + boolean perHostRejected = false; + try { + semaphore.acquireChannelLock(hostA, true); + } catch (TooManyConnectionsPerHostException e) { + perHostRejected = true; + } + assertTrue(perHostRejected, "second hostA acquire must be rejected by the per-host limit, not the global one"); + + // If the global permit taken during that failed attempt had leaked, 0 global permits would remain + // and this different-host acquire would wrongly throw. It must succeed, proving releaseGlobal ran. + semaphore.acquireChannelLock(hostB, true); // must not throw + } + @Test @Timeout(unit = TimeUnit.MILLISECONDS, value = 1000) public void defaultNonBlockingOverloadDelegatesToBlockingAcquire() throws IOException {