Skip to content

Commit 7f3aebf

Browse files
committed
Guard the WebSocket read path against a completed future
1 parent cb3564f commit 7f3aebf

4 files changed

Lines changed: 165 additions & 14 deletions

File tree

client/src/main/java/org/asynchttpclient/netty/handler/WebSocketHandler.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,16 @@ private void abort(Channel channel, NettyResponseFuture<?> future, WebSocketUpgr
107107
public void handleRead(Channel channel, NettyResponseFuture<?> future, Object e) throws Exception {
108108

109109
if (e instanceof HttpResponse) {
110+
// Unlike HttpHandler, this check cannot guard the whole method: a successful upgrade completes
111+
// the future (see upgrade() below) and the channel then keeps serving frames, so isDone() is
112+
// true for all normal WebSocket traffic. It belongs on the upgrade response alone, where a 101
113+
// arriving just as a request timeout aborted the future would otherwise still run onOpen and
114+
// deliver the WebSocket lifecycle after onThrowable had already fired.
115+
if (future.isDone()) {
116+
channelManager.closeChannel(channel);
117+
return;
118+
}
119+
110120
HttpResponse response = (HttpResponse) e;
111121
if (logger.isDebugEnabled()) {
112122
HttpRequest httpRequest = future.getNettyRequest().getHttpRequest();

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

Lines changed: 40 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -194,10 +194,10 @@ void requestTimeoutDuringTlsHandshakeDoesNotLeakThePerHostPermit() throws Except
194194
.setSslEngineFactory(createSslEngineFactory(new AtomicBoolean(true)))
195195
.build();
196196

197-
try (BlackHoleServer server = new BlackHoleServer();
197+
try (BlackHoleServer blackHole = new BlackHoleServer();
198198
AsyncHttpClient client = asyncHttpClient(cfg)) {
199199
ExecutionException e = assertThrows(ExecutionException.class,
200-
() -> client.prepareGet(server.url()).execute().get(30, TimeUnit.SECONDS));
200+
() -> client.prepareGet(blackHole.url()).execute().get(30, TimeUnit.SECONDS));
201201
assertInstanceOf(TimeoutException.class, e.getCause(),
202202
"sanity: the request timeout must win over the handshake timeout");
203203

@@ -225,13 +225,13 @@ void requestTimeoutClosesTheConnectingSocketWithoutWaitingForHandshakeTimeout()
225225
.setSslEngineFactory(createSslEngineFactory(new AtomicBoolean(true)))
226226
.build();
227227

228-
try (BlackHoleServer server = new BlackHoleServer();
228+
try (BlackHoleServer blackHole = new BlackHoleServer();
229229
AsyncHttpClient client = asyncHttpClient(cfg)) {
230230
ExecutionException e = assertThrows(ExecutionException.class,
231-
() -> client.prepareGet(server.url()).execute().get(30, TimeUnit.SECONDS));
231+
() -> client.prepareGet(blackHole.url()).execute().get(30, TimeUnit.SECONDS));
232232
assertInstanceOf(TimeoutException.class, e.getCause());
233233

234-
Socket peer = server.awaitFirstConnection(10, TimeUnit.SECONDS);
234+
Socket peer = blackHole.awaitFirstConnection(10, TimeUnit.SECONDS);
235235
assertNotNull(peer, "sanity: the client established the TCP connection");
236236
// Well inside handshakeTimeout: if the abort could not close the connecting channel, draining
237237
// this socket blocks until the read times out instead of reaching EOF.
@@ -244,6 +244,8 @@ void requestTimeoutClosesTheConnectingSocketWithoutWaitingForHandshakeTimeout()
244244
} catch (SocketTimeoutException timeout) {
245245
fail("issue #2189: the request timeout must close the connecting socket, not leave it "
246246
+ "until handshakeTimeout");
247+
} catch (IOException reset) {
248+
// a reset rather than a clean FIN still means the client closed in time
247249
}
248250

249251
assertTrue(probe.get().released.await(10, TimeUnit.SECONDS), "the permit must come back with it");
@@ -286,13 +288,18 @@ public void handle(String target, org.eclipse.jetty.server.Request baseRequest,
286288
Future<Response> inFlight = client.prepareGet(server.getHttpUrl() + "/foo").execute();
287289
assertTrue(started.await(20, TimeUnit.SECONDS), "sanity: the first request reached the server");
288290

289-
ExecutionException refused = assertThrows(ExecutionException.class,
290-
() -> client.prepareGet(server.getHttpUrl() + "/foo").execute().get(20, TimeUnit.SECONDS),
291-
"a second connection must not be admitted while the first still holds the only permit");
292-
assertInstanceOf(TooManyConnectionsPerHostException.class, refused.getCause());
293-
294-
release.countDown();
291+
try {
292+
ExecutionException refused = assertThrows(ExecutionException.class,
293+
() -> client.prepareGet(server.getHttpUrl() + "/foo").execute().get(20, TimeUnit.SECONDS),
294+
"a second connection must not be admitted while the first still holds the only permit");
295+
assertInstanceOf(TooManyConnectionsPerHostException.class, refused.getCause());
296+
} finally {
297+
release.countDown();
298+
}
295299
assertEquals(200, inFlight.get(30, TimeUnit.SECONDS).getStatusCode());
300+
301+
CountingSemaphore semaphore = probe.get();
302+
assertEquals(1, semaphore.acquires.get(), "only the served connection ever took a permit");
296303
}
297304
}
298305

@@ -334,13 +341,18 @@ private static final class BlackHoleServer implements Closeable {
334341
private final List<Socket> accepted = Collections.synchronizedList(new ArrayList<>());
335342
private final BlockingQueue<Socket> firstAccepted = new ArrayBlockingQueue<>(1);
336343
private final Thread thread;
344+
private volatile boolean closed;
337345

338346
BlackHoleServer() throws IOException {
339347
serverSocket = new ServerSocket(0, 0, InetAddress.getLoopbackAddress());
340348
thread = new Thread(() -> {
341349
try {
342-
while (!Thread.currentThread().isInterrupted()) {
350+
while (!closed) {
343351
Socket socket = serverSocket.accept();
352+
if (closed) {
353+
socket.close();
354+
return;
355+
}
344356
accepted.add(socket);
345357
firstAccepted.offer(socket);
346358
}
@@ -352,8 +364,16 @@ private static final class BlackHoleServer implements Closeable {
352364
thread.start();
353365
}
354366

367+
/**
368+
* Brackets an IPv6 literal, which the loopback address is wherever java.net.preferIPv6Addresses is
369+
* set: an unbracketed "https://::1:443/foo" does not parse as a URI at all.
370+
*/
355371
String url() {
356-
return "https://" + serverSocket.getInetAddress().getHostAddress() + ':' + serverSocket.getLocalPort() + "/foo";
372+
String host = serverSocket.getInetAddress().getHostAddress();
373+
if (host.indexOf(':') >= 0) {
374+
host = '[' + host + ']';
375+
}
376+
return "https://" + host + ':' + serverSocket.getLocalPort() + "/foo";
357377
}
358378

359379
Socket awaitFirstConnection(long timeout, TimeUnit unit) throws InterruptedException {
@@ -362,12 +382,18 @@ Socket awaitFirstConnection(long timeout, TimeUnit unit) throws InterruptedExcep
362382

363383
@Override
364384
public void close() {
385+
closed = true;
365386
try {
366387
serverSocket.close();
367388
} catch (IOException ignored) {
368389
// best effort test cleanup
369390
}
370-
thread.interrupt();
391+
try {
392+
// join before draining, so a connection accepted during close() cannot be added afterwards
393+
thread.join(TimeUnit.SECONDS.toMillis(5));
394+
} catch (InterruptedException e) {
395+
Thread.currentThread().interrupt();
396+
}
371397
synchronized (accepted) {
372398
for (Socket socket : accepted) {
373399
try {

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,10 @@ void requestTimeoutAbortDuringTheHandshakeDoesNotStrandThePermit() throws Except
296296
future.abort(new TimeoutException("Request timeout to example.com:12345 after 100 ms"));
297297

298298
assertTrue(future.isDone());
299+
// The premise the whole fix rests on: the token is already off the future, so the abort has
300+
// nothing to give back and conservation depends entirely on the channel owning it.
301+
assertEquals(0, availablePerHost(semaphore, key),
302+
"abort cannot reclaim a token onSuccess already took");
299303

300304
// The orphaned socket eventually goes away (peer close, or the handshake timeout).
301305
channel.close().sync();
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
/*
2+
* Copyright (c) 2026 AsyncHttpClient Project. All rights reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.asynchttpclient.netty.request;
17+
18+
import io.netty.channel.embedded.EmbeddedChannel;
19+
import io.netty.util.HashedWheelTimer;
20+
import io.netty.util.Timer;
21+
import org.asynchttpclient.AsyncCompletionHandler;
22+
import org.asynchttpclient.AsyncHttpClientConfig;
23+
import org.asynchttpclient.Request;
24+
import org.asynchttpclient.RequestBuilder;
25+
import org.asynchttpclient.Response;
26+
import org.asynchttpclient.channel.ChannelPoolPartitioning;
27+
import org.asynchttpclient.netty.NettyResponseFuture;
28+
import org.asynchttpclient.netty.channel.ChannelManager;
29+
import org.junit.jupiter.api.AfterEach;
30+
import org.junit.jupiter.api.BeforeEach;
31+
import org.junit.jupiter.api.Test;
32+
33+
import java.net.ConnectException;
34+
import java.util.concurrent.ExecutionException;
35+
import java.util.concurrent.TimeUnit;
36+
import java.util.concurrent.TimeoutException;
37+
38+
import static org.asynchttpclient.Dsl.config;
39+
import static org.junit.jupiter.api.Assertions.assertFalse;
40+
import static org.junit.jupiter.api.Assertions.assertSame;
41+
import static org.junit.jupiter.api.Assertions.assertThrows;
42+
43+
/**
44+
* Pins the ordering inside {@link NettyRequestSender#abort(io.netty.channel.Channel, NettyResponseFuture,
45+
* Throwable)}: the future is completed with the caller's cause before the channel is closed.
46+
*
47+
* <p>Closing first is not inert. Once the connect path publishes the channel on the future (issue #2189), a
48+
* request timeout closes a socket whose TLS handshake is still in flight; that failure races back through
49+
* {@code NettyConnectListener.onFailure}, which aborts the same future with a {@link ConnectException}. If
50+
* the close runs first that cause can win, and a request timeout surfaces as a connect error instead of a
51+
* {@link TimeoutException}. An {@link EmbeddedChannel} makes this deterministic - it runs close listeners
52+
* inline, so the induced abort always beats a subsequent one.
53+
*/
54+
class NettyRequestSenderAbortTest {
55+
56+
private AsyncHttpClientConfig config;
57+
private ChannelManager channelManager;
58+
private NettyRequestSender sender;
59+
private Timer timer;
60+
61+
@BeforeEach
62+
void setUp() {
63+
config = config().build();
64+
timer = new HashedWheelTimer();
65+
channelManager = new ChannelManager(config, timer);
66+
sender = new NettyRequestSender(config, channelManager, timer, null);
67+
}
68+
69+
@AfterEach
70+
void tearDown() {
71+
if (channelManager != null) {
72+
channelManager.close();
73+
}
74+
if (timer != null) {
75+
timer.stop();
76+
}
77+
}
78+
79+
private NettyResponseFuture<Object> newFuture() {
80+
Request request = new RequestBuilder().setUrl("https://example.com:12345").build();
81+
return new NettyResponseFuture<>(request, new AsyncCompletionHandler<Object>() {
82+
@Override
83+
public Object onCompleted(Response response) {
84+
return null;
85+
}
86+
}, null, 0, ChannelPoolPartitioning.PerHostChannelPoolPartitioning.INSTANCE, null, null);
87+
}
88+
89+
@Test
90+
void abortCauseSurvivesAnAbortInducedByTheCloseItTriggers() throws Exception {
91+
NettyResponseFuture<Object> future = newFuture();
92+
EmbeddedChannel channel = new EmbeddedChannel();
93+
future.attachChannel(channel, false);
94+
// Stand in for the TLS handshake failing because we closed the channel, which NettyConnectListener
95+
// reports by aborting this same future with a ConnectException.
96+
channel.closeFuture().addListener(f -> future.abort(new ConnectException("closed mid-handshake")));
97+
98+
TimeoutException requestTimeout = new TimeoutException("Request timeout to example.com:12345 after 300 ms");
99+
try {
100+
sender.abort(channel, future, requestTimeout);
101+
102+
ExecutionException thrown = assertThrows(ExecutionException.class,
103+
() -> future.get(5, TimeUnit.SECONDS));
104+
assertSame(requestTimeout, thrown.getCause(),
105+
"the caller's cause must win over the one induced by the close it triggered");
106+
assertFalse(channel.isOpen(), "abort must still close the channel");
107+
} finally {
108+
channel.finishAndReleaseAll();
109+
}
110+
}
111+
}

0 commit comments

Comments
 (0)