diff --git a/client/src/main/java/org/asynchttpclient/AsyncHttpClientConfig.java b/client/src/main/java/org/asynchttpclient/AsyncHttpClientConfig.java index 7304626083..b29ce5306a 100644 --- a/client/src/main/java/org/asynchttpclient/AsyncHttpClientConfig.java +++ b/client/src/main/java/org/asynchttpclient/AsyncHttpClientConfig.java @@ -114,6 +114,34 @@ public interface AsyncHttpClientConfig { */ Duration getRequestTimeout(); + /** + * Whether request and read timeouts are armed on an event loop rather than on {@link #getNettyTimer()}. + *

+ * The timer is a hashed wheel: it fires on the first tick at or after the deadline, so a deadline near or + * below {@link #getHashedWheelTimerTickDuration()} is rounded up to it, and one thread carries every expiry + * for the whole client. An event loop instead schedules by deadline and derives its own select timeout from + * the nearest one, so nothing is rounded up, and the loops share the load rather than funnelling it through + * a single thread. Both effects matter most to short deadlines, where a tick is a large fraction of the + * budget and a burst of expiries has no headroom to absorb. + *

+ * The cost is where the expiry runs. On the timer it runs on the timer thread; on an event loop it runs on + * an I/O thread, and so does whatever the caller chained onto the response future, because that future is + * completed from there. Blocking an I/O thread stalls every connection it serves, so a caller enabling this + * should hand its own work off with {@code handleAsync} or an {@code AsyncHandler} that does the same. + * That is why this is opt-in rather than the default. + *

+ * The loop is always the one that owns the exchange's channel. Until there is a channel -- while an address + * is being resolved and a connection made -- the timer carries the timeout, and the exchange moves it onto + * the loop once the connection succeeds. + *

+ * The connection-pool cleaner stays on the timer either way. + * + * @return {@code true} to arm request and read timeouts on an event loop + */ + default boolean isUseEventLoopTimeouts() { + return false; + } + /** * Is HTTP redirect enabled * diff --git a/client/src/main/java/org/asynchttpclient/DefaultAsyncHttpClientConfig.java b/client/src/main/java/org/asynchttpclient/DefaultAsyncHttpClientConfig.java index 75aa1bd16a..a1eed3cc97 100644 --- a/client/src/main/java/org/asynchttpclient/DefaultAsyncHttpClientConfig.java +++ b/client/src/main/java/org/asynchttpclient/DefaultAsyncHttpClientConfig.java @@ -97,6 +97,7 @@ import static org.asynchttpclient.config.AsyncHttpClientConfigDefaults.defaultStrict302Handling; import static org.asynchttpclient.config.AsyncHttpClientConfigDefaults.defaultTcpNoDelay; import static org.asynchttpclient.config.AsyncHttpClientConfigDefaults.defaultThreadPoolName; +import static org.asynchttpclient.config.AsyncHttpClientConfigDefaults.defaultUseEventLoopTimeouts; import static org.asynchttpclient.config.AsyncHttpClientConfigDefaults.defaultUseInsecureTrustManager; import static org.asynchttpclient.config.AsyncHttpClientConfigDefaults.defaultUseLaxCookieEncoder; import static org.asynchttpclient.config.AsyncHttpClientConfigDefaults.defaultUseNativeTransport; @@ -157,6 +158,7 @@ public class DefaultAsyncHttpClientConfig implements AsyncHttpClientConfig { private final Duration connectTimeout; private final Duration requestTimeout; private final Duration readTimeout; + private final boolean useEventLoopTimeouts; private final Duration shutdownQuietPeriod; private final Duration shutdownTimeout; @@ -258,6 +260,7 @@ private DefaultAsyncHttpClientConfig(// http Duration connectTimeout, Duration requestTimeout, Duration readTimeout, + boolean useEventLoopTimeouts, Duration shutdownQuietPeriod, Duration shutdownTimeout, @@ -367,6 +370,7 @@ private DefaultAsyncHttpClientConfig(// http this.connectTimeout = connectTimeout; this.requestTimeout = requestTimeout; this.readTimeout = readTimeout; + this.useEventLoopTimeouts = useEventLoopTimeouts; this.shutdownQuietPeriod = shutdownQuietPeriod; this.shutdownTimeout = shutdownTimeout; @@ -585,6 +589,11 @@ public Duration getReadTimeout() { return readTimeout; } + @Override + public boolean isUseEventLoopTimeouts() { + return useEventLoopTimeouts; + } + @Override public Duration getShutdownQuietPeriod() { return shutdownQuietPeriod; @@ -958,6 +967,7 @@ public static class Builder { private Duration connectTimeout = defaultConnectTimeout(); private Duration requestTimeout = defaultRequestTimeout(); private Duration readTimeout = defaultReadTimeout(); + private boolean useEventLoopTimeouts = defaultUseEventLoopTimeouts(); private Duration shutdownQuietPeriod = defaultShutdownQuietPeriod(); private Duration shutdownTimeout = defaultShutdownTimeout(); @@ -1064,6 +1074,7 @@ public Builder(AsyncHttpClientConfig config) { connectTimeout = config.getConnectTimeout(); requestTimeout = config.getRequestTimeout(); readTimeout = config.getReadTimeout(); + useEventLoopTimeouts = config.isUseEventLoopTimeouts(); shutdownQuietPeriod = config.getShutdownQuietPeriod(); shutdownTimeout = config.getShutdownTimeout(); @@ -1355,6 +1366,17 @@ public Builder setReadTimeout(Duration readTimeout) { return this; } + /** + * @param useEventLoopTimeouts whether to arm request and read timeouts on an event loop instead of on + * the client's timer; see {@link AsyncHttpClientConfig#isUseEventLoopTimeouts()} + * for the trade-off this makes + * @return this + */ + public Builder setUseEventLoopTimeouts(boolean useEventLoopTimeouts) { + this.useEventLoopTimeouts = useEventLoopTimeouts; + return this; + } + public Builder setShutdownQuietPeriod(Duration shutdownQuietPeriod) { this.shutdownQuietPeriod = shutdownQuietPeriod; return this; @@ -1764,6 +1786,7 @@ public DefaultAsyncHttpClientConfig build() { connectTimeout, requestTimeout, readTimeout, + useEventLoopTimeouts, shutdownQuietPeriod, shutdownTimeout, keepAlive, diff --git a/client/src/main/java/org/asynchttpclient/config/AsyncHttpClientConfigDefaults.java b/client/src/main/java/org/asynchttpclient/config/AsyncHttpClientConfigDefaults.java index a31fdf2855..50fcd723aa 100644 --- a/client/src/main/java/org/asynchttpclient/config/AsyncHttpClientConfigDefaults.java +++ b/client/src/main/java/org/asynchttpclient/config/AsyncHttpClientConfigDefaults.java @@ -41,6 +41,7 @@ public final class AsyncHttpClientConfigDefaults { public static final String CONNECTION_POOL_CLEANER_PERIOD_CONFIG = "connectionPoolCleanerPeriod"; public static final String READ_TIMEOUT_CONFIG = "readTimeout"; public static final String REQUEST_TIMEOUT_CONFIG = "requestTimeout"; + public static final String USE_EVENT_LOOP_TIMEOUTS_CONFIG = "useEventLoopTimeouts"; public static final String CONNECTION_TTL_CONFIG = "connectionTtl"; public static final String FOLLOW_REDIRECT_CONFIG = "followRedirect"; public static final String MAX_REDIRECTS_CONFIG = "maxRedirects"; @@ -154,6 +155,10 @@ public static Duration defaultRequestTimeout() { return AsyncHttpClientConfigHelper.getAsyncHttpClientConfig().getDuration(ASYNC_CLIENT_CONFIG_ROOT + REQUEST_TIMEOUT_CONFIG); } + public static boolean defaultUseEventLoopTimeouts() { + return AsyncHttpClientConfigHelper.getAsyncHttpClientConfig().getBoolean(ASYNC_CLIENT_CONFIG_ROOT + USE_EVENT_LOOP_TIMEOUTS_CONFIG); + } + public static Duration defaultConnectionTtl() { return AsyncHttpClientConfigHelper.getAsyncHttpClientConfig().getDuration(ASYNC_CLIENT_CONFIG_ROOT + CONNECTION_TTL_CONFIG); } diff --git a/client/src/main/java/org/asynchttpclient/netty/channel/NettyConnectListener.java b/client/src/main/java/org/asynchttpclient/netty/channel/NettyConnectListener.java index 049921c13f..cc03f3407c 100755 --- a/client/src/main/java/org/asynchttpclient/netty/channel/NettyConnectListener.java +++ b/client/src/main/java/org/asynchttpclient/netty/channel/NettyConnectListener.java @@ -124,6 +124,11 @@ public void onSuccess(Channel channel, InetSocketAddress remoteAddress) { // mid-handshake could not close the socket, stranding it until handshakeTimeout (issue #2189). future.attachChannel(channel, false); + // The timeouts were armed before there was a channel to arm them on; hand them the one the exchange + // ended up with. This listener runs on that channel's own loop, so the move needs no wakeup, and from + // here on an expiry runs on the thread that would have to close the socket. + timeoutsHolder.rehomeOn(channel.eventLoop()); + Request request = future.getTargetRequest(); Uri uri = request.getUri(); // don't set a null resolved address - if the remoteAddress is null we keep diff --git a/client/src/main/java/org/asynchttpclient/netty/request/NettyRequestSender.java b/client/src/main/java/org/asynchttpclient/netty/request/NettyRequestSender.java index af3610164d..41dffa515f 100755 --- a/client/src/main/java/org/asynchttpclient/netty/request/NettyRequestSender.java +++ b/client/src/main/java/org/asynchttpclient/netty/request/NettyRequestSender.java @@ -82,6 +82,7 @@ import org.asynchttpclient.resolver.RequestHostnameResolver; import org.asynchttpclient.uri.Uri; import org.asynchttpclient.ws.WebSocketUpgradeHandler; +import org.jetbrains.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -401,15 +402,17 @@ private ListenableFuture sendRequestWithOpenChannel(NettyResponseFuture handler, HttpReques private void scheduleRequestTimeout(NettyResponseFuture nettyResponseFuture, InetSocketAddress originalRemoteAddress) { + scheduleRequestTimeout(nettyResponseFuture, originalRemoteAddress, null); + } + + /** + * @param channel the channel the exchange will run on when it is already known, so the timeout can be armed + * on the loop that owns it. Null on the connect path: the timeout is armed before the channel + * exists, deliberately, so that it also bounds address resolution and the connect itself, and + * {@code TimeoutsHolder#rehomeOn} moves it onto the loop once there is one. + */ + private void scheduleRequestTimeout(NettyResponseFuture nettyResponseFuture, + InetSocketAddress originalRemoteAddress, + @Nullable Channel channel) { nettyResponseFuture.touch(); - TimeoutsHolder timeoutsHolder = new TimeoutsHolder(nettyTimer, nettyResponseFuture, this, config, - originalRemoteAddress); + TimeoutsHolder timeoutsHolder = new TimeoutsHolder(nettyTimer, timeoutExecutor(channel), nettyResponseFuture, + this, config, originalRemoteAddress); nettyResponseFuture.setTimeoutsHolder(timeoutsHolder); + // Only now that the future can be reached from the holder and the channel from the future, since either + // may be needed by an expiry that lands immediately; see TimeoutsHolder#start. + timeoutsHolder.start(); + } + + /** + * The loop to arm an exchange's timeouts on, or null to leave them on the client's timer. Only ever the + * exchange's own channel's loop: any other loop would be woken by an entry it has no interest in, and the + * group's chooser hands out channels from the same counter, so drawing from it here would shift which loops + * connections land on. + */ + private @Nullable EventExecutor timeoutExecutor(@Nullable Channel channel) { + return config.isUseEventLoopTimeouts() && channel != null ? channel.eventLoop() : null; } private static void scheduleReadTimeout(NettyResponseFuture nettyResponseFuture) { diff --git a/client/src/main/java/org/asynchttpclient/netty/timeout/TimeoutTimerTask.java b/client/src/main/java/org/asynchttpclient/netty/timeout/TimeoutTimerTask.java index b7e678fa84..8f4cc95e17 100755 --- a/client/src/main/java/org/asynchttpclient/netty/timeout/TimeoutTimerTask.java +++ b/client/src/main/java/org/asynchttpclient/netty/timeout/TimeoutTimerTask.java @@ -15,17 +15,27 @@ */ package org.asynchttpclient.netty.timeout; +import io.netty.util.Timeout; import io.netty.util.TimerTask; +import io.netty.util.concurrent.ScheduledFuture; import org.asynchttpclient.netty.NettyResponseFuture; import org.asynchttpclient.netty.request.NettyRequestSender; +import org.jetbrains.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.net.InetSocketAddress; +import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicBoolean; -public abstract class TimeoutTimerTask implements TimerTask { +/** + * Also a {@link Runnable} so the same task can be armed either on a {@link io.netty.util.Timer} or on an + * event loop, which schedules {@code Runnable}s. Neither subclass reads the {@link Timeout} handed to + * {@link TimerTask#run(Timeout)}, so the two entry points are interchangeable. Which one an exchange uses is + * {@link org.asynchttpclient.AsyncHttpClientConfig#isUseEventLoopTimeouts()}. + */ +public abstract class TimeoutTimerTask implements TimerTask, Runnable { private static final Logger LOGGER = LoggerFactory.getLogger(TimeoutTimerTask.class); @@ -33,6 +43,11 @@ public abstract class TimeoutTimerTask implements TimerTask { protected final NettyRequestSender requestSender; final TimeoutsHolder timeoutsHolder; volatile NettyResponseFuture nettyResponseFuture; + // The scheduled entry this task is armed on, one field per scheduler so that a scheduler changing its + // return type is a compile error rather than a cancellation that silently stops working. At most one is + // ever set. Held here rather than in a wrapper so arming allocates nothing beyond what the scheduler needs. + private volatile @Nullable Timeout timerHandle; + private volatile @Nullable ScheduledFuture loopHandle; TimeoutTimerTask(NettyResponseFuture nettyResponseFuture, NettyRequestSender requestSender, TimeoutsHolder timeoutsHolder) { this.nettyResponseFuture = nettyResponseFuture; @@ -40,6 +55,65 @@ public abstract class TimeoutTimerTask implements TimerTask { this.timeoutsHolder = timeoutsHolder; } + /** + * Narrows {@link TimerTask#run(Timeout)} to not throw, so that {@link #run()} can call it with nothing to + * catch. Neither subclass throws, and no other can exist: the only constructor is package private. + */ + @Override + public abstract void run(Timeout timeout); + + @Override + public void run() { + // The argument is the timer's handle on this task and nothing reads it, so an event loop, which + // schedules a Runnable and has no such handle, enters through the same body. + run(null); + } + + void armedOn(Timeout handle) { + timerHandle = handle; + } + + void armedOn(ScheduledFuture handle) { + loopHandle = handle; + } + + /** + * Cancels the scheduled entry this task was armed on, if any. Never interrupts: on the event-loop path the + * task may be running on the very thread this is called from, and nothing in it answers interruption. + * + * @return whether an entry was taken back out of its scheduler before it could run + */ + boolean cancelArmed() { + Timeout timer = timerHandle; + if (timer != null) { + timerHandle = null; + return timer.cancel(); + } + ScheduledFuture scheduled = loopHandle; + if (scheduled != null) { + loopHandle = null; + try { + return scheduled.cancel(false); + } catch (RejectedExecutionException e) { + // Cancelling from off the loop enqueues the removal, which a loop that is already shutting down + // rejects. The entry dies with the loop either way, and this runs under + // ListenableFuture#cancel, which has never thrown for a client that is closing. + LOGGER.debug("Event loop rejected a timeout cancellation", e); + return false; + } + } + return false; + } + + /** + * Whether this task has been claimed, either by firing or by {@link #clean()}. Stands in for the + * scheduler's own already-expired flag, which the two schedulers spell differently, and is if anything the + * more precise of the two: it flips when {@code run} is entered rather than when the entry is marked. + */ + boolean isClaimed() { + return done.get(); + } + void expire(String message, long time) { LOGGER.debug("{} for {} after {} ms", message, nettyResponseFuture, time); requestSender.abort(nettyResponseFuture.channel(), nettyResponseFuture, new TimeoutException(message)); diff --git a/client/src/main/java/org/asynchttpclient/netty/timeout/TimeoutsHolder.java b/client/src/main/java/org/asynchttpclient/netty/timeout/TimeoutsHolder.java index 93f6b26a26..10d4e96aa7 100755 --- a/client/src/main/java/org/asynchttpclient/netty/timeout/TimeoutsHolder.java +++ b/client/src/main/java/org/asynchttpclient/netty/timeout/TimeoutsHolder.java @@ -15,37 +15,63 @@ */ package org.asynchttpclient.netty.timeout; -import io.netty.util.Timeout; import io.netty.util.Timer; -import io.netty.util.TimerTask; +import io.netty.util.concurrent.EventExecutor; import org.asynchttpclient.AsyncHttpClientConfig; import org.asynchttpclient.Request; import org.asynchttpclient.netty.NettyResponseFuture; import org.asynchttpclient.netty.request.NettyRequestSender; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.net.InetSocketAddress; +import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import static org.asynchttpclient.util.DateUtils.unpreciseMillisTime; +/** + * The request and read timeouts of one exchange, armed either on the client's {@link Timer} or on the event + * loop of the channel the exchange runs on. What the two differ in, and why the choice is the caller's, is + * {@link AsyncHttpClientConfig#isUseEventLoopTimeouts()}. + */ public class TimeoutsHolder { - private final Timeout requestTimeout; + private static final Logger LOGGER = LoggerFactory.getLogger(TimeoutsHolder.class); + private final AtomicBoolean cancelled = new AtomicBoolean(); private final Timer nettyTimer; + private volatile @Nullable EventExecutor eventExecutor; private final NettyRequestSender requestSender; private final long requestTimeoutMillisTime; private final long readTimeoutValue; - private volatile Timeout readTimeout; + private final boolean useEventLoopTimeouts; + private final @Nullable RequestTimeoutTimerTask requestTimeoutTask; + private volatile @Nullable ReadTimeoutTimerTask readTimeoutTask; private final NettyResponseFuture nettyResponseFuture; private volatile InetSocketAddress remoteAddress; public TimeoutsHolder(Timer nettyTimer, NettyResponseFuture nettyResponseFuture, NettyRequestSender requestSender, AsyncHttpClientConfig config, InetSocketAddress originalRemoteAddress) { + this(nettyTimer, null, nettyResponseFuture, requestSender, config, originalRemoteAddress); + } + + /** + * @param eventExecutor the loop of the channel this exchange will run on, or {@code null} to arm the + * timeouts on {@code nettyTimer} instead. Only ever a channel's own loop, so that an + * expiry runs on the thread that would have to close the socket and cancelling one on + * completion touches no other loop's queue. Null until a channel exists; + * {@link #rehomeOn} moves the timeouts once one does. + */ + public TimeoutsHolder(Timer nettyTimer, @Nullable EventExecutor eventExecutor, NettyResponseFuture nettyResponseFuture, + NettyRequestSender requestSender, AsyncHttpClientConfig config, InetSocketAddress originalRemoteAddress) { this.nettyTimer = nettyTimer; + this.eventExecutor = eventExecutor; this.nettyResponseFuture = nettyResponseFuture; this.requestSender = requestSender; + useEventLoopTimeouts = config.isUseEventLoopTimeouts(); remoteAddress = originalRemoteAddress; final Request targetRequest = nettyResponseFuture.getTargetRequest(); @@ -60,13 +86,44 @@ public TimeoutsHolder(Timer nettyTimer, NettyResponseFuture nettyResponseFutu if (requestTimeoutInMs > -1) { requestTimeoutMillisTime = unpreciseMillisTime() + requestTimeoutInMs; - requestTimeout = newTimeout(new RequestTimeoutTimerTask(nettyResponseFuture, requestSender, this, requestTimeoutInMs), requestTimeoutInMs); + requestTimeoutTask = new RequestTimeoutTimerTask(nettyResponseFuture, requestSender, this, requestTimeoutInMs); } else { requestTimeoutMillisTime = -1L; - requestTimeout = null; + requestTimeoutTask = null; + } + } + + /** + * Arms the request timeout, which the constructor deliberately leaves undone. The task holds this holder and + * can run the moment it is armed, and on an event loop nothing rounds a short deadline up to the next tick, + * so arming from the constructor let it run before its own fields were frozen, before the future had been + * handed the holder, and on the pooled path before the channel had been attached to the future -- an expiry + * that then had no channel to close. The caller does all three first and arms last. + */ + public void start() { + if (requestTimeoutTask != null) { + arm(requestTimeoutTask, remainingRequestTimeout()); } } + /** + * Moves this exchange's timeouts onto {@code executor}, the loop of the channel it turned out to run on. The + * connect path arms the request timeout before there is a channel -- deliberately, since it bounds address + * resolution and the connect as well -- so there the loop is only known once the connection succeeds. A + * no-op when the timeouts belong on the timer, or once the request timeout has fired or been cancelled. + */ + public void rehomeOn(EventExecutor executor) { + if (!useEventLoopTimeouts) { + return; + } + eventExecutor = executor; + RequestTimeoutTimerTask task = requestTimeoutTask; + if (task == null || cancelled.get() || task.isClaimed() || !task.cancelArmed()) { + return; + } + arm(task, remainingRequestTimeout()); + } + public void setResolvedRemoteAddress(InetSocketAddress address) { remoteAddress = address; } @@ -81,14 +138,16 @@ public void startReadTimeout() { } } - void startReadTimeout(ReadTimeoutTimerTask task) { - if (requestTimeout == null || !requestTimeout.isExpired() && readTimeoutValue < requestTimeoutMillisTime - unpreciseMillisTime()) { + void startReadTimeout(@Nullable ReadTimeoutTimerTask task) { + if (requestTimeoutTask == null + || !requestTimeoutTask.isClaimed() && readTimeoutValue < requestTimeoutMillisTime - unpreciseMillisTime()) { // only schedule a new readTimeout if the requestTimeout doesn't happen first if (task == null) { // first call triggered from outside (else is read timeout is re-scheduling itself) task = new ReadTimeoutTimerTask(nettyResponseFuture, requestSender, this, readTimeoutValue); } - readTimeout = newTimeout(task, readTimeoutValue); + readTimeoutTask = task; + arm(task, readTimeoutValue); } else if (task != null) { // read timeout couldn't re-scheduling itself, clean up @@ -98,24 +157,64 @@ void startReadTimeout(ReadTimeoutTimerTask task) { public void cancel() { if (cancelled.compareAndSet(false, true)) { - if (requestTimeout != null) { - requestTimeout.cancel(); - ((TimeoutTimerTask) requestTimeout.task()).clean(); - } - if (readTimeout != null) { - readTimeout.cancel(); - ((TimeoutTimerTask) readTimeout.task()).clean(); - } + release(requestTimeoutTask); + release(readTimeoutTask); } } - private Timeout newTimeout(TimerTask task, long delay) { + private static void release(@Nullable TimeoutTimerTask task) { + if (task != null) { + task.cancelArmed(); + task.clean(); + } + } + + private long remainingRequestTimeout() { + // A deadline already behind us is armed at zero rather than negative, so the task still runs and still + // cancels its read-timeout sibling, which is bookkeeping only it does. + return Math.max(requestTimeoutMillisTime - unpreciseMillisTime(), 0L); + } + + /** + * Arms {@code task} to run after {@code delay} milliseconds, recording the scheduled entry on the task so it + * can cancel itself later. Leaves it unarmed when the client is shutting down, in which case there is no + * timeout to deliver anyway. + */ + private void arm(TimeoutTimerTask task, long delay) { // requestSender or nettyTimer might be null in unit tests or in some edge // cases where a channel's remote address wasn't available. In such cases // avoid scheduling any timeouts rather than throwing a NPE. - if (requestSender == null || nettyTimer == null || requestSender.isClosed()) { - return null; + if (requestSender == null || requestSender.isClosed()) { + return; + } + EventExecutor executor = eventExecutor; + if (executor != null && !executor.isShuttingDown()) { + try { + task.armedOn(executor.schedule(task, delay, TimeUnit.MILLISECONDS)); + cancelIfRaced(task); + return; + } catch (RejectedExecutionException e) { + // The loop began shutting down between the check above and here. Losing the timeout entirely + // would leave the exchange with nothing to end it, so fall through to the timer, which the + // client keeps running until it is itself closed. + LOGGER.debug("Event loop rejected a timeout, falling back to the timer", e); + } + } + if (nettyTimer == null) { + return; + } + task.armedOn(nettyTimer.newTimeout(task, delay, TimeUnit.MILLISECONDS)); + cancelIfRaced(task); + } + + /** + * Takes a just-armed entry back out of its scheduler when the exchange finished while it was being armed. + * {@link #cancel} is one shot, so a handle recorded after it ran is one nobody would ever cancel: the entry + * would sit in the scheduler until the full deadline, waking a loop for a request that is long done. + */ + private void cancelIfRaced(TimeoutTimerTask task) { + if (cancelled.get()) { + release(task); } - return nettyTimer.newTimeout(task, delay, TimeUnit.MILLISECONDS); } } diff --git a/client/src/main/resources/org/asynchttpclient/config/ahc-default.properties b/client/src/main/resources/org/asynchttpclient/config/ahc-default.properties index 6bf4e0f7b2..34fb663803 100644 --- a/client/src/main/resources/org/asynchttpclient/config/ahc-default.properties +++ b/client/src/main/resources/org/asynchttpclient/config/ahc-default.properties @@ -7,6 +7,7 @@ org.asynchttpclient.pooledConnectionIdleTimeout=PT1M org.asynchttpclient.connectionPoolCleanerPeriod=PT0.1S org.asynchttpclient.readTimeout=PT1M org.asynchttpclient.requestTimeout=PT1M +org.asynchttpclient.useEventLoopTimeouts=false org.asynchttpclient.connectionTtl=-PT0.001S org.asynchttpclient.followRedirect=false org.asynchttpclient.maxRedirects=5 diff --git a/client/src/test/java/org/asynchttpclient/EventLoopTimeoutTest.java b/client/src/test/java/org/asynchttpclient/EventLoopTimeoutTest.java new file mode 100644 index 0000000000..52d3bc5a4d --- /dev/null +++ b/client/src/test/java/org/asynchttpclient/EventLoopTimeoutTest.java @@ -0,0 +1,232 @@ +/* + * Copyright (c) 2026 AsyncHttpClient Project. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.asynchttpclient; + +import io.netty.channel.Channel; +import io.netty.channel.EventLoopGroup; +import io.netty.channel.nio.NioEventLoopGroup; +import io.netty.util.HashedWheelTimer; +import io.netty.util.concurrent.DefaultThreadFactory; +import org.asynchttpclient.testserver.HttpServer; +import org.asynchttpclient.testserver.HttpTest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.net.InetSocketAddress; +import java.time.Duration; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicReference; + +import static org.asynchttpclient.Dsl.config; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Which scheduler an exchange's timeouts are armed on, which is what + * {@link AsyncHttpClientConfig#isUseEventLoopTimeouts()} chooses. Off, every expiry in the client is delivered + * from the timer's one thread; on, it is delivered from the event loop of the channel the exchange runs on. + *

+ * The scheduler an expiry came from is observable through the thread {@link AsyncHandler#onThrowable} is called + * on, so these assert against the timer and the loops themselves rather than against thread names: a name is a + * property of whichever thread factory the config happens to carry, the loop that owns a channel is not. + */ +public class EventLoopTimeoutTest extends HttpTest { + + private static final Duration SHORT_TIMEOUT = Duration.ofMillis(200); + + private HttpServer server; + private EventLoopGroup eventLoopGroup; + private HashedWheelTimer timer; + private final AtomicReference timerThread = new AtomicReference<>(); + // Released before the server is closed, so a request left hanging on purpose never delays teardown. + private final CountDownLatch released = new CountDownLatch(1); + + @BeforeEach + public void start() throws Throwable { + server = new HttpServer(); + server.start(); + eventLoopGroup = new NioEventLoopGroup(2, new DefaultThreadFactory("ahc-timeout-test")); + // The client's own wheel settings, so that the timer case is timed the way it would be in production. + timer = new HashedWheelTimer(runnable -> { + Thread thread = new Thread(runnable, "ahc-timeout-test-timer"); + thread.setDaemon(true); + timerThread.set(thread); + return thread; + }, 100, TimeUnit.MILLISECONDS, 512, false); + } + + @AfterEach + public void stop() throws Throwable { + released.countDown(); + server.close(); + timer.stop(); + eventLoopGroup.shutdownGracefully(0, 0, TimeUnit.MILLISECONDS).await(10, TimeUnit.SECONDS); + } + + @Test + public void byDefaultAnExpiryIsDeliveredFromTheTimerThread() throws Throwable { + Recorder recorder = runAgainstAnUnansweringServer(baseConfig(false)); + + assertSame(timerThread.get(), recorder.deliveredOn.get(), + "expected the timer's own thread, got " + recorder.deliveredOn.get()); + } + + @Test + public void anExchangeThatConnectedExpiresOnItsChannelsLoop() throws Throwable { + // The request timeout is armed before the connect, so on this path it starts on the timer and is moved + // to the loop once there is a channel. A deadline it could reach before connecting would be delivered + // from the timer quite correctly -- there was no channel to deliver it from -- and prove nothing, hence + // a budget the first connect of a JVM comfortably fits inside. + Recorder recorder = runAgainstAnUnansweringServer(baseConfig(true).setRequestTimeout(Duration.ofSeconds(1))); + + assertNull(recorder.pooledChannel.get(), "this request was meant to open its own connection"); + assertDeliveredOnTheLoopOf(recorder.connectedChannel.get(), recorder); + } + + @Test + public void anExchangeOnAPooledChannelExpiresOnThatChannelsLoop() throws Throwable { + Recorder first = new Recorder(); + Recorder second = new Recorder(); + + withClient(baseConfig(true)).run(client -> withServer(server).run(server -> { + server.enqueueOk(); + client.prepareGet(server.getHttpUrl() + "/foo/bar").execute(first); + first.awaitCompletion(); + // Waited for, not assumed: the connection is offered to the pool around the same time as the future + // completes, and a second request that overtook the offer would open its own connection and test + // the wrong branch. + assertTrue(first.offered.await(10, TimeUnit.SECONDS), "the first connection was never pooled"); + + server.enqueueResponse(response -> awaitRelease()); + client.prepareGet(server.getHttpUrl() + "/foo/bar").execute(second); + second.awaitTimeout(); + })); + + assertNotNull(second.pooledChannel.get(), "the second request did not reuse the pooled connection"); + assertDeliveredOnTheLoopOf(second.pooledChannel.get(), second); + } + + @Test + public void aReadTimeoutIsDeliveredFromTheChannelsLoopAsWell() throws Throwable { + // A request timeout far enough out that the read timeout is the one that fires: the read timeout is + // armed after the request is written, by which point the exchange is already homed on its loop. + Recorder recorder = runAgainstAnUnansweringServer(baseConfig(true) + .setRequestTimeout(Duration.ofSeconds(10)) + .setReadTimeout(SHORT_TIMEOUT)); + + assertTrue(recorder.cause.get().getMessage().startsWith("Read timeout"), + "expected a read timeout, got " + recorder.cause.get().getMessage()); + assertDeliveredOnTheLoopOf(recorder.connectedChannel.get(), recorder); + } + + private DefaultAsyncHttpClientConfig.Builder baseConfig(boolean useEventLoopTimeouts) { + return config() + .setNettyTimer(timer) + .setEventLoopGroup(eventLoopGroup) + .setMaxRedirects(0) + .setRequestTimeout(SHORT_TIMEOUT) + .setUseEventLoopTimeouts(useEventLoopTimeouts); + } + + /** + * Runs one request against an endpoint that never answers, and returns what its handler saw. + */ + private Recorder runAgainstAnUnansweringServer(DefaultAsyncHttpClientConfig.Builder builder) throws Throwable { + Recorder recorder = new Recorder(); + + withClient(builder).run(client -> withServer(server).run(server -> { + server.enqueueResponse(response -> awaitRelease()); + + client.prepareGet(server.getHttpUrl() + "/foo/bar").execute(recorder); + recorder.awaitTimeout(); + })); + + return recorder; + } + + private static void assertDeliveredOnTheLoopOf(Channel channel, Recorder recorder) { + assertNotNull(channel, "the exchange never reported a channel"); + assertTrue(channel.eventLoop().inEventLoop(recorder.deliveredOn.get()), + "expected the channel's own loop, got " + recorder.deliveredOn.get()); + } + + private void awaitRelease() { + try { + released.await(30, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + /** + * Records the channel the exchange ran on, and the thread its expiry was delivered from. + */ + private static final class Recorder extends AsyncCompletionHandler { + + private final CountDownLatch settled = new CountDownLatch(1); + private final CountDownLatch offered = new CountDownLatch(1); + private final AtomicReference connectedChannel = new AtomicReference<>(); + private final AtomicReference pooledChannel = new AtomicReference<>(); + private final AtomicReference deliveredOn = new AtomicReference<>(); + private final AtomicReference cause = new AtomicReference<>(); + + @Override + public void onTcpConnectSuccess(InetSocketAddress remoteAddress, Channel connection) { + connectedChannel.set(connection); + } + + @Override + public void onConnectionPooled(Channel connection) { + pooledChannel.set(connection); + } + + @Override + public void onConnectionOffer(Channel connection) { + offered.countDown(); + } + + @Override + public Void onCompleted(Response response) { + settled.countDown(); + return null; + } + + @Override + public void onThrowable(Throwable t) { + deliveredOn.set(Thread.currentThread()); + cause.set(t); + settled.countDown(); + } + + void awaitCompletion() throws InterruptedException { + assertTrue(settled.await(30, TimeUnit.SECONDS), "the request never settled"); + assertNull(cause.get(), "the request was meant to succeed, got " + cause.get()); + } + + void awaitTimeout() throws InterruptedException { + assertTrue(settled.await(30, TimeUnit.SECONDS), "the request neither completed nor timed out"); + assertNotNull(cause.get(), "expected the request to be aborted"); + assertEquals(TimeoutException.class, cause.get().getClass(), "expected a timeout, got " + cause.get()); + assertNotNull(deliveredOn.get(), "onThrowable was not called"); + } + } +} diff --git a/pom.xml b/pom.xml index ed83e98417..a8a3872216 100644 --- a/pom.xml +++ b/pom.xml @@ -501,6 +501,12 @@ "new": "method java.lang.String org.asynchttpclient.util.AuthenticatorUtils::computeRspAuth(org.asynchttpclient.Realm, java.lang.String, java.lang.String, java.lang.String, java.lang.String, java.lang.String, java.lang.String, java.lang.String)", "justification": "The public computeRspAuth(Realm) was removed by the Digest mutual-authentication fix; revapi pairs it with the new private helper of the same name and reports the removal as a visibility reduction. It computed the expected rspauth from the Realm carried on the response future, whose uri is stale after a redirect and whose cnonce is regenerated on every build() - i.e. never the values actually sent on the wire - so it could only ever produce a value that disagrees with a conformant server. Verification now derives every parameter from the request's own Authorization header. The method had no caller inside the library; leaving it would invite it to be wired back in." }, + { + "code": "java.method.exception.checkedRemoved", + "old": "method void io.netty.util.TimerTask::run(io.netty.util.Timeout) throws java.lang.Exception @ org.asynchttpclient.netty.timeout.TimeoutTimerTask", + "new": "method void org.asynchttpclient.netty.timeout.TimeoutTimerTask::run(io.netty.util.Timeout)", + "justification": "TimeoutTimerTask now also implements Runnable, so a timeout can be armed on an event loop as well as on the client's Timer, and run() delegates to run(Timeout). Netty declares run(Timeout) throws Exception, which run() would have to catch and could only log - a handler for an exception neither subclass throws and no other subclass can throw, the only constructor being package private, so nothing outside this internal package can extend the class. Narrowing the declaration removes the dead handler. Binary compatible; the sole source effect would be on code catching a narrower checked exception around a direct run(Timeout) call, which has no caller outside the library. Scoped to this one method." + }, { "code": "java.annotation.removed", "old": "method void io.netty.channel.ChannelInboundHandlerAdapter::userEventTriggered(io.netty.channel.ChannelHandlerContext, java.lang.Object) throws java.lang.Exception @ org.asynchttpclient.netty.handler.Http2Handler",