From b675033a91cf08dfa4414b07f38fb60591b7c7cf Mon Sep 17 00:00:00 2001 From: Pavel Ptashyts <49400901+pavel-ptashyts@users.noreply.github.com> Date: Mon, 20 Jul 2026 11:32:25 +0200 Subject: [PATCH 1/5] Offload fallback DNS from event loops The legacy per-request NameResolver path still executes the JDK DefaultNameResolver on the caller thread. Redirects and SOCKS proxy bootstrap can enter that path from a Netty event-loop thread, so a cold or slow OS lookup can park the loop. Add an internal resolver executor and use it only when the fallback NameResolver path is invoked from an AHC event loop. AddressResolverGroup users stay on the existing async path, off-loop initial execute calls keep their current behavior, and resolver results are completed back on the original event loop before the request flow continues. Codex on behalf of Pavel Ptashyts Co-Authored-By: Codex --- .../DefaultAsyncHttpClient.java | 17 ++- .../netty/channel/ChannelManager.java | 65 +++++++++- .../netty/request/NettyRequestSender.java | 114 +++++++++++++++- .../AddressResolverGroupTest.java | 122 ++++++++++++++++++ 4 files changed, 311 insertions(+), 7 deletions(-) diff --git a/client/src/main/java/org/asynchttpclient/DefaultAsyncHttpClient.java b/client/src/main/java/org/asynchttpclient/DefaultAsyncHttpClient.java index 3b417a5a39..6a42593de1 100644 --- a/client/src/main/java/org/asynchttpclient/DefaultAsyncHttpClient.java +++ b/client/src/main/java/org/asynchttpclient/DefaultAsyncHttpClient.java @@ -35,6 +35,8 @@ import org.slf4j.LoggerFactory; import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import java.util.concurrent.ThreadFactory; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; @@ -62,6 +64,7 @@ public class DefaultAsyncHttpClient implements AsyncHttpClient { private final AtomicBoolean closed = new AtomicBoolean(false); private final ChannelManager channelManager; private final NettyRequestSender requestSender; + private final ExecutorService nameResolverExecutor; private final boolean allowStopNettyTimer; private final Timer nettyTimer; @@ -105,8 +108,10 @@ public DefaultAsyncHttpClient(AsyncHttpClientConfig config) { nettyTimer = configTimer; } - channelManager = new ChannelManager(config, nettyTimer); - requestSender = new NettyRequestSender(config, channelManager, nettyTimer, new AsyncHttpClientState(closed)); + nameResolverExecutor = newNameResolverExecutor(config); + channelManager = new ChannelManager(config, nettyTimer, nameResolverExecutor); + requestSender = new NettyRequestSender(config, channelManager, nettyTimer, new AsyncHttpClientState(closed), + nameResolverExecutor); channelManager.configureBootstraps(requestSender); CookieStore cookieStore = config.getCookieStore(); @@ -134,6 +139,13 @@ private static Timer newNettyTimer(AsyncHttpClientConfig config) { return timer; } + private static ExecutorService newNameResolverExecutor(AsyncHttpClientConfig config) { + ThreadFactory threadFactory = config.getThreadFactory() != null + ? config.getThreadFactory() + : new DefaultThreadFactory(config.getThreadPoolName() + "-resolver"); + return Executors.newFixedThreadPool(Math.max(1, config.getIoThreadsCount()), threadFactory); + } + @Override public void close() { if (closed.compareAndSet(false, true)) { @@ -142,6 +154,7 @@ public void close() { } catch (Throwable t) { LOGGER.warn("Unexpected error on ChannelManager close", t); } + nameResolverExecutor.shutdownNow(); CookieStore cookieStore = config.getCookieStore(); if (cookieStore != null) { cookieStore.decrementAndGet(); diff --git a/client/src/main/java/org/asynchttpclient/netty/channel/ChannelManager.java b/client/src/main/java/org/asynchttpclient/netty/channel/ChannelManager.java index d5db73d540..8b8d14902b 100755 --- a/client/src/main/java/org/asynchttpclient/netty/channel/ChannelManager.java +++ b/client/src/main/java/org/asynchttpclient/netty/channel/ChannelManager.java @@ -59,6 +59,7 @@ import io.netty.resolver.NameResolver; import io.netty.util.Timer; import io.netty.util.concurrent.DefaultThreadFactory; +import io.netty.util.concurrent.EventExecutor; import io.netty.util.concurrent.Future; import io.netty.util.concurrent.GlobalEventExecutor; import io.netty.util.concurrent.ImmediateEventExecutor; @@ -99,6 +100,8 @@ import java.util.Map.Entry; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executor; +import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.ThreadFactory; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; @@ -106,6 +109,8 @@ import java.util.function.Function; import java.util.stream.Collectors; +import static java.util.Objects.requireNonNull; + public class ChannelManager { public static final String HTTP_CLIENT_CODEC = "http"; @@ -138,6 +143,7 @@ public class ChannelManager { private final Map.Entry, Object>[] channelOptions; private final long handshakeTimeout; private final @Nullable AddressResolverGroup addressResolverGroup; + private final Executor nameResolverExecutor; private final ChannelPool channelPool; private final ChannelGroup openChannels; @@ -175,7 +181,12 @@ private boolean isInstanceof(Object object, String name) { } public ChannelManager(final AsyncHttpClientConfig config, Timer nettyTimer) { + this(config, nettyTimer, Runnable::run); + } + + public ChannelManager(final AsyncHttpClientConfig config, Timer nettyTimer, Executor nameResolverExecutor) { this.config = config; + this.nameResolverExecutor = requireNonNull(nameResolverExecutor, "nameResolverExecutor"); sslEngineFactory = config.getSslEngineFactory() != null ? config.getSslEngineFactory() : new DefaultSslEngineFactory(); try { @@ -886,7 +897,7 @@ public Future getBootstrap(Uri uri, NameResolver nameRes } }); } else { - nameResolver.resolve(proxy.getHost()).addListener((Future whenProxyAddress) -> { + resolveProxyHost(nameResolver, proxy.getHost()).addListener((Future whenProxyAddress) -> { if (whenProxyAddress.isSuccess()) { InetSocketAddress proxyAddress = new InetSocketAddress(whenProxyAddress.get(), proxy.getPort()); configureSocksBootstrap(socksBootstrap, httpBootstrapHandler, proxyAddress, proxy, promise); @@ -907,6 +918,58 @@ public Future getBootstrap(Uri uri, NameResolver nameRes return promise; } + private Future resolveProxyHost(NameResolver nameResolver, String host) { + EventExecutor eventLoop = currentEventLoop(); + if (eventLoop == null) { + return nameResolver.resolve(host); + } + + Promise promise = ImmediateEventExecutor.INSTANCE.newPromise(); + try { + nameResolverExecutor.execute(() -> { + try { + nameResolver.resolve(host).addListener((Future whenResolved) -> { + if (whenResolved.isSuccess()) { + completeSuccessOnEventLoop(eventLoop, promise, whenResolved.getNow()); + } else { + completeFailureOnEventLoop(eventLoop, promise, whenResolved.cause()); + } + }); + } catch (Throwable t) { + completeFailureOnEventLoop(eventLoop, promise, t); + } + }); + } catch (RejectedExecutionException e) { + promise.tryFailure(e); + } + return promise; + } + + private static void completeSuccessOnEventLoop(EventExecutor eventLoop, Promise promise, T result) { + try { + eventLoop.execute(() -> promise.trySuccess(result)); + } catch (RejectedExecutionException e) { + promise.tryFailure(e); + } + } + + private static void completeFailureOnEventLoop(EventExecutor eventLoop, Promise promise, Throwable cause) { + try { + eventLoop.execute(() -> promise.tryFailure(cause)); + } catch (RejectedExecutionException e) { + promise.tryFailure(e); + } + } + + private @Nullable EventExecutor currentEventLoop() { + for (EventExecutor executor : eventLoopGroup) { + if (executor.inEventLoop()) { + return executor; + } + } + return null; + } + private void configureSocksBootstrap(Bootstrap socksBootstrap, ChannelHandler httpBootstrapHandler, InetSocketAddress proxyAddress, ProxyServer proxy, Promise promise) { socksBootstrap.handler(new ChannelInitializer() { 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 36af9019b1..005baf59c7 100755 --- a/client/src/main/java/org/asynchttpclient/netty/request/NettyRequestSender.java +++ b/client/src/main/java/org/asynchttpclient/netty/request/NettyRequestSender.java @@ -37,6 +37,7 @@ import io.netty.handler.codec.http2.Http2StreamChannelBootstrap; import io.netty.resolver.AddressResolver; import io.netty.resolver.AddressResolverGroup; +import io.netty.resolver.NameResolver; import io.netty.util.AsciiString; import io.netty.util.Timeout; import io.netty.util.Timer; @@ -82,6 +83,7 @@ import org.asynchttpclient.uri.Uri; import org.asynchttpclient.ws.WebSocketUpgradeHandler; +import org.jetbrains.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -91,10 +93,13 @@ import java.net.SocketAddress; import java.net.UnknownHostException; import java.time.Duration; +import java.util.ArrayList; import java.util.Iterator; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.concurrent.Executor; +import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Consumer; @@ -121,11 +126,19 @@ public final class NettyRequestSender { private final AsyncHttpClientState clientState; private final NettyRequestFactory requestFactory; private final RoundRobinAddressSelector rrSelector = new RoundRobinAddressSelector(); + private final Executor nameResolverExecutor; // Deprioritizes a recently-failed IP when ordering a direct connection's resolved addresses, in any // LoadBalance mode. Null when the failed-IP cooldown is disabled; call sites gate on ipCooldown != null. private final FailedIpCooldownHolder ipCooldown; - public NettyRequestSender(AsyncHttpClientConfig config, ChannelManager channelManager, Timer nettyTimer, AsyncHttpClientState clientState) { + public NettyRequestSender(AsyncHttpClientConfig config, ChannelManager channelManager, Timer nettyTimer, + AsyncHttpClientState clientState) { + this(config, channelManager, nettyTimer, clientState, Runnable::run); + } + + public NettyRequestSender(AsyncHttpClientConfig config, ChannelManager channelManager, Timer nettyTimer, + AsyncHttpClientState clientState, + Executor nameResolverExecutor) { this.config = config; this.channelManager = channelManager; connectionSemaphore = config.getConnectionSemaphoreFactory() == null @@ -133,6 +146,7 @@ public NettyRequestSender(AsyncHttpClientConfig config, ChannelManager channelMa : config.getConnectionSemaphoreFactory().newConnectionSemaphore(config); this.nettyTimer = nettyTimer; this.clientState = clientState; + this.nameResolverExecutor = requireNonNull(nameResolverExecutor, "nameResolverExecutor"); requestFactory = new NettyRequestFactory(config); // Guard the period against a custom AsyncHttpClientConfig that enables the cooldown but returns a // null period: leave the cooldown off rather than NPE while constructing the client. @@ -596,7 +610,95 @@ private Future> resolveHostname(Request request, InetSoc AddressResolver resolver = group.getResolver(channelManager.getEventLoopGroup().next()); return RequestHostnameResolver.INSTANCE.resolve(resolver, unresolvedRemoteAddress, asyncHandler); } - return RequestHostnameResolver.INSTANCE.resolve(request.getNameResolver(), unresolvedRemoteAddress, asyncHandler); + return resolveWithRequestNameResolver(request.getNameResolver(), unresolvedRemoteAddress, asyncHandler); + } + + private Future> resolveWithRequestNameResolver(NameResolver nameResolver, + InetSocketAddress unresolvedRemoteAddress, + AsyncHandler asyncHandler) { + EventExecutor eventLoop = currentEventLoop(); + if (eventLoop == null) { + return RequestHostnameResolver.INSTANCE.resolve(nameResolver, unresolvedRemoteAddress, asyncHandler); + } + + String hostname = unresolvedRemoteAddress.getHostString(); + int port = unresolvedRemoteAddress.getPort(); + Promise> promise = ImmediateEventExecutor.INSTANCE.newPromise(); + try { + asyncHandler.onHostnameResolutionAttempt(hostname); + } catch (Exception e) { + LOGGER.error("onHostnameResolutionAttempt crashed", e); + promise.tryFailure(e); + return promise; + } + + try { + nameResolverExecutor.execute(() -> { + try { + nameResolver.resolveAll(hostname).addListener((Future> whenResolved) -> { + if (whenResolved.isSuccess()) { + completeResolutionSuccessOnEventLoop(eventLoop, promise, hostname, port, asyncHandler, + whenResolved.getNow()); + } else { + completeResolutionFailureOnEventLoop(eventLoop, promise, hostname, asyncHandler, + whenResolved.cause()); + } + }); + } catch (Throwable t) { + completeResolutionFailureOnEventLoop(eventLoop, promise, hostname, asyncHandler, t); + } + }); + } catch (RejectedExecutionException e) { + promise.tryFailure(e); + } + return promise; + } + + private static void completeResolutionSuccessOnEventLoop(EventExecutor eventLoop, + Promise> promise, + String hostname, + int port, + AsyncHandler asyncHandler, + List addresses) { + try { + eventLoop.execute(() -> { + ArrayList socketAddresses = new ArrayList<>(addresses.size()); + for (InetAddress address : addresses) { + socketAddresses.add(new InetSocketAddress(address, port)); + } + try { + asyncHandler.onHostnameResolutionSuccess(hostname, socketAddresses); + } catch (Exception e) { + LOGGER.error("onHostnameResolutionSuccess crashed", e); + promise.tryFailure(e); + return; + } + promise.trySuccess(socketAddresses); + }); + } catch (RejectedExecutionException e) { + promise.tryFailure(e); + } + } + + private static void completeResolutionFailureOnEventLoop(EventExecutor eventLoop, + Promise promise, + String hostname, + AsyncHandler asyncHandler, + Throwable cause) { + try { + eventLoop.execute(() -> { + try { + asyncHandler.onHostnameResolutionFailure(hostname, cause); + } catch (Exception e) { + LOGGER.error("onHostnameResolutionFailure crashed", e); + promise.tryFailure(e); + return; + } + promise.tryFailure(cause); + }); + } catch (RejectedExecutionException e) { + promise.tryFailure(e); + } } private NettyResponseFuture newNettyResponseFuture(Request request, AsyncHandler asyncHandler, NettyRequest nettyRequest, ProxyServer proxyServer) { @@ -1342,12 +1444,16 @@ private Channel pollHttp2(Object h2Key) { } private boolean isOnEventLoop() { + return currentEventLoop() != null; + } + + private @Nullable EventExecutor currentEventLoop() { for (EventExecutor executor : channelManager.getEventLoopGroup()) { if (executor.inEventLoop()) { - return true; + return executor; } } - return false; + return null; } private Channel pollPooledChannel(NettyResponseFuture future, Request request, ProxyServer proxy, AsyncHandler asyncHandler) { diff --git a/client/src/test/java/org/asynchttpclient/AddressResolverGroupTest.java b/client/src/test/java/org/asynchttpclient/AddressResolverGroupTest.java index 41de0c7cff..87a96d7c9f 100644 --- a/client/src/test/java/org/asynchttpclient/AddressResolverGroupTest.java +++ b/client/src/test/java/org/asynchttpclient/AddressResolverGroupTest.java @@ -16,19 +16,33 @@ package org.asynchttpclient; import io.github.artsok.RepeatedIfExceptionsTest; +import io.netty.channel.EventLoopGroup; import io.netty.channel.socket.nio.NioDatagramChannel; +import io.netty.resolver.InetNameResolver; import io.netty.resolver.dns.DnsAddressResolverGroup; import io.netty.resolver.dns.DnsServerAddressStreamProviders; +import io.netty.util.concurrent.EventExecutor; +import io.netty.util.concurrent.ImmediateEventExecutor; +import io.netty.util.concurrent.Promise; +import org.asynchttpclient.proxy.ProxyServer; +import org.asynchttpclient.proxy.ProxyType; import org.asynchttpclient.test.EventCollectingHandler; import org.asynchttpclient.testserver.HttpServer; import org.asynchttpclient.testserver.HttpTest; +import org.asynchttpclient.uri.Uri; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Tag; +import java.net.InetAddress; import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; import static java.util.concurrent.TimeUnit.SECONDS; import static org.asynchttpclient.Dsl.asyncHttpClient; @@ -37,6 +51,7 @@ import static org.asynchttpclient.test.TestUtils.isExternalNetworkAvailable; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -47,6 +62,9 @@ public class AddressResolverGroupTest extends HttpTest { private static final String GOOGLE_URL = "https://www.google.com/"; private static final String EXAMPLE_URL = "https://www.example.com/"; + private static final String INITIAL_HOST = "initial.test"; + private static final String REDIRECT_HOST = "redirect.test"; + private static final String SOCKS_HOST = "socks.test"; private HttpServer server; @@ -118,6 +136,57 @@ public void defaultConfigDoesNotSetAddressResolverGroup() { "Default config should not have an AddressResolverGroup"); } + @RepeatedIfExceptionsTest(repeats = 5) + public void fallbackNameResolverOnRedirectRunsOffEventLoop() throws Throwable { + server.enqueueRedirect(302, "http://" + REDIRECT_HOST + ':' + server.getHttpPort() + "/target"); + server.enqueueOk(); + + EventLoopProbeNameResolver resolver = new EventLoopProbeNameResolver(); + try (DefaultAsyncHttpClient client = new DefaultAsyncHttpClient(config().setFollowRedirect(true).build())) { + resolver.setEventLoopGroup(client.channelManager().getEventLoopGroup()); + + Response response = client.executeRequest(get("http://" + INITIAL_HOST + ':' + server.getHttpPort() + "/start") + .setNameResolver(resolver) + .build()) + .get(5, SECONDS); + + assertEquals(200, response.getStatusCode()); + } + + assertTrue(resolver.redirectResolutionAttempted.get(), "redirect host should have been resolved"); + assertFalse(resolver.redirectResolutionOnEventLoop.get(), "redirect DNS must not run on an event-loop thread"); + } + + @RepeatedIfExceptionsTest(repeats = 5) + public void fallbackSocksProxyResolutionRunsOffEventLoop() throws Throwable { + EventLoopProbeNameResolver resolver = new EventLoopProbeNameResolver(); + ProxyServer proxy = new ProxyServer.Builder(SOCKS_HOST, 1080) + .setProxyType(ProxyType.SOCKS_V5) + .build(); + + try (DefaultAsyncHttpClient client = new DefaultAsyncHttpClient(config().build())) { + EventLoopGroup eventLoopGroup = client.channelManager().getEventLoopGroup(); + resolver.setEventLoopGroup(eventLoopGroup); + + CountDownLatch complete = new CountDownLatch(1); + AtomicReference failure = new AtomicReference<>(); + eventLoopGroup.next().execute(() -> client.channelManager() + .getBootstrap(Uri.create("http://target.test/"), resolver, proxy) + .addListener(bootstrap -> { + if (!bootstrap.isSuccess()) { + failure.set(bootstrap.cause()); + } + complete.countDown(); + })); + + assertTrue(complete.await(5, SECONDS), "SOCKS bootstrap resolution should complete"); + assertNull(failure.get(), "SOCKS bootstrap should resolve the proxy host"); + } + + assertTrue(resolver.socksResolutionAttempted.get(), "SOCKS proxy host should have been resolved"); + assertFalse(resolver.socksResolutionOnEventLoop.get(), "SOCKS DNS must not run on an event-loop thread"); + } + @RepeatedIfExceptionsTest(repeats = 5) public void unknownHostWithDnsResolverGroupFails() throws Throwable { DnsAddressResolverGroup resolverGroup = new DnsAddressResolverGroup( @@ -172,4 +241,57 @@ public void resolveMultipleRealDomainsWithDnsResolverGroup() throws Throwable { "Expected successful HTTP status for example.com but got " + response2.getStatusCode()); } } + + private static final class EventLoopProbeNameResolver extends InetNameResolver { + private final AtomicReference eventLoopGroup = new AtomicReference<>(); + private final AtomicBoolean redirectResolutionAttempted = new AtomicBoolean(); + private final AtomicBoolean redirectResolutionOnEventLoop = new AtomicBoolean(); + private final AtomicBoolean socksResolutionAttempted = new AtomicBoolean(); + private final AtomicBoolean socksResolutionOnEventLoop = new AtomicBoolean(); + + EventLoopProbeNameResolver() { + super(ImmediateEventExecutor.INSTANCE); + } + + void setEventLoopGroup(EventLoopGroup eventLoopGroup) { + this.eventLoopGroup.set(eventLoopGroup); + } + + @Override + protected void doResolve(String inetHost, Promise promise) { + recordResolution(inetHost); + promise.setSuccess(InetAddress.getLoopbackAddress()); + } + + @Override + protected void doResolveAll(String inetHost, Promise> promise) { + recordResolution(inetHost); + promise.setSuccess(Collections.singletonList(InetAddress.getLoopbackAddress())); + } + + private void recordResolution(String inetHost) { + boolean onEventLoop = isOnEventLoop(); + if (REDIRECT_HOST.equals(inetHost)) { + redirectResolutionAttempted.set(true); + redirectResolutionOnEventLoop.set(onEventLoop); + } else if (SOCKS_HOST.equals(inetHost)) { + socksResolutionAttempted.set(true); + socksResolutionOnEventLoop.set(onEventLoop); + } + } + + private boolean isOnEventLoop() { + EventLoopGroup group = eventLoopGroup.get(); + if (group == null) { + return false; + } + Thread currentThread = Thread.currentThread(); + for (EventExecutor executor : group) { + if (executor.inEventLoop(currentThread)) { + return true; + } + } + return false; + } + } } From a378eb300de7ddb823a83ac978b28ffa2481ca0a Mon Sep 17 00:00:00 2001 From: Pavel Ptashyts <49400901+pavel-ptashyts@users.noreply.github.com> Date: Mon, 20 Jul 2026 13:15:18 +0200 Subject: [PATCH 2/5] Configure fallback DNS offload The fallback resolver pool was always enabled, matched the I/O thread count, and used an unbounded queue. It also discarded queued resolutions during client shutdown, which could leave their promises incomplete. Expose enable, thread-count, and queue-size settings. Bound the queue, fail tracked work on shutdown, and reject overflow deterministically. Limit automatic offload to AHC's default blocking resolver so custom resolvers keep their existing caller-thread contract. Codex on behalf of Pavel Ptashyts Co-Authored-By: Codex --- .../AsyncHttpClientConfig.java | 30 ++++ .../DefaultAsyncHttpClient.java | 17 +- .../DefaultAsyncHttpClientConfig.java | 51 ++++++ .../config/AsyncHttpClientConfigDefaults.java | 18 ++ .../netty/channel/ChannelManager.java | 61 ++----- .../netty/request/NettyRequestSender.java | 43 ++--- .../netty/resolver/NameResolverOffload.java | 159 ++++++++++++++++++ .../config/ahc-default.properties | 3 + .../AddressResolverGroupTest.java | 56 +++++- .../AsyncHttpClientDefaultsTest.java | 21 +++ .../resolver/NameResolverOffloadTest.java | 125 ++++++++++++++ 11 files changed, 492 insertions(+), 92 deletions(-) create mode 100644 client/src/main/java/org/asynchttpclient/netty/resolver/NameResolverOffload.java create mode 100644 client/src/test/java/org/asynchttpclient/netty/resolver/NameResolverOffloadTest.java diff --git a/client/src/main/java/org/asynchttpclient/AsyncHttpClientConfig.java b/client/src/main/java/org/asynchttpclient/AsyncHttpClientConfig.java index cae3900ee0..a38d586d1c 100644 --- a/client/src/main/java/org/asynchttpclient/AsyncHttpClientConfig.java +++ b/client/src/main/java/org/asynchttpclient/AsyncHttpClientConfig.java @@ -165,6 +165,36 @@ public interface AsyncHttpClientConfig { @Nullable ThreadFactory getThreadFactory(); + /** + * Returns whether fallback resolution with the default blocking name resolver should be offloaded from + * Netty event-loop threads. + * + * @return {@code true} if fallback name resolution should be offloaded + */ + default boolean isFallbackNameResolverOffloadEnabled() { + return true; + } + + /** + * Returns the number of threads used to offload fallback name resolution. + * + * @return the configured fallback resolver thread count. Values less than or equal to {@code 0} use + * {@link #getIoThreadsCount()}. + */ + default int getFallbackNameResolverOffloadThreadsCount() { + return -1; + } + + /** + * Returns the maximum number of pending fallback name resolutions. + * + * @return the configured fallback resolver queue size. Values less than or equal to {@code 0} use the + * default queue size. + */ + default int getFallbackNameResolverOffloadQueueSize() { + return 0; + } + /** * An instance of {@link ProxyServer} used by an {@link AsyncHttpClient} * diff --git a/client/src/main/java/org/asynchttpclient/DefaultAsyncHttpClient.java b/client/src/main/java/org/asynchttpclient/DefaultAsyncHttpClient.java index 6a42593de1..3b417a5a39 100644 --- a/client/src/main/java/org/asynchttpclient/DefaultAsyncHttpClient.java +++ b/client/src/main/java/org/asynchttpclient/DefaultAsyncHttpClient.java @@ -35,8 +35,6 @@ import org.slf4j.LoggerFactory; import java.util.List; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; import java.util.concurrent.ThreadFactory; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; @@ -64,7 +62,6 @@ public class DefaultAsyncHttpClient implements AsyncHttpClient { private final AtomicBoolean closed = new AtomicBoolean(false); private final ChannelManager channelManager; private final NettyRequestSender requestSender; - private final ExecutorService nameResolverExecutor; private final boolean allowStopNettyTimer; private final Timer nettyTimer; @@ -108,10 +105,8 @@ public DefaultAsyncHttpClient(AsyncHttpClientConfig config) { nettyTimer = configTimer; } - nameResolverExecutor = newNameResolverExecutor(config); - channelManager = new ChannelManager(config, nettyTimer, nameResolverExecutor); - requestSender = new NettyRequestSender(config, channelManager, nettyTimer, new AsyncHttpClientState(closed), - nameResolverExecutor); + channelManager = new ChannelManager(config, nettyTimer); + requestSender = new NettyRequestSender(config, channelManager, nettyTimer, new AsyncHttpClientState(closed)); channelManager.configureBootstraps(requestSender); CookieStore cookieStore = config.getCookieStore(); @@ -139,13 +134,6 @@ private static Timer newNettyTimer(AsyncHttpClientConfig config) { return timer; } - private static ExecutorService newNameResolverExecutor(AsyncHttpClientConfig config) { - ThreadFactory threadFactory = config.getThreadFactory() != null - ? config.getThreadFactory() - : new DefaultThreadFactory(config.getThreadPoolName() + "-resolver"); - return Executors.newFixedThreadPool(Math.max(1, config.getIoThreadsCount()), threadFactory); - } - @Override public void close() { if (closed.compareAndSet(false, true)) { @@ -154,7 +142,6 @@ public void close() { } catch (Throwable t) { LOGGER.warn("Unexpected error on ChannelManager close", t); } - nameResolverExecutor.shutdownNow(); CookieStore cookieStore = config.getCookieStore(); if (cookieStore != null) { cookieStore.decrementAndGet(); diff --git a/client/src/main/java/org/asynchttpclient/DefaultAsyncHttpClientConfig.java b/client/src/main/java/org/asynchttpclient/DefaultAsyncHttpClientConfig.java index 467e3d9ad3..47467865fc 100644 --- a/client/src/main/java/org/asynchttpclient/DefaultAsyncHttpClientConfig.java +++ b/client/src/main/java/org/asynchttpclient/DefaultAsyncHttpClientConfig.java @@ -64,6 +64,9 @@ import static org.asynchttpclient.config.AsyncHttpClientConfigDefaults.defaultExpiredCookieEvictionDelay; import static org.asynchttpclient.config.AsyncHttpClientConfigDefaults.defaultFailedIpCooldownEnabled; import static org.asynchttpclient.config.AsyncHttpClientConfigDefaults.defaultFailedIpCooldownPeriod; +import static org.asynchttpclient.config.AsyncHttpClientConfigDefaults.defaultFallbackNameResolverOffloadEnabled; +import static org.asynchttpclient.config.AsyncHttpClientConfigDefaults.defaultFallbackNameResolverOffloadQueueSize; +import static org.asynchttpclient.config.AsyncHttpClientConfigDefaults.defaultFallbackNameResolverOffloadThreadsCount; import static org.asynchttpclient.config.AsyncHttpClientConfigDefaults.defaultFilterInsecureCipherSuites; import static org.asynchttpclient.config.AsyncHttpClientConfigDefaults.defaultFollowRedirect; import static org.asynchttpclient.config.AsyncHttpClientConfigDefaults.defaultHandshakeTimeout; @@ -226,6 +229,9 @@ public class DefaultAsyncHttpClientConfig implements AsyncHttpClientConfig { private final @Nullable Consumer wsAdditionalChannelInitializer; private final ResponseBodyPartFactory responseBodyPartFactory; private final int ioThreadsCount; + private final boolean fallbackNameResolverOffloadEnabled; + private final int fallbackNameResolverOffloadThreadsCount; + private final int fallbackNameResolverOffloadQueueSize; private final long hashedWheelTimerTickDuration; private final int hashedWheelTimerSize; @@ -330,6 +336,9 @@ private DefaultAsyncHttpClientConfig(// http @Nullable Consumer wsAdditionalChannelInitializer, ResponseBodyPartFactory responseBodyPartFactory, int ioThreadsCount, + boolean fallbackNameResolverOffloadEnabled, + int fallbackNameResolverOffloadThreadsCount, + int fallbackNameResolverOffloadQueueSize, long hashedWheelTimerTickDuration, int hashedWheelTimerSize) { @@ -449,6 +458,9 @@ private DefaultAsyncHttpClientConfig(// http this.wsAdditionalChannelInitializer = wsAdditionalChannelInitializer; this.responseBodyPartFactory = responseBodyPartFactory; this.ioThreadsCount = ioThreadsCount; + this.fallbackNameResolverOffloadEnabled = fallbackNameResolverOffloadEnabled; + this.fallbackNameResolverOffloadThreadsCount = fallbackNameResolverOffloadThreadsCount; + this.fallbackNameResolverOffloadQueueSize = fallbackNameResolverOffloadQueueSize; this.hashedWheelTimerTickDuration = hashedWheelTimerTickDuration; this.hashedWheelTimerSize = hashedWheelTimerSize; } @@ -907,6 +919,21 @@ public int getIoThreadsCount() { return ioThreadsCount; } + @Override + public boolean isFallbackNameResolverOffloadEnabled() { + return fallbackNameResolverOffloadEnabled; + } + + @Override + public int getFallbackNameResolverOffloadThreadsCount() { + return fallbackNameResolverOffloadThreadsCount; + } + + @Override + public int getFallbackNameResolverOffloadQueueSize() { + return fallbackNameResolverOffloadQueueSize; + } + /** * Builder for an {@link AsyncHttpClient} */ @@ -1016,6 +1043,9 @@ public static class Builder { private @Nullable Consumer wsAdditionalChannelInitializer; private ResponseBodyPartFactory responseBodyPartFactory = ResponseBodyPartFactory.EAGER; private int ioThreadsCount = defaultIoThreadsCount(); + private boolean fallbackNameResolverOffloadEnabled = defaultFallbackNameResolverOffloadEnabled(); + private int fallbackNameResolverOffloadThreadsCount = defaultFallbackNameResolverOffloadThreadsCount(); + private int fallbackNameResolverOffloadQueueSize = defaultFallbackNameResolverOffloadQueueSize(); private long hashedWheelTickDuration = defaultHashedWheelTimerTickDuration(); private int hashedWheelSize = defaultHashedWheelTimerSize(); @@ -1127,6 +1157,9 @@ public Builder(AsyncHttpClientConfig config) { wsAdditionalChannelInitializer = config.getWsAdditionalChannelInitializer(); responseBodyPartFactory = config.getResponseBodyPartFactory(); ioThreadsCount = config.getIoThreadsCount(); + fallbackNameResolverOffloadEnabled = config.isFallbackNameResolverOffloadEnabled(); + fallbackNameResolverOffloadThreadsCount = config.getFallbackNameResolverOffloadThreadsCount(); + fallbackNameResolverOffloadQueueSize = config.getFallbackNameResolverOffloadQueueSize(); hashedWheelTickDuration = config.getHashedWheelTimerTickDuration(); hashedWheelSize = config.getHashedWheelTimerSize(); } @@ -1688,6 +1721,21 @@ public Builder setIoThreadsCount(int ioThreadsCount) { return this; } + public Builder setFallbackNameResolverOffloadEnabled(boolean fallbackNameResolverOffloadEnabled) { + this.fallbackNameResolverOffloadEnabled = fallbackNameResolverOffloadEnabled; + return this; + } + + public Builder setFallbackNameResolverOffloadThreadsCount(int fallbackNameResolverOffloadThreadsCount) { + this.fallbackNameResolverOffloadThreadsCount = fallbackNameResolverOffloadThreadsCount; + return this; + } + + public Builder setFallbackNameResolverOffloadQueueSize(int fallbackNameResolverOffloadQueueSize) { + this.fallbackNameResolverOffloadQueueSize = fallbackNameResolverOffloadQueueSize; + return this; + } + private ProxyServerSelector resolveProxyServerSelector() { if (proxyServerSelector != null) { return proxyServerSelector; @@ -1793,6 +1841,9 @@ public DefaultAsyncHttpClientConfig build() { wsAdditionalChannelInitializer, responseBodyPartFactory, ioThreadsCount, + fallbackNameResolverOffloadEnabled, + fallbackNameResolverOffloadThreadsCount, + fallbackNameResolverOffloadQueueSize, hashedWheelTickDuration, hashedWheelSize); } diff --git a/client/src/main/java/org/asynchttpclient/config/AsyncHttpClientConfigDefaults.java b/client/src/main/java/org/asynchttpclient/config/AsyncHttpClientConfigDefaults.java index 550381fbda..bf07e101af 100644 --- a/client/src/main/java/org/asynchttpclient/config/AsyncHttpClientConfigDefaults.java +++ b/client/src/main/java/org/asynchttpclient/config/AsyncHttpClientConfigDefaults.java @@ -90,6 +90,9 @@ public final class AsyncHttpClientConfigDefaults { public static final String USE_NATIVE_TRANSPORT_CONFIG = "useNativeTransport"; public static final String USE_ONLY_EPOLL_NATIVE_TRANSPORT = "useOnlyEpollNativeTransport"; public static final String IO_THREADS_COUNT_CONFIG = "ioThreadsCount"; + public static final String FALLBACK_NAME_RESOLVER_OFFLOAD_ENABLED_CONFIG = "fallbackNameResolverOffloadEnabled"; + public static final String FALLBACK_NAME_RESOLVER_OFFLOAD_THREADS_COUNT_CONFIG = "fallbackNameResolverOffloadThreadsCount"; + public static final String FALLBACK_NAME_RESOLVER_OFFLOAD_QUEUE_SIZE_CONFIG = "fallbackNameResolverOffloadQueueSize"; public static final String HASHED_WHEEL_TIMER_TICK_DURATION = "hashedWheelTimerTickDuration"; public static final String HASHED_WHEEL_TIMER_SIZE = "hashedWheelTimerSize"; public static final String EXPIRED_COOKIE_EVICTION_DELAY = "expiredCookieEvictionDelay"; @@ -347,6 +350,21 @@ public static int defaultIoThreadsCount() { return threads; } + public static boolean defaultFallbackNameResolverOffloadEnabled() { + return AsyncHttpClientConfigHelper.getAsyncHttpClientConfig().getBoolean( + ASYNC_CLIENT_CONFIG_ROOT + FALLBACK_NAME_RESOLVER_OFFLOAD_ENABLED_CONFIG); + } + + public static int defaultFallbackNameResolverOffloadThreadsCount() { + return AsyncHttpClientConfigHelper.getAsyncHttpClientConfig().getInt( + ASYNC_CLIENT_CONFIG_ROOT + FALLBACK_NAME_RESOLVER_OFFLOAD_THREADS_COUNT_CONFIG); + } + + public static int defaultFallbackNameResolverOffloadQueueSize() { + return AsyncHttpClientConfigHelper.getAsyncHttpClientConfig().getInt( + ASYNC_CLIENT_CONFIG_ROOT + FALLBACK_NAME_RESOLVER_OFFLOAD_QUEUE_SIZE_CONFIG); + } + public static int defaultHashedWheelTimerTickDuration() { return AsyncHttpClientConfigHelper.getAsyncHttpClientConfig().getInt(ASYNC_CLIENT_CONFIG_ROOT + HASHED_WHEEL_TIMER_TICK_DURATION); } diff --git a/client/src/main/java/org/asynchttpclient/netty/channel/ChannelManager.java b/client/src/main/java/org/asynchttpclient/netty/channel/ChannelManager.java index 8b8d14902b..ff27562ec3 100755 --- a/client/src/main/java/org/asynchttpclient/netty/channel/ChannelManager.java +++ b/client/src/main/java/org/asynchttpclient/netty/channel/ChannelManager.java @@ -82,6 +82,7 @@ import org.asynchttpclient.netty.handler.HttpHandler; import org.asynchttpclient.netty.handler.WebSocketHandler; import org.asynchttpclient.netty.request.NettyRequestSender; +import org.asynchttpclient.netty.resolver.NameResolverOffload; import org.asynchttpclient.netty.ssl.DefaultSslEngineFactory; import org.asynchttpclient.proxy.ProxyServer; import org.asynchttpclient.proxy.ProxyType; @@ -100,8 +101,6 @@ import java.util.Map.Entry; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.Executor; -import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.ThreadFactory; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; @@ -109,8 +108,6 @@ import java.util.function.Function; import java.util.stream.Collectors; -import static java.util.Objects.requireNonNull; - public class ChannelManager { public static final String HTTP_CLIENT_CODEC = "http"; @@ -143,7 +140,7 @@ public class ChannelManager { private final Map.Entry, Object>[] channelOptions; private final long handshakeTimeout; private final @Nullable AddressResolverGroup addressResolverGroup; - private final Executor nameResolverExecutor; + private final NameResolverOffload nameResolverOffload; private final ChannelPool channelPool; private final ChannelGroup openChannels; @@ -181,12 +178,8 @@ private boolean isInstanceof(Object object, String name) { } public ChannelManager(final AsyncHttpClientConfig config, Timer nettyTimer) { - this(config, nettyTimer, Runnable::run); - } - - public ChannelManager(final AsyncHttpClientConfig config, Timer nettyTimer, Executor nameResolverExecutor) { this.config = config; - this.nameResolverExecutor = requireNonNull(nameResolverExecutor, "nameResolverExecutor"); + nameResolverOffload = new NameResolverOffload(config); sslEngineFactory = config.getSslEngineFactory() != null ? config.getSslEngineFactory() : new DefaultSslEngineFactory(); try { @@ -706,6 +699,7 @@ private void doClose() { } public void close() { + nameResolverOffload.close(); // Fail any requests parked waiting for a sibling HTTP/2 connection to register (see the // http2ConnectionWaiters field): the client is closing, so no connection will arrive and their // request-timeout backstop is not scheduled yet. Do this synchronously up front — doClose() only @@ -920,47 +914,22 @@ public Future getBootstrap(Uri uri, NameResolver nameRes private Future resolveProxyHost(NameResolver nameResolver, String host) { EventExecutor eventLoop = currentEventLoop(); - if (eventLoop == null) { + if (eventLoop == null || !nameResolverOffload.shouldOffload(nameResolver)) { return nameResolver.resolve(host); } Promise promise = ImmediateEventExecutor.INSTANCE.newPromise(); - try { - nameResolverExecutor.execute(() -> { - try { - nameResolver.resolve(host).addListener((Future whenResolved) -> { - if (whenResolved.isSuccess()) { - completeSuccessOnEventLoop(eventLoop, promise, whenResolved.getNow()); - } else { - completeFailureOnEventLoop(eventLoop, promise, whenResolved.cause()); - } - }); - } catch (Throwable t) { - completeFailureOnEventLoop(eventLoop, promise, t); - } - }); - } catch (RejectedExecutionException e) { - promise.tryFailure(e); - } + nameResolverOffload.execute(eventLoop, promise, () -> + nameResolver.resolve(host).addListener((Future whenResolved) -> { + if (whenResolved.isSuccess()) { + nameResolverOffload.completeSuccess(eventLoop, promise, whenResolved.getNow()); + } else { + nameResolverOffload.completeFailure(eventLoop, promise, whenResolved.cause()); + } + })); return promise; } - private static void completeSuccessOnEventLoop(EventExecutor eventLoop, Promise promise, T result) { - try { - eventLoop.execute(() -> promise.trySuccess(result)); - } catch (RejectedExecutionException e) { - promise.tryFailure(e); - } - } - - private static void completeFailureOnEventLoop(EventExecutor eventLoop, Promise promise, Throwable cause) { - try { - eventLoop.execute(() -> promise.tryFailure(cause)); - } catch (RejectedExecutionException e) { - promise.tryFailure(e); - } - } - private @Nullable EventExecutor currentEventLoop() { for (EventExecutor executor : eventLoopGroup) { if (executor.inEventLoop()) { @@ -1184,6 +1153,10 @@ public EventLoopGroup getEventLoopGroup() { return eventLoopGroup; } + public NameResolverOffload getNameResolverOffload() { + return nameResolverOffload; + } + /** * Return the {@link AddressResolverGroup} used for async DNS resolution, or {@code null} * if per-request name resolvers should be used (legacy behavior). 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 005baf59c7..b1fb3d97b1 100755 --- a/client/src/main/java/org/asynchttpclient/netty/request/NettyRequestSender.java +++ b/client/src/main/java/org/asynchttpclient/netty/request/NettyRequestSender.java @@ -76,6 +76,7 @@ import org.asynchttpclient.netty.handler.Http2ContentDecompressor; import org.asynchttpclient.netty.request.body.NettyBody; import org.asynchttpclient.netty.request.body.NettyDirectBody; +import org.asynchttpclient.netty.resolver.NameResolverOffload; import org.asynchttpclient.netty.timeout.TimeoutsHolder; import org.asynchttpclient.proxy.ProxyServer; import org.asynchttpclient.proxy.ProxyType; @@ -98,7 +99,6 @@ import java.util.List; import java.util.Locale; import java.util.Map; -import java.util.concurrent.Executor; import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; @@ -126,19 +126,13 @@ public final class NettyRequestSender { private final AsyncHttpClientState clientState; private final NettyRequestFactory requestFactory; private final RoundRobinAddressSelector rrSelector = new RoundRobinAddressSelector(); - private final Executor nameResolverExecutor; + private final NameResolverOffload nameResolverOffload; // Deprioritizes a recently-failed IP when ordering a direct connection's resolved addresses, in any // LoadBalance mode. Null when the failed-IP cooldown is disabled; call sites gate on ipCooldown != null. private final FailedIpCooldownHolder ipCooldown; public NettyRequestSender(AsyncHttpClientConfig config, ChannelManager channelManager, Timer nettyTimer, AsyncHttpClientState clientState) { - this(config, channelManager, nettyTimer, clientState, Runnable::run); - } - - public NettyRequestSender(AsyncHttpClientConfig config, ChannelManager channelManager, Timer nettyTimer, - AsyncHttpClientState clientState, - Executor nameResolverExecutor) { this.config = config; this.channelManager = channelManager; connectionSemaphore = config.getConnectionSemaphoreFactory() == null @@ -146,7 +140,7 @@ public NettyRequestSender(AsyncHttpClientConfig config, ChannelManager channelMa : config.getConnectionSemaphoreFactory().newConnectionSemaphore(config); this.nettyTimer = nettyTimer; this.clientState = clientState; - this.nameResolverExecutor = requireNonNull(nameResolverExecutor, "nameResolverExecutor"); + nameResolverOffload = channelManager.getNameResolverOffload(); requestFactory = new NettyRequestFactory(config); // Guard the period against a custom AsyncHttpClientConfig that enables the cooldown but returns a // null period: leave the cooldown off rather than NPE while constructing the client. @@ -617,7 +611,7 @@ private Future> resolveWithRequestNameResolver(NameResol InetSocketAddress unresolvedRemoteAddress, AsyncHandler asyncHandler) { EventExecutor eventLoop = currentEventLoop(); - if (eventLoop == null) { + if (eventLoop == null || !nameResolverOffload.shouldOffload(nameResolver)) { return RequestHostnameResolver.INSTANCE.resolve(nameResolver, unresolvedRemoteAddress, asyncHandler); } @@ -632,25 +626,16 @@ private Future> resolveWithRequestNameResolver(NameResol return promise; } - try { - nameResolverExecutor.execute(() -> { - try { - nameResolver.resolveAll(hostname).addListener((Future> whenResolved) -> { - if (whenResolved.isSuccess()) { - completeResolutionSuccessOnEventLoop(eventLoop, promise, hostname, port, asyncHandler, - whenResolved.getNow()); - } else { - completeResolutionFailureOnEventLoop(eventLoop, promise, hostname, asyncHandler, - whenResolved.cause()); - } - }); - } catch (Throwable t) { - completeResolutionFailureOnEventLoop(eventLoop, promise, hostname, asyncHandler, t); - } - }); - } catch (RejectedExecutionException e) { - promise.tryFailure(e); - } + nameResolverOffload.execute(eventLoop, promise, () -> + nameResolver.resolveAll(hostname).addListener((Future> whenResolved) -> { + if (whenResolved.isSuccess()) { + completeResolutionSuccessOnEventLoop(eventLoop, promise, hostname, port, asyncHandler, + whenResolved.getNow()); + } else { + completeResolutionFailureOnEventLoop(eventLoop, promise, hostname, asyncHandler, + whenResolved.cause()); + } + })); return promise; } diff --git a/client/src/main/java/org/asynchttpclient/netty/resolver/NameResolverOffload.java b/client/src/main/java/org/asynchttpclient/netty/resolver/NameResolverOffload.java new file mode 100644 index 0000000000..c7ae688b4c --- /dev/null +++ b/client/src/main/java/org/asynchttpclient/netty/resolver/NameResolverOffload.java @@ -0,0 +1,159 @@ +/* + * 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.netty.resolver; + +import io.netty.resolver.NameResolver; +import io.netty.util.concurrent.DefaultThreadFactory; +import io.netty.util.concurrent.EventExecutor; +import io.netty.util.concurrent.Promise; +import org.asynchttpclient.AsyncHttpClientConfig; +import org.jetbrains.annotations.Nullable; + +import java.net.InetAddress; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import static java.util.Objects.requireNonNull; +import static org.asynchttpclient.RequestBuilderBase.DEFAULT_NAME_RESOLVER; + +/** + * Offloads the default blocking fallback name resolver and tracks its pending promises for client shutdown. + */ +public final class NameResolverOffload implements AutoCloseable { + + private final @Nullable ExecutorService executor; + private final Map, EventExecutor> pending = new ConcurrentHashMap<>(); + private final AtomicBoolean closed = new AtomicBoolean(); + + public NameResolverOffload(AsyncHttpClientConfig config) { + requireNonNull(config, "config"); + if (!config.isFallbackNameResolverOffloadEnabled()) { + executor = null; + return; + } + + int threads = configuredThreads(config); + int queueSize = configuredQueueSize(config, threads); + ThreadFactory threadFactory = config.getThreadFactory() != null + ? config.getThreadFactory() + : new DefaultThreadFactory(config.getThreadPoolName() + "-resolver"); + executor = new ThreadPoolExecutor(threads, threads, 0L, TimeUnit.MILLISECONDS, + new LinkedBlockingQueue<>(queueSize), threadFactory); + } + + public boolean shouldOffload(NameResolver nameResolver) { + return executor != null && nameResolver == DEFAULT_NAME_RESOLVER; + } + + public void execute(EventExecutor eventLoop, Promise promise, Runnable task) { + requireNonNull(eventLoop, "eventLoop"); + requireNonNull(promise, "promise"); + requireNonNull(task, "task"); + + ExecutorService resolverExecutor = executor; + if (resolverExecutor == null) { + completeFailure(eventLoop, promise, + new RejectedExecutionException("Fallback name resolver offload is disabled")); + return; + } + + pending.put(promise, eventLoop); + promise.addListener(ignored -> pending.remove(promise)); + + if (closed.get()) { + completeFailure(eventLoop, promise, closedFailure()); + return; + } + + try { + resolverExecutor.execute(() -> { + if (promise.isDone()) { + return; + } + try { + task.run(); + } catch (RuntimeException e) { + completeFailure(eventLoop, promise, e); + } + }); + } catch (RejectedExecutionException e) { + completeFailure(eventLoop, promise, e); + } + } + + public void completeSuccess(EventExecutor eventLoop, Promise promise, T result) { + if (eventLoop.inEventLoop()) { + promise.trySuccess(result); + return; + } + try { + eventLoop.execute(() -> promise.trySuccess(result)); + } catch (RejectedExecutionException e) { + promise.tryFailure(e); + } + } + + public void completeFailure(EventExecutor eventLoop, Promise promise, Throwable cause) { + if (eventLoop.inEventLoop()) { + promise.tryFailure(cause); + return; + } + try { + eventLoop.execute(() -> promise.tryFailure(cause)); + } catch (RejectedExecutionException e) { + promise.tryFailure(cause); + } + } + + @Override + public void close() { + ExecutorService resolverExecutor = executor; + if (resolverExecutor == null || !closed.compareAndSet(false, true)) { + return; + } + + resolverExecutor.shutdownNow(); + RejectedExecutionException failure = closedFailure(); + pending.forEach((promise, eventLoop) -> completeFailure(eventLoop, promise, failure)); + } + + private static int configuredThreads(AsyncHttpClientConfig config) { + int threads = config.getFallbackNameResolverOffloadThreadsCount(); + if (threads <= 0) { + threads = config.getIoThreadsCount(); + } + return Math.max(1, threads); + } + + private static int configuredQueueSize(AsyncHttpClientConfig config, int threads) { + int queueSize = config.getFallbackNameResolverOffloadQueueSize(); + if (queueSize <= 0) { + queueSize = threads > Integer.MAX_VALUE / 16 ? Integer.MAX_VALUE : Math.max(1024, threads * 16); + } + return Math.max(1, queueSize); + } + + private static RejectedExecutionException closedFailure() { + return new RejectedExecutionException("Fallback name resolver offload is closed"); + } +} 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 5df97add5b..37639e9772 100644 --- a/client/src/main/resources/org/asynchttpclient/config/ahc-default.properties +++ b/client/src/main/resources/org/asynchttpclient/config/ahc-default.properties @@ -55,6 +55,9 @@ org.asynchttpclient.shutdownTimeout=PT15S org.asynchttpclient.useNativeTransport=false org.asynchttpclient.useOnlyEpollNativeTransport=false org.asynchttpclient.ioThreadsCount=-1 +org.asynchttpclient.fallbackNameResolverOffloadEnabled=true +org.asynchttpclient.fallbackNameResolverOffloadThreadsCount=-1 +org.asynchttpclient.fallbackNameResolverOffloadQueueSize=0 org.asynchttpclient.hashedWheelTimerTickDuration=100 org.asynchttpclient.hashedWheelTimerSize=512 org.asynchttpclient.expiredCookieEvictionDelay=30000 diff --git a/client/src/test/java/org/asynchttpclient/AddressResolverGroupTest.java b/client/src/test/java/org/asynchttpclient/AddressResolverGroupTest.java index 87a96d7c9f..90c426a6f3 100644 --- a/client/src/test/java/org/asynchttpclient/AddressResolverGroupTest.java +++ b/client/src/test/java/org/asynchttpclient/AddressResolverGroupTest.java @@ -137,7 +137,7 @@ public void defaultConfigDoesNotSetAddressResolverGroup() { } @RepeatedIfExceptionsTest(repeats = 5) - public void fallbackNameResolverOnRedirectRunsOffEventLoop() throws Throwable { + public void customNameResolverOnRedirectKeepsEventLoopContext() throws Throwable { server.enqueueRedirect(302, "http://" + REDIRECT_HOST + ':' + server.getHttpPort() + "/target"); server.enqueueOk(); @@ -154,11 +154,11 @@ public void fallbackNameResolverOnRedirectRunsOffEventLoop() throws Throwable { } assertTrue(resolver.redirectResolutionAttempted.get(), "redirect host should have been resolved"); - assertFalse(resolver.redirectResolutionOnEventLoop.get(), "redirect DNS must not run on an event-loop thread"); + assertTrue(resolver.redirectResolutionOnEventLoop.get(), "custom resolver should retain its calling context"); } @RepeatedIfExceptionsTest(repeats = 5) - public void fallbackSocksProxyResolutionRunsOffEventLoop() throws Throwable { + public void customSocksProxyResolverKeepsEventLoopContext() throws Throwable { EventLoopProbeNameResolver resolver = new EventLoopProbeNameResolver(); ProxyServer proxy = new ProxyServer.Builder(SOCKS_HOST, 1080) .setProxyType(ProxyType.SOCKS_V5) @@ -184,7 +184,46 @@ public void fallbackSocksProxyResolutionRunsOffEventLoop() throws Throwable { } assertTrue(resolver.socksResolutionAttempted.get(), "SOCKS proxy host should have been resolved"); - assertFalse(resolver.socksResolutionOnEventLoop.get(), "SOCKS DNS must not run on an event-loop thread"); + assertTrue(resolver.socksResolutionOnEventLoop.get(), "custom resolver should retain its calling context"); + } + + @RepeatedIfExceptionsTest(repeats = 5) + public void defaultNameResolverOnRedirectUsesOffloadPool() throws Throwable { + server.enqueueRedirect(302, "http://localhost:" + server.getHttpPort() + "/target"); + server.enqueueOk(); + + String poolName = "ahc-fallback-resolver-enabled"; + try (AsyncHttpClient client = asyncHttpClient(config() + .setFollowRedirect(true) + .setThreadPoolName(poolName))) { + Response response = client.prepareGet("http://127.0.0.1:" + server.getHttpPort() + "/start") + .execute() + .get(5, SECONDS); + + assertEquals(200, response.getStatusCode()); + assertTrue(hasLiveThreadNamed(poolName + "-resolver"), + "redirect fallback DNS should use the resolver offload pool"); + } + } + + @RepeatedIfExceptionsTest(repeats = 5) + public void defaultNameResolverOnRedirectCanKeepInlineBehavior() throws Throwable { + server.enqueueRedirect(302, "http://localhost:" + server.getHttpPort() + "/target"); + server.enqueueOk(); + + String poolName = "ahc-fallback-resolver-disabled"; + try (AsyncHttpClient client = asyncHttpClient(config() + .setFollowRedirect(true) + .setThreadPoolName(poolName) + .setFallbackNameResolverOffloadEnabled(false))) { + Response response = client.prepareGet("http://127.0.0.1:" + server.getHttpPort() + "/start") + .execute() + .get(5, SECONDS); + + assertEquals(200, response.getStatusCode()); + assertFalse(hasLiveThreadNamed(poolName + "-resolver"), + "disabled offload must not create a resolver worker"); + } } @RepeatedIfExceptionsTest(repeats = 5) @@ -294,4 +333,13 @@ private boolean isOnEventLoop() { return false; } } + + private static boolean hasLiveThreadNamed(String namePart) { + for (Thread thread : Thread.getAllStackTraces().keySet()) { + if (thread.isAlive() && thread.getName().contains(namePart)) { + return true; + } + } + return false; + } } diff --git a/client/src/test/java/org/asynchttpclient/AsyncHttpClientDefaultsTest.java b/client/src/test/java/org/asynchttpclient/AsyncHttpClientDefaultsTest.java index d125a9fa48..6d6d6b2c4e 100644 --- a/client/src/test/java/org/asynchttpclient/AsyncHttpClientDefaultsTest.java +++ b/client/src/test/java/org/asynchttpclient/AsyncHttpClientDefaultsTest.java @@ -144,6 +144,27 @@ public void testDefaultUseInsecureTrustManager() { testBooleanSystemProperty("useInsecureTrustManager", "defaultUseInsecureTrustManager", "false"); } + @RepeatedIfExceptionsTest(repeats = 5) + public void testDefaultFallbackNameResolverOffloadEnabled() { + assertTrue(AsyncHttpClientConfigDefaults.defaultFallbackNameResolverOffloadEnabled()); + testBooleanSystemProperty("fallbackNameResolverOffloadEnabled", + "defaultFallbackNameResolverOffloadEnabled", "false"); + } + + @RepeatedIfExceptionsTest(repeats = 5) + public void testDefaultFallbackNameResolverOffloadThreadsCount() { + assertEquals(-1, AsyncHttpClientConfigDefaults.defaultFallbackNameResolverOffloadThreadsCount()); + testIntegerSystemProperty("fallbackNameResolverOffloadThreadsCount", + "defaultFallbackNameResolverOffloadThreadsCount", "2"); + } + + @RepeatedIfExceptionsTest(repeats = 5) + public void testDefaultFallbackNameResolverOffloadQueueSize() { + assertEquals(0, AsyncHttpClientConfigDefaults.defaultFallbackNameResolverOffloadQueueSize()); + testIntegerSystemProperty("fallbackNameResolverOffloadQueueSize", + "defaultFallbackNameResolverOffloadQueueSize", "17"); + } + @RepeatedIfExceptionsTest(repeats = 5) public void testDefaultHashedWheelTimerTickDuration() { assertEquals(AsyncHttpClientConfigDefaults.defaultHashedWheelTimerTickDuration(), 100); diff --git a/client/src/test/java/org/asynchttpclient/netty/resolver/NameResolverOffloadTest.java b/client/src/test/java/org/asynchttpclient/netty/resolver/NameResolverOffloadTest.java new file mode 100644 index 0000000000..b2887311f3 --- /dev/null +++ b/client/src/test/java/org/asynchttpclient/netty/resolver/NameResolverOffloadTest.java @@ -0,0 +1,125 @@ +/* + * 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.netty.resolver; + +import io.netty.channel.DefaultEventLoop; +import io.netty.resolver.DefaultNameResolver; +import io.netty.util.concurrent.ImmediateEventExecutor; +import io.netty.util.concurrent.Promise; +import org.asynchttpclient.DefaultAsyncHttpClientConfig; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.atomic.AtomicReference; + +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.asynchttpclient.RequestBuilderBase.DEFAULT_NAME_RESOLVER; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class NameResolverOffloadTest { + + private final DefaultEventLoop eventLoop = new DefaultEventLoop(); + + @AfterEach + void closeEventLoop() { + eventLoop.shutdownGracefully(0, 5, SECONDS).syncUninterruptibly(); + } + + @Test + void offloadsDefaultResolverToConfiguredWorker() throws Exception { + DefaultAsyncHttpClientConfig config = new DefaultAsyncHttpClientConfig.Builder() + .setThreadPoolName("ahc-resolver-unit") + .setFallbackNameResolverOffloadThreadsCount(1) + .setFallbackNameResolverOffloadQueueSize(1) + .build(); + + try (NameResolverOffload offload = new NameResolverOffload(config)) { + assertTrue(offload.shouldOffload(DEFAULT_NAME_RESOLVER)); + assertFalse(offload.shouldOffload(new DefaultNameResolver(ImmediateEventExecutor.INSTANCE))); + + Promise promise = ImmediateEventExecutor.INSTANCE.newPromise(); + AtomicReference threadName = new AtomicReference<>(); + offload.execute(eventLoop, promise, () -> { + threadName.set(Thread.currentThread().getName()); + offload.completeSuccess(eventLoop, promise, null); + }); + + promise.get(5, SECONDS); + assertTrue(threadName.get().contains("ahc-resolver-unit-resolver")); + } + } + + @Test + void disabledOffloadDoesNotSelectDefaultResolver() { + DefaultAsyncHttpClientConfig config = new DefaultAsyncHttpClientConfig.Builder() + .setFallbackNameResolverOffloadEnabled(false) + .build(); + + try (NameResolverOffload offload = new NameResolverOffload(config)) { + assertFalse(offload.shouldOffload(DEFAULT_NAME_RESOLVER)); + } + } + + @Test + void boundedQueueRejectsOverflowAndCloseFailsPendingWork() throws Exception { + DefaultAsyncHttpClientConfig config = new DefaultAsyncHttpClientConfig.Builder() + .setFallbackNameResolverOffloadThreadsCount(1) + .setFallbackNameResolverOffloadQueueSize(1) + .build(); + CountDownLatch workerStarted = new CountDownLatch(1); + CountDownLatch releaseWorker = new CountDownLatch(1); + + try (NameResolverOffload offload = new NameResolverOffload(config)) { + Promise running = ImmediateEventExecutor.INSTANCE.newPromise(); + Promise queued = ImmediateEventExecutor.INSTANCE.newPromise(); + Promise rejected = ImmediateEventExecutor.INSTANCE.newPromise(); + + offload.execute(eventLoop, running, () -> { + workerStarted.countDown(); + try { + releaseWorker.await(); + offload.completeSuccess(eventLoop, running, null); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + assertTrue(workerStarted.await(5, SECONDS)); + + offload.execute(eventLoop, queued, () -> offload.completeSuccess(eventLoop, queued, null)); + offload.execute(eventLoop, rejected, () -> offload.completeSuccess(eventLoop, rejected, null)); + + ExecutionException overflow = assertThrows(ExecutionException.class, () -> rejected.get(5, SECONDS)); + assertInstanceOf(RejectedExecutionException.class, overflow.getCause()); + + offload.close(); + assertRejected(running); + assertRejected(queued); + } + } + + private static void assertRejected(Promise promise) { + ExecutionException failure = assertThrows(ExecutionException.class, () -> promise.get(5, SECONDS)); + assertInstanceOf(RejectedExecutionException.class, failure.getCause()); + assertEquals("Fallback name resolver offload is closed", failure.getCause().getMessage()); + } +} From 7394d19eea508ccd9e401d44f0be972c0d237d55 Mon Sep 17 00:00:00 2001 From: Pavel Ptashyts <49400901+pavel-ptashyts@users.noreply.github.com> Date: Mon, 20 Jul 2026 14:05:01 +0200 Subject: [PATCH 3/5] Document fallback resolver settings Mark the new fallback resolver offload configuration methods as introduced in 3.0.12 and clarify that the queue-size setting controls queued work rather than active workers. Codex on behalf of Pavel Ptashyts Co-Authored-By: Codex --- .../main/java/org/asynchttpclient/AsyncHttpClientConfig.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/client/src/main/java/org/asynchttpclient/AsyncHttpClientConfig.java b/client/src/main/java/org/asynchttpclient/AsyncHttpClientConfig.java index a38d586d1c..11bf9e2139 100644 --- a/client/src/main/java/org/asynchttpclient/AsyncHttpClientConfig.java +++ b/client/src/main/java/org/asynchttpclient/AsyncHttpClientConfig.java @@ -170,6 +170,7 @@ public interface AsyncHttpClientConfig { * Netty event-loop threads. * * @return {@code true} if fallback name resolution should be offloaded + * @since 3.0.12 */ default boolean isFallbackNameResolverOffloadEnabled() { return true; @@ -180,16 +181,18 @@ default boolean isFallbackNameResolverOffloadEnabled() { * * @return the configured fallback resolver thread count. Values less than or equal to {@code 0} use * {@link #getIoThreadsCount()}. + * @since 3.0.12 */ default int getFallbackNameResolverOffloadThreadsCount() { return -1; } /** - * Returns the maximum number of pending fallback name resolutions. + * Returns the fallback name resolver worker queue capacity. * * @return the configured fallback resolver queue size. Values less than or equal to {@code 0} use the * default queue size. + * @since 3.0.12 */ default int getFallbackNameResolverOffloadQueueSize() { return 0; From a3552474c6d3bda2aa8aa9d3f646995c124c6b77 Mon Sep 17 00:00:00 2001 From: Pavel Ptashyts <49400901+pavel-ptashyts@users.noreply.github.com> Date: Mon, 20 Jul 2026 14:26:00 +0200 Subject: [PATCH 4/5] Disable resolver offload by default Keep fallback resolver offloading opt-in so existing clients preserve inline resolution unless they explicitly enable the worker pool. Cover both the disabled default and enabled behavior without leaking cached test configuration into later tests. Codex on behalf of Pavel Ptashyts Co-Authored-By: Codex --- .../java/org/asynchttpclient/AsyncHttpClientConfig.java | 4 ++-- .../org/asynchttpclient/config/ahc-default.properties | 2 +- .../org/asynchttpclient/AddressResolverGroupTest.java | 8 ++++---- .../org/asynchttpclient/AsyncHttpClientDefaultsTest.java | 5 +++-- .../netty/resolver/NameResolverOffloadTest.java | 8 ++++---- 5 files changed, 14 insertions(+), 13 deletions(-) diff --git a/client/src/main/java/org/asynchttpclient/AsyncHttpClientConfig.java b/client/src/main/java/org/asynchttpclient/AsyncHttpClientConfig.java index 11bf9e2139..94eebf1809 100644 --- a/client/src/main/java/org/asynchttpclient/AsyncHttpClientConfig.java +++ b/client/src/main/java/org/asynchttpclient/AsyncHttpClientConfig.java @@ -167,13 +167,13 @@ public interface AsyncHttpClientConfig { /** * Returns whether fallback resolution with the default blocking name resolver should be offloaded from - * Netty event-loop threads. + * Netty event-loop threads. Disabled by default. * * @return {@code true} if fallback name resolution should be offloaded * @since 3.0.12 */ default boolean isFallbackNameResolverOffloadEnabled() { - return true; + return false; } /** 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 37639e9772..0e6b5d05f1 100644 --- a/client/src/main/resources/org/asynchttpclient/config/ahc-default.properties +++ b/client/src/main/resources/org/asynchttpclient/config/ahc-default.properties @@ -55,7 +55,7 @@ org.asynchttpclient.shutdownTimeout=PT15S org.asynchttpclient.useNativeTransport=false org.asynchttpclient.useOnlyEpollNativeTransport=false org.asynchttpclient.ioThreadsCount=-1 -org.asynchttpclient.fallbackNameResolverOffloadEnabled=true +org.asynchttpclient.fallbackNameResolverOffloadEnabled=false org.asynchttpclient.fallbackNameResolverOffloadThreadsCount=-1 org.asynchttpclient.fallbackNameResolverOffloadQueueSize=0 org.asynchttpclient.hashedWheelTimerTickDuration=100 diff --git a/client/src/test/java/org/asynchttpclient/AddressResolverGroupTest.java b/client/src/test/java/org/asynchttpclient/AddressResolverGroupTest.java index 90c426a6f3..3ea680c046 100644 --- a/client/src/test/java/org/asynchttpclient/AddressResolverGroupTest.java +++ b/client/src/test/java/org/asynchttpclient/AddressResolverGroupTest.java @@ -195,7 +195,8 @@ public void defaultNameResolverOnRedirectUsesOffloadPool() throws Throwable { String poolName = "ahc-fallback-resolver-enabled"; try (AsyncHttpClient client = asyncHttpClient(config() .setFollowRedirect(true) - .setThreadPoolName(poolName))) { + .setThreadPoolName(poolName) + .setFallbackNameResolverOffloadEnabled(true))) { Response response = client.prepareGet("http://127.0.0.1:" + server.getHttpPort() + "/start") .execute() .get(5, SECONDS); @@ -207,15 +208,14 @@ public void defaultNameResolverOnRedirectUsesOffloadPool() throws Throwable { } @RepeatedIfExceptionsTest(repeats = 5) - public void defaultNameResolverOnRedirectCanKeepInlineBehavior() throws Throwable { + public void defaultNameResolverOnRedirectKeepsInlineBehavior() throws Throwable { server.enqueueRedirect(302, "http://localhost:" + server.getHttpPort() + "/target"); server.enqueueOk(); String poolName = "ahc-fallback-resolver-disabled"; try (AsyncHttpClient client = asyncHttpClient(config() .setFollowRedirect(true) - .setThreadPoolName(poolName) - .setFallbackNameResolverOffloadEnabled(false))) { + .setThreadPoolName(poolName))) { Response response = client.prepareGet("http://127.0.0.1:" + server.getHttpPort() + "/start") .execute() .get(5, SECONDS); diff --git a/client/src/test/java/org/asynchttpclient/AsyncHttpClientDefaultsTest.java b/client/src/test/java/org/asynchttpclient/AsyncHttpClientDefaultsTest.java index 6d6d6b2c4e..d4892c2db4 100644 --- a/client/src/test/java/org/asynchttpclient/AsyncHttpClientDefaultsTest.java +++ b/client/src/test/java/org/asynchttpclient/AsyncHttpClientDefaultsTest.java @@ -146,9 +146,10 @@ public void testDefaultUseInsecureTrustManager() { @RepeatedIfExceptionsTest(repeats = 5) public void testDefaultFallbackNameResolverOffloadEnabled() { - assertTrue(AsyncHttpClientConfigDefaults.defaultFallbackNameResolverOffloadEnabled()); + assertFalse(AsyncHttpClientConfigDefaults.defaultFallbackNameResolverOffloadEnabled()); testBooleanSystemProperty("fallbackNameResolverOffloadEnabled", - "defaultFallbackNameResolverOffloadEnabled", "false"); + "defaultFallbackNameResolverOffloadEnabled", "true"); + AsyncHttpClientConfigHelper.reloadProperties(); } @RepeatedIfExceptionsTest(repeats = 5) diff --git a/client/src/test/java/org/asynchttpclient/netty/resolver/NameResolverOffloadTest.java b/client/src/test/java/org/asynchttpclient/netty/resolver/NameResolverOffloadTest.java index b2887311f3..92596ad39c 100644 --- a/client/src/test/java/org/asynchttpclient/netty/resolver/NameResolverOffloadTest.java +++ b/client/src/test/java/org/asynchttpclient/netty/resolver/NameResolverOffloadTest.java @@ -49,6 +49,7 @@ void closeEventLoop() { void offloadsDefaultResolverToConfiguredWorker() throws Exception { DefaultAsyncHttpClientConfig config = new DefaultAsyncHttpClientConfig.Builder() .setThreadPoolName("ahc-resolver-unit") + .setFallbackNameResolverOffloadEnabled(true) .setFallbackNameResolverOffloadThreadsCount(1) .setFallbackNameResolverOffloadQueueSize(1) .build(); @@ -70,10 +71,8 @@ void offloadsDefaultResolverToConfiguredWorker() throws Exception { } @Test - void disabledOffloadDoesNotSelectDefaultResolver() { - DefaultAsyncHttpClientConfig config = new DefaultAsyncHttpClientConfig.Builder() - .setFallbackNameResolverOffloadEnabled(false) - .build(); + void defaultOffloadDoesNotSelectDefaultResolver() { + DefaultAsyncHttpClientConfig config = new DefaultAsyncHttpClientConfig.Builder().build(); try (NameResolverOffload offload = new NameResolverOffload(config)) { assertFalse(offload.shouldOffload(DEFAULT_NAME_RESOLVER)); @@ -83,6 +82,7 @@ void disabledOffloadDoesNotSelectDefaultResolver() { @Test void boundedQueueRejectsOverflowAndCloseFailsPendingWork() throws Exception { DefaultAsyncHttpClientConfig config = new DefaultAsyncHttpClientConfig.Builder() + .setFallbackNameResolverOffloadEnabled(true) .setFallbackNameResolverOffloadThreadsCount(1) .setFallbackNameResolverOffloadQueueSize(1) .build(); From 435e5673b49b2b407c77d9558cc6da770bec7d7f Mon Sep 17 00:00:00 2001 From: Pavel Ptashyts <49400901+pavel-ptashyts@users.noreply.github.com> Date: Mon, 20 Jul 2026 17:01:21 +0200 Subject: [PATCH 5/5] Cover SOCKS resolver offload wiring Exercise the enabled default resolver through the SOCKS bootstrap path from an AHC event loop. This verifies that proxy-host fallback DNS uses the configured resolver worker instead of blocking the event loop. Document the resolver helper and accessor as intentional public cross-package wiring while directing applications to the client config. Codex on behalf of Pavel Ptashyts Co-Authored-By: Codex --- .../netty/channel/ChannelManager.java | 8 ++++ .../netty/resolver/NameResolverOffload.java | 37 +++++++++++++++++++ .../AddressResolverGroupTest.java | 31 ++++++++++++++++ 3 files changed, 76 insertions(+) diff --git a/client/src/main/java/org/asynchttpclient/netty/channel/ChannelManager.java b/client/src/main/java/org/asynchttpclient/netty/channel/ChannelManager.java index ff27562ec3..821a9b844f 100755 --- a/client/src/main/java/org/asynchttpclient/netty/channel/ChannelManager.java +++ b/client/src/main/java/org/asynchttpclient/netty/channel/ChannelManager.java @@ -1153,6 +1153,14 @@ public EventLoopGroup getEventLoopGroup() { return eventLoopGroup; } + /** + * Returns the client-owned fallback resolver offload support. + * This method is public only for cross-package request-sender wiring; applications should configure + * resolver offloading through {@link AsyncHttpClientConfig}. + * + * @return fallback resolver offload support + * @since 3.0.12 + */ public NameResolverOffload getNameResolverOffload() { return nameResolverOffload; } diff --git a/client/src/main/java/org/asynchttpclient/netty/resolver/NameResolverOffload.java b/client/src/main/java/org/asynchttpclient/netty/resolver/NameResolverOffload.java index c7ae688b4c..adb1e4cfea 100644 --- a/client/src/main/java/org/asynchttpclient/netty/resolver/NameResolverOffload.java +++ b/client/src/main/java/org/asynchttpclient/netty/resolver/NameResolverOffload.java @@ -38,6 +38,10 @@ /** * Offloads the default blocking fallback name resolver and tracks its pending promises for client shutdown. + * This type is public only for cross-package client wiring; applications should configure resolver offloading + * through {@link AsyncHttpClientConfig}. + * + * @since 3.0.12 */ public final class NameResolverOffload implements AutoCloseable { @@ -45,6 +49,11 @@ public final class NameResolverOffload implements AutoCloseable { private final Map, EventExecutor> pending = new ConcurrentHashMap<>(); private final AtomicBoolean closed = new AtomicBoolean(); + /** + * Creates the client-owned fallback resolver offload support. + * + * @param config client configuration + */ public NameResolverOffload(AsyncHttpClientConfig config) { requireNonNull(config, "config"); if (!config.isFallbackNameResolverOffloadEnabled()) { @@ -61,10 +70,23 @@ public NameResolverOffload(AsyncHttpClientConfig config) { new LinkedBlockingQueue<>(queueSize), threadFactory); } + /** + * Returns whether the resolver is the default blocking resolver and offloading is enabled. + * + * @param nameResolver resolver selected for the request + * @return {@code true} when the resolution should be offloaded + */ public boolean shouldOffload(NameResolver nameResolver) { return executor != null && nameResolver == DEFAULT_NAME_RESOLVER; } + /** + * Submits fallback resolution work and associates it with its request promise. + * + * @param eventLoop event loop that owns the request flow + * @param promise request promise to fail on rejection or shutdown + * @param task fallback resolution work + */ public void execute(EventExecutor eventLoop, Promise promise, Runnable task) { requireNonNull(eventLoop, "eventLoop"); requireNonNull(promise, "promise"); @@ -101,6 +123,14 @@ public void execute(EventExecutor eventLoop, Promise promise, Runnable task) } } + /** + * Completes a resolution promise on its request event loop. + * + * @param eventLoop event loop that owns the request flow + * @param promise request promise + * @param result resolution result + * @param result type + */ public void completeSuccess(EventExecutor eventLoop, Promise promise, T result) { if (eventLoop.inEventLoop()) { promise.trySuccess(result); @@ -113,6 +143,13 @@ public void completeSuccess(EventExecutor eventLoop, Promise promise, T r } } + /** + * Fails a resolution promise on its request event loop. + * + * @param eventLoop event loop that owns the request flow + * @param promise request promise + * @param cause resolution failure + */ public void completeFailure(EventExecutor eventLoop, Promise promise, Throwable cause) { if (eventLoop.inEventLoop()) { promise.tryFailure(cause); diff --git a/client/src/test/java/org/asynchttpclient/AddressResolverGroupTest.java b/client/src/test/java/org/asynchttpclient/AddressResolverGroupTest.java index 3ea680c046..0f8815a9c0 100644 --- a/client/src/test/java/org/asynchttpclient/AddressResolverGroupTest.java +++ b/client/src/test/java/org/asynchttpclient/AddressResolverGroupTest.java @@ -48,6 +48,7 @@ import static org.asynchttpclient.Dsl.asyncHttpClient; import static org.asynchttpclient.Dsl.config; import static org.asynchttpclient.Dsl.get; +import static org.asynchttpclient.RequestBuilderBase.DEFAULT_NAME_RESOLVER; import static org.asynchttpclient.test.TestUtils.isExternalNetworkAvailable; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -187,6 +188,36 @@ public void customSocksProxyResolverKeepsEventLoopContext() throws Throwable { assertTrue(resolver.socksResolutionOnEventLoop.get(), "custom resolver should retain its calling context"); } + @RepeatedIfExceptionsTest(repeats = 5) + public void defaultSocksProxyResolverUsesOffloadPool() throws Throwable { + String poolName = "ahc-socks-resolver-enabled"; + ProxyServer proxy = new ProxyServer.Builder("localhost", 1080) + .setProxyType(ProxyType.SOCKS_V5) + .build(); + + try (DefaultAsyncHttpClient client = new DefaultAsyncHttpClient(config() + .setThreadPoolName(poolName) + .setFallbackNameResolverOffloadEnabled(true) + .build())) { + EventLoopGroup eventLoopGroup = client.channelManager().getEventLoopGroup(); + CountDownLatch complete = new CountDownLatch(1); + AtomicReference failure = new AtomicReference<>(); + eventLoopGroup.next().execute(() -> client.channelManager() + .getBootstrap(Uri.create("http://target.test/"), DEFAULT_NAME_RESOLVER, proxy) + .addListener(bootstrap -> { + if (!bootstrap.isSuccess()) { + failure.set(bootstrap.cause()); + } + complete.countDown(); + })); + + assertTrue(complete.await(5, SECONDS), "SOCKS bootstrap resolution should complete"); + assertNull(failure.get(), "SOCKS bootstrap should resolve the proxy host"); + assertTrue(hasLiveThreadNamed(poolName + "-resolver"), + "SOCKS fallback DNS should use the resolver offload pool"); + } + } + @RepeatedIfExceptionsTest(repeats = 5) public void defaultNameResolverOnRedirectUsesOffloadPool() throws Throwable { server.enqueueRedirect(302, "http://localhost:" + server.getHttpPort() + "/target");