Skip to content

Commit 67c0d05

Browse files
authored
DNSCacheManager support (#158)
1 parent 8f96435 commit 67c0d05

5 files changed

Lines changed: 576 additions & 1 deletion

File tree

‎src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@
6060
import java.util.zip.Inflater;
6161
import java.util.zip.InflaterInputStream;
6262
import org.apache.commons.lang3.StringUtils;
63+
import org.apache.http.conn.DnsResolver;
6364
import org.apache.jmeter.protocol.http.control.AuthManager;
6465
import org.apache.jmeter.protocol.http.control.Authorization;
6566
import org.apache.jmeter.protocol.http.control.Cookie;
@@ -317,13 +318,25 @@ public class HTTP2JettyClient {
317318
* {@code findAuthentication} (realm/URI matching quirks) and prevents per-sample list growth.
318319
*/
319320
private final Set<String> registeredAuthFingerprints = ConcurrentHashMap.newKeySet();
321+
/**
322+
* The sampler's DNS Cache Manager, or {@code null} when the plan has none. Held so
323+
* {@link #configureHttpClient} can install {@link JMeterDnsSocketAddressResolver} on every
324+
* protocol-variant client before any of them is started.
325+
*/
326+
private final DnsResolver dnsResolver;
320327

321328
public HTTP2JettyClient(boolean http1UpgradeRequired, String name) {
322329
this(http1UpgradeRequired, name, null);
323330
}
324331

325332
public HTTP2JettyClient(boolean http1UpgradeRequired, String name,
326333
HTTP2ClientProfileConfig profileConfig) {
334+
this(http1UpgradeRequired, name, profileConfig, null);
335+
}
336+
337+
public HTTP2JettyClient(boolean http1UpgradeRequired, String name,
338+
HTTP2ClientProfileConfig profileConfig, DnsResolver dnsResolver) {
339+
this.dnsResolver = dnsResolver;
327340
loadProperties(profileConfig);
328341
lowLevelDebug(PLUGIN_BUILD_TAG);
329342

@@ -3348,6 +3361,7 @@ public Thread newThread(Runnable r) {
33483361

33493362
private void configureHttpClient(HttpClient client, ClientConnector connector) {
33503363
client.setUserAgentField(null);
3364+
configureDnsResolution(client);
33513365
connector.setByteBufferPool(this.bufferPool);
33523366
client.setMaxRequestsQueuedPerDestination(maxRequestsQueuedPerDestination);
33533367
client.setMaxConnectionsPerDestination(maxConnectionsPerDestination);
@@ -3366,6 +3380,23 @@ private void configureHttpClient(HttpClient client, ClientConnector connector) {
33663380
}
33673381
}
33683382

3383+
/**
3384+
* Routes host name resolution through the plan's DNS Cache Manager, when there is one.
3385+
*
3386+
* <p>With no manager configured nothing is set and {@code HttpClient.doStart} installs its own
3387+
* {@code SocketAddressResolver.Async}, which is the same default {@code HTTPHC4Impl} falls back
3388+
* to ({@code SystemDefaultDnsResolver}). Under a proxy this still resolves the proxy host rather
3389+
* than the target host, because Jetty resolves {@code HttpDestination.resolveOrigin()} - again
3390+
* matching HC4, which connects to the proxy hop of the route.
3391+
*/
3392+
private void configureDnsResolution(HttpClient client) {
3393+
if (dnsResolver == null) {
3394+
return;
3395+
}
3396+
client.setSocketAddressResolver(new JMeterDnsSocketAddressResolver(dnsResolver,
3397+
client::getExecutor, client::getScheduler, client.getAddressResolutionTimeout()));
3398+
}
3399+
33693400
private static void addConnectionLogging(HttpClient client) {
33703401
client.addBean(new Connection.Listener() {
33713402
@Override
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
package com.blazemeter.jmeter.http2.core;
2+
3+
import java.net.InetAddress;
4+
import java.net.InetSocketAddress;
5+
import java.net.UnknownHostException;
6+
import java.util.ArrayList;
7+
import java.util.List;
8+
import java.util.Map;
9+
import java.util.concurrent.Executor;
10+
import java.util.concurrent.TimeUnit;
11+
import java.util.concurrent.TimeoutException;
12+
import java.util.concurrent.atomic.AtomicBoolean;
13+
import java.util.function.Supplier;
14+
import org.apache.http.conn.DnsResolver;
15+
import org.eclipse.jetty.util.Promise;
16+
import org.eclipse.jetty.util.SocketAddressResolver;
17+
import org.eclipse.jetty.util.thread.Scheduler;
18+
19+
/**
20+
* Resolves host names through JMeter's DNS Cache Manager instead of {@code InetAddress}, so a test
21+
* plan's custom DNS servers, static host entries and per-thread DNS cache apply to this plugin
22+
* exactly as they do to {@code HTTPHC4Impl}.
23+
*
24+
* <p>{@code DNSCacheManager} is an {@code org.apache.http.conn.DnsResolver}, which is what
25+
* {@code HTTPHC4Impl} hands to its connection operator on the one-time client init. This class is
26+
* the Jetty-side equivalent: {@code HttpClient} resolves through a {@link SocketAddressResolver},
27+
* and installing one is enough to cover every protocol, because HTTP/1.1, h2, h2c and HTTP/3 all
28+
* reach the network through {@code HttpClient.newConnection}.
29+
*
30+
* <p>Deliberately modelled on {@link SocketAddressResolver.Async}, which it replaces: the lookup
31+
* runs on the client executor rather than on the caller (which may be a selector thread), and a
32+
* scheduled task fails the promise if the lookup outlives the timeout. The guard is not optional
33+
* here - JMeter never calls {@code DNSCacheManager.setTimeoutMs}, so a custom resolver inherits
34+
* dnsjava's own retry behaviour and a black-holed DNS server would otherwise pin an executor
35+
* thread with nothing failing the request.
36+
*
37+
* <p>The executor and scheduler are read lazily because the resolver is installed while the
38+
* {@code HttpClient} is still being built: {@code HttpClient.doStart} only creates its default
39+
* {@code Async} resolver when none was set, so ours has to be in place before {@code start()},
40+
* at which point {@code getExecutor()} and {@code getScheduler()} are still {@code null}.
41+
*/
42+
public class JMeterDnsSocketAddressResolver implements SocketAddressResolver {
43+
44+
private final DnsResolver dnsResolver;
45+
private final Supplier<Executor> executorSupplier;
46+
private final Supplier<Scheduler> schedulerSupplier;
47+
private final long timeoutMs;
48+
49+
public JMeterDnsSocketAddressResolver(DnsResolver dnsResolver,
50+
Supplier<Executor> executorSupplier,
51+
Supplier<Scheduler> schedulerSupplier,
52+
long timeoutMs) {
53+
this.dnsResolver = dnsResolver;
54+
this.executorSupplier = executorSupplier;
55+
this.schedulerSupplier = schedulerSupplier;
56+
this.timeoutMs = timeoutMs;
57+
}
58+
59+
@Override
60+
public void resolve(String host, int port, Map<String, Object> context,
61+
Promise<List<InetSocketAddress>> promise) {
62+
Executor executor = executorSupplier.get();
63+
if (executor == null) {
64+
// Only reachable if a caller resolves before the client is started; resolving inline is
65+
// still better than dropping the request on the floor.
66+
resolveAndComplete(host, port, promise, new AtomicBoolean());
67+
return;
68+
}
69+
executor.execute(() -> {
70+
AtomicBoolean complete = new AtomicBoolean();
71+
Scheduler.Task timeoutTask = scheduleTimeout(host, Thread.currentThread(), complete, promise);
72+
try {
73+
resolveAndComplete(host, port, promise, complete);
74+
} finally {
75+
if (timeoutTask != null) {
76+
timeoutTask.cancel();
77+
}
78+
// The timeout task interrupts this thread to unblock the lookup; clear the flag so the
79+
// pooled thread does not carry it into unrelated work.
80+
Thread.interrupted();
81+
}
82+
});
83+
}
84+
85+
private Scheduler.Task scheduleTimeout(String host, Thread resolvingThread,
86+
AtomicBoolean complete,
87+
Promise<List<InetSocketAddress>> promise) {
88+
Scheduler scheduler = schedulerSupplier.get();
89+
if (timeoutMs <= 0 || scheduler == null) {
90+
return null;
91+
}
92+
return scheduler.schedule(() -> {
93+
if (complete.compareAndSet(false, true)) {
94+
promise.failed(new TimeoutException(
95+
"DNS timeout " + timeoutMs + " ms resolving " + host));
96+
resolvingThread.interrupt();
97+
}
98+
}, timeoutMs, TimeUnit.MILLISECONDS);
99+
}
100+
101+
private void resolveAndComplete(String host, int port,
102+
Promise<List<InetSocketAddress>> promise,
103+
AtomicBoolean complete) {
104+
try {
105+
InetAddress[] addresses = resolveAddresses(host);
106+
// DNSCacheManager returns null when a custom lookup cannot parse the name, and an empty
107+
// array when a static host entry is matched case-insensitively by isStaticHost but read
108+
// case-sensitively by fromStaticHost (a JMeter bug still present on master). Neither may
109+
// reach Jetty as a success: HttpClient indexes straight into the returned list.
110+
if (addresses == null || addresses.length == 0) {
111+
throw new UnknownHostException(host);
112+
}
113+
List<InetSocketAddress> result = new ArrayList<>(addresses.length);
114+
for (InetAddress address : addresses) {
115+
result.add(new InetSocketAddress(address, port));
116+
}
117+
if (complete.compareAndSet(false, true)) {
118+
promise.succeeded(result);
119+
}
120+
} catch (Throwable failure) {
121+
if (complete.compareAndSet(false, true)) {
122+
promise.failed(failure);
123+
}
124+
}
125+
}
126+
127+
private InetAddress[] resolveAddresses(String host) throws UnknownHostException {
128+
// DNSCacheManager keeps its cache in a plain LinkedHashMap and JMeter clones one instance per
129+
// thread, but a single JMeter thread resolves concurrently here (embedded resources, and the
130+
// HTTP/3 vs HTTP/2 race, run on plugin executors). Serializing keeps that map consistent;
131+
// cache hits make the critical section negligible.
132+
synchronized (dnsResolver) {
133+
return dnsResolver.resolve(host);
134+
}
135+
}
136+
}

‎src/main/java/com/blazemeter/jmeter/http2/sampler/HTTP2Sampler.java‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
import org.apache.jmeter.engine.event.LoopIterationEvent;
3838
import org.apache.jmeter.engine.event.LoopIterationListener;
3939
import org.apache.jmeter.processor.PreProcessor;
40+
import org.apache.jmeter.protocol.http.control.DNSCacheManager;
4041
import org.apache.jmeter.protocol.http.parser.BaseParser;
4142
import org.apache.jmeter.protocol.http.parser.LinkExtractorParseException;
4243
import org.apache.jmeter.protocol.http.parser.LinkExtractorParser;
@@ -874,7 +875,7 @@ private HTTP2JettyClient buildClient() throws Exception {
874875
HTTP2ClientKey connectionKey = buildConnectionKey();
875876
HTTP2JettyClient client = new HTTP2JettyClient(isHttp1UpgradeEnabled(),
876877
"http2[" + connectionKey.target + ":" + Thread.currentThread().getId() + "]",
877-
buildProfileConfig());
878+
buildProfileConfig(), getDNSResolver());
878879
client.start();
879880
CONNECTIONS.get().put(connectionKey, client);
880881
return client;
@@ -923,9 +924,22 @@ private String buildProfileKey() {
923924
appendLongKey(key, "h1cd", getHttp1OnlyCooldownMs());
924925
appendLongKey(key, "h2cttl", getH2cCacheTtlMs());
925926
appendBooleanKey(key, "h2cup", isHttp1UpgradeEnabled());
927+
appendDnsResolverKey(key);
926928
return key.toString();
927929
}
928930

931+
/**
932+
* A cached client carries the DNS Cache Manager it was built with, so two samplers under
933+
* different managers (or one with a manager and one without) must not share it. Identity is
934+
* enough: JMeter clones the manager once per thread and the client cache is per thread too, so
935+
* the instance is stable for as long as the entry can be reused.
936+
*/
937+
private void appendDnsResolverKey(StringBuilder key) {
938+
DNSCacheManager dnsCacheManager = getDNSResolver();
939+
key.append(";dns=")
940+
.append(dnsCacheManager == null ? "-" : System.identityHashCode(dnsCacheManager));
941+
}
942+
929943
private void appendBooleanKey(StringBuilder key, String name, Boolean value) {
930944
key.append(';').append(name).append('=').append(value == null ? "-" : value);
931945
}

0 commit comments

Comments
 (0)