Skip to content

Commit 7ca122f

Browse files
hyperxproUbuntu
andauthored
Guard against null TimeoutsHolder in NettyConnectListener.onSuccess (#2176)
Motivation NettyConnectListener.onSuccess NPEs on timeoutsHolder.setResolvedRemoteAddress(...) when a request timeout fires concurrently with a successful connect: abort() calls cancelTimeouts() (nulls the holder) before setting isDone, and per JMM the IO thread can observe holder==null while isDone==0. PR #2127 only guarded the remoteAddress parameter, not the holder. Modification - Add null guard on timeoutsHolder in onSuccess — close the channel and bail out if the holder was nulled out concurrently. - Broaden futureIsAlreadyCancelled → futureIsAlreadyCompleted (isCancelled() → isDone()) so aborted futures don't slip past the early-out (abort()/done() set isDone, not isCancelled). - Release the partition-key lock on both early-exit paths. - Add NettyConnectListenerTest reproducing the exact NPE from the bug report. Fixes #2172 Co-authored-by: Ubuntu <ubuntu@ip-172-31-9-101.ap-south-1.compute.internal>
1 parent 1ab1ea3 commit 7ca122f

2 files changed

Lines changed: 151 additions & 6 deletions

File tree

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

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -55,17 +55,19 @@ public NettyConnectListener(NettyResponseFuture<T> future, NettyRequestSender re
5555
this.connectionSemaphore = connectionSemaphore;
5656
}
5757

58-
private boolean futureIsAlreadyCancelled(Channel channel) {
59-
// If Future is cancelled then we will close the channel silently
60-
if (future.isCancelled()) {
58+
private boolean futureIsAlreadyCompleted(Channel channel) {
59+
// Use isDone() (covers cancel + abort + done) rather than isCancelled() alone:
60+
// abort() and done() set isDone but not isCancelled, so a future that has been
61+
// aborted (e.g. by a request timeout) would otherwise slip past this check.
62+
if (future.isDone()) {
6163
Channels.silentlyCloseChannel(channel);
6264
return true;
6365
}
6466
return false;
6567
}
6668

6769
private void writeRequest(Channel channel) {
68-
if (futureIsAlreadyCancelled(channel)) {
70+
if (futureIsAlreadyCompleted(channel)) {
6971
return;
7072
}
7173

@@ -87,9 +89,21 @@ public void onSuccess(Channel channel, InetSocketAddress remoteAddress) {
8789
final Object partitionKeyLock = (connectionSemaphore != null) ? future.takePartitionKeyLock() : null;
8890

8991
Channels.setActiveToken(channel);
90-
TimeoutsHolder timeoutsHolder = future.getTimeoutsHolder();
9192

92-
if (futureIsAlreadyCancelled(channel)) {
93+
if (futureIsAlreadyCompleted(channel)) {
94+
releaseSemaphoreImmediately(partitionKeyLock);
95+
return;
96+
}
97+
98+
TimeoutsHolder timeoutsHolder = future.getTimeoutsHolder();
99+
if (timeoutsHolder == null) {
100+
// The future is being terminated concurrently: cancelTimeouts() has nulled the
101+
// holder but the isDone flag may not yet be visible on this thread. Per JMM,
102+
// observing one volatile write does not require observing later ones, so the
103+
// futureIsAlreadyCompleted check above can pass while the holder is already null.
104+
// Drop this connection rather than NPE-ing on setResolvedRemoteAddress below.
105+
Channels.silentlyCloseChannel(channel);
106+
releaseSemaphoreImmediately(partitionKeyLock);
93107
return;
94108
}
95109

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
/*
2+
* Copyright (c) 2014-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.channel;
17+
18+
import io.netty.channel.embedded.EmbeddedChannel;
19+
import org.asynchttpclient.AsyncCompletionHandler;
20+
import org.asynchttpclient.DefaultAsyncHttpClientConfig;
21+
import org.asynchttpclient.Request;
22+
import org.asynchttpclient.RequestBuilder;
23+
import org.asynchttpclient.Response;
24+
import org.asynchttpclient.channel.ChannelPoolPartitioning;
25+
import org.asynchttpclient.netty.NettyResponseFuture;
26+
import org.asynchttpclient.netty.timeout.TimeoutsHolder;
27+
import org.junit.jupiter.api.Test;
28+
29+
import java.io.IOException;
30+
import java.net.InetSocketAddress;
31+
32+
import static org.junit.jupiter.api.Assertions.assertFalse;
33+
import static org.junit.jupiter.api.Assertions.assertTrue;
34+
35+
public class NettyConnectListenerTest {
36+
37+
private static NettyResponseFuture<Object> newFuture() {
38+
Request request = new RequestBuilder().setUrl("http://example.com:12345").build();
39+
return new NettyResponseFuture<>(
40+
request,
41+
new AsyncCompletionHandler<Object>() {
42+
@Override
43+
public Object onCompleted(Response response) {
44+
return null;
45+
}
46+
},
47+
null,
48+
0,
49+
ChannelPoolPartitioning.PerHostChannelPoolPartitioning.INSTANCE,
50+
null,
51+
null);
52+
}
53+
54+
/**
55+
* Reproduces the race in issue #2172: a TimeoutsHolder was previously installed
56+
* on the future, but cancelTimeouts() has nulled it out before onSuccess fires
57+
* on the IO event loop. The previous code would NPE on
58+
* timeoutsHolder.setResolvedRemoteAddress(...). With the fix, the listener
59+
* silently closes the freshly-connected channel and returns.
60+
*/
61+
@Test
62+
public void onSuccessShouldNotThrowWhenTimeoutsHolderIsNull() {
63+
NettyResponseFuture<Object> future = newFuture();
64+
TimeoutsHolder holder = new TimeoutsHolder(null, future, null,
65+
new DefaultAsyncHttpClientConfig.Builder().build(), null);
66+
future.setTimeoutsHolder(holder);
67+
// Simulate the race: cancelTimeouts has nulled the holder, but isDone is not
68+
// (yet) observable on this thread.
69+
future.cancelTimeouts();
70+
71+
NettyConnectListener<Object> listener = new NettyConnectListener<>(future, null, null, null);
72+
EmbeddedChannel channel = new EmbeddedChannel();
73+
74+
// Must not throw NPE.
75+
listener.onSuccess(channel, new InetSocketAddress("127.0.0.1", 80));
76+
77+
// Listener should have closed the freshly-connected channel.
78+
assertFalse(channel.isOpen(), "channel should be closed when holder is null");
79+
assertFalse(future.isDone(),
80+
"future state was not modified by cancelTimeouts alone — still not done");
81+
}
82+
83+
/**
84+
* When the future has been aborted (e.g. by a request timeout firing while the
85+
* connect was in flight), abort() calls terminateAndExit() which both nulls the
86+
* holder and sets isDone=1. The early-out check must catch this — under the old
87+
* isCancelled()-only check it would have fallen through to the holder NPE since
88+
* abort() does not set isCancelled.
89+
*/
90+
@Test
91+
public void onSuccessShouldExitEarlyWhenFutureWasAborted() {
92+
NettyResponseFuture<Object> future = newFuture();
93+
TimeoutsHolder holder = new TimeoutsHolder(null, future, null,
94+
new DefaultAsyncHttpClientConfig.Builder().build(), null);
95+
future.setTimeoutsHolder(holder);
96+
future.abort(new IOException("request timeout"));
97+
98+
assertTrue(future.isDone(), "abort() should mark the future done");
99+
assertFalse(future.isCancelled(),
100+
"abort() must not set isCancelled — that's the whole reason the old check was insufficient");
101+
102+
NettyConnectListener<Object> listener = new NettyConnectListener<>(future, null, null, null);
103+
EmbeddedChannel channel = new EmbeddedChannel();
104+
105+
// Must not throw NPE.
106+
listener.onSuccess(channel, new InetSocketAddress("127.0.0.1", 80));
107+
108+
assertFalse(channel.isOpen(), "channel should be closed when future is already done");
109+
}
110+
111+
/**
112+
* Cancelling the future also nulls the holder and sets isCancelled=1.
113+
* Mirrors the abort case but via the explicit cancel path; guards against
114+
* future regressions of the early-out for either flag.
115+
*/
116+
@Test
117+
public void onSuccessShouldExitEarlyWhenFutureWasCancelled() {
118+
NettyResponseFuture<Object> future = newFuture();
119+
TimeoutsHolder holder = new TimeoutsHolder(null, future, null,
120+
new DefaultAsyncHttpClientConfig.Builder().build(), null);
121+
future.setTimeoutsHolder(holder);
122+
future.cancel(true);
123+
124+
NettyConnectListener<Object> listener = new NettyConnectListener<>(future, null, null, null);
125+
EmbeddedChannel channel = new EmbeddedChannel();
126+
127+
listener.onSuccess(channel, new InetSocketAddress("127.0.0.1", 80));
128+
129+
assertFalse(channel.isOpen());
130+
}
131+
}

0 commit comments

Comments
 (0)