Skip to content

Commit f63ecbb

Browse files
committed
Bind the connection permit to the channel on connect
NettyConnectListener.onSuccess takes the per-host permit off the future with takePartitionKeyLock(), a getAndSet(null), after which the future can no longer release it: a later abort() finds the field null and returns nothing. Ownership only passed to the channel when attachSemaphoreToChannelClose ran, which on the TLS paths happened after the handshake completed. Every exit taken before that point dropped the token in a dead local and leaked the permit permanently. That window is not just a race. A TLS handshake that fails - bad certificate, peer reset, or Netty's own handshake timeout - always leaves through it, as does an AsyncHandler callback throwing in the same window. Since onFailure may then retry, and the retry finds a null partitionKeyLock and acquires a fresh permit, a run of failures against one origin drains maxConnectionsPerHost and pins it: every later request to that host fails with TooManyConnectionsPerHostException with no live connection to account for it. The combined limiter releases the global slot through the same call, so one bad origin also starves unrelated hosts. Only recreating the client recovers. Bind the permit to channel.closeFuture() the moment it leaves the future, and route every release through one getAndSet so it happens exactly once - a double release would push the semaphore above the cap and can prematurely prune a live per-host entry. Close-release is now the default rather than a per-branch opt-in, so the failure paths are covered by the close they already perform, and a new branch cannot reintroduce the leak by forgetting to opt in. Also publish the channel on the future before the handshake starts. Until then future.channel() is null and NettyRequestSender.abort only closes a non-null channel, so a request timeout firing mid-handshake could not close the socket and left it, and its permit, alive until handshakeTimeout. The field is now volatile: it is written on the event loop but read by the timer thread in TimeoutTimerTask.expire and by cancel() on the caller thread. Fixes #2189
1 parent 9f4926c commit f63ecbb

5 files changed

Lines changed: 700 additions & 43 deletions

File tree

client/src/main/java/org/asynchttpclient/netty/NettyResponseFuture.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,8 +117,11 @@ public final class NettyResponseFuture<V> implements ListenableFuture<V> {
117117
// volatile where we don't need CAS ops
118118
private volatile long touch = unpreciseMillisTime();
119119
private volatile ChannelState channelState = ChannelState.NEW;
120+
// Written on the event loop but read off it: the HashedWheelTimer thread reads it in
121+
// TimeoutTimerTask.expire to close the channel on a request timeout, and cancel() reads it on the
122+
// caller thread. Without volatile those readers can miss the write and skip the close (issue #2189).
123+
private volatile Channel channel;
120124
// state mutated only inside the event loop
121-
private Channel channel;
122125
private boolean keepAlive = true;
123126
private Request targetRequest;
124127
private Request currentRequest;

client/src/main/java/org/asynchttpclient/netty/channel/NettyConnectListener.java

Lines changed: 34 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434

3535
import java.net.ConnectException;
3636
import java.net.InetSocketAddress;
37+
import java.util.concurrent.atomic.AtomicReference;
3738

3839
/**
3940
* Non Blocking connect.
@@ -78,19 +79,26 @@ private void writeRequest(Channel channel) {
7879
Channels.setAttribute(channel, future);
7980

8081
channelManager.registerOpenChannel(channel);
81-
future.attachChannel(channel, false);
8282
requestSender.writeRequest(future, channel);
8383
}
8484

8585
public void onSuccess(Channel channel, InetSocketAddress remoteAddress) {
86-
// Take the semaphore lock from the future. For HTTP/1.1, we'll transfer it to channel.closeFuture().
87-
// For HTTP/2, we release it immediately after ALPN negotiation since the connection is multiplexed.
88-
final Object partitionKeyLock = (connectionSemaphore != null) ? future.takePartitionKeyLock() : null;
86+
// takePartitionKeyLock() is a getAndSet(null): the future can no longer release this permit, so bind
87+
// it to the channel here, before anything below can abandon that channel. Everything downstream --
88+
// a failed or timed-out TLS handshake, a crashing AsyncHandler callback, a connect retry -- closes
89+
// the channel but cannot reach the token, so deferring this to the individual branches leaked the
90+
// permit permanently on every one of those paths (issue #2189). The HTTP/2 branches release it
91+
// earlier and leave this listener a no-op.
92+
final Object partitionKeyLock = connectionSemaphore != null ? future.takePartitionKeyLock() : null;
93+
final AtomicReference<Object> permit = new AtomicReference<>(partitionKeyLock);
94+
if (partitionKeyLock != null) {
95+
channel.closeFuture().addListener(f -> releasePermitOnce(permit));
96+
}
8997

9098
Channels.setActiveToken(channel);
9199

92100
if (futureIsAlreadyCompleted(channel)) {
93-
releaseSemaphoreImmediately(partitionKeyLock);
101+
releasePermitOnce(permit);
94102
return;
95103
}
96104

@@ -102,10 +110,15 @@ public void onSuccess(Channel channel, InetSocketAddress remoteAddress) {
102110
// futureIsAlreadyCompleted check above can pass while the holder is already null.
103111
// Drop this connection rather than NPE-ing on setResolvedRemoteAddress below.
104112
Channels.silentlyCloseChannel(channel);
105-
releaseSemaphoreImmediately(partitionKeyLock);
113+
releasePermitOnce(permit);
106114
return;
107115
}
108116

117+
// Publish the channel before the (possibly long) TLS handshake. Until this runs future.channel() is
118+
// null, and NettyRequestSender.abort only closes a non-null channel -- so a request timeout firing
119+
// mid-handshake could not close the socket, stranding it until handshakeTimeout (issue #2189).
120+
future.attachChannel(channel, false);
121+
109122
Request request = future.getTargetRequest();
110123
Uri uri = request.getUri();
111124
// don't set a null resolved address - if the remoteAddress is null we keep
@@ -153,7 +166,6 @@ protected void onSuccess(Channel value) {
153166
return;
154167
}
155168
// After SSL handshake to proxy, continue with normal proxy request
156-
attachSemaphoreToChannelClose(channel, partitionKeyLock);
157169
writeRequest(channel);
158170
}
159171

@@ -215,9 +227,7 @@ protected void onSuccess(Channel value) {
215227
}
216228
if (http2Negotiated && !uri.isWebSocket()) {
217229
channelManager.upgradePipelineToHttp2(channel.pipeline());
218-
registerHttp2AndManageSemaphore(channel, partitionKeyLock);
219-
} else {
220-
attachSemaphoreToChannelClose(channel, partitionKeyLock);
230+
registerHttp2AndManageSemaphore(channel, permit);
221231
}
222232
writeRequest(channel);
223233
}
@@ -240,31 +250,21 @@ protected void onFailure(Throwable cause) {
240250
// excluded for the same RFC 8441 reason as the TLS path above — it stays on HTTP/1.1.
241251
if (!uri.isSecured() && channelManager.isHttp2CleartextEnabled() && !uri.isWebSocket()) {
242252
channelManager.upgradePipelineToHttp2(channel.pipeline());
243-
registerHttp2AndManageSemaphore(channel, partitionKeyLock);
244-
} else {
245-
attachSemaphoreToChannelClose(channel, partitionKeyLock);
253+
registerHttp2AndManageSemaphore(channel, permit);
246254
}
247255
writeRequest(channel);
248256
}
249257
}
250258

251259
/**
252-
* Attaches the semaphore lock to the channel's close future (HTTP/1.1 behavior).
253-
* The semaphore slot is released when the connection closes.
254-
*/
255-
private void attachSemaphoreToChannelClose(Channel channel, Object partitionKeyLock) {
256-
if (connectionSemaphore != null && partitionKeyLock != null) {
257-
channel.closeFuture().addListener(f -> connectionSemaphore.releaseChannelLock(partitionKeyLock));
258-
}
259-
}
260-
261-
/**
262-
* Releases the semaphore lock immediately (HTTP/2 behavior).
263-
* HTTP/2 connections are multiplexed, so the semaphore should not be held
264-
* for the lifetime of the connection.
260+
* Returns the per-host permit this connection holds, at most once. The channel closeFuture, the
261+
* early-exit paths and the HTTP/2 immediate release all race to call this; a double release would
262+
* push the semaphore above {@code maxConnectionsPerHost}, and an unmatched one can prematurely
263+
* prune a live entry from the per-host map.
265264
*/
266-
private void releaseSemaphoreImmediately(Object partitionKeyLock) {
267-
if (connectionSemaphore != null && partitionKeyLock != null) {
265+
private void releasePermitOnce(AtomicReference<Object> permit) {
266+
Object partitionKeyLock = permit.getAndSet(null);
267+
if (partitionKeyLock != null && connectionSemaphore != null) {
268268
connectionSemaphore.releaseChannelLock(partitionKeyLock);
269269
}
270270
}
@@ -281,27 +281,22 @@ private void releaseSemaphoreImmediately(Object partitionKeyLock) {
281281
* a further request fails to acquire a permit and multiplexes onto a sibling-IP connection via
282282
* {@link ChannelManager#pollHttp2SiblingConnection(Object)} rather than opening another (issue #2214).
283283
*/
284-
private void registerHttp2AndManageSemaphore(Channel channel, Object partitionKeyLock) {
284+
private void registerHttp2AndManageSemaphore(Channel channel, AtomicReference<Object> permit) {
285285
// Register under the future's partition key so the H2 connection is found by the same key the
286286
// pool is polled with, including the IP-aware key used by LoadBalance.ROUND_ROBIN. Read the key
287287
// once: it is volatile (repinned on IP failover) and both uses below must agree.
288288
Object partitionKey = future.getPartitionKey();
289289
channelManager.registerHttp2Connection(partitionKey, channel);
290290
if (partitionKey instanceof RoundRobinPartitionKey) {
291291
// The permit must be freed as soon as the connection stops serving new requests: on close, or
292-
// at drain start after GOAWAY (issue #2214). Both paths go through releasePermitOnce(), which
293-
// guarantees a single release; a double release would push the semaphore above the cap. The
294-
// state attribute is always present after upgradePipelineToHttp2, the fallback is just
295-
// belt and braces.
292+
// at drain start after GOAWAY (issue #2214). The closeFuture listener installed in onSuccess
293+
// covers close; this adds the drain hook. Both funnel through releasePermitOnce.
296294
Http2ConnectionState state = channel.attr(Http2ConnectionState.HTTP2_STATE_KEY).get();
297-
if (state != null && connectionSemaphore != null && partitionKeyLock != null) {
298-
state.setPermitRelease(() -> connectionSemaphore.releaseChannelLock(partitionKeyLock));
299-
channel.closeFuture().addListener(f -> state.releasePermitOnce());
300-
} else {
301-
attachSemaphoreToChannelClose(channel, partitionKeyLock);
295+
if (state != null) {
296+
state.setPermitRelease(() -> releasePermitOnce(permit));
302297
}
303298
} else {
304-
releaseSemaphoreImmediately(partitionKeyLock);
299+
releasePermitOnce(permit);
305300
}
306301
}
307302

client/src/test/java/org/asynchttpclient/netty/channel/ChannelManagerHttp2DrainPermitTest.java

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727

2828
import java.net.InetAddress;
2929
import java.util.concurrent.Semaphore;
30+
import java.util.concurrent.atomic.AtomicReference;
3031

3132
import static org.asynchttpclient.Dsl.config;
3233
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
@@ -81,9 +82,18 @@ private EmbeddedChannel registerRrConnectionHoldingPermit(ConnectionSemaphore se
8182
Http2ConnectionState state = new Http2ConnectionState();
8283
channel.attr(Http2ConnectionState.HTTP2_STATE_KEY).set(state);
8384
channelManager.registerHttp2Connection(registryKey, channel);
84-
// Mirror NettyConnectListener.registerHttp2AndManageSemaphore's round-robin branch.
85-
state.setPermitRelease(() -> semaphore.releaseChannelLock(baseKey));
86-
channel.closeFuture().addListener(f -> state.releasePermitOnce());
85+
// Mirror NettyConnectListener's round-robin wiring: the drain hook installed by
86+
// registerHttp2AndManageSemaphore, plus the closeFuture release installed in onSuccess. Both funnel
87+
// through a single getAndSet so the permit is returned exactly once.
88+
AtomicReference<Object> permit = new AtomicReference<>(baseKey);
89+
Runnable release = () -> {
90+
Object key = permit.getAndSet(null);
91+
if (key != null) {
92+
semaphore.releaseChannelLock(key);
93+
}
94+
};
95+
state.setPermitRelease(release);
96+
channel.closeFuture().addListener(f -> release.run());
8797
return channel;
8898
}
8999

0 commit comments

Comments
 (0)