Skip to content

Commit fef1b9a

Browse files
Copilot3dgiordano
andauthored
Merge remote-tracking branch 'origin/development' into SOURCE-ADDRESS
# Conflicts: # src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java # src/main/java/com/blazemeter/jmeter/http2/sampler/HTTP2Sampler.java Co-authored-by: 3dgiordano <797057+3dgiordano@users.noreply.github.com>
2 parents 2cd7d2e + ad4960a commit fef1b9a

6 files changed

Lines changed: 869 additions & 15 deletions

File tree

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

Lines changed: 90 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@
6565
import java.util.zip.Inflater;
6666
import java.util.zip.InflaterInputStream;
6767
import org.apache.commons.lang3.StringUtils;
68+
import org.apache.http.conn.DnsResolver;
6869
import org.apache.jmeter.protocol.http.control.AuthManager;
6970
import org.apache.jmeter.protocol.http.control.Authorization;
7071
import org.apache.jmeter.protocol.http.control.Cookie;
@@ -188,6 +189,15 @@ public class HTTP2JettyClient {
188189
private static final String ATTR_SKIP_H2C_UPGRADE = "bzm.skipH2cUpgrade";
189190
private static final String ATTR_ORIGIN_KEY = "bzm.http3.origin";
190191
private static final String ATTR_REQUEST_HEADERS_SERIALIZED = "bzm.request.headers.serialized";
192+
/**
193+
* JMeter's deprecated "BASIC_DIGEST" Auth Manager mechanism, still selectable in the GUI and
194+
* still present in older plans. HC4 keeps honouring it: {@code AuthManager.setupCredentials}
195+
* registers the credentials without binding them to a scheme, so they answer either challenge,
196+
* and the preemptive auth cache treats the row as Basic. Referenced by name so this file does
197+
* not have to carry a deprecation suppression, the same way the surrounding code compares
198+
* mechanisms by {@code name()}.
199+
*/
200+
private static final String BASIC_DIGEST_MECHANISM = "BASIC_DIGEST";
191201
private static final String PROP_SKIP_REDUNDANT_MANUAL_DECODE =
192202
"blazemeter.http.skipManualDecodeWhenAdvertised";
193203
private static final Path DEBUG_LOG_PATH = resolveDebugLogPath();
@@ -327,13 +337,25 @@ public class HTTP2JettyClient {
327337
* QUIC one too - no transport {@code doStart} propagates the bind address to it.
328338
*/
329339
private final List<ClientConnector> connectors = new ArrayList<>();
340+
/**
341+
* The sampler's DNS Cache Manager, or {@code null} when the plan has none. Held so
342+
* {@link #configureHttpClient} can install {@link JMeterDnsSocketAddressResolver} on every
343+
* protocol-variant client before any of them is started.
344+
*/
345+
private final DnsResolver dnsResolver;
330346

331347
public HTTP2JettyClient(boolean http1UpgradeRequired, String name) {
332348
this(http1UpgradeRequired, name, null);
333349
}
334350

335351
public HTTP2JettyClient(boolean http1UpgradeRequired, String name,
336352
HTTP2ClientProfileConfig profileConfig) {
353+
this(http1UpgradeRequired, name, profileConfig, null);
354+
}
355+
356+
public HTTP2JettyClient(boolean http1UpgradeRequired, String name,
357+
HTTP2ClientProfileConfig profileConfig, DnsResolver dnsResolver) {
358+
this.dnsResolver = dnsResolver;
337359
loadProperties(profileConfig);
338360
lowLevelDebug(PLUGIN_BUILD_TAG);
339361

@@ -3358,6 +3380,7 @@ public Thread newThread(Runnable r) {
33583380

33593381
private void configureHttpClient(HttpClient client, ClientConnector connector) {
33603382
client.setUserAgentField(null);
3383+
configureDnsResolution(client);
33613384
connector.setByteBufferPool(this.bufferPool);
33623385
client.setMaxRequestsQueuedPerDestination(maxRequestsQueuedPerDestination);
33633386
client.setMaxConnectionsPerDestination(maxConnectionsPerDestination);
@@ -3376,6 +3399,23 @@ private void configureHttpClient(HttpClient client, ClientConnector connector) {
33763399
}
33773400
}
33783401

3402+
/**
3403+
* Routes host name resolution through the plan's DNS Cache Manager, when there is one.
3404+
*
3405+
* <p>With no manager configured nothing is set and {@code HttpClient.doStart} installs its own
3406+
* {@code SocketAddressResolver.Async}, which is the same default {@code HTTPHC4Impl} falls back
3407+
* to ({@code SystemDefaultDnsResolver}). Under a proxy this still resolves the proxy host rather
3408+
* than the target host, because Jetty resolves {@code HttpDestination.resolveOrigin()} - again
3409+
* matching HC4, which connects to the proxy hop of the route.
3410+
*/
3411+
private void configureDnsResolution(HttpClient client) {
3412+
if (dnsResolver == null) {
3413+
return;
3414+
}
3415+
client.setSocketAddressResolver(new JMeterDnsSocketAddressResolver(dnsResolver,
3416+
client::getExecutor, client::getScheduler, client.getAddressResolutionTimeout()));
3417+
}
3418+
33793419
private static void addConnectionLogging(HttpClient client) {
33803420
client.addBean(new Connection.Listener() {
33813421
@Override
@@ -3682,24 +3722,44 @@ private void setAuthManager(HTTP2Sampler sampler) {
36823722
private boolean isSupportedMechanism(Authorization auth) {
36833723
String authName = auth.getMechanism().name();
36843724
return authName.equals(AuthManager.Mechanism.BASIC.name())
3685-
|| authName.equals(AuthManager.Mechanism.DIGEST.name());
3725+
|| authName.equals(AuthManager.Mechanism.DIGEST.name())
3726+
|| authName.equals(BASIC_DIGEST_MECHANISM);
3727+
}
3728+
3729+
/**
3730+
* Whether the row may answer a {@code Basic} challenge, which {@code BASIC_DIGEST} rows may.
3731+
*/
3732+
private static boolean answersBasicChallenge(Authorization auth) {
3733+
String authName = auth.getMechanism().name();
3734+
return authName.equals(AuthManager.Mechanism.BASIC.name())
3735+
|| authName.equals(BASIC_DIGEST_MECHANISM);
3736+
}
3737+
3738+
/**
3739+
* Whether the row may answer a {@code Digest} challenge, which {@code BASIC_DIGEST} rows may.
3740+
*/
3741+
private static boolean answersDigestChallenge(Authorization auth) {
3742+
String authName = auth.getMechanism().name();
3743+
return authName.equals(AuthManager.Mechanism.DIGEST.name())
3744+
|| authName.equals(BASIC_DIGEST_MECHANISM);
36863745
}
36873746

36883747
private void addAuthenticationToJettyClient(Authorization auth) {
36893748
String authName = auth.getMechanism().name();
3690-
if (authName.equals(AuthManager.Mechanism.BASIC.name())
3691-
&& BzmHttpPluginProperties.getPropDefault("httpJettyClient.auth.preemptive", false)) {
3749+
boolean preemptive =
3750+
BzmHttpPluginProperties.getPropDefault("httpJettyClient.auth.preemptive", false);
3751+
if (preemptive && answersBasicChallenge(auth)) {
36923752
BasicAuthentication.BasicResult result =
36933753
new BasicAuthentication.BasicResult(URI.create(auth.getURL()), auth.getUser(),
36943754
auth.getPass());
36953755
// Results are keyed by URI (Map.put replaces); safe to re-register every sample.
36963756
forEachAuthenticationStore(store -> store.addAuthenticationResult(result));
3697-
return;
3698-
}
3699-
3700-
String fingerprint = authFingerprint(auth);
3701-
if (!registeredAuthFingerprints.add(fingerprint)) {
3702-
return;
3757+
if (authName.equals(AuthManager.Mechanism.BASIC.name())) {
3758+
return;
3759+
}
3760+
// A BASIC_DIGEST row falls through: sending Basic up front is what HC4's auth cache does,
3761+
// but the credentials must still be able to answer whichever challenge the server sends
3762+
// back, which is the whole point of the mechanism.
37033763
}
37043764

37053765
URI uri = URI.create(auth.getURL());
@@ -3708,10 +3768,26 @@ private void addAuthenticationToJettyClient(Authorization auth) {
37083768
// Blank JMeter realm must match any challenge realm; "" would only match "".
37093769
realm = Authentication.ANY_REALM;
37103770
}
3711-
AbstractAuthentication authentication =
3712-
authName.equals(AuthManager.Mechanism.BASIC.name())
3713-
? new BasicAuthentication(uri, realm, auth.getUser(), auth.getPass())
3714-
: new DigestAuthentication(uri, realm, auth.getUser(), auth.getPass());
3771+
if (answersBasicChallenge(auth)) {
3772+
registerAuthentication(auth,
3773+
new BasicAuthentication(uri, realm, auth.getUser(), auth.getPass()));
3774+
}
3775+
if (answersDigestChallenge(auth)) {
3776+
registerAuthentication(auth,
3777+
new DigestAuthentication(uri, realm, auth.getUser(), auth.getPass()));
3778+
}
3779+
}
3780+
3781+
/**
3782+
* Adds one Jetty authentication to every protocol-variant store, once per distinct row.
3783+
*
3784+
* <p>The fingerprint carries the Jetty authentication type because a single {@code BASIC_DIGEST}
3785+
* row produces two of them, and both have to get through.
3786+
*/
3787+
private void registerAuthentication(Authorization auth, AbstractAuthentication authentication) {
3788+
if (!registeredAuthFingerprints.add(authFingerprint(auth) + '|' + authentication.getType())) {
3789+
return;
3790+
}
37153791
forEachAuthenticationStore(store -> store.addAuthentication(authentication));
37163792
}
37173793

@@ -4034,7 +4110,7 @@ private void addPreemptiveAuthorizationHeader(Request request, URL url,
40344110
StreamSupport.stream(authManager.getAuthObjects().spliterator(), false)
40354111
.map(j -> (Authorization) j.getObjectValue())
40364112
.filter(auth -> auth != null
4037-
&& AuthManager.Mechanism.BASIC.equals(auth.getMechanism())
4113+
&& answersBasicChallenge(auth)
40384114
&& !StringUtils.isEmpty(auth.getURL()))
40394115
.filter(auth -> url.toString().startsWith(auth.getURL()))
40404116
.findFirst()
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
@@ -39,6 +39,7 @@
3939
import org.apache.jmeter.engine.event.LoopIterationEvent;
4040
import org.apache.jmeter.engine.event.LoopIterationListener;
4141
import org.apache.jmeter.processor.PreProcessor;
42+
import org.apache.jmeter.protocol.http.control.DNSCacheManager;
4243
import org.apache.jmeter.protocol.http.parser.BaseParser;
4344
import org.apache.jmeter.protocol.http.parser.LinkExtractorParseException;
4445
import org.apache.jmeter.protocol.http.parser.LinkExtractorParser;
@@ -879,7 +880,7 @@ private HTTP2JettyClient buildClient() throws Exception {
879880
InetAddress sourceAddress = resolveSourceAddress();
880881
HTTP2JettyClient client = new HTTP2JettyClient(isHttp1UpgradeEnabled(),
881882
"http2[" + connectionKey.target + ":" + Thread.currentThread().getId() + "]",
882-
buildProfileConfig());
883+
buildProfileConfig(), getDNSResolver());
883884
if (sourceAddress != null) {
884885
client.setSourceAddress(sourceAddress);
885886
}
@@ -932,6 +933,7 @@ private String buildProfileKey() {
932933
appendLongKey(key, "h2cttl", getH2cCacheTtlMs());
933934
appendBooleanKey(key, "h2cup", isHttp1UpgradeEnabled());
934935
appendSourceAddressKey(key);
936+
appendDnsResolverKey(key);
935937
return key.toString();
936938
}
937939

@@ -963,6 +965,18 @@ private void appendSourceAddressKey(StringBuilder key) {
963965
}
964966
}
965967

968+
/**
969+
* A cached client carries the DNS Cache Manager it was built with, so two samplers under
970+
* different managers (or one with a manager and one without) must not share it. Identity is
971+
* enough: JMeter clones the manager once per thread and the client cache is per thread too, so
972+
* the instance is stable for as long as the entry can be reused.
973+
*/
974+
private void appendDnsResolverKey(StringBuilder key) {
975+
DNSCacheManager dnsCacheManager = getDNSResolver();
976+
key.append(";dns=")
977+
.append(dnsCacheManager == null ? "-" : System.identityHashCode(dnsCacheManager));
978+
}
979+
966980
private void appendBooleanKey(StringBuilder key, String name, Boolean value) {
967981
key.append(';').append(name).append('=').append(value == null ? "-" : value);
968982
}

0 commit comments

Comments
 (0)