From e9786ff071f0a2130bedbd00a995512973033de9 Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Wed, 22 Jul 2026 13:10:49 +0200 Subject: [PATCH 01/80] Add support for HTTP Client 5 (`HTTPHC5Impl`) --- src/protocol/http/build.gradle.kts | 1 + .../protocol/http/sampler/HTTPHC5Impl.java | 438 ++++++++++++++++++ .../http/sampler/HTTPSamplerFactory.java | 11 +- .../http/sampler/TestHTTPSamplerFactory.java | 45 ++ 4 files changed, 493 insertions(+), 2 deletions(-) create mode 100644 src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java create mode 100644 src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPSamplerFactory.java diff --git a/src/protocol/http/build.gradle.kts b/src/protocol/http/build.gradle.kts index 58feab63dbb..9496815ccac 100644 --- a/src/protocol/http/build.gradle.kts +++ b/src/protocol/http/build.gradle.kts @@ -61,6 +61,7 @@ dependencies { exclude("com.google.code.findbugs", "jsr305") } implementation("dnsjava:dnsjava") + implementation("org.apache.httpcomponents.client5:httpclient5") implementation("org.apache.httpcomponents:httpmime") implementation("org.apache.httpcomponents:httpcore") implementation("org.brotli:dec") diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java new file mode 100644 index 00000000000..773adfd6ab1 --- /dev/null +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java @@ -0,0 +1,438 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you 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.apache.jmeter.protocol.http.sampler; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.net.URI; +import java.net.URL; +import java.net.URLDecoder; +import java.nio.charset.Charset; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import org.apache.hc.client5.http.config.RequestConfig; +import org.apache.hc.client5.http.entity.UrlEncodedFormEntity; +import org.apache.hc.client5.http.entity.mime.MultipartEntityBuilder; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.CloseableHttpResponse; +import org.apache.hc.client5.http.impl.classic.HttpClients; +import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.Header; +import org.apache.hc.core5.http.HttpEntity; +import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.NameValuePair; +import org.apache.hc.core5.http.io.entity.FileEntity; +import org.apache.hc.core5.http.io.entity.StringEntity; +import org.apache.hc.core5.http.message.BasicNameValuePair; +import org.apache.hc.core5.util.Timeout; +import org.apache.jmeter.protocol.http.control.CacheManager; +import org.apache.jmeter.protocol.http.control.CookieManager; +import org.apache.jmeter.protocol.http.control.HeaderManager; +import org.apache.jmeter.protocol.http.util.HTTPArgument; +import org.apache.jmeter.protocol.http.util.HTTPConstants; +import org.apache.jmeter.protocol.http.util.HTTPFileArg; +import org.apache.jmeter.services.FileServer; +import org.apache.jmeter.testelement.property.CollectionProperty; +import org.apache.jmeter.testelement.property.JMeterProperty; +import org.apache.jmeter.threads.JMeterContextService; +import org.apache.jmeter.threads.JMeterVariables; +import org.apache.jmeter.util.JsseSSLManager; +import org.apache.jmeter.util.SSLManager; +import org.apache.jorphan.util.JOrphanUtils; +import org.apache.jorphan.util.StringUtilities; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * HTTP Sampler using Apache HttpClient 5.x. + */ +public class HTTPHC5Impl extends HTTPHCAbstractImpl { + + private static final Logger log = LoggerFactory.getLogger(HTTPHC5Impl.class); + + private static final ThreadLocal> HTTP_CLIENTS = + ThreadLocal.withInitial(HashMap::new); + + private volatile org.apache.hc.client5.http.classic.methods.HttpUriRequestBase currentRequest; + + protected HTTPHC5Impl(HTTPSamplerBase testElement) { + super(testElement); + } + + @Override + protected HTTPSampleResult sample(URL url, String method, boolean areFollowingRedirect, int frameDepth) { + HTTPSampleResult result = createSampleResult(url, method); + org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request = null; + CloseableHttpResponse response = null; + try { + resetStateIfNeeded(); + request = createRequest(url.toURI(), method, areFollowingRedirect); + setupRequest(url, request, result, areFollowingRedirect); + result.sampleStart(); + + CacheManager cacheManager = getCacheManager(); + if (cacheManager != null && HTTPConstants.GET.equalsIgnoreCase(method)) { + log.debug("Cache Manager is not supported by HttpClient5 yet"); + } + + currentRequest = request; + response = getClient(createHttpClientKey(url)).execute(request); + result.sampleEnd(); + currentRequest = null; + + updateResult(response, request, result); + saveConnectionCookies(response, result.getURL(), getCookieManager()); + return resultProcessing(areFollowingRedirect, frameDepth, result); + } catch (Exception e) { + if (result.getEndTime() == 0) { + result.sampleEnd(); + } + if (request != null) { + result.setRequestHeaders(getRequestHeaders(request)); + } + return errorResult(e, result); + } finally { + JOrphanUtils.closeQuietly(response); + currentRequest = null; + } + } + + private HTTPSampleResult createSampleResult(URL url, String method) { + HTTPSampleResult result = new HTTPSampleResult(); + configureSampleLabel(result, url); + result.setHTTPMethod(method); + result.setURL(url); + return result; + } + + private org.apache.hc.client5.http.classic.methods.HttpUriRequestBase createRequest( + URI uri, String method, boolean areFollowingRedirect) { + return new org.apache.hc.client5.http.classic.methods.HttpUriRequestBase(method, uri); + } + + private void setupRequest(URL url, org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request, + HTTPSampleResult result, boolean areFollowingRedirect) throws IOException { + RequestConfig.Builder config = RequestConfig.custom() + .setRedirectsEnabled(getAutoRedirects() && !areFollowingRedirect); + int responseTimeout = getResponseTimeout(); + if (responseTimeout > 0) { + config.setResponseTimeout(Timeout.ofMilliseconds(responseTimeout)); + } + int connectTimeout = getConnectTimeout(); + if (connectTimeout > 0) { + config.setConnectTimeout(Timeout.ofMilliseconds(connectTimeout)); + } + request.setConfig(config.build()); + request.setHeader(HTTPConstants.HEADER_CONNECTION, + getUseKeepAlive() ? HTTPConstants.KEEP_ALIVE : HTTPConstants.CONNECTION_CLOSE); + setConnectionHeaders(request, url, getHeaderManager()); + + String cookies = setConnectionCookie(request, url, getCookieManager()); + if (StringUtilities.isNotEmpty(cookies)) { + result.setCookies(cookies); + } else { + result.setCookies(getOnlyCookieFromHeaders(request)); + } + + if (canHaveBody(request.getMethod())) { + result.setQueryString(setupRequestEntity(request)); + } + } + + private static boolean canHaveBody(String method) { + return !HTTPConstants.HEAD.equals(method) && !HTTPConstants.TRACE.equals(method); + } + + private String setupRequestEntity(org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request) throws IOException { + HTTPFileArg[] files = getHTTPFiles(); + String contentEncoding = getContentEncoding(); + Charset charset = Charset.forName(contentEncoding); + HttpEntity entity; + String requestData; + if (getUseMultipart()) { + MultipartEntityBuilder builder = MultipartEntityBuilder.create().setCharset(charset); + for (JMeterProperty property : getArguments().getEnabledArguments()) { + HTTPArgument argument = (HTTPArgument) property.getObjectValue(); + if (!argument.isSkippable(argument.getName())) { + ContentType contentType = StringUtilities.isNotEmpty(argument.getContentType()) + ? ContentType.parse(argument.getContentType()) + : ContentType.TEXT_PLAIN.withCharset(charset); + builder.addTextBody(argument.getName(), argument.getValue(), contentType); + } + } + for (HTTPFileArg file : files) { + File resolvedFile = FileServer.getFileServer().getResolvedFile(file.getPath()); + ContentType contentType = StringUtilities.isNotEmpty(file.getMimeType()) + ? ContentType.parse(file.getMimeType()) : ContentType.DEFAULT_BINARY; + builder.addBinaryBody(file.getParamName(), resolvedFile, contentType, file.getName()); + } + entity = builder.build(); + requestData = getEntityPreview(entity, contentEncoding); + } else if (!hasArguments() && getSendFileAsPostBody()) { + HTTPFileArg file = files[0]; + if (request.getFirstHeader(HTTPConstants.HEADER_CONTENT_TYPE) == null && StringUtilities.isNotEmpty(file.getMimeType())) { + request.setHeader(HTTPConstants.HEADER_CONTENT_TYPE, file.getMimeType()); + } + entity = new FileEntity(FileServer.getFileServer().getResolvedFile(file.getPath()), null); + requestData = ""; + } else if (getSendParameterValuesAsPostBody()) { + StringBuilder body = new StringBuilder(); + for (JMeterProperty property : getArguments().getEnabledArguments()) { + body.append(((HTTPArgument) property.getObjectValue()).getEncodedValue(contentEncoding)); + } + entity = new StringEntity(body.toString(), charset); + requestData = body.toString(); + } else if (hasArguments()) { + entity = new UrlEncodedFormEntity(createNameValuePairs(contentEncoding), charset); + requestData = getEntityPreview(entity, contentEncoding); + } else { + return ""; + } + request.setEntity(entity); + return requestData; + } + + private List createNameValuePairs(String contentEncoding) throws IOException { + List pairs = new ArrayList<>(); + for (JMeterProperty property : getArguments().getEnabledArguments()) { + HTTPArgument argument = (HTTPArgument) property.getObjectValue(); + String name = argument.getName(); + if (argument.isSkippable(name)) { + continue; + } + String value = argument.getValue(); + if (!argument.isAlwaysEncoded()) { + name = URLDecoder.decode(name, contentEncoding); + value = URLDecoder.decode(value, contentEncoding); + } + pairs.add(new BasicNameValuePair(name, value)); + } + return pairs; + } + + private static String getEntityPreview(HttpEntity entity, String contentEncoding) throws IOException { + if (!entity.isRepeatable()) { + return ""; + } + ByteArrayOutputStream output = new ByteArrayOutputStream(); + entity.writeTo(output); + return output.toString(contentEncoding); + } + + private static void setConnectionHeaders(org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request, + URL url, HeaderManager headerManager) { + if (headerManager == null) { + return; + } + CollectionProperty headers = headerManager.getHeaders(); + if (headers == null) { + return; + } + for (JMeterProperty property : headers) { + org.apache.jmeter.protocol.http.control.Header header = + (org.apache.jmeter.protocol.http.control.Header) property.getObjectValue(); + if (!HTTPConstants.HEADER_CONTENT_LENGTH.equalsIgnoreCase(header.getName())) { + request.addHeader(header.getName(), header.getValue()); + } + } + } + + private static String setConnectionCookie(org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request, + URL url, CookieManager cookieManager) { + if (cookieManager == null) { + return null; + } + String cookies = cookieManager.getCookieHeaderForURL(url); + if (cookies != null) { + request.setHeader(HTTPConstants.HEADER_COOKIE, cookies); + } + return cookies; + } + + private void updateResult(CloseableHttpResponse response, + org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request, HTTPSampleResult result) throws IOException { + result.setRequestHeaders(getRequestHeaders(request)); + Header contentType = response.getFirstHeader(HTTPConstants.HEADER_CONTENT_TYPE); + if (contentType != null) { + result.setContentType(contentType.getValue()); + result.setEncodingAndType(contentType.getValue()); + } + HttpEntity entity = response.getEntity(); + long bodySize = 0; + if (entity != null) { + byte[] body = readResponse(result, entity.getContent(), entity.getContentLength()); + result.setResponseData(body); + bodySize = body.length; + } + int statusCode = response.getCode(); + result.setResponseCode(Integer.toString(statusCode)); + result.setResponseMessage(response.getReasonPhrase()); + result.setSuccessful(isSuccessCode(statusCode)); + result.setResponseHeaders(getResponseHeaders(response)); + result.setHeadersSize(result.getResponseHeaders().length()); + result.setBodySize(bodySize); + if (result.isRedirect()) { + Header location = response.getFirstHeader(HTTPConstants.HEADER_LOCATION); + if (location != null) { + result.setRedirectLocation(location.getValue()); + } + } + } + + private static String getResponseHeaders(CloseableHttpResponse response) { + StringBuilder headers = new StringBuilder(); + headers.append(response.getVersion()).append(' ').append(response.getCode()).append(' ') + .append(response.getReasonPhrase()).append('\n'); + for (Header header : response.getHeaders()) { + headers.append(header.getName()).append(": ").append(header.getValue()).append('\n'); + } + return headers.toString(); + } + + private static String getRequestHeaders(org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request) { + StringBuilder headers = new StringBuilder(); + for (Header header : request.getHeaders()) { + if (ALL_EXCEPT_COOKIE.test(header.getName())) { + headers.append(header.getName()).append(": ").append(header.getValue()).append('\n'); + } + } + return headers.toString(); + } + + private static String getOnlyCookieFromHeaders(org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request) { + Header cookie = request.getFirstHeader(HTTPConstants.HEADER_COOKIE); + return cookie == null ? "" : cookie.getValue(); + } + + private static void saveConnectionCookies(CloseableHttpResponse response, URL url, CookieManager cookieManager) { + if (cookieManager == null) { + return; + } + for (Header header : response.getHeaders(HTTPConstants.HEADER_SET_COOKIE)) { + cookieManager.addCookieFromHeader(header.getValue(), url); + } + } + + private CloseableHttpClient getClient(HttpClientKey key) { + Map clients = HTTP_CLIENTS.get(); + return clients.computeIfAbsent(key, this::createClient); + } + + private CloseableHttpClient createClient(HttpClientKey key) { + org.apache.hc.client5.http.impl.classic.HttpClientBuilder builder = HttpClients.custom().disableAutomaticRetries(); + if (key.hasProxy) { + builder.setProxy(new HttpHost(key.proxyScheme, key.proxyHost, key.proxyPort)); + } + return builder.build(); + } + + private HttpClientKey createHttpClientKey(URL url) { + String proxyScheme = getProxyScheme(); + String proxyHost = getProxyHost(); + int proxyPort = getProxyPortInt(); + boolean useDynamicProxy = isDynamicProxy(proxyHost, proxyPort); + boolean useStaticProxy = isStaticProxy(url.getHost()); + if (!useDynamicProxy) { + proxyScheme = PROXY_SCHEME; + proxyHost = PROXY_HOST; + proxyPort = PROXY_PORT; + } + return new HttpClientKey(url.getProtocol(), url.getAuthority(), useDynamicProxy || useStaticProxy, + proxyScheme, proxyHost, proxyPort); + } + + @Override + protected void notifyFirstSampleAfterLoopRestart() { + JMeterVariables variables = JMeterContextService.getContext().getVariables(); + resetStateOnThreadGroupIteration.set(variables != null && !variables.isSameUserOnNextIteration() + && RESET_STATE_ON_THREAD_GROUP_ITERATION); + } + + private void resetStateIfNeeded() { + if (resetStateOnThreadGroupIteration.get()) { + closeThreadLocalClients(); + ((JsseSSLManager) SSLManager.getInstance()).resetContext(); + resetStateOnThreadGroupIteration.set(false); + } + } + + @Override + protected void threadFinished() { + closeThreadLocalClients(); + } + + private static void closeThreadLocalClients() { + Map clients = HTTP_CLIENTS.get(); + for (CloseableHttpClient client : clients.values()) { + JOrphanUtils.closeQuietly(client); + } + clients.clear(); + } + + @Override + public boolean interrupt() { + org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request = currentRequest; + if (request != null) { + currentRequest = null; + request.cancel(); + } + return request != null; + } + + private static final class HttpClientKey { + private final String protocol; + private final String authority; + private final boolean hasProxy; + private final String proxyScheme; + private final String proxyHost; + private final int proxyPort; + + private HttpClientKey(String protocol, String authority, boolean hasProxy, String proxyScheme, + String proxyHost, int proxyPort) { + this.protocol = protocol; + this.authority = authority; + this.hasProxy = hasProxy; + this.proxyScheme = proxyScheme; + this.proxyHost = proxyHost; + this.proxyPort = proxyPort; + } + + @Override + public boolean equals(Object object) { + if (this == object) { + return true; + } + if (!(object instanceof HttpClientKey other)) { + return false; + } + return hasProxy == other.hasProxy && proxyPort == other.proxyPort + && Objects.equals(protocol, other.protocol) && Objects.equals(authority, other.authority) + && Objects.equals(proxyScheme, other.proxyScheme) && Objects.equals(proxyHost, other.proxyHost); + } + + @Override + public int hashCode() { + return Objects.hash(protocol, authority, hasProxy, proxyScheme, proxyHost, proxyPort); + } + } +} \ No newline at end of file diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPSamplerFactory.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPSamplerFactory.java index 1b9798bad4f..c7781156dc4 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPSamplerFactory.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPSamplerFactory.java @@ -38,6 +38,8 @@ public final class HTTPSamplerFactory { //+ JMX implementation attribute values (also displayed in GUI) - do not change public static final String IMPL_HTTP_CLIENT4 = "HttpClient4"; // $NON-NLS-1$ + public static final String IMPL_HTTP_CLIENT5 = "HttpClient5"; // $NON-NLS-1$ + public static final String IMPL_HTTP_CLIENT3_1 = "HttpClient3.1"; // $NON-NLS-1$ public static final String IMPL_JAVA = "Java"; // $NON-NLS-1$ @@ -62,7 +64,7 @@ public static HTTPSamplerBase newInstance() { /** * Create a new instance of the required sampler type * - * @param alias HTTP_SAMPLER or HTTP_SAMPLER_APACHE or IMPL_HTTP_CLIENT3_1 or IMPL_HTTP_CLIENT4 + * @param alias HTTP_SAMPLER or HTTP_SAMPLER_APACHE or IMPL_HTTP_CLIENT3_1, IMPL_HTTP_CLIENT4 or IMPL_HTTP_CLIENT5 * @return the appropriate sampler * @throws UnsupportedOperationException if alias is not recognised */ @@ -76,11 +78,14 @@ public static HTTPSamplerBase newInstance(String alias) { if (alias.equals(IMPL_HTTP_CLIENT4) || alias.equals(HTTP_SAMPLER_APACHE) || alias.equals(IMPL_HTTP_CLIENT3_1)) { return new HTTPSamplerProxy(IMPL_HTTP_CLIENT4); } + if (alias.equals(IMPL_HTTP_CLIENT5)) { + return new HTTPSamplerProxy(IMPL_HTTP_CLIENT5); + } throw new IllegalArgumentException("Unknown sampler type: '" + alias+"'"); } public static String[] getImplementations(){ - return new String[]{IMPL_HTTP_CLIENT4,IMPL_JAVA}; + return new String[]{IMPL_HTTP_CLIENT4, IMPL_HTTP_CLIENT5, IMPL_JAVA}; } public static HTTPAbstractImpl getImplementation(String impl, HTTPSamplerBase base){ @@ -94,6 +99,8 @@ public static HTTPAbstractImpl getImplementation(String impl, HTTPSamplerBase ba return new HTTPJavaImpl(base); } else if (IMPL_HTTP_CLIENT4.equals(impl) || IMPL_HTTP_CLIENT3_1.equals(impl)) { return new HTTPHC4Impl(base); + } else if (IMPL_HTTP_CLIENT5.equals(impl)) { + return new HTTPHC5Impl(base); } else { throw new IllegalArgumentException("Unknown implementation type: '"+impl+"'"); } diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPSamplerFactory.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPSamplerFactory.java new file mode 100644 index 00000000000..1c889457a3b --- /dev/null +++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPSamplerFactory.java @@ -0,0 +1,45 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you 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.apache.jmeter.protocol.http.sampler; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Arrays; + +import org.junit.jupiter.api.Test; + +public class TestHTTPSamplerFactory { + + @Test + void httpClient5IsSelectable() { + assertTrue(Arrays.asList(HTTPSamplerFactory.getImplementations()).contains("HttpClient5")); + + HTTPSamplerBase sampler = HTTPSamplerFactory.newInstance("HttpClient5"); + + assertEquals("HttpClient5", sampler.getImplementation()); + assertInstanceOf(HTTPHC5Impl.class, HTTPSamplerFactory.getImplementation(sampler.getImplementation(), sampler)); + } + + @Test + void unknownImplementationIsRejected() { + assertThrows(IllegalArgumentException.class, () -> HTTPSamplerFactory.newInstance("HttpClient6")); + } +} \ No newline at end of file From 882e3749b971c35cb804e2c1903295876753511b Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Thu, 23 Jul 2026 06:19:38 +0200 Subject: [PATCH 02/80] Refactor `HTTPHC5Impl` to improve connection and timeout handling methods --- .../protocol/http/sampler/HTTPHC5Impl.java | 55 +++++++++++-------- .../http/sampler/TestHTTPSamplerFactory.java | 2 +- 2 files changed, 32 insertions(+), 25 deletions(-) diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java index 773adfd6ab1..1cf79902b01 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java @@ -30,12 +30,14 @@ import java.util.Map; import java.util.Objects; +import org.apache.hc.client5.http.config.ConnectionConfig; import org.apache.hc.client5.http.config.RequestConfig; import org.apache.hc.client5.http.entity.UrlEncodedFormEntity; import org.apache.hc.client5.http.entity.mime.MultipartEntityBuilder; import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; -import org.apache.hc.client5.http.impl.classic.CloseableHttpResponse; import org.apache.hc.client5.http.impl.classic.HttpClients; +import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder; +import org.apache.hc.core5.http.ClassicHttpResponse; import org.apache.hc.core5.http.ContentType; import org.apache.hc.core5.http.Header; import org.apache.hc.core5.http.HttpEntity; @@ -83,10 +85,10 @@ protected HTTPHC5Impl(HTTPSamplerBase testElement) { protected HTTPSampleResult sample(URL url, String method, boolean areFollowingRedirect, int frameDepth) { HTTPSampleResult result = createSampleResult(url, method); org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request = null; - CloseableHttpResponse response = null; + ClassicHttpResponse response = null; try { resetStateIfNeeded(); - request = createRequest(url.toURI(), method, areFollowingRedirect); + request = createRequest(url.toURI(), method); setupRequest(url, request, result, areFollowingRedirect); result.sampleStart(); @@ -96,7 +98,7 @@ protected HTTPSampleResult sample(URL url, String method, boolean areFollowingRe } currentRequest = request; - response = getClient(createHttpClientKey(url)).execute(request); + response = getClient(createHttpClientKey(url)).executeOpen(null, request, null); result.sampleEnd(); currentRequest = null; @@ -125,8 +127,7 @@ private HTTPSampleResult createSampleResult(URL url, String method) { return result; } - private org.apache.hc.client5.http.classic.methods.HttpUriRequestBase createRequest( - URI uri, String method, boolean areFollowingRedirect) { + private static org.apache.hc.client5.http.classic.methods.HttpUriRequestBase createRequest(URI uri, String method) { return new org.apache.hc.client5.http.classic.methods.HttpUriRequestBase(method, uri); } @@ -138,14 +139,10 @@ private void setupRequest(URL url, org.apache.hc.client5.http.classic.methods.Ht if (responseTimeout > 0) { config.setResponseTimeout(Timeout.ofMilliseconds(responseTimeout)); } - int connectTimeout = getConnectTimeout(); - if (connectTimeout > 0) { - config.setConnectTimeout(Timeout.ofMilliseconds(connectTimeout)); - } request.setConfig(config.build()); request.setHeader(HTTPConstants.HEADER_CONNECTION, getUseKeepAlive() ? HTTPConstants.KEEP_ALIVE : HTTPConstants.CONNECTION_CLOSE); - setConnectionHeaders(request, url, getHeaderManager()); + setConnectionHeaders(request, getHeaderManager()); String cookies = setConnectionCookie(request, url, getCookieManager()); if (StringUtilities.isNotEmpty(cookies)) { @@ -240,7 +237,7 @@ private static String getEntityPreview(HttpEntity entity, String contentEncoding } private static void setConnectionHeaders(org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request, - URL url, HeaderManager headerManager) { + HeaderManager headerManager) { if (headerManager == null) { return; } @@ -269,7 +266,7 @@ private static String setConnectionCookie(org.apache.hc.client5.http.classic.met return cookies; } - private void updateResult(CloseableHttpResponse response, + private void updateResult(ClassicHttpResponse response, org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request, HTTPSampleResult result) throws IOException { result.setRequestHeaders(getRequestHeaders(request)); Header contentType = response.getFirstHeader(HTTPConstants.HEADER_CONTENT_TYPE); @@ -299,7 +296,7 @@ private void updateResult(CloseableHttpResponse response, } } - private static String getResponseHeaders(CloseableHttpResponse response) { + private static String getResponseHeaders(ClassicHttpResponse response) { StringBuilder headers = new StringBuilder(); headers.append(response.getVersion()).append(' ').append(response.getCode()).append(' ') .append(response.getReasonPhrase()).append('\n'); @@ -324,7 +321,7 @@ private static String getOnlyCookieFromHeaders(org.apache.hc.client5.http.classi return cookie == null ? "" : cookie.getValue(); } - private static void saveConnectionCookies(CloseableHttpResponse response, URL url, CookieManager cookieManager) { + private static void saveConnectionCookies(ClassicHttpResponse response, URL url, CookieManager cookieManager) { if (cookieManager == null) { return; } @@ -333,13 +330,20 @@ private static void saveConnectionCookies(CloseableHttpResponse response, URL ur } } - private CloseableHttpClient getClient(HttpClientKey key) { + private static CloseableHttpClient getClient(HttpClientKey key) { Map clients = HTTP_CLIENTS.get(); - return clients.computeIfAbsent(key, this::createClient); + return clients.computeIfAbsent(key, HTTPHC5Impl::createClient); } - private CloseableHttpClient createClient(HttpClientKey key) { + private static CloseableHttpClient createClient(HttpClientKey key) { org.apache.hc.client5.http.impl.classic.HttpClientBuilder builder = HttpClients.custom().disableAutomaticRetries(); + if (key.connectTimeout > 0) { + builder.setConnectionManager(PoolingHttpClientConnectionManagerBuilder.create() + .setDefaultConnectionConfig(ConnectionConfig.custom() + .setConnectTimeout(Timeout.ofMilliseconds(key.connectTimeout)) + .build()) + .build()); + } if (key.hasProxy) { builder.setProxy(new HttpHost(key.proxyScheme, key.proxyHost, key.proxyPort)); } @@ -350,6 +354,7 @@ private HttpClientKey createHttpClientKey(URL url) { String proxyScheme = getProxyScheme(); String proxyHost = getProxyHost(); int proxyPort = getProxyPortInt(); + int connectTimeout = getConnectTimeout(); boolean useDynamicProxy = isDynamicProxy(proxyHost, proxyPort); boolean useStaticProxy = isStaticProxy(url.getHost()); if (!useDynamicProxy) { @@ -358,7 +363,7 @@ private HttpClientKey createHttpClientKey(URL url) { proxyPort = PROXY_PORT; } return new HttpClientKey(url.getProtocol(), url.getAuthority(), useDynamicProxy || useStaticProxy, - proxyScheme, proxyHost, proxyPort); + proxyScheme, proxyHost, proxyPort, connectTimeout); } @Override @@ -368,7 +373,7 @@ protected void notifyFirstSampleAfterLoopRestart() { && RESET_STATE_ON_THREAD_GROUP_ITERATION); } - private void resetStateIfNeeded() { + private static void resetStateIfNeeded() { if (resetStateOnThreadGroupIteration.get()) { closeThreadLocalClients(); ((JsseSSLManager) SSLManager.getInstance()).resetContext(); @@ -406,15 +411,17 @@ private static final class HttpClientKey { private final String proxyScheme; private final String proxyHost; private final int proxyPort; + private final int connectTimeout; private HttpClientKey(String protocol, String authority, boolean hasProxy, String proxyScheme, - String proxyHost, int proxyPort) { + String proxyHost, int proxyPort, int connectTimeout) { this.protocol = protocol; this.authority = authority; this.hasProxy = hasProxy; this.proxyScheme = proxyScheme; this.proxyHost = proxyHost; this.proxyPort = proxyPort; + this.connectTimeout = connectTimeout; } @Override @@ -425,14 +432,14 @@ public boolean equals(Object object) { if (!(object instanceof HttpClientKey other)) { return false; } - return hasProxy == other.hasProxy && proxyPort == other.proxyPort + return hasProxy == other.hasProxy && proxyPort == other.proxyPort && connectTimeout == other.connectTimeout && Objects.equals(protocol, other.protocol) && Objects.equals(authority, other.authority) && Objects.equals(proxyScheme, other.proxyScheme) && Objects.equals(proxyHost, other.proxyHost); } @Override public int hashCode() { - return Objects.hash(protocol, authority, hasProxy, proxyScheme, proxyHost, proxyPort); + return Objects.hash(protocol, authority, hasProxy, proxyScheme, proxyHost, proxyPort, connectTimeout); } } -} \ No newline at end of file +} diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPSamplerFactory.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPSamplerFactory.java index 1c889457a3b..a617f13df1d 100644 --- a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPSamplerFactory.java +++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPSamplerFactory.java @@ -42,4 +42,4 @@ void httpClient5IsSelectable() { void unknownImplementationIsRejected() { assertThrows(IllegalArgumentException.class, () -> HTTPSamplerFactory.newInstance("HttpClient6")); } -} \ No newline at end of file +} From 300716b18c4e351bd4603e7fec9984a15f91c2de Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Thu, 23 Jul 2026 07:04:02 +0200 Subject: [PATCH 03/80] Add support for DNS resolution and response decompression in HTTPHC5Impl --- .../http/control/DNSCacheManager.java | 12 +- .../protocol/http/sampler/HTTPHC5Impl.java | 111 ++++++++++++++++-- 2 files changed, 110 insertions(+), 13 deletions(-) diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/DNSCacheManager.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/DNSCacheManager.java index 7af898b28d5..308ca34b2d4 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/DNSCacheManager.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/DNSCacheManager.java @@ -62,7 +62,8 @@ * * @since 2.12 */ -public class DNSCacheManager extends ConfigTestElement implements TestIterationListener, Serializable, DnsResolver { +public class DNSCacheManager extends ConfigTestElement implements TestIterationListener, Serializable, DnsResolver, + org.apache.hc.client5.http.DnsResolver { private static final long serialVersionUID = 2122L; @@ -248,6 +249,15 @@ public InetAddress[] resolve(String host) throws UnknownHostException { } } + @Override + public String resolveCanonicalHostname(String host) throws UnknownHostException { + InetAddress[] addresses = resolve(host); + if (addresses == null || addresses.length == 0) { + return host; + } + return addresses[0].getCanonicalHostName(); + } + private static void logCache(String hitOrMiss, String host, InetAddress[] addresses) { if (log.isDebugEnabled()) { log.debug("Cache {} thread#{}: {} => {}", hitOrMiss, JMeterContextService.getContext().getThreadNum(), host, diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java index 1cf79902b01..0398b0e18fc 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java @@ -20,6 +20,7 @@ import java.io.ByteArrayOutputStream; import java.io.File; import java.io.IOException; +import java.net.InetAddress; import java.net.URI; import java.net.URL; import java.net.URLDecoder; @@ -27,25 +28,41 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Objects; +import org.apache.hc.client5.http.classic.ExecChain; +import org.apache.hc.client5.http.classic.ExecChainHandler; import org.apache.hc.client5.http.config.ConnectionConfig; import org.apache.hc.client5.http.config.RequestConfig; +import org.apache.hc.client5.http.entity.BrotliInputStreamFactory; +import org.apache.hc.client5.http.entity.DecompressingEntity; +import org.apache.hc.client5.http.entity.DeflateInputStreamFactory; +import org.apache.hc.client5.http.entity.GZIPInputStreamFactory; +import org.apache.hc.client5.http.entity.InputStreamFactory; import org.apache.hc.client5.http.entity.UrlEncodedFormEntity; import org.apache.hc.client5.http.entity.mime.MultipartEntityBuilder; import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; import org.apache.hc.client5.http.impl.classic.HttpClients; import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder; +import org.apache.hc.client5.http.impl.routing.DefaultRoutePlanner; +import org.apache.hc.client5.http.protocol.HttpClientContext; import org.apache.hc.core5.http.ClassicHttpResponse; import org.apache.hc.core5.http.ContentType; import org.apache.hc.core5.http.Header; +import org.apache.hc.core5.http.HeaderElement; import org.apache.hc.core5.http.HttpEntity; +import org.apache.hc.core5.http.HttpHeaders; import org.apache.hc.core5.http.HttpHost; import org.apache.hc.core5.http.NameValuePair; +import org.apache.hc.core5.http.config.Lookup; +import org.apache.hc.core5.http.config.RegistryBuilder; import org.apache.hc.core5.http.io.entity.FileEntity; import org.apache.hc.core5.http.io.entity.StringEntity; +import org.apache.hc.core5.http.message.BasicHeaderValueParser; import org.apache.hc.core5.http.message.BasicNameValuePair; +import org.apache.hc.core5.http.message.ParserCursor; import org.apache.hc.core5.util.Timeout; import org.apache.jmeter.protocol.http.control.CacheManager; import org.apache.jmeter.protocol.http.control.CookieManager; @@ -75,6 +92,52 @@ public class HTTPHC5Impl extends HTTPHCAbstractImpl { private static final ThreadLocal> HTTP_CLIENTS = ThreadLocal.withInitial(HashMap::new); + private static final String[] HEADERS_TO_SAVE = {HttpHeaders.CONTENT_LENGTH, HttpHeaders.CONTENT_ENCODING, + HttpHeaders.CONTENT_MD5}; + + private static final Lookup CONTENT_DECODERS = RegistryBuilder.create() + .register("br", BrotliInputStreamFactory.getInstance()) + .register("gzip", GZIPInputStreamFactory.getInstance()) + .register("x-gzip", GZIPInputStreamFactory.getInstance()) + .register("deflate", DeflateInputStreamFactory.getInstance()) + .build(); + + private static final ExecChainHandler RESPONSE_CONTENT_ENCODING = (request, scope, chain) -> { + HttpClientContext context = scope.clientContext; + RequestConfig requestConfig = context.getRequestConfigOrDefault(); + ClassicHttpResponse response = chain.proceed(request, scope); + HttpEntity entity = response.getEntity(); + if (!requestConfig.isContentCompressionEnabled() || entity == null || entity.getContentLength() == 0 + || entity.getContentEncoding() == null) { + return response; + } + + Header[][] headersToSave = new Header[HEADERS_TO_SAVE.length][]; + for (int i = 0; i < HEADERS_TO_SAVE.length; i++) { + headersToSave[i] = response.getHeaders(HEADERS_TO_SAVE[i]); + } + String contentEncoding = entity.getContentEncoding(); + HeaderElement[] codecs = BasicHeaderValueParser.INSTANCE.parseElements(contentEncoding, + new ParserCursor(0, contentEncoding.length())); + for (HeaderElement codec : codecs) { + InputStreamFactory decoderFactory = CONTENT_DECODERS.lookup(codec.getName().toLowerCase(Locale.ROOT)); + if (decoderFactory != null) { + response.setEntity(new DecompressingEntity(response.getEntity(), decoderFactory)); + response.removeHeaders(HttpHeaders.CONTENT_LENGTH); + response.removeHeaders(HttpHeaders.CONTENT_ENCODING); + response.removeHeaders(HttpHeaders.CONTENT_MD5); + } + } + for (Header[] headers : headersToSave) { + for (Header header : headers) { + if (!response.containsHeader(header.getName())) { + response.addHeader(header); + } + } + } + return response; + }; + private volatile org.apache.hc.client5.http.classic.methods.HttpUriRequestBase currentRequest; protected HTTPHC5Impl(HTTPSamplerBase testElement) { @@ -337,24 +400,41 @@ private static CloseableHttpClient getClient(HttpClientKey key) { private static CloseableHttpClient createClient(HttpClientKey key) { org.apache.hc.client5.http.impl.classic.HttpClientBuilder builder = HttpClients.custom().disableAutomaticRetries(); + PoolingHttpClientConnectionManagerBuilder connectionManagerBuilder = PoolingHttpClientConnectionManagerBuilder.create(); + if (key.dnsResolver != null) { + connectionManagerBuilder.setDnsResolver(key.dnsResolver); + } if (key.connectTimeout > 0) { - builder.setConnectionManager(PoolingHttpClientConnectionManagerBuilder.create() + connectionManagerBuilder .setDefaultConnectionConfig(ConnectionConfig.custom() .setConnectTimeout(Timeout.ofMilliseconds(key.connectTimeout)) - .build()) - .build()); + .build()); } - if (key.hasProxy) { - builder.setProxy(new HttpHost(key.proxyScheme, key.proxyHost, key.proxyPort)); - } - return builder.build(); + builder.setConnectionManager(connectionManagerBuilder.build()); + builder.setRoutePlanner(new DefaultRoutePlanner(null) { + @Override + protected HttpHost determineProxy(HttpHost target, org.apache.hc.core5.http.protocol.HttpContext context) { + return key.hasProxy ? new HttpHost(key.proxyScheme, key.proxyHost, key.proxyPort) : null; + } + + @Override + protected InetAddress determineLocalAddress(HttpHost firstHop, + org.apache.hc.core5.http.protocol.HttpContext context) { + return key.localAddress; + } + }); + return builder.disableContentCompression() + .addExecInterceptorFirst("response-content-encoding", RESPONSE_CONTENT_ENCODING) + .build(); } - private HttpClientKey createHttpClientKey(URL url) { + private HttpClientKey createHttpClientKey(URL url) throws IOException { String proxyScheme = getProxyScheme(); String proxyHost = getProxyHost(); int proxyPort = getProxyPortInt(); int connectTimeout = getConnectTimeout(); + org.apache.hc.client5.http.DnsResolver dnsResolver = testElement.getDNSResolver(); + InetAddress localAddress = getIpSourceAddress(); boolean useDynamicProxy = isDynamicProxy(proxyHost, proxyPort); boolean useStaticProxy = isStaticProxy(url.getHost()); if (!useDynamicProxy) { @@ -363,7 +443,7 @@ private HttpClientKey createHttpClientKey(URL url) { proxyPort = PROXY_PORT; } return new HttpClientKey(url.getProtocol(), url.getAuthority(), useDynamicProxy || useStaticProxy, - proxyScheme, proxyHost, proxyPort, connectTimeout); + proxyScheme, proxyHost, proxyPort, connectTimeout, dnsResolver, localAddress); } @Override @@ -412,9 +492,12 @@ private static final class HttpClientKey { private final String proxyHost; private final int proxyPort; private final int connectTimeout; + private final org.apache.hc.client5.http.DnsResolver dnsResolver; + private final InetAddress localAddress; private HttpClientKey(String protocol, String authority, boolean hasProxy, String proxyScheme, - String proxyHost, int proxyPort, int connectTimeout) { + String proxyHost, int proxyPort, int connectTimeout, org.apache.hc.client5.http.DnsResolver dnsResolver, + InetAddress localAddress) { this.protocol = protocol; this.authority = authority; this.hasProxy = hasProxy; @@ -422,6 +505,8 @@ private HttpClientKey(String protocol, String authority, boolean hasProxy, Strin this.proxyHost = proxyHost; this.proxyPort = proxyPort; this.connectTimeout = connectTimeout; + this.dnsResolver = dnsResolver; + this.localAddress = localAddress; } @Override @@ -434,12 +519,14 @@ public boolean equals(Object object) { } return hasProxy == other.hasProxy && proxyPort == other.proxyPort && connectTimeout == other.connectTimeout && Objects.equals(protocol, other.protocol) && Objects.equals(authority, other.authority) - && Objects.equals(proxyScheme, other.proxyScheme) && Objects.equals(proxyHost, other.proxyHost); + && Objects.equals(proxyScheme, other.proxyScheme) && Objects.equals(proxyHost, other.proxyHost) + && Objects.equals(dnsResolver, other.dnsResolver) && Objects.equals(localAddress, other.localAddress); } @Override public int hashCode() { - return Objects.hash(protocol, authority, hasProxy, proxyScheme, proxyHost, proxyPort, connectTimeout); + return Objects.hash(protocol, authority, hasProxy, proxyScheme, proxyHost, proxyPort, connectTimeout, dnsResolver, + localAddress); } } } From 39fdf752d623e5316de33dc6b77d64dab90d35c5 Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Thu, 23 Jul 2026 08:22:58 +0200 Subject: [PATCH 04/80] Refactor `DNSCacheManager` to simplify interface and integrate with `HTTPHC5Impl` for DNS resolution --- .../http/control/DNSCacheManager.java | 12 +------ .../protocol/http/sampler/HTTPHC5Impl.java | 35 ++++++++++++++----- 2 files changed, 27 insertions(+), 20 deletions(-) diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/DNSCacheManager.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/DNSCacheManager.java index 308ca34b2d4..7af898b28d5 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/DNSCacheManager.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/DNSCacheManager.java @@ -62,8 +62,7 @@ * * @since 2.12 */ -public class DNSCacheManager extends ConfigTestElement implements TestIterationListener, Serializable, DnsResolver, - org.apache.hc.client5.http.DnsResolver { +public class DNSCacheManager extends ConfigTestElement implements TestIterationListener, Serializable, DnsResolver { private static final long serialVersionUID = 2122L; @@ -249,15 +248,6 @@ public InetAddress[] resolve(String host) throws UnknownHostException { } } - @Override - public String resolveCanonicalHostname(String host) throws UnknownHostException { - InetAddress[] addresses = resolve(host); - if (addresses == null || addresses.length == 0) { - return host; - } - return addresses[0].getCanonicalHostName(); - } - private static void logCache(String hitOrMiss, String host, InetAddress[] addresses) { if (log.isDebugEnabled()) { log.debug("Cache {} thread#{}: {} => {}", hitOrMiss, JMeterContextService.getContext().getThreadNum(), host, diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java index 0398b0e18fc..687c1c6598f 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java @@ -24,6 +24,7 @@ import java.net.URI; import java.net.URL; import java.net.URLDecoder; +import java.net.UnknownHostException; import java.nio.charset.Charset; import java.util.ArrayList; import java.util.HashMap; @@ -66,6 +67,7 @@ import org.apache.hc.core5.util.Timeout; import org.apache.jmeter.protocol.http.control.CacheManager; import org.apache.jmeter.protocol.http.control.CookieManager; +import org.apache.jmeter.protocol.http.control.DNSCacheManager; import org.apache.jmeter.protocol.http.control.HeaderManager; import org.apache.jmeter.protocol.http.util.HTTPArgument; import org.apache.jmeter.protocol.http.util.HTTPConstants; @@ -401,8 +403,8 @@ private static CloseableHttpClient getClient(HttpClientKey key) { private static CloseableHttpClient createClient(HttpClientKey key) { org.apache.hc.client5.http.impl.classic.HttpClientBuilder builder = HttpClients.custom().disableAutomaticRetries(); PoolingHttpClientConnectionManagerBuilder connectionManagerBuilder = PoolingHttpClientConnectionManagerBuilder.create(); - if (key.dnsResolver != null) { - connectionManagerBuilder.setDnsResolver(key.dnsResolver); + if (key.dnsCacheManager != null) { + connectionManagerBuilder.setDnsResolver(createDnsResolver(key.dnsCacheManager)); } if (key.connectTimeout > 0) { connectionManagerBuilder @@ -428,12 +430,27 @@ protected InetAddress determineLocalAddress(HttpHost firstHop, .build(); } + private static org.apache.hc.client5.http.DnsResolver createDnsResolver(DNSCacheManager dnsCacheManager) { + return new org.apache.hc.client5.http.DnsResolver() { + @Override + public InetAddress[] resolve(String host) throws UnknownHostException { + return dnsCacheManager.resolve(host); + } + + @Override + public String resolveCanonicalHostname(String host) throws UnknownHostException { + InetAddress[] addresses = resolve(host); + return addresses == null || addresses.length == 0 ? host : addresses[0].getCanonicalHostName(); + } + }; + } + private HttpClientKey createHttpClientKey(URL url) throws IOException { String proxyScheme = getProxyScheme(); String proxyHost = getProxyHost(); int proxyPort = getProxyPortInt(); int connectTimeout = getConnectTimeout(); - org.apache.hc.client5.http.DnsResolver dnsResolver = testElement.getDNSResolver(); + DNSCacheManager dnsCacheManager = testElement.getDNSResolver(); InetAddress localAddress = getIpSourceAddress(); boolean useDynamicProxy = isDynamicProxy(proxyHost, proxyPort); boolean useStaticProxy = isStaticProxy(url.getHost()); @@ -443,7 +460,7 @@ private HttpClientKey createHttpClientKey(URL url) throws IOException { proxyPort = PROXY_PORT; } return new HttpClientKey(url.getProtocol(), url.getAuthority(), useDynamicProxy || useStaticProxy, - proxyScheme, proxyHost, proxyPort, connectTimeout, dnsResolver, localAddress); + proxyScheme, proxyHost, proxyPort, connectTimeout, dnsCacheManager, localAddress); } @Override @@ -492,11 +509,11 @@ private static final class HttpClientKey { private final String proxyHost; private final int proxyPort; private final int connectTimeout; - private final org.apache.hc.client5.http.DnsResolver dnsResolver; + private final DNSCacheManager dnsCacheManager; private final InetAddress localAddress; private HttpClientKey(String protocol, String authority, boolean hasProxy, String proxyScheme, - String proxyHost, int proxyPort, int connectTimeout, org.apache.hc.client5.http.DnsResolver dnsResolver, + String proxyHost, int proxyPort, int connectTimeout, DNSCacheManager dnsCacheManager, InetAddress localAddress) { this.protocol = protocol; this.authority = authority; @@ -505,7 +522,7 @@ private HttpClientKey(String protocol, String authority, boolean hasProxy, Strin this.proxyHost = proxyHost; this.proxyPort = proxyPort; this.connectTimeout = connectTimeout; - this.dnsResolver = dnsResolver; + this.dnsCacheManager = dnsCacheManager; this.localAddress = localAddress; } @@ -520,12 +537,12 @@ public boolean equals(Object object) { return hasProxy == other.hasProxy && proxyPort == other.proxyPort && connectTimeout == other.connectTimeout && Objects.equals(protocol, other.protocol) && Objects.equals(authority, other.authority) && Objects.equals(proxyScheme, other.proxyScheme) && Objects.equals(proxyHost, other.proxyHost) - && Objects.equals(dnsResolver, other.dnsResolver) && Objects.equals(localAddress, other.localAddress); + && Objects.equals(dnsCacheManager, other.dnsCacheManager) && Objects.equals(localAddress, other.localAddress); } @Override public int hashCode() { - return Objects.hash(protocol, authority, hasProxy, proxyScheme, proxyHost, proxyPort, connectTimeout, dnsResolver, + return Objects.hash(protocol, authority, hasProxy, proxyScheme, proxyHost, proxyPort, connectTimeout, dnsCacheManager, localAddress); } } From 794d1a634aa7d55ffb066c87d168755026e61397 Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Thu, 23 Jul 2026 08:49:03 +0200 Subject: [PATCH 05/80] Handle null `RequestConfig` in `HTTPHC5Impl` to ensure default configuration is used --- .../org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java index 687c1c6598f..1243f301059 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java @@ -106,7 +106,10 @@ public class HTTPHC5Impl extends HTTPHCAbstractImpl { private static final ExecChainHandler RESPONSE_CONTENT_ENCODING = (request, scope, chain) -> { HttpClientContext context = scope.clientContext; - RequestConfig requestConfig = context.getRequestConfigOrDefault(); + RequestConfig requestConfig = context.getRequestConfig(); + if (requestConfig == null) { + requestConfig = RequestConfig.DEFAULT; + } ClassicHttpResponse response = chain.proceed(request, scope); HttpEntity entity = response.getEntity(); if (!requestConfig.isContentCompressionEnabled() || entity == null || entity.getContentLength() == 0 From a59a57c35ee1affdd1aabe0152139a113e37cdb3 Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Thu, 23 Jul 2026 10:22:15 +0200 Subject: [PATCH 06/80] Add caching and conditional requests support in `HTTPHC5Impl` using `CacheManager` --- .../protocol/http/control/CacheManager.java | 69 ++++++++++++++++++ .../protocol/http/sampler/HTTPHC5Impl.java | 73 ++++++++++++++++--- xdocs/usermanual/component_reference.xml | 20 ++--- xdocs/usermanual/properties_reference.xml | 2 + 4 files changed, 146 insertions(+), 18 deletions(-) diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/CacheManager.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/CacheManager.java index 8852f6332e4..9e8a2b5ce55 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/CacheManager.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/CacheManager.java @@ -245,6 +245,33 @@ public void saveDetails(HttpResponse method, HTTPSampleResult res) { } } + /** + * Save the Last-Modified, Etag, and Expires headers if the result is + * cacheable. Version for Apache HttpClient 5 implementation. + * + * @param response response to extract header information from + * @param res result to decide if result is cacheable + */ + public void saveDetails(org.apache.hc.core5.http.ClassicHttpResponse response, HTTPSampleResult res) { + final String varyHeader = getHeader(response, HTTPConstants.VARY); + if (isCacheable(res, varyHeader)) { + String lastModified = getHeader(response, HTTPConstants.LAST_MODIFIED); + String expires = getHeader(response, HTTPConstants.EXPIRES); + String etag = getHeader(response, HTTPConstants.ETAG); + String cacheControl = getHeader(response, HTTPConstants.CACHE_CONTROL); + String date = getHeader(response, HTTPConstants.DATE); + if (anyNotBlank(lastModified, expires, etag, cacheControl)) { + setCache(lastModified, cacheControl, expires, etag, + res.getUrlAsString(), date, getVaryHeader(varyHeader, asHeaders(res.getRequestHeaders()))); + } + } + } + + private static String getHeader(org.apache.hc.core5.http.HttpMessage message, String name) { + org.apache.hc.core5.http.Header header = message.getFirstHeader(name); + return header == null ? null : header.getValue(); + } + // helper method to save the cache entry private void setCache(String lastModified, String cacheControl, String expires, String etag, String url, String date, Map.Entry varyHeader) { @@ -410,6 +437,29 @@ public void setHeaders(URL url, HttpRequestBase request) { } } + /** + * Check the cache, and if there is a match, set conditional request headers. + * + * @param url URL to look up in cache + * @param request request where to set the headers + */ + public void setHeaders(URL url, org.apache.hc.core5.http.ClassicHttpRequest request) { + CacheEntry entry = getEntry(url.toString(), asHeaders(request.getHeaders())); + if (log.isDebugEnabled()) { + log.debug("setHeaders for HTTP Method:{}(HC5) URL:{} Entry:{}", request.getMethod(), url, entry); + } + if (entry != null) { + String lastModified = entry.getLastModified(); + if (lastModified != null) { + request.setHeader(HTTPConstants.IF_MODIFIED_SINCE, lastModified); + } + String etag = entry.getEtag(); + if (etag != null) { + request.setHeader(HTTPConstants.IF_NONE_MATCH, etag); + } + } + } + /** * Check the cache, and if there is a match, set the headers: *
    @@ -460,6 +510,17 @@ public boolean inCache(URL url, Header[] allHeaders) { return entryStillValid(url, getEntry(url.toString(), allHeaders)); } + /** + * Check whether the URL has a valid entry for the supplied HttpClient 5 request headers. + * + * @param url URL to look up in cache + * @param allHeaders request headers + * @return {@code true} if the matching entry has not expired + */ + public boolean inCache(URL url, org.apache.hc.core5.http.Header[] allHeaders) { + return entryStillValid(url, getEntry(url.toString(), asHeaders(allHeaders))); + } + public boolean inCache(URL url, org.apache.jmeter.protocol.http.control.Header[] allHeaders) { return entryStillValid(url, getEntry(url.toString(), asHeaders(allHeaders))); } @@ -484,6 +545,14 @@ private static Header[] asHeaders(String allHeaders) { return result.toArray(new Header[result.size()]); } + private static Header[] asHeaders(org.apache.hc.core5.http.Header[] allHeaders) { + Header[] result = new Header[allHeaders.length]; + for (int i = 0; i < allHeaders.length; i++) { + result[i] = new BasicHeader(allHeaders[i].getName(), allHeaders[i].getValue()); + } + return result; + } + private static class HeaderAdapter implements Header { private final org.apache.jmeter.protocol.http.control.Header delegate; diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java index 1243f301059..586cae3e1b0 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java @@ -33,6 +33,8 @@ import java.util.Map; import java.util.Objects; +import org.apache.hc.client5.http.auth.AuthScope; +import org.apache.hc.client5.http.auth.UsernamePasswordCredentials; import org.apache.hc.client5.http.classic.ExecChain; import org.apache.hc.client5.http.classic.ExecChainHandler; import org.apache.hc.client5.http.config.ConnectionConfig; @@ -44,6 +46,7 @@ import org.apache.hc.client5.http.entity.InputStreamFactory; import org.apache.hc.client5.http.entity.UrlEncodedFormEntity; import org.apache.hc.client5.http.entity.mime.MultipartEntityBuilder; +import org.apache.hc.client5.http.impl.auth.BasicCredentialsProvider; import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; import org.apache.hc.client5.http.impl.classic.HttpClients; import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder; @@ -65,6 +68,8 @@ import org.apache.hc.core5.http.message.BasicNameValuePair; import org.apache.hc.core5.http.message.ParserCursor; import org.apache.hc.core5.util.Timeout; +import org.apache.jmeter.protocol.http.control.AuthManager; +import org.apache.jmeter.protocol.http.control.Authorization; import org.apache.jmeter.protocol.http.control.CacheManager; import org.apache.jmeter.protocol.http.control.CookieManager; import org.apache.jmeter.protocol.http.control.DNSCacheManager; @@ -81,16 +86,12 @@ import org.apache.jmeter.util.SSLManager; import org.apache.jorphan.util.JOrphanUtils; import org.apache.jorphan.util.StringUtilities; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; /** * HTTP Sampler using Apache HttpClient 5.x. */ public class HTTPHC5Impl extends HTTPHCAbstractImpl { - private static final Logger log = LoggerFactory.getLogger(HTTPHC5Impl.class); - private static final ThreadLocal> HTTP_CLIENTS = ThreadLocal.withInitial(HashMap::new); @@ -162,15 +163,22 @@ protected HTTPSampleResult sample(URL url, String method, boolean areFollowingRe CacheManager cacheManager = getCacheManager(); if (cacheManager != null && HTTPConstants.GET.equalsIgnoreCase(method)) { - log.debug("Cache Manager is not supported by HttpClient5 yet"); + if (cacheManager.inCache(url, request.getHeaders())) { + return updateSampleResultForResourceInCache(result); + } } currentRequest = request; - response = getClient(createHttpClientKey(url)).executeOpen(null, request, null); + HttpClientKey clientKey = createHttpClientKey(url); + HttpClientContext context = createHttpClientContext(url, clientKey, request); + response = getClient(clientKey).executeOpen(null, request, context); result.sampleEnd(); currentRequest = null; updateResult(response, request, result); + if (cacheManager != null) { + cacheManager.saveDetails(response, result); + } saveConnectionCookies(response, result.getURL(), getCookieManager()); return resultProcessing(areFollowingRedirect, frameDepth, result); } catch (Exception e) { @@ -211,6 +219,10 @@ private void setupRequest(URL url, org.apache.hc.client5.http.classic.methods.Ht request.setHeader(HTTPConstants.HEADER_CONNECTION, getUseKeepAlive() ? HTTPConstants.KEEP_ALIVE : HTTPConstants.CONNECTION_CLOSE); setConnectionHeaders(request, getHeaderManager()); + CacheManager cacheManager = getCacheManager(); + if (cacheManager != null) { + cacheManager.setHeaders(url, request); + } String cookies = setConnectionCookie(request, url, getCookieManager()); if (StringUtilities.isNotEmpty(cookies)) { @@ -334,6 +346,42 @@ private static String setConnectionCookie(org.apache.hc.client5.http.classic.met return cookies; } + private HttpClientContext createHttpClientContext(URL url, HttpClientKey key, + org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request) { + HttpClientContext context = HttpClientContext.create(); + BasicCredentialsProvider credentialsProvider = new BasicCredentialsProvider(); + configureTargetCredentials(url, request, credentialsProvider); + configureProxyCredentials(key, credentialsProvider); + context.setCredentialsProvider(credentialsProvider); + return context; + } + + private void configureTargetCredentials(URL url, + org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request, + BasicCredentialsProvider credentialsProvider) { + AuthManager authManager = getAuthManager(); + Authorization authorization = authManager == null ? null : authManager.getAuthForURL(url); + if (authorization == null) { + return; + } + credentialsProvider.setCredentials(new AuthScope(url.getHost(), getPort(url)), + new UsernamePasswordCredentials(authorization.getUser(), authorization.getPass().toCharArray())); + if (AuthManager.Mechanism.BASIC.equals(authorization.getMechanism())) { + request.setHeader(HttpHeaders.AUTHORIZATION, authorization.toBasicHeader()); + } + } + + private static void configureProxyCredentials(HttpClientKey key, BasicCredentialsProvider credentialsProvider) { + if (key.hasProxy && StringUtilities.isNotEmpty(key.proxyUser)) { + credentialsProvider.setCredentials(new AuthScope(key.proxyHost, key.proxyPort), + new UsernamePasswordCredentials(key.proxyUser, key.proxyPass.toCharArray())); + } + } + + private static int getPort(URL url) { + return url.getPort() == -1 ? url.getDefaultPort() : url.getPort(); + } + private void updateResult(ClassicHttpResponse response, org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request, HTTPSampleResult result) throws IOException { result.setRequestHeaders(getRequestHeaders(request)); @@ -453,6 +501,8 @@ private HttpClientKey createHttpClientKey(URL url) throws IOException { String proxyHost = getProxyHost(); int proxyPort = getProxyPortInt(); int connectTimeout = getConnectTimeout(); + String proxyUser = getProxyUser(); + String proxyPass = getProxyPass(); DNSCacheManager dnsCacheManager = testElement.getDNSResolver(); InetAddress localAddress = getIpSourceAddress(); boolean useDynamicProxy = isDynamicProxy(proxyHost, proxyPort); @@ -463,7 +513,7 @@ private HttpClientKey createHttpClientKey(URL url) throws IOException { proxyPort = PROXY_PORT; } return new HttpClientKey(url.getProtocol(), url.getAuthority(), useDynamicProxy || useStaticProxy, - proxyScheme, proxyHost, proxyPort, connectTimeout, dnsCacheManager, localAddress); + proxyScheme, proxyHost, proxyPort, proxyUser, proxyPass, connectTimeout, dnsCacheManager, localAddress); } @Override @@ -511,12 +561,14 @@ private static final class HttpClientKey { private final String proxyScheme; private final String proxyHost; private final int proxyPort; + private final String proxyUser; + private final String proxyPass; private final int connectTimeout; private final DNSCacheManager dnsCacheManager; private final InetAddress localAddress; private HttpClientKey(String protocol, String authority, boolean hasProxy, String proxyScheme, - String proxyHost, int proxyPort, int connectTimeout, DNSCacheManager dnsCacheManager, + String proxyHost, int proxyPort, String proxyUser, String proxyPass, int connectTimeout, DNSCacheManager dnsCacheManager, InetAddress localAddress) { this.protocol = protocol; this.authority = authority; @@ -524,6 +576,8 @@ private HttpClientKey(String protocol, String authority, boolean hasProxy, Strin this.proxyScheme = proxyScheme; this.proxyHost = proxyHost; this.proxyPort = proxyPort; + this.proxyUser = proxyUser; + this.proxyPass = proxyPass; this.connectTimeout = connectTimeout; this.dnsCacheManager = dnsCacheManager; this.localAddress = localAddress; @@ -540,12 +594,13 @@ public boolean equals(Object object) { return hasProxy == other.hasProxy && proxyPort == other.proxyPort && connectTimeout == other.connectTimeout && Objects.equals(protocol, other.protocol) && Objects.equals(authority, other.authority) && Objects.equals(proxyScheme, other.proxyScheme) && Objects.equals(proxyHost, other.proxyHost) + && Objects.equals(proxyUser, other.proxyUser) && Objects.equals(proxyPass, other.proxyPass) && Objects.equals(dnsCacheManager, other.dnsCacheManager) && Objects.equals(localAddress, other.localAddress); } @Override public int hashCode() { - return Objects.hash(protocol, authority, hasProxy, proxyScheme, proxyHost, proxyPort, connectTimeout, dnsCacheManager, + return Objects.hash(protocol, authority, hasProxy, proxyScheme, proxyHost, proxyPort, proxyUser, proxyPass, connectTimeout, dnsCacheManager, localAddress); } } diff --git a/xdocs/usermanual/component_reference.xml b/xdocs/usermanual/component_reference.xml index 783051d8bb1..0b38315d45e 100644 --- a/xdocs/usermanual/component_reference.xml +++ b/xdocs/usermanual/component_reference.xml @@ -142,6 +142,7 @@ Latency is set to the time it takes to login.
    Java
    uses the HTTP implementation provided by the JVM. This has some limitations in comparison with the HttpClient implementations - see below.
    HTTPClient4
    uses Apache HttpComponents HttpClient 4.x.
    +
    HTTPClient5
    uses Apache HttpComponents HttpClient 5.x.
    Blank Value
    does not set implementation on HTTP Samplers, so relies on HTTP Request Defaults if present or on jmeter.httpsampler property defined in jmeter.properties
    @@ -241,13 +242,13 @@ https.default.protocol=SSLv3 Port the proxy server is listening to. (Optional) username for proxy server. (Optional) password for proxy server. (N.B. this is stored unencrypted in the test plan) - Java, HttpClient4. + Java, HttpClient4, HttpClient5. If not specified (and not defined by HTTP Request Defaults), the default depends on the value of the JMeter property jmeter.httpsampler, failing that, the HttpClient4 implementation is used. HTTP, HTTPS or FILE. Default: HTTP GET, POST, HEAD, TRACE, OPTIONS, PUT, DELETE, PATCH (not supported for - JAVA implementation). With HttpClient4, the following methods related to WebDav are + JAVA implementation). With HttpClient4 or HttpClient5, the following methods related to WebDav are also allowed: COPY, LOCK, MKCOL, MOVE, PROPFIND, PROPPATCH, UNLOCK, REPORT, MKCALENDAR, SEARCH. @@ -484,7 +485,7 @@ so the value may be greater than the number of bytes in the response content.

    Retry handling

    -By default retry has been set to 0 for both HttpClient4 and Java implementations, meaning no retry is attempted.
    +By default retry has been set to 0 for HttpClient4, HttpClient5 and Java implementations, meaning no retry is attempted.
    For HttpClient4, the retry count can be overridden by setting the relevant JMeter property, for example: @@ -3623,7 +3624,7 @@ By default, a Graphite implementation is provided. DNS Cache Manager is designed for using in the root of Thread Group or Test Plan. Do not place it as child element of particular HTTP Sampler - DNS Cache Manager works only with HTTP requests using HTTPClient4 implementation. + DNS Cache Manager works only with HTTP requests using HTTPClient4 or HTTPClient5 implementation.

    The DNS Cache Manager element allows to test applications, which have several servers behind load balancers (CDN, etc.), when user receives content from different IP's. By default JMeter uses JVM DNS cache. That's why only one server from the cluster receives load. DNS Cache Manager resolves names for each thread separately each iteration and @@ -3649,7 +3650,7 @@ By default, a Graphite implementation is provided.

    The IP address for the test server will be looked up by using the custom DNS resolver. When none is given, the system DNS resolver will be used.

    -

    Now you can use www.example.com in your HTTPClient4 samplers and the requests will be made against +

    Now you can use www.example.com in your HTTPClient4 or HTTPClient5 samplers and the requests will be made against a123.another.example.org with all headers set to www.example.com.

    @@ -3683,14 +3684,14 @@ transmits the login information when it encounters this type of page.

    The Authorization headers may not be shown in the Tree View Listener "Request" tab. The Java implementation does pre-emptive authentication, but it does not return the Authorization header when JMeter fetches the headers. -The HttpComponents (HC 4.5.X) implementation defaults to pre-emptive since 3.2 and the header will be shown. -To disable this, set the values as below, in which case authentication will only be performed in response to a challenge. +The HttpComponents HC 4.5.X and HC 5.X implementations use pre-emptive Basic authentication and the header will be shown. +To disable this for HttpClient4, set the value below, in which case authentication will only be performed in response to a challenge.

    In the file jmeter.properties set httpclient4.auth.preemptive=false

    -Note: the above settings only apply to the HttpClient sampler. +Note: the above setting applies only to HttpClient4. When looking for a match against a URL, JMeter checks each entry in turn, and stops when it finds the first match. @@ -3719,6 +3720,7 @@ information for the user named, "jmeter".
    Java
    BASIC
    HttpClient 4
    BASIC, DIGEST and Kerberos
    +
    HttpClient 5
    BASIC and DIGEST
    @@ -3918,7 +3920,7 @@ All port values are treated equally; a sampler that does not specify a port will Port the web server is listening to. Connection Timeout. Number of milliseconds to wait for a connection to open. Response Timeout. Number of milliseconds to wait for a response. - Java, HttpClient4. + Java, HttpClient4, HttpClient5. If not specified the default depends on the value of the JMeter property jmeter.httpsampler, failing that, the Java implementation is used. HTTP or HTTPS. diff --git a/xdocs/usermanual/properties_reference.xml b/xdocs/usermanual/properties_reference.xml index a40ae5027a6..1b5f23217b9 100644 --- a/xdocs/usermanual/properties_reference.xml +++ b/xdocs/usermanual/properties_reference.xml @@ -796,6 +796,8 @@ JMETER-SERVER
    HTTPSampler2
    HttpClient4
    Use Apache HTTPClient version 4
    +
    HttpClient5
    +
    Use Apache HTTPClient version 5
    Defaults to: HttpClient4
    From 7e524f2166f8f5427b14a809a3fcaf50f86e2426 Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Thu, 23 Jul 2026 14:53:45 +0200 Subject: [PATCH 07/80] Add HTTP/2 support in `HTTPHC5Impl` with configurable HTTP version handling --- bin/jmeter.properties | 5 +- src/bom-thirdparty/build.gradle.kts | 1 + .../jmeter/resources/messages.properties | 1 + .../jmeter/resources/messages_de.properties | 1 + .../jmeter/resources/messages_es.properties | 1 + .../jmeter/resources/messages_fr.properties | 1 + .../jmeter/resources/messages_ja.properties | 1 + .../jmeter/resources/messages_ko.properties | 1 + .../jmeter/resources/messages_no.properties | 1 + .../jmeter/resources/messages_pl.properties | 1 + .../resources/messages_pt_BR.properties | 1 + .../jmeter/resources/messages_tr.properties | 1 + .../resources/messages_zh_CN.properties | 1 + .../resources/messages_zh_TW.properties | 1 + src/protocol/http/build.gradle.kts | 1 + .../http/config/gui/HttpDefaultsGui.java | 5 + .../http/control/gui/HttpTestSampleGui.java | 7 + .../protocol/http/sampler/HTTPHC5Impl.java | 165 ++++++++++++++++-- .../http/sampler/HTTPSamplerBase.java | 10 ++ .../http/sampler/HTTPSamplerBaseSchema.kt | 3 + xdocs/usermanual/component_reference.xml | 3 + xdocs/usermanual/properties_reference.xml | 5 +- 22 files changed, 198 insertions(+), 19 deletions(-) diff --git a/bin/jmeter.properties b/bin/jmeter.properties index dd4dd7eaacd..91c948e429c 100644 --- a/bin/jmeter.properties +++ b/bin/jmeter.properties @@ -378,8 +378,9 @@ remote_hosts=127.0.0.1 #httpclient.timeout=0 # 0 == no timeout -# Set the http version (defaults to 1.1) -#httpclient.version=1.1 (or use the parameter http.protocol.version) +# Set the default HTTP version for HttpClient5 samplers when HTTP Version is empty +# Valid values are HTTP/1.1 and HTTP/2 (defaults to HTTP/1.1) +#httpclient.version=HTTP/1.1 # Define characters per second > 0 to emulate slow connections #httpclient.socket.http.cps=0 diff --git a/src/bom-thirdparty/build.gradle.kts b/src/bom-thirdparty/build.gradle.kts index 9d32d3ec947..0d28723da8b 100644 --- a/src/bom-thirdparty/build.gradle.kts +++ b/src/bom-thirdparty/build.gradle.kts @@ -107,6 +107,7 @@ dependencies { because("User might still rely on commons-text") } api("org.apache.httpcomponents.client5:httpclient5:5.6.4") + api("org.apache.httpcomponents.core5:httpcore5-h2:5.3.4") api("org.apache.httpcomponents:httpasyncclient:4.1.5") api("org.apache.httpcomponents:httpclient:4.5.14") api("org.apache.httpcomponents:httpcore-nio:4.4.16") diff --git a/src/core/src/main/resources/org/apache/jmeter/resources/messages.properties b/src/core/src/main/resources/org/apache/jmeter/resources/messages.properties index 3137d879e74..3ba59ea9f1f 100644 --- a/src/core/src/main/resources/org/apache/jmeter/resources/messages.properties +++ b/src/core/src/main/resources/org/apache/jmeter/resources/messages.properties @@ -473,6 +473,7 @@ html_assertion_title=HTML Assertion html_extractor_title=CSS Selector Extractor html_extractor_type=CSS Selector Extractor Implementation http_implementation=Implementation: +http_version=HTTP Version: html_report=Generate HTML report http_response_code=HTTP response code http_url_rewriting_modifier_title=HTTP URL Re-writing Modifier diff --git a/src/core/src/main/resources/org/apache/jmeter/resources/messages_de.properties b/src/core/src/main/resources/org/apache/jmeter/resources/messages_de.properties index f1278670119..17ff43ab91f 100644 --- a/src/core/src/main/resources/org/apache/jmeter/resources/messages_de.properties +++ b/src/core/src/main/resources/org/apache/jmeter/resources/messages_de.properties @@ -16,6 +16,7 @@ # about=Über Apache JMeter +http_version=HTTP-Version\: add=Hinzufügen add_as_child=Als ein Kind hinzufügen add_parameter=Variable hinzufügen diff --git a/src/core/src/main/resources/org/apache/jmeter/resources/messages_es.properties b/src/core/src/main/resources/org/apache/jmeter/resources/messages_es.properties index 8fe4fb5a53d..dcb50db6a35 100644 --- a/src/core/src/main/resources/org/apache/jmeter/resources/messages_es.properties +++ b/src/core/src/main/resources/org/apache/jmeter/resources/messages_es.properties @@ -273,6 +273,7 @@ html_assertion_file=Escribir el reporte JTidy en fichero html_assertion_label=Aserción HTML html_assertion_title=Aserción HTML http_implementation=Implementación HTTP\: +http_version=Versión HTTP\: http_response_code=código de respuesta HTTP http_url_rewriting_modifier_title=Modificador de re-escritura HTTP URL http_user_parameter_modifier=Modificador de Parámetro de Usuario HTTP diff --git a/src/core/src/main/resources/org/apache/jmeter/resources/messages_fr.properties b/src/core/src/main/resources/org/apache/jmeter/resources/messages_fr.properties index 044d7ba8351..209f0a84645 100644 --- a/src/core/src/main/resources/org/apache/jmeter/resources/messages_fr.properties +++ b/src/core/src/main/resources/org/apache/jmeter/resources/messages_fr.properties @@ -467,6 +467,7 @@ html_assertion_title=Assertion HTML html_extractor_title=Extracteur par Sélecteur CSS html_extractor_type=Implémentation de l'extracteur Sélecteur CSS http_implementation=Implémentation \: +http_version=Version HTTP \: html_report=Générer le rapport HTML http_response_code=Code de réponse HTTP http_url_rewriting_modifier_title=Transcripteur d'URL HTTP diff --git a/src/core/src/main/resources/org/apache/jmeter/resources/messages_ja.properties b/src/core/src/main/resources/org/apache/jmeter/resources/messages_ja.properties index 561489d9e29..6fe16f42798 100644 --- a/src/core/src/main/resources/org/apache/jmeter/resources/messages_ja.properties +++ b/src/core/src/main/resources/org/apache/jmeter/resources/messages_ja.properties @@ -176,6 +176,7 @@ grouping_store_first_only=各グループの最初のサンプラーだけ保存 header_manager_title=HTTP ヘッダマネージャ headers_stored=ヘッダーマネージャに保存されているヘッダ help=ヘルプ +http_version=HTTP バージョン\: http_response_code=HTTP応答コード http_url_rewriting_modifier_title=HTTP URL-Rewriting 修飾子 http_user_parameter_modifier=HTTPユーザーパラメータの変更 diff --git a/src/core/src/main/resources/org/apache/jmeter/resources/messages_ko.properties b/src/core/src/main/resources/org/apache/jmeter/resources/messages_ko.properties index 8b963980d24..1de63f00cd6 100644 --- a/src/core/src/main/resources/org/apache/jmeter/resources/messages_ko.properties +++ b/src/core/src/main/resources/org/apache/jmeter/resources/messages_ko.properties @@ -471,6 +471,7 @@ html_assertion_title=HTML Assertion html_extractor_title=CSS Selector 추출기 html_extractor_type=CSS Selector 추출기 구현 http_implementation=구현\: +http_version=HTTP 버전\: html_report=HTML 보고서 생성 http_response_code=HTTP 응답 코드 http_url_rewriting_modifier_title=HTTP URL Re-writing Modifier diff --git a/src/core/src/main/resources/org/apache/jmeter/resources/messages_no.properties b/src/core/src/main/resources/org/apache/jmeter/resources/messages_no.properties index a6daacc3059..160b0288aa1 100644 --- a/src/core/src/main/resources/org/apache/jmeter/resources/messages_no.properties +++ b/src/core/src/main/resources/org/apache/jmeter/resources/messages_no.properties @@ -71,6 +71,7 @@ graph_results_deviation=Avvik graph_results_title=Graf resultater headers_stored=Headere lagret hos headermanager help=Hjelp +http_version=HTTP-versjon\: infinite=Uendelig interleave_control_title=Vekslende kontroller iterator_num=Løkketeller\: diff --git a/src/core/src/main/resources/org/apache/jmeter/resources/messages_pl.properties b/src/core/src/main/resources/org/apache/jmeter/resources/messages_pl.properties index fb5aedd2f24..9d81a10d112 100644 --- a/src/core/src/main/resources/org/apache/jmeter/resources/messages_pl.properties +++ b/src/core/src/main/resources/org/apache/jmeter/resources/messages_pl.properties @@ -16,6 +16,7 @@ # about=O programie Apache JMeter +http_version=Wersja HTTP\: action_check_message=Aktualnie jest przeprowadzany test, zatrzymaj albo wyłącz test, aby uruchomić to polecenie action_check_title=Test uruchomiony active_total_threads_tooltip=Uruchamianie wątków / całkowita liczba wątków do uruchomienia diff --git a/src/core/src/main/resources/org/apache/jmeter/resources/messages_pt_BR.properties b/src/core/src/main/resources/org/apache/jmeter/resources/messages_pt_BR.properties index e9960bf8719..e278975db23 100644 --- a/src/core/src/main/resources/org/apache/jmeter/resources/messages_pt_BR.properties +++ b/src/core/src/main/resources/org/apache/jmeter/resources/messages_pt_BR.properties @@ -261,6 +261,7 @@ html_assertion_file=Escrever relatório do JTidy em arquivo html_assertion_label=Asserção HTML html_assertion_title=Asserção HTML http_implementation=Implementação\: +http_version=Versão HTTP\: http_response_code=Código da Resposta HTTP http_url_rewriting_modifier_title=Modificador de Re-escrita de URL HTTP http_user_parameter_modifier=Modificador de Parâmetros HTTP do Usuário diff --git a/src/core/src/main/resources/org/apache/jmeter/resources/messages_tr.properties b/src/core/src/main/resources/org/apache/jmeter/resources/messages_tr.properties index 352f8bd7e7a..a18b1fffbf4 100644 --- a/src/core/src/main/resources/org/apache/jmeter/resources/messages_tr.properties +++ b/src/core/src/main/resources/org/apache/jmeter/resources/messages_tr.properties @@ -246,6 +246,7 @@ html_assertion_file=JTidy raporunu dosyaya yaz html_assertion_label=HTML Doğrulama html_assertion_title=HTML Doğrulama http_implementation=Uygulaması\: +http_version=HTTP sürümü\: http_response_code=HTTP cevap kodu http_url_rewriting_modifier_title=HTTP URL Yeniden Yazma Niteleyicisi http_user_parameter_modifier=HTTP Kullanıcı Parametresi Niteleyicisi diff --git a/src/core/src/main/resources/org/apache/jmeter/resources/messages_zh_CN.properties b/src/core/src/main/resources/org/apache/jmeter/resources/messages_zh_CN.properties index 1763f2be645..253e35f83ed 100644 --- a/src/core/src/main/resources/org/apache/jmeter/resources/messages_zh_CN.properties +++ b/src/core/src/main/resources/org/apache/jmeter/resources/messages_zh_CN.properties @@ -433,6 +433,7 @@ html_assertion_title=HTML断言 html_extractor_title=CSS/JQuery提取器 html_extractor_type=CSS 选择器提取器实现 http_implementation=实现: +http_version=HTTP 版本: http_response_code=HTTP响应代码 http_url_rewriting_modifier_title=HTTP URL 重写修饰符 http_user_parameter_modifier=HTTP 用户参数修饰符 diff --git a/src/core/src/main/resources/org/apache/jmeter/resources/messages_zh_TW.properties b/src/core/src/main/resources/org/apache/jmeter/resources/messages_zh_TW.properties index 93107f3298c..49012192320 100644 --- a/src/core/src/main/resources/org/apache/jmeter/resources/messages_zh_TW.properties +++ b/src/core/src/main/resources/org/apache/jmeter/resources/messages_zh_TW.properties @@ -206,6 +206,7 @@ headers_stored=標頭管理員中儲存的標頭資料 help=輔助說明 html_assertion_label=HTML 驗證 html_assertion_title=HTML 驗證 +http_version=HTTP 版本: http_response_code=HTTP 回應代碼 http_url_rewriting_modifier_title=HTTP URL 重導修飾詞 http_user_parameter_modifier=HTTP 使用者參數修飾詞 diff --git a/src/protocol/http/build.gradle.kts b/src/protocol/http/build.gradle.kts index 9496815ccac..2b99a8bbfc2 100644 --- a/src/protocol/http/build.gradle.kts +++ b/src/protocol/http/build.gradle.kts @@ -62,6 +62,7 @@ dependencies { } implementation("dnsjava:dnsjava") implementation("org.apache.httpcomponents.client5:httpclient5") + implementation("org.apache.httpcomponents.core5:httpcore5-h2") implementation("org.apache.httpcomponents:httpmime") implementation("org.apache.httpcomponents:httpcore") implementation("org.brotli:dec") diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/config/gui/HttpDefaultsGui.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/config/gui/HttpDefaultsGui.java index 8b49bcbb4ec..79534daa33a 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/config/gui/HttpDefaultsGui.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/config/gui/HttpDefaultsGui.java @@ -79,6 +79,7 @@ public class HttpDefaultsGui extends AbstractConfigGui { private JTextField proxyUser; private JPasswordField proxyPass; private final JComboBox httpImplementation = new JComboBox<>(HTTPSamplerFactory.getImplementations()); + private final JComboBox httpVersion = new JComboBox<>(new String[] {"", "HTTP/1.1", "HTTP/2"}); private JTextField connectTimeOut; private JTextField responseTimeOut; @@ -152,6 +153,7 @@ public void modifyTestElement(TestElement config) { } config.set(httpSchema.getImplementation(), String.valueOf(httpImplementation.getSelectedItem())); + config.set(httpSchema.getHttpVersion(), String.valueOf(httpVersion.getSelectedItem())); } @Override @@ -169,6 +171,7 @@ public void configure(TestElement el) { HTTPSamplerBaseSchema httpSchema = HTTPSamplerBaseSchema.INSTANCE; sourceIpType.setSelectedIndex(samplerBase.get(httpSchema.getIpSourceType())); httpImplementation.setSelectedItem(samplerBase.getString(httpSchema.getImplementation())); + httpVersion.setSelectedItem(samplerBase.getString(httpSchema.getHttpVersion())); } private void init() { // WARNING: called from ctor so must not be overridden (i.e. must be private or final) @@ -329,6 +332,8 @@ protected final JPanel getImplementationPanel(){ implPanel.add(new JLabel(JMeterUtils.getResString("http_implementation"))); // $NON-NLS-1$ httpImplementation.addItem("");// $NON-NLS-1$ implPanel.add(httpImplementation); + implPanel.add(new JLabel(JMeterUtils.getResString("http_version"))); // $NON-NLS-1$ + implPanel.add(httpVersion); return implPanel; } diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/gui/HttpTestSampleGui.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/gui/HttpTestSampleGui.java index 77863a286bd..5ce2acc909e 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/gui/HttpTestSampleGui.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/gui/HttpTestSampleGui.java @@ -81,6 +81,7 @@ public class HttpTestSampleGui extends AbstractSamplerGui { private JTextField proxyUser; private JPasswordField proxyPass; private final JComboBox httpImplementation = new JComboBox<>(HTTPSamplerFactory.getImplementations()); + private final JComboBox httpVersion = new JComboBox<>(new String[] {"", "HTTP/1.1", "HTTP/2"}); private JTextField connectTimeOut; private JTextField responseTimeOut; @@ -135,6 +136,7 @@ public void configure(TestElement element) { if (!isAJP) { sourceIpType.setSelectedIndex(samplerBase.getIpSourceType()); httpImplementation.setSelectedItem(samplerBase.getString(httpSchema.getImplementation())); + httpVersion.setSelectedItem(samplerBase.getHttpVersion()); } } @@ -179,6 +181,9 @@ public void modifyTestElement(TestElement sampler) { String selectedImplementation = String.valueOf(httpImplementation.getSelectedItem()); samplerBase.set(httpSchema.getImplementation(), StringUtilities.isBlank(selectedImplementation) ? null : selectedImplementation); + String selectedHttpVersion = String.valueOf(httpVersion.getSelectedItem()); + samplerBase.set(httpSchema.getHttpVersion(), + StringUtilities.isBlank(selectedHttpVersion) ? null : selectedHttpVersion); } } @@ -349,6 +354,8 @@ protected final JPanel getImplementationPanel(){ implPanel.add(new JLabel(JMeterUtils.getResString("http_implementation"))); // $NON-NLS-1$ httpImplementation.addItem("");// $NON-NLS-1$ implPanel.add(httpImplementation); + implPanel.add(new JLabel(JMeterUtils.getResString("http_version"))); // $NON-NLS-1$ + implPanel.add(httpVersion); return implPanel; } diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java index 586cae3e1b0..43d82c33305 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java @@ -26,19 +26,28 @@ import java.net.URLDecoder; import java.net.UnknownHostException; import java.nio.charset.Charset; +import java.security.GeneralSecurityException; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Objects; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.concurrent.TimeoutException; +import javax.net.ssl.SSLContext; + +import org.apache.hc.client5.http.async.methods.SimpleHttpRequest; +import org.apache.hc.client5.http.async.methods.SimpleHttpResponse; import org.apache.hc.client5.http.auth.AuthScope; import org.apache.hc.client5.http.auth.UsernamePasswordCredentials; import org.apache.hc.client5.http.classic.ExecChain; import org.apache.hc.client5.http.classic.ExecChainHandler; import org.apache.hc.client5.http.config.ConnectionConfig; import org.apache.hc.client5.http.config.RequestConfig; +import org.apache.hc.client5.http.config.TlsConfig; import org.apache.hc.client5.http.entity.BrotliInputStreamFactory; import org.apache.hc.client5.http.entity.DecompressingEntity; import org.apache.hc.client5.http.entity.DeflateInputStreamFactory; @@ -46,12 +55,18 @@ import org.apache.hc.client5.http.entity.InputStreamFactory; import org.apache.hc.client5.http.entity.UrlEncodedFormEntity; import org.apache.hc.client5.http.entity.mime.MultipartEntityBuilder; +import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient; +import org.apache.hc.client5.http.impl.async.H2AsyncClientBuilder; +import org.apache.hc.client5.http.impl.async.HttpAsyncClients; import org.apache.hc.client5.http.impl.auth.BasicCredentialsProvider; import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; import org.apache.hc.client5.http.impl.classic.HttpClients; import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder; import org.apache.hc.client5.http.impl.routing.DefaultRoutePlanner; import org.apache.hc.client5.http.protocol.HttpClientContext; +import org.apache.hc.client5.http.ssl.ClientTlsStrategyBuilder; +import org.apache.hc.client5.http.ssl.NoopHostnameVerifier; +import org.apache.hc.client5.http.ssl.TrustAllStrategy; import org.apache.hc.core5.http.ClassicHttpResponse; import org.apache.hc.core5.http.ContentType; import org.apache.hc.core5.http.Header; @@ -59,14 +74,21 @@ import org.apache.hc.core5.http.HttpEntity; import org.apache.hc.core5.http.HttpHeaders; import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.HttpVersion; import org.apache.hc.core5.http.NameValuePair; import org.apache.hc.core5.http.config.Lookup; import org.apache.hc.core5.http.config.RegistryBuilder; +import org.apache.hc.core5.http.io.entity.ByteArrayEntity; +import org.apache.hc.core5.http.io.entity.EntityUtils; import org.apache.hc.core5.http.io.entity.FileEntity; import org.apache.hc.core5.http.io.entity.StringEntity; +import org.apache.hc.core5.http.message.BasicClassicHttpResponse; import org.apache.hc.core5.http.message.BasicHeaderValueParser; import org.apache.hc.core5.http.message.BasicNameValuePair; import org.apache.hc.core5.http.message.ParserCursor; +import org.apache.hc.core5.http.nio.ssl.TlsStrategy; +import org.apache.hc.core5.http2.HttpVersionPolicy; +import org.apache.hc.core5.ssl.SSLContexts; import org.apache.hc.core5.util.Timeout; import org.apache.jmeter.protocol.http.control.AuthManager; import org.apache.jmeter.protocol.http.control.Authorization; @@ -95,6 +117,9 @@ public class HTTPHC5Impl extends HTTPHCAbstractImpl { private static final ThreadLocal> HTTP_CLIENTS = ThreadLocal.withInitial(HashMap::new); + private static final ThreadLocal> HTTP_2_CLIENTS = + ThreadLocal.withInitial(HashMap::new); + private static final String[] HEADERS_TO_SAVE = {HttpHeaders.CONTENT_LENGTH, HttpHeaders.CONTENT_ENCODING, HttpHeaders.CONTENT_MD5}; @@ -105,6 +130,8 @@ public class HTTPHC5Impl extends HTTPHCAbstractImpl { .register("deflate", DeflateInputStreamFactory.getInstance()) .build(); + private static final TlsStrategy HTTP_2_TLS_STRATEGY = createHttp2TlsStrategy(); + private static final ExecChainHandler RESPONSE_CONTENT_ENCODING = (request, scope, chain) -> { HttpClientContext context = scope.clientContext; RequestConfig requestConfig = context.getRequestConfig(); @@ -146,6 +173,19 @@ public class HTTPHC5Impl extends HTTPHCAbstractImpl { private volatile org.apache.hc.client5.http.classic.methods.HttpUriRequestBase currentRequest; + @SuppressWarnings("deprecation") // buildAsync is unavailable before HttpClient 5.5 + private static TlsStrategy createHttp2TlsStrategy() { + try { + SSLContext sslContext = SSLContexts.custom().loadTrustMaterial(null, TrustAllStrategy.INSTANCE).build(); + return ClientTlsStrategyBuilder.create() + .setSslContext(sslContext) + .setHostnameVerifier(NoopHostnameVerifier.INSTANCE) + .build(); + } catch (GeneralSecurityException e) { + throw new IllegalStateException("Could not create HTTP/2 TLS strategy", e); + } + } + protected HTTPHC5Impl(HTTPSamplerBase testElement) { super(testElement); } @@ -171,7 +211,9 @@ protected HTTPSampleResult sample(URL url, String method, boolean areFollowingRe currentRequest = request; HttpClientKey clientKey = createHttpClientKey(url); HttpClientContext context = createHttpClientContext(url, clientKey, request); - response = getClient(clientKey).executeOpen(null, request, context); + response = clientKey.httpVersionPolicy == HttpVersionPolicy.FORCE_HTTP_2 + ? executeHttp2(getHttp2Client(clientKey), request, context) + : getClient(clientKey).executeOpen(null, request, context); result.sampleEnd(); currentRequest = null; @@ -209,6 +251,7 @@ private static org.apache.hc.client5.http.classic.methods.HttpUriRequestBase cre private void setupRequest(URL url, org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request, HTTPSampleResult result, boolean areFollowingRedirect) throws IOException { + HttpVersionPolicy httpVersionPolicy = getHttpVersionPolicy(testElement.getHttpVersion(), HTTP_VERSION); RequestConfig.Builder config = RequestConfig.custom() .setRedirectsEnabled(getAutoRedirects() && !areFollowingRedirect); int responseTimeout = getResponseTimeout(); @@ -216,9 +259,13 @@ private void setupRequest(URL url, org.apache.hc.client5.http.classic.methods.Ht config.setResponseTimeout(Timeout.ofMilliseconds(responseTimeout)); } request.setConfig(config.build()); - request.setHeader(HTTPConstants.HEADER_CONNECTION, - getUseKeepAlive() ? HTTPConstants.KEEP_ALIVE : HTTPConstants.CONNECTION_CLOSE); - setConnectionHeaders(request, getHeaderManager()); + if (httpVersionPolicy == HttpVersionPolicy.FORCE_HTTP_1) { + request.setHeader(HTTPConstants.HEADER_CONNECTION, + getUseKeepAlive() ? HTTPConstants.KEEP_ALIVE : HTTPConstants.CONNECTION_CLOSE); + } else { + request.setVersion(HttpVersion.HTTP_2); + } + setConnectionHeaders(request, getHeaderManager(), httpVersionPolicy); CacheManager cacheManager = getCacheManager(); if (cacheManager != null) { cacheManager.setHeaders(url, request); @@ -317,7 +364,7 @@ private static String getEntityPreview(HttpEntity entity, String contentEncoding } private static void setConnectionHeaders(org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request, - HeaderManager headerManager) { + HeaderManager headerManager, HttpVersionPolicy httpVersionPolicy) { if (headerManager == null) { return; } @@ -328,7 +375,9 @@ private static void setConnectionHeaders(org.apache.hc.client5.http.classic.meth for (JMeterProperty property : headers) { org.apache.jmeter.protocol.http.control.Header header = (org.apache.jmeter.protocol.http.control.Header) property.getObjectValue(); - if (!HTTPConstants.HEADER_CONTENT_LENGTH.equalsIgnoreCase(header.getName())) { + if (!HTTPConstants.HEADER_CONTENT_LENGTH.equalsIgnoreCase(header.getName()) + && (httpVersionPolicy != HttpVersionPolicy.FORCE_HTTP_2 + || !HTTPConstants.HEADER_CONNECTION.equalsIgnoreCase(header.getName()))) { request.addHeader(header.getName(), header.getValue()); } } @@ -451,9 +500,17 @@ private static CloseableHttpClient getClient(HttpClientKey key) { return clients.computeIfAbsent(key, HTTPHC5Impl::createClient); } + private static CloseableHttpAsyncClient getHttp2Client(HttpClientKey key) { + Map clients = HTTP_2_CLIENTS.get(); + return clients.computeIfAbsent(key, HTTPHC5Impl::createHttp2Client); + } + private static CloseableHttpClient createClient(HttpClientKey key) { org.apache.hc.client5.http.impl.classic.HttpClientBuilder builder = HttpClients.custom().disableAutomaticRetries(); PoolingHttpClientConnectionManagerBuilder connectionManagerBuilder = PoolingHttpClientConnectionManagerBuilder.create(); + connectionManagerBuilder.setDefaultTlsConfig(TlsConfig.custom() + .setVersionPolicy(key.httpVersionPolicy) + .build()); if (key.dnsCacheManager != null) { connectionManagerBuilder.setDnsResolver(createDnsResolver(key.dnsCacheManager)); } @@ -464,7 +521,72 @@ private static CloseableHttpClient createClient(HttpClientKey key) { .build()); } builder.setConnectionManager(connectionManagerBuilder.build()); - builder.setRoutePlanner(new DefaultRoutePlanner(null) { + builder.setRoutePlanner(createRoutePlanner(key)); + return builder.disableContentCompression() + .addExecInterceptorFirst("response-content-encoding", RESPONSE_CONTENT_ENCODING) + .build(); + } + + private static CloseableHttpAsyncClient createHttp2Client(HttpClientKey key) { + H2AsyncClientBuilder builder = HttpAsyncClients.customHttp2() + .disableAutomaticRetries() + .setTlsStrategy(HTTP_2_TLS_STRATEGY) + .setRoutePlanner(createRoutePlanner(key)); + if (key.dnsCacheManager != null) { + builder.setDnsResolver(createDnsResolver(key.dnsCacheManager)); + } + if (key.connectTimeout > 0) { + builder.setDefaultConnectionConfig(ConnectionConfig.custom() + .setConnectTimeout(Timeout.ofMilliseconds(key.connectTimeout)) + .build()); + } + CloseableHttpAsyncClient asyncClient = builder.build(); + asyncClient.start(); + return asyncClient; + } + + @SuppressWarnings("deprecation") // SimpleHttpRequest.copy is required for HttpClient 5.3 compatibility + private static ClassicHttpResponse executeHttp2(CloseableHttpAsyncClient client, + org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request, HttpClientContext context) + throws IOException { + SimpleHttpRequest asyncRequest = SimpleHttpRequest.copy(request); + asyncRequest.setConfig(request.getConfig()); + HttpEntity requestEntity = request.getEntity(); + if (requestEntity != null) { + asyncRequest.setBody(EntityUtils.toByteArray(requestEntity), + requestEntity.getContentType() == null ? ContentType.DEFAULT_BINARY + : ContentType.parse(requestEntity.getContentType())); + } + Future responseFuture = client.execute(asyncRequest, context, null); + try { + return createClassicResponse(responseFuture.get(1, java.util.concurrent.TimeUnit.MINUTES)); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while executing HTTP/2 request", e); + } catch (TimeoutException e) { + responseFuture.cancel(true); + throw new IOException("Timed out while executing HTTP/2 request", e); + } catch (ExecutionException e) { + throw new IOException("Could not execute HTTP/2 request", e.getCause()); + } + } + + private static ClassicHttpResponse createClassicResponse(SimpleHttpResponse asyncResponse) { + BasicClassicHttpResponse response = new BasicClassicHttpResponse(asyncResponse.getCode(), + asyncResponse.getReasonPhrase()); + response.setVersion(asyncResponse.getVersion()); + for (Header header : asyncResponse.getHeaders()) { + response.addHeader(header); + } + byte[] responseBody = asyncResponse.getBodyBytes(); + if (responseBody != null) { + response.setEntity(new ByteArrayEntity(responseBody, asyncResponse.getContentType())); + } + return response; + } + + private static DefaultRoutePlanner createRoutePlanner(HttpClientKey key) { + return new DefaultRoutePlanner(null) { @Override protected HttpHost determineProxy(HttpHost target, org.apache.hc.core5.http.protocol.HttpContext context) { return key.hasProxy ? new HttpHost(key.proxyScheme, key.proxyHost, key.proxyPort) : null; @@ -475,10 +597,7 @@ protected InetAddress determineLocalAddress(HttpHost firstHop, org.apache.hc.core5.http.protocol.HttpContext context) { return key.localAddress; } - }); - return builder.disableContentCompression() - .addExecInterceptorFirst("response-content-encoding", RESPONSE_CONTENT_ENCODING) - .build(); + }; } private static org.apache.hc.client5.http.DnsResolver createDnsResolver(DNSCacheManager dnsCacheManager) { @@ -507,13 +626,21 @@ private HttpClientKey createHttpClientKey(URL url) throws IOException { InetAddress localAddress = getIpSourceAddress(); boolean useDynamicProxy = isDynamicProxy(proxyHost, proxyPort); boolean useStaticProxy = isStaticProxy(url.getHost()); + HttpVersionPolicy httpVersionPolicy = getHttpVersionPolicy(testElement.getHttpVersion(), HTTP_VERSION); if (!useDynamicProxy) { proxyScheme = PROXY_SCHEME; proxyHost = PROXY_HOST; proxyPort = PROXY_PORT; } return new HttpClientKey(url.getProtocol(), url.getAuthority(), useDynamicProxy || useStaticProxy, - proxyScheme, proxyHost, proxyPort, proxyUser, proxyPass, connectTimeout, dnsCacheManager, localAddress); + proxyScheme, proxyHost, proxyPort, proxyUser, proxyPass, connectTimeout, dnsCacheManager, localAddress, + httpVersionPolicy); + } + + static HttpVersionPolicy getHttpVersionPolicy(String samplerHttpVersion, String defaultHttpVersion) { + String httpVersion = StringUtilities.isBlank(samplerHttpVersion) ? defaultHttpVersion : samplerHttpVersion; + return "HTTP/2".equals(httpVersion) || "2".equals(httpVersion) + ? HttpVersionPolicy.FORCE_HTTP_2 : HttpVersionPolicy.FORCE_HTTP_1; } @Override @@ -542,6 +669,11 @@ private static void closeThreadLocalClients() { JOrphanUtils.closeQuietly(client); } clients.clear(); + Map http2Clients = HTTP_2_CLIENTS.get(); + for (CloseableHttpAsyncClient client : http2Clients.values()) { + JOrphanUtils.closeQuietly(client); + } + http2Clients.clear(); } @Override @@ -566,10 +698,11 @@ private static final class HttpClientKey { private final int connectTimeout; private final DNSCacheManager dnsCacheManager; private final InetAddress localAddress; + private final HttpVersionPolicy httpVersionPolicy; private HttpClientKey(String protocol, String authority, boolean hasProxy, String proxyScheme, String proxyHost, int proxyPort, String proxyUser, String proxyPass, int connectTimeout, DNSCacheManager dnsCacheManager, - InetAddress localAddress) { + InetAddress localAddress, HttpVersionPolicy httpVersionPolicy) { this.protocol = protocol; this.authority = authority; this.hasProxy = hasProxy; @@ -581,6 +714,7 @@ private HttpClientKey(String protocol, String authority, boolean hasProxy, Strin this.connectTimeout = connectTimeout; this.dnsCacheManager = dnsCacheManager; this.localAddress = localAddress; + this.httpVersionPolicy = httpVersionPolicy; } @Override @@ -595,13 +729,14 @@ public boolean equals(Object object) { && Objects.equals(protocol, other.protocol) && Objects.equals(authority, other.authority) && Objects.equals(proxyScheme, other.proxyScheme) && Objects.equals(proxyHost, other.proxyHost) && Objects.equals(proxyUser, other.proxyUser) && Objects.equals(proxyPass, other.proxyPass) - && Objects.equals(dnsCacheManager, other.dnsCacheManager) && Objects.equals(localAddress, other.localAddress); + && Objects.equals(dnsCacheManager, other.dnsCacheManager) && Objects.equals(localAddress, other.localAddress) + && httpVersionPolicy == other.httpVersionPolicy; } @Override public int hashCode() { return Objects.hash(protocol, authority, hasProxy, proxyScheme, proxyHost, proxyPort, proxyUser, proxyPass, connectTimeout, dnsCacheManager, - localAddress); + localAddress, httpVersionPolicy); } } } diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPSamplerBase.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPSamplerBase.java index 3105d0cf1ec..ee307623677 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPSamplerBase.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPSamplerBase.java @@ -160,6 +160,8 @@ public abstract class HTTPSamplerBase extends AbstractSampler public static final String IMPLEMENTATION = "HTTPSampler.implementation"; // $NON-NLS-1$ + public static final String HTTP_VERSION = "HTTPSampler.httpVersion"; // $NON-NLS-1$ + public static final String PATH = "HTTPSampler.path"; // $NON-NLS-1$ public static final String FOLLOW_REDIRECTS = HTTPSamplerBaseSchema.INSTANCE.getFollowRedirects().getName(); @@ -640,6 +642,14 @@ public String getImplementation() { return get(getSchema().getImplementation()); } + public void setHttpVersion(String value) { + set(getSchema().getHttpVersion(), value); + } + + public String getHttpVersion() { + return get(getSchema().getHttpVersion()); + } + public boolean useMD5() { return get(getSchema().getStoreAsMD5()); } diff --git a/src/protocol/http/src/main/kotlin/org/apache/jmeter/protocol/http/sampler/HTTPSamplerBaseSchema.kt b/src/protocol/http/src/main/kotlin/org/apache/jmeter/protocol/http/sampler/HTTPSamplerBaseSchema.kt index 89bb2f58e28..84698fff881 100644 --- a/src/protocol/http/src/main/kotlin/org/apache/jmeter/protocol/http/sampler/HTTPSamplerBaseSchema.kt +++ b/src/protocol/http/src/main/kotlin/org/apache/jmeter/protocol/http/sampler/HTTPSamplerBaseSchema.kt @@ -87,6 +87,9 @@ public abstract class HTTPSamplerBaseSchema : TestElementSchema() { public val implementation: StringPropertyDescriptor by string("HTTPSampler.implementation") + public val httpVersion: StringPropertyDescriptor + by string("HTTPSampler.httpVersion") + public val connectTimeout: IntegerPropertyDescriptor by int("HTTPSampler.connect_timeout") diff --git a/xdocs/usermanual/component_reference.xml b/xdocs/usermanual/component_reference.xml index 0b38315d45e..dbb899812c5 100644 --- a/xdocs/usermanual/component_reference.xml +++ b/xdocs/usermanual/component_reference.xml @@ -245,6 +245,9 @@ https.default.protocol=SSLv3 Java, HttpClient4, HttpClient5. If not specified (and not defined by HTTP Request Defaults), the default depends on the value of the JMeter property jmeter.httpsampler, failing that, the HttpClient4 implementation is used. + HTTP/1.1 or HTTP/2. Applies only to the + HttpClient5 implementation. An empty value uses the httpclient.version property, which + defaults to HTTP/1.1. HTTP, HTTPS or FILE. Default: HTTP GET, POST, HEAD, TRACE, OPTIONS, PUT, DELETE, PATCH (not supported for diff --git a/xdocs/usermanual/properties_reference.xml b/xdocs/usermanual/properties_reference.xml index 1b5f23217b9..1a584be6f1d 100644 --- a/xdocs/usermanual/properties_reference.xml +++ b/xdocs/usermanual/properties_reference.xml @@ -442,8 +442,9 @@ JMETER-SERVER Defaults to: 0 - Set the http version.
    - Defaults to: 1.1 (or use the parameter http.protocol.version) + Set the default HTTP version for HttpClient5 samplers with an empty HTTP Version value.
    + Valid values are HTTP/1.1 and HTTP/2.
    + Defaults to: HTTP/1.1
    Set characters per second to a value greater then zero to emulate slow connections.
    From a03603ae7a73a2a244787266e95203fbbcd7bacf Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Thu, 23 Jul 2026 16:15:25 +0200 Subject: [PATCH 08/80] Add unit tests for `HTTPHC5Impl` features including HTTP/2, caching, proxies, and authentication --- .../protocol/http/sampler/HTTPHC5Impl.java | 16 +- .../http/sampler/TestHTTPHC5Features.java | 276 ++++++++++++++++++ 2 files changed, 287 insertions(+), 5 deletions(-) create mode 100644 src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java index 43d82c33305..458ea7f4151 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java @@ -56,12 +56,13 @@ import org.apache.hc.client5.http.entity.UrlEncodedFormEntity; import org.apache.hc.client5.http.entity.mime.MultipartEntityBuilder; import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient; -import org.apache.hc.client5.http.impl.async.H2AsyncClientBuilder; +import org.apache.hc.client5.http.impl.async.HttpAsyncClientBuilder; import org.apache.hc.client5.http.impl.async.HttpAsyncClients; import org.apache.hc.client5.http.impl.auth.BasicCredentialsProvider; import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; import org.apache.hc.client5.http.impl.classic.HttpClients; import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder; +import org.apache.hc.client5.http.impl.nio.PoolingAsyncClientConnectionManagerBuilder; import org.apache.hc.client5.http.impl.routing.DefaultRoutePlanner; import org.apache.hc.client5.http.protocol.HttpClientContext; import org.apache.hc.client5.http.ssl.ClientTlsStrategyBuilder; @@ -528,18 +529,23 @@ private static CloseableHttpClient createClient(HttpClientKey key) { } private static CloseableHttpAsyncClient createHttp2Client(HttpClientKey key) { - H2AsyncClientBuilder builder = HttpAsyncClients.customHttp2() + HttpAsyncClientBuilder builder = HttpAsyncClients.custom() .disableAutomaticRetries() - .setTlsStrategy(HTTP_2_TLS_STRATEGY) .setRoutePlanner(createRoutePlanner(key)); + PoolingAsyncClientConnectionManagerBuilder connectionManagerBuilder = PoolingAsyncClientConnectionManagerBuilder.create(); + connectionManagerBuilder.setTlsStrategy(HTTP_2_TLS_STRATEGY); + connectionManagerBuilder.setDefaultTlsConfig(TlsConfig.custom() + .setVersionPolicy(key.httpVersionPolicy) + .build()); if (key.dnsCacheManager != null) { - builder.setDnsResolver(createDnsResolver(key.dnsCacheManager)); + connectionManagerBuilder.setDnsResolver(createDnsResolver(key.dnsCacheManager)); } if (key.connectTimeout > 0) { - builder.setDefaultConnectionConfig(ConnectionConfig.custom() + connectionManagerBuilder.setDefaultConnectionConfig(ConnectionConfig.custom() .setConnectTimeout(Timeout.ofMilliseconds(key.connectTimeout)) .build()); } + builder.setConnectionManager(connectionManagerBuilder.build()); CloseableHttpAsyncClient asyncClient = builder.build(); asyncClient.start(); return asyncClient; diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java new file mode 100644 index 00000000000..c69dc254a37 --- /dev/null +++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java @@ -0,0 +1,276 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you 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.apache.jmeter.protocol.http.sampler; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +import java.io.ByteArrayInputStream; +import java.io.DataInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.nio.charset.StandardCharsets; + +import org.apache.hc.core5.http2.HttpVersionPolicy; +import org.apache.jmeter.protocol.http.control.AuthManager; +import org.apache.jmeter.protocol.http.control.CacheManager; +import org.apache.jmeter.protocol.http.util.HTTPConstants; +import org.junit.jupiter.api.Test; + +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.client.WireMock; +import com.github.tomakehurst.wiremock.core.WireMockConfiguration; + +class TestHTTPHC5Features { + + @Test + void usesHttpClientVersionWhenSamplerVersionIsEmpty() { + assertEquals(HttpVersionPolicy.FORCE_HTTP_2, HTTPHC5Impl.getHttpVersionPolicy("", "HTTP/2")); + } + + @Test + void usesSamplerHttpVersionWhenSpecified() { + assertEquals(HttpVersionPolicy.FORCE_HTTP_1, HTTPHC5Impl.getHttpVersionPolicy("HTTP/1.1", "HTTP/2")); + assertEquals(HttpVersionPolicy.FORCE_HTTP_2, HTTPHC5Impl.getHttpVersionPolicy("HTTP/2", "HTTP/1.1")); + } + + @Test + void defaultsToHttp11ForUnsupportedHttpVersion() { + assertEquals(HttpVersionPolicy.FORCE_HTTP_1, HTTPHC5Impl.getHttpVersionPolicy("HTTP/3", "HTTP/2")); + } + + @Test + void doesNotRequireProtocolUpgradeConfiguration() throws Exception { + try (InputStream classFile = HTTPHC5Impl.class.getResourceAsStream("HTTPHC5Impl.class")) { + assertFalse(new String(classFile.readAllBytes(), StandardCharsets.ISO_8859_1) + .contains("setProtocolUpgradeEnabled")); + } + } + + @Test + void doesNotRequireHttpAsyncClassicAdapter() throws Exception { + try (InputStream classFile = HTTPHC5Impl.class.getResourceAsStream("HTTPHC5Impl.class")) { + assertFalse(hasMethodReference(classFile.readAllBytes(), + "org/apache/hc/client5/http/impl/async/HttpAsyncClients", "classic")); + } + } + + @Test + void usesHttp2WhenSelected() throws Exception { + WireMockServer server = new WireMockServer(WireMockConfiguration.wireMockConfig() + .dynamicHttpsPort() + .http2TlsDisabled(false)); + try { + server.start(); + server.stubFor(get(urlEqualTo("/http2")).willReturn(aResponse().withStatus(200))); + HTTPSamplerBase sampler = newSampler(); + sampler.setHttpVersion("HTTP/2"); + + HTTPSampleResult result = sampler.sample( + new URL("https://localhost:" + server.httpsPort() + "/http2"), HTTPConstants.GET, false, 1); + + assertEquals("200", result.getResponseCode()); + assertEquals("HTTP/2", result.getResponseHeaders().substring(0, "HTTP/2".length())); + } finally { + server.stop(); + } + } + + @Test + void usesHttp2WithProxy() throws Exception { + WireMockServer server = createServer(); + server.start(); + try { + server.stubFor(get(urlEqualTo("/http2proxy")).willReturn(aResponse().withStatus(200))); + HTTPSamplerBase sampler = newSampler(); + sampler.setHttpVersion("HTTP/2"); + sampler.setProxyHost("localhost"); + sampler.setProxyPortInt(Integer.toString(server.port())); + + HTTPSampleResult result = sampler.sample( + new URL(server.url("/http2proxy")), HTTPConstants.GET, false, 1); + + assertEquals("200", result.getResponseCode()); + } finally { + server.stop(); + } + } + + @Test + void usesHttp11WhenSelected() throws Exception { + WireMockServer server = createServer(); + server.start(); + try { + server.stubFor(get(urlEqualTo("/http11")).willReturn(aResponse().withStatus(200))); + HTTPSamplerBase sampler = newSampler(); + sampler.setHttpVersion("HTTP/1.1"); + + HTTPSampleResult result = sampler.sample( + new URL(server.url("/http11")), HTTPConstants.GET, false, 1); + + assertEquals("200", result.getResponseCode()); + assertEquals("HTTP/1.1", result.getResponseHeaders().substring(0, "HTTP/1.1".length())); + } finally { + server.stop(); + } + } + + @Test + void sendsConditionalRequestForCachedResource() throws Exception { + WireMockServer server = createServer(); + server.start(); + try { + server.stubFor(get(urlEqualTo("/cache")) + .willReturn(aResponse().withHeader("ETag", "cache-tag").withStatus(200))); + HTTPSamplerBase sampler = newSampler(); + sampler.setCacheManager(new CacheManager()); + URL url = new URL(server.url("/cache")); + + assertEquals("200", sampler.sample(url, HTTPConstants.GET, false, 1).getResponseCode()); + assertEquals("200", sampler.sample(url, HTTPConstants.GET, false, 1).getResponseCode()); + + server.verify(1, getRequestedFor(urlEqualTo("/cache")) + .withHeader("If-None-Match", WireMock.equalTo("cache-tag"))); + } finally { + server.stop(); + } + } + + @Test + void sendsBasicCredentialsFromAuthorizationManager() throws Exception { + WireMockServer server = createServer(); + server.start(); + try { + server.stubFor(get(urlEqualTo("/auth")) + .withHeader("Authorization", WireMock.equalTo("Basic dXNlcjpwYXNz")) + .willReturn(aResponse().withStatus(200))); + server.stubFor(get(urlEqualTo("/auth")).atPriority(10) + .willReturn(aResponse().withHeader("WWW-Authenticate", "Basic realm=\"test\"").withStatus(401))); + AuthManager authManager = new AuthManager(); + authManager.set(-1, server.url("/"), "user", "pass", "", "", AuthManager.Mechanism.BASIC); + HTTPSamplerBase sampler = newSampler(); + sampler.setAuthManager(authManager); + + assertEquals("200", sampler.sample(new URL(server.url("/auth")), HTTPConstants.GET, false, 1).getResponseCode()); + } finally { + server.stop(); + } + } + + @Test + void authenticatesWithConfiguredProxyCredentials() throws Exception { + WireMockServer server = createServer(); + server.start(); + try { + server.stubFor(get(urlEqualTo("/proxy")) + .withHeader("Proxy-Authorization", WireMock.equalTo("Basic dXNlcjpwYXNz")) + .willReturn(aResponse().withStatus(200))); + server.stubFor(get(urlEqualTo("/proxy")).atPriority(10) + .willReturn(aResponse().withHeader("Proxy-Authenticate", "Basic realm=\"proxy\"").withStatus(407))); + HTTPSamplerBase sampler = newSampler(); + sampler.setProxyHost("localhost"); + sampler.setProxyPortInt(Integer.toString(server.port())); + sampler.setProxyUser("user"); + sampler.setProxyPass("pass"); + + assertEquals("200", sampler.sample(new URL(server.url("/proxy")), HTTPConstants.GET, false, 1).getResponseCode()); + } finally { + server.stop(); + } + } + + private static HTTPSamplerBase newSampler() { + return HTTPSamplerFactory.newInstance("HttpClient5"); + } + + private static WireMockServer createServer() { + return new WireMockServer(WireMockConfiguration.wireMockConfig().dynamicPort()); + } + + private static boolean hasMethodReference(byte[] classBytes, String className, String methodName) throws IOException { + try (DataInputStream input = new DataInputStream(new ByteArrayInputStream(classBytes))) { + input.readInt(); + input.readUnsignedShort(); + input.readUnsignedShort(); + int constantPoolCount = input.readUnsignedShort(); + int[] tags = new int[constantPoolCount]; + int[] firstReferences = new int[constantPoolCount]; + int[] secondReferences = new int[constantPoolCount]; + String[] utf8Values = new String[constantPoolCount]; + int i = 1; + while (i < constantPoolCount) { + tags[i] = input.readUnsignedByte(); + switch (tags[i]) { + case 1: + utf8Values[i] = input.readUTF(); + break; + case 3: + case 4: + input.readInt(); + break; + case 5: + case 6: + input.readLong(); + i++; + break; + case 7: + case 8: + case 16: + case 19: + case 20: + firstReferences[i] = input.readUnsignedShort(); + break; + case 9: + case 10: + case 11: + case 12: + case 17: + case 18: + firstReferences[i] = input.readUnsignedShort(); + secondReferences[i] = input.readUnsignedShort(); + break; + case 15: + input.readUnsignedByte(); + firstReferences[i] = input.readUnsignedShort(); + break; + default: + throw new IOException("Unknown class-file constant-pool tag " + tags[i]); + } + i++; + } + for (int methodReferenceIndex = 1; methodReferenceIndex < constantPoolCount; methodReferenceIndex++) { + if (tags[methodReferenceIndex] != 10) { + continue; + } + int classIndex = firstReferences[methodReferenceIndex]; + int nameAndTypeIndex = secondReferences[methodReferenceIndex]; + String referencedClass = utf8Values[firstReferences[classIndex]]; + String referencedMethod = utf8Values[firstReferences[nameAndTypeIndex]]; + if (className.equals(referencedClass) && methodName.equals(referencedMethod)) { + return true; + } + } + return false; + } + } +} From 2d09b883833b6dd72da21f051a4b0678fd0305fd Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Thu, 23 Jul 2026 17:06:48 +0200 Subject: [PATCH 09/80] Add HTTP/2 support to `HTTPJavaImpl` with configurable version handling, caching, proxies, and user authentication --- bin/jmeter.properties | 2 +- .../protocol/http/control/CacheManager.java | 22 + .../protocol/http/sampler/HTTPJavaImpl.java | 422 ++++++++++++++++++ .../http/sampler/TestHTTPJavaFeatures.java | 100 +++++ xdocs/usermanual/component_reference.xml | 4 +- xdocs/usermanual/properties_reference.xml | 2 +- 6 files changed, 548 insertions(+), 4 deletions(-) create mode 100644 src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java diff --git a/bin/jmeter.properties b/bin/jmeter.properties index 91c948e429c..8820915bb25 100644 --- a/bin/jmeter.properties +++ b/bin/jmeter.properties @@ -378,7 +378,7 @@ remote_hosts=127.0.0.1 #httpclient.timeout=0 # 0 == no timeout -# Set the default HTTP version for HttpClient5 samplers when HTTP Version is empty +# Set the default HTTP version for HttpClient5 and Java samplers when HTTP Version is empty # Valid values are HTTP/1.1 and HTTP/2 (defaults to HTTP/1.1) #httpclient.version=HTTP/1.1 diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/CacheManager.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/CacheManager.java index 9e8a2b5ce55..32509ab81f8 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/CacheManager.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/CacheManager.java @@ -267,6 +267,28 @@ public void saveDetails(org.apache.hc.core5.http.ClassicHttpResponse response, H } } + /** + * Save the Last-Modified, Etag, and Expires headers if the result is + * cacheable. Version for Java HttpClient implementation. + * + * @param response response to extract header information from + * @param res result to decide if result is cacheable + */ + public void saveDetails(java.net.http.HttpResponse response, HTTPSampleResult res) { + final String varyHeader = response.headers().firstValue(HTTPConstants.VARY).orElse(null); + if (isCacheable(res, varyHeader)) { + String lastModified = response.headers().firstValue(HTTPConstants.LAST_MODIFIED).orElse(null); + String expires = response.headers().firstValue(HTTPConstants.EXPIRES).orElse(null); + String etag = response.headers().firstValue(HTTPConstants.ETAG).orElse(null); + String cacheControl = response.headers().firstValue(HTTPConstants.CACHE_CONTROL).orElse(null); + String date = response.headers().firstValue(HTTPConstants.DATE).orElse(null); + if (anyNotBlank(lastModified, expires, etag, cacheControl)) { + setCache(lastModified, cacheControl, expires, etag, + res.getUrlAsString(), date, getVaryHeader(varyHeader, asHeaders(res.getRequestHeaders()))); + } + } + } + private static String getHeader(org.apache.hc.core5.http.HttpMessage message, String name) { org.apache.hc.core5.http.Header header = message.getFirstHeader(name); return header == null ? null : header.getValue(); diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java index d44f7466d79..711d3fb6d1d 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java @@ -17,22 +17,36 @@ package org.apache.jmeter.protocol.http.sampler; +import java.io.ByteArrayOutputStream; import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; +import java.net.Authenticator; import java.net.BindException; import java.net.HttpURLConnection; import java.net.InetSocketAddress; +import java.net.PasswordAuthentication; import java.net.Proxy; +import java.net.ProxySelector; +import java.net.URI; import java.net.URL; import java.net.URLConnection; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; import java.util.ArrayList; import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.function.Predicate; import java.util.zip.GZIPInputStream; +import javax.net.ssl.SSLContext; + import org.apache.jmeter.protocol.http.control.AuthManager; import org.apache.jmeter.protocol.http.control.Authorization; import org.apache.jmeter.protocol.http.control.CacheManager; @@ -44,6 +58,7 @@ import org.apache.jmeter.testelement.property.CollectionProperty; import org.apache.jmeter.testelement.property.JMeterProperty; import org.apache.jmeter.util.JMeterUtils; +import org.apache.jmeter.util.JsseSSLManager; import org.apache.jmeter.util.SSLManager; import org.apache.jorphan.io.CountingInputStream; import org.apache.jorphan.util.StringUtilities; @@ -59,8 +74,23 @@ public class HTTPJavaImpl extends HTTPAbstractImpl { private static final boolean OBEY_CONTENT_LENGTH = JMeterUtils.getPropDefault("httpsampler.obey_contentlength", false); // $NON-NLS-1$ + private static final String DEFAULT_HTTP_VERSION = + JMeterUtils.getPropDefault("httpclient.version", HTTPConstants.HTTP_1_1); // $NON-NLS-1$ + + private static final ThreadLocal> HTTP_2_CLIENTS = + ThreadLocal.withInitial(HashMap::new); + private static final Logger log = LoggerFactory.getLogger(HTTPJavaImpl.class); + static boolean isHttp2(String samplerHttpVersion, String defaultHttpVersion) { + String httpVersion = StringUtilities.isBlank(samplerHttpVersion) ? defaultHttpVersion : samplerHttpVersion; + return "HTTP/2".equalsIgnoreCase(httpVersion) || "2".equalsIgnoreCase(httpVersion); // $NON-NLS-1$ $NON-NLS-2$ + } + + private boolean isHttp2() { + return isHttp2(testElement.getHttpVersion(), DEFAULT_HTTP_VERSION); + } + private static final int MAX_CONN_RETRIES = JMeterUtils.getPropDefault("http.java.sampler.retries" // $NON-NLS-1$ ,0); // Maximum connection retries @@ -501,6 +531,9 @@ private static Map setConnectionAuthorization(HttpURLConnection */ @Override protected HTTPSampleResult sample(URL url, String method, boolean areFollowingRedirect, int frameDepth) { + if (isHttp2()) { + return sampleHttp2(url, method, areFollowingRedirect, frameDepth); + } HttpURLConnection conn = null; String urlStr = url.toString(); @@ -726,4 +759,393 @@ public boolean interrupt() { } return conn != null; } + + private HTTPSampleResult sampleHttp2(URL url, String method, boolean areFollowingRedirect, int frameDepth) { + if (log.isDebugEnabled()) { + log.debug("Start : sampleHttp2 {}, method {}, followingRedirect {}, depth {}", + url, method, areFollowingRedirect, frameDepth); + } + + HTTPSampleResult res = new HTTPSampleResult(); + configureSampleLabel(res, url); + res.setURL(url); + res.setHTTPMethod(method); + + res.sampleStart(); + + final CacheManager cacheManager = getCacheManager(); + if (cacheManager != null && HTTPConstants.GET.equalsIgnoreCase(method)) { + if (cacheManager.inCache(url, getHeaders(getHeaderManager()))) { + return updateSampleResultForResourceInCache(res); + } + } + + try { + CapturingHttpURLConnection capturingConn = new CapturingHttpURLConnection(url, method); + + setConnectionHeaders(capturingConn, url, getHeaderManager(), getCacheManager()); + String cookies = setConnectionCookie(capturingConn, url, getCookieManager()); + Map securityHeaders = setConnectionAuthorization(capturingConn, url, getAuthManager()); + + byte[] requestBodyBytes = new byte[0]; + if (method.equals(HTTPConstants.POST)) { + setPostHeaders(capturingConn); + String postBody = sendPostData(capturingConn); + res.setQueryString(postBody); + requestBodyBytes = capturingConn.getCapturedBytes(); + } else if (method.equals(HTTPConstants.PUT)) { + setPutHeaders(capturingConn); + String putBody = sendPutData(capturingConn); + res.setQueryString(putBody); + requestBodyBytes = capturingConn.getCapturedBytes(); + } + + res.setRequestHeaders(getAllHeadersExceptCookie(capturingConn, securityHeaders)); + if (StringUtilities.isNotEmpty(cookies)) { + res.setCookies(cookies); + } else { + res.setCookies(getOnlyCookieFromHeaders(capturingConn, securityHeaders)); + } + + URI uri = url.toURI(); + HttpRequest.Builder reqBuilder = HttpRequest.newBuilder(uri); + + if (method.equalsIgnoreCase(HTTPConstants.POST) || method.equalsIgnoreCase(HTTPConstants.PUT) + || method.equalsIgnoreCase(HTTPConstants.PATCH)) { + reqBuilder.method(method, HttpRequest.BodyPublishers.ofByteArray(requestBodyBytes)); + } else if (method.equalsIgnoreCase(HTTPConstants.GET)) { + reqBuilder.GET(); + } else if (method.equalsIgnoreCase(HTTPConstants.DELETE)) { + reqBuilder.DELETE(); + } else { + reqBuilder.method(method, HttpRequest.BodyPublishers.noBody()); + } + + int rto = getResponseTimeout(); + if (rto > 0) { + reqBuilder.timeout(Duration.ofMillis(rto)); + } + + Map> props = capturingConn.getRequestProperties(); + for (Map.Entry> entry : props.entrySet()) { + String headerName = entry.getKey(); + if (headerName == null || isRestrictedHeader(headerName)) { + continue; + } + for (String value : entry.getValue()) { + reqBuilder.header(headerName, value); + } + } + + HttpClient client = getHttpClient(url); + HttpRequest httpRequest = reqBuilder.build(); + + HttpResponse response = client.send(httpRequest, HttpResponse.BodyHandlers.ofInputStream()); + res.latencyEnd(); + + byte[] responseData = readResponse(response, res); + + res.sampleEnd(); + + res.setResponseData(responseData); + + int statusCode = response.statusCode(); + res.setResponseCode(Integer.toString(statusCode)); + res.setSuccessful(isSuccessCode(statusCode)); + res.setResponseMessage(""); // $NON-NLS-1$ + + String responseHeaders = getResponseHeaders(response); + res.setResponseHeaders(responseHeaders); + + String ct = response.headers().firstValue(HTTPConstants.HEADER_CONTENT_TYPE).orElse(null); + if (ct != null) { + res.setContentType(ct); + res.setEncodingAndType(ct); + } + + if (res.isRedirect()) { + String location = response.headers().firstValue(HTTPConstants.HEADER_LOCATION).orElse(null); + if (location != null) { + res.setRedirectLocation(location); + } + } + + res.setHeadersSize( + responseHeaders.length() + + StringUtilities.count(responseHeaders, '\n') + + 2); + + if (getAutoRedirects()) { + res.setURL(response.uri().toURL()); + } + + saveConnectionCookies(response, url, getCookieManager()); + + if (cacheManager != null) { + cacheManager.saveDetails(response, res); + } + + res = resultProcessing(areFollowingRedirect, frameDepth, res); + + log.debug("End : sampleHttp2"); + return res; + } catch (Exception e) { + if (res.getEndTime() == 0) { + res.sampleEnd(); + } + return errorResult(e, res); + } + } + + private byte[] readResponse(HttpResponse response, SampleResult res) throws IOException { + InputStream in = response.body(); + if (in == null) { + return NULL_BA; + } + + boolean gzipped = response.headers().firstValue(HTTPConstants.HEADER_CONTENT_ENCODING) + .map(HTTPConstants.ENCODING_GZIP::equalsIgnoreCase) + .orElse(false); + + long contentLength = response.headers().firstValueAsLong(HTTPConstants.HEADER_CONTENT_LENGTH).orElse(-1L); + + if (contentLength == 0 && OBEY_CONTENT_LENGTH) { + log.info("Content-Length: 0, not reading http-body"); + res.setResponseHeaders(getResponseHeaders(response)); + res.latencyEnd(); + return NULL_BA; + } + + CountingInputStream instream = new CountingInputStream(in); + InputStream stream = gzipped ? new GZIPInputStream(instream) : instream; + + try { + byte[] responseData = readResponse(res, stream, contentLength); + res.setBodySize(instream.getBytesRead()); + return responseData; + } finally { + instream.close(); + } + } + + private static String getResponseHeaders(HttpResponse response) { + StringBuilder headerBuf = new StringBuilder(); + String versionStr = (response.version() == HttpClient.Version.HTTP_2) ? "HTTP/2" : "HTTP/1.1"; // $NON-NLS-1$ $NON-NLS-2$ + headerBuf.append(versionStr).append(" ").append(response.statusCode()).append("\n"); // $NON-NLS-1$ $NON-NLS-2$ + + response.headers().map().forEach((key, values) -> { + if (key != null) { + for (String val : values) { + headerBuf.append(key).append(": ").append(val).append("\n"); // $NON-NLS-1$ $NON-NLS-2$ + } + } + }); + return headerBuf.toString(); + } + + private static void saveConnectionCookies(HttpResponse response, URL u, CookieManager cookieManager) { + if (cookieManager != null) { + List setCookies = response.headers().allValues(HTTPConstants.HEADER_SET_COOKIE); + for (String setCookie : setCookies) { + cookieManager.addCookieFromHeader(setCookie, u); + } + } + } + + private static boolean isRestrictedHeader(String name) { + return HTTPConstants.HEADER_CONNECTION.equalsIgnoreCase(name) + || HTTPConstants.HEADER_CONTENT_LENGTH.equalsIgnoreCase(name) + || "Host".equalsIgnoreCase(name) // $NON-NLS-1$ + || "Expect".equalsIgnoreCase(name) // $NON-NLS-1$ + || "Upgrade".equalsIgnoreCase(name); // $NON-NLS-1$ + } + + private HttpClient getHttpClient(URL url) { + int connectTimeout = getConnectTimeout(); + String proxyHost = getProxyHost(); + int proxyPort = getProxyPortInt(); + String proxyUser = getProxyUser(); + String proxyPass = getProxyPass(); + boolean autoRedirects = getAutoRedirects(); + SSLContext sslContext = null; + + if (HTTPConstants.PROTOCOL_HTTPS.equalsIgnoreCase(url.getProtocol())) { + try { + SSLManager sslmgr = SSLManager.getInstance(); + if (sslmgr instanceof JsseSSLManager jsseSSLManager) { + sslContext = jsseSSLManager.getContext(); + } + } catch (Exception e) { + log.warn("Problem getting SSLContext for HTTP/2 HttpClient: ", e); // $NON-NLS-1$ + } + } + + HttpClientKey key = new HttpClientKey(connectTimeout, proxyHost, proxyPort, + proxyUser, proxyPass, autoRedirects, sslContext); + + return HTTP_2_CLIENTS.get().computeIfAbsent(key, HTTPJavaImpl::createHttpClient); + } + + private static HttpClient createHttpClient(HttpClientKey key) { + HttpClient.Builder builder = HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_2) + .followRedirects(key.autoRedirects ? HttpClient.Redirect.NORMAL : HttpClient.Redirect.NEVER); + + if (key.connectTimeout > 0) { + builder.connectTimeout(Duration.ofMillis(key.connectTimeout)); + } + + if (StringUtilities.isNotEmpty(key.proxyHost) && key.proxyPort > 0) { + builder.proxy(ProxySelector.of(new InetSocketAddress(key.proxyHost, key.proxyPort))); + if (StringUtilities.isNotEmpty(key.proxyUser)) { + builder.authenticator(new Authenticator() { + @Override + protected PasswordAuthentication getPasswordAuthentication() { + if (getRequestorType() == RequestorType.PROXY) { + return new PasswordAuthentication(key.proxyUser, + key.proxyPass != null ? key.proxyPass.toCharArray() : new char[0]); + } + return super.getPasswordAuthentication(); + } + }); + } + } + + if (key.sslContext != null) { + builder.sslContext(key.sslContext); + } + + return builder.build(); + } + + private static class HttpClientKey { + private final int connectTimeout; + private final String proxyHost; + private final int proxyPort; + private final String proxyUser; + private final String proxyPass; + private final boolean autoRedirects; + private final SSLContext sslContext; + + HttpClientKey(int connectTimeout, String proxyHost, int proxyPort, + String proxyUser, String proxyPass, boolean autoRedirects, + SSLContext sslContext) { + this.connectTimeout = connectTimeout; + this.proxyHost = proxyHost; + this.proxyPort = proxyPort; + this.proxyUser = proxyUser; + this.proxyPass = proxyPass; + this.autoRedirects = autoRedirects; + this.sslContext = sslContext; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof HttpClientKey that)) { + return false; + } + return connectTimeout == that.connectTimeout + && proxyPort == that.proxyPort + && autoRedirects == that.autoRedirects + && Objects.equals(proxyHost, that.proxyHost) + && Objects.equals(proxyUser, that.proxyUser) + && Objects.equals(proxyPass, that.proxyPass) + && Objects.equals(sslContext, that.sslContext); + } + + @Override + public int hashCode() { + return Objects.hash(connectTimeout, proxyHost, proxyPort, proxyUser, proxyPass, autoRedirects, sslContext); + } + } + + private static class CapturingHttpURLConnection extends HttpURLConnection { + private final Map> requestProperties = new LinkedHashMap<>(); + private final ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + + CapturingHttpURLConnection(URL url, String method) { + super(url); + this.method = method; + } + + @Override + public void setRequestProperty(String key, String value) { + if (key == null) { + return; + } + List list = new ArrayList<>(); + list.add(value); + requestProperties.put(key, list); + } + + @Override + public void addRequestProperty(String key, String value) { + if (key == null) { + return; + } + requestProperties.computeIfAbsent(key, k -> new ArrayList<>()).add(value); + } + + @Override + public String getRequestProperty(String key) { + if (key == null) { + return null; + } + List values = requestProperties.get(key); + if (values == null || values.isEmpty()) { + for (Map.Entry> entry : requestProperties.entrySet()) { + if (key.equalsIgnoreCase(entry.getKey())) { + values = entry.getValue(); + break; + } + } + } + return (values != null && !values.isEmpty()) ? values.get(0) : null; + } + + @Override + public Map> getRequestProperties() { + return Collections.unmodifiableMap(requestProperties); + } + + @Override + public java.io.OutputStream getOutputStream() throws IOException { + return outputStream; + } + + byte[] getCapturedBytes() { + return outputStream.toByteArray(); + } + + @Override + public void connect() throws IOException { + } + + @Override + public void disconnect() { + } + + @Override + public boolean usingProxy() { + return false; + } + + @Override + public String getHeaderField(int n) { + return null; + } + + @Override + public String getHeaderFieldKey(int n) { + return null; + } + + @Override + public String getHeaderField(String name) { + return null; + } + } } diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java new file mode 100644 index 00000000000..d3646c86bea --- /dev/null +++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java @@ -0,0 +1,100 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you 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.apache.jmeter.protocol.http.sampler; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.URL; + +import org.apache.jmeter.protocol.http.util.HTTPConstants; +import org.junit.jupiter.api.Test; + +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.core.WireMockConfiguration; + +class TestHTTPJavaFeatures { + + @Test + void usesHttpClientVersionWhenSamplerVersionIsEmpty() { + assertTrue(HTTPJavaImpl.isHttp2("", "HTTP/2")); + assertFalse(HTTPJavaImpl.isHttp2("", "HTTP/1.1")); + } + + @Test + void usesSamplerHttpVersionWhenSpecified() { + assertFalse(HTTPJavaImpl.isHttp2("HTTP/1.1", "HTTP/2")); + assertTrue(HTTPJavaImpl.isHttp2("HTTP/2", "HTTP/1.1")); + } + + @Test + void defaultsToHttp11ForUnsupportedHttpVersion() { + assertFalse(HTTPJavaImpl.isHttp2("HTTP/3", "HTTP/2")); + } + + @Test + void usesHttp2WhenSelected() throws Exception { + WireMockServer server = createServer(); + server.start(); + try { + server.stubFor(get(urlEqualTo("/http2")).willReturn(aResponse().withStatus(200))); + HTTPSamplerBase sampler = newSampler(); + sampler.setHttpVersion("HTTP/2"); + + HTTPSampleResult result = sampler.sample( + new URL(server.url("/http2")), HTTPConstants.GET, false, 1); + + assertEquals("200", result.getResponseCode()); + assertEquals("HTTP/2", result.getResponseHeaders().substring(0, "HTTP/2".length())); + } finally { + server.stop(); + } + } + + @Test + void usesHttp2WithProxy() throws Exception { + WireMockServer server = createServer(); + server.start(); + try { + server.stubFor(get(urlEqualTo("/http2proxy")).willReturn(aResponse().withStatus(200))); + HTTPSamplerBase sampler = newSampler(); + sampler.setHttpVersion("HTTP/2"); + sampler.setProxyHost("localhost"); + sampler.setProxyPortInt(Integer.toString(server.port())); + + HTTPSampleResult result = sampler.sample( + new URL(server.url("/http2proxy")), HTTPConstants.GET, false, 1); + + assertEquals("200", result.getResponseCode()); + } finally { + server.stop(); + } + } + + private static HTTPSamplerBase newSampler() { + return HTTPSamplerFactory.newInstance("Java"); + } + + private static WireMockServer createServer() { + return new WireMockServer(WireMockConfiguration.wireMockConfig().dynamicPort()); + } +} diff --git a/xdocs/usermanual/component_reference.xml b/xdocs/usermanual/component_reference.xml index dbb899812c5..2303bafc118 100644 --- a/xdocs/usermanual/component_reference.xml +++ b/xdocs/usermanual/component_reference.xml @@ -245,8 +245,8 @@ https.default.protocol=SSLv3 Java, HttpClient4, HttpClient5. If not specified (and not defined by HTTP Request Defaults), the default depends on the value of the JMeter property jmeter.httpsampler, failing that, the HttpClient4 implementation is used. - HTTP/1.1 or HTTP/2. Applies only to the - HttpClient5 implementation. An empty value uses the httpclient.version property, which + HTTP/1.1 or HTTP/2. Applies to the + HttpClient5 and Java implementations. An empty value uses the httpclient.version property, which defaults to HTTP/1.1. HTTP, HTTPS or FILE. Default: HTTP GET, POST, HEAD, TRACE, diff --git a/xdocs/usermanual/properties_reference.xml b/xdocs/usermanual/properties_reference.xml index 1a584be6f1d..721a41d46bf 100644 --- a/xdocs/usermanual/properties_reference.xml +++ b/xdocs/usermanual/properties_reference.xml @@ -442,7 +442,7 @@ JMETER-SERVER Defaults to: 0 - Set the default HTTP version for HttpClient5 samplers with an empty HTTP Version value.
    + Set the default HTTP version for HttpClient5 and Java samplers with an empty HTTP Version value.
    Valid values are HTTP/1.1 and HTTP/2.
    Defaults to: HTTP/1.1
    From 1752403b37b2edb0391e1a6c89f385ccff5f33ae Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Fri, 24 Jul 2026 07:27:07 +0200 Subject: [PATCH 10/80] Add `httpVersion` to ignored properties in `JMeterTest` --- .../src/test/java/org/apache/jmeter/junit/JMeterTest.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/dist-check/src/test/java/org/apache/jmeter/junit/JMeterTest.java b/src/dist-check/src/test/java/org/apache/jmeter/junit/JMeterTest.java index 2aca34f3e1e..0aff1b1a678 100644 --- a/src/dist-check/src/test/java/org/apache/jmeter/junit/JMeterTest.java +++ b/src/dist-check/src/test/java/org/apache/jmeter/junit/JMeterTest.java @@ -385,6 +385,7 @@ public void GUIComponents2(GuiComponentHolder componentHolder) throws Exception // TODO: support expressions? IGNORED_PROPERTIES.add(HTTPSamplerBaseSchema.INSTANCE.getIpSourceType()); IGNORED_PROPERTIES.add(HTTPSamplerBaseSchema.INSTANCE.getImplementation()); + IGNORED_PROPERTIES.add(HTTPSamplerBaseSchema.INSTANCE.getHttpVersion()); // TODO: support expressions in UrlConfigGui IGNORED_PROPERTIES.add(HTTPSamplerBaseSchema.INSTANCE.getFollowRedirects()); IGNORED_PROPERTIES.add(HTTPSamplerBaseSchema.INSTANCE.getAutoRedirects()); From e76132604b1194b788737f430b5eb3e99ad089ef Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Fri, 24 Jul 2026 12:18:37 +0200 Subject: [PATCH 11/80] Update `HTTPHC5Impl` to use `HttpVersionPolicy.NEGOTIATE` for HTTP/2 and improve fallback handling with new tests --- .../protocol/http/sampler/HTTPHC5Impl.java | 8 +++--- .../http/sampler/TestHTTPHC5Features.java | 25 +++++++++++++++++-- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java index 458ea7f4151..30c1a97b55c 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java @@ -212,7 +212,7 @@ protected HTTPSampleResult sample(URL url, String method, boolean areFollowingRe currentRequest = request; HttpClientKey clientKey = createHttpClientKey(url); HttpClientContext context = createHttpClientContext(url, clientKey, request); - response = clientKey.httpVersionPolicy == HttpVersionPolicy.FORCE_HTTP_2 + response = clientKey.httpVersionPolicy != HttpVersionPolicy.FORCE_HTTP_1 ? executeHttp2(getHttp2Client(clientKey), request, context) : getClient(clientKey).executeOpen(null, request, context); result.sampleEnd(); @@ -263,7 +263,7 @@ private void setupRequest(URL url, org.apache.hc.client5.http.classic.methods.Ht if (httpVersionPolicy == HttpVersionPolicy.FORCE_HTTP_1) { request.setHeader(HTTPConstants.HEADER_CONNECTION, getUseKeepAlive() ? HTTPConstants.KEEP_ALIVE : HTTPConstants.CONNECTION_CLOSE); - } else { + } else if (httpVersionPolicy == HttpVersionPolicy.FORCE_HTTP_2) { request.setVersion(HttpVersion.HTTP_2); } setConnectionHeaders(request, getHeaderManager(), httpVersionPolicy); @@ -377,7 +377,7 @@ private static void setConnectionHeaders(org.apache.hc.client5.http.classic.meth org.apache.jmeter.protocol.http.control.Header header = (org.apache.jmeter.protocol.http.control.Header) property.getObjectValue(); if (!HTTPConstants.HEADER_CONTENT_LENGTH.equalsIgnoreCase(header.getName()) - && (httpVersionPolicy != HttpVersionPolicy.FORCE_HTTP_2 + && (httpVersionPolicy == HttpVersionPolicy.FORCE_HTTP_1 || !HTTPConstants.HEADER_CONNECTION.equalsIgnoreCase(header.getName()))) { request.addHeader(header.getName(), header.getValue()); } @@ -646,7 +646,7 @@ private HttpClientKey createHttpClientKey(URL url) throws IOException { static HttpVersionPolicy getHttpVersionPolicy(String samplerHttpVersion, String defaultHttpVersion) { String httpVersion = StringUtilities.isBlank(samplerHttpVersion) ? defaultHttpVersion : samplerHttpVersion; return "HTTP/2".equals(httpVersion) || "2".equals(httpVersion) - ? HttpVersionPolicy.FORCE_HTTP_2 : HttpVersionPolicy.FORCE_HTTP_1; + ? HttpVersionPolicy.NEGOTIATE : HttpVersionPolicy.FORCE_HTTP_1; } @Override diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java index c69dc254a37..604bc240593 100644 --- a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java +++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java @@ -45,13 +45,13 @@ class TestHTTPHC5Features { @Test void usesHttpClientVersionWhenSamplerVersionIsEmpty() { - assertEquals(HttpVersionPolicy.FORCE_HTTP_2, HTTPHC5Impl.getHttpVersionPolicy("", "HTTP/2")); + assertEquals(HttpVersionPolicy.NEGOTIATE, HTTPHC5Impl.getHttpVersionPolicy("", "HTTP/2")); } @Test void usesSamplerHttpVersionWhenSpecified() { assertEquals(HttpVersionPolicy.FORCE_HTTP_1, HTTPHC5Impl.getHttpVersionPolicy("HTTP/1.1", "HTTP/2")); - assertEquals(HttpVersionPolicy.FORCE_HTTP_2, HTTPHC5Impl.getHttpVersionPolicy("HTTP/2", "HTTP/1.1")); + assertEquals(HttpVersionPolicy.NEGOTIATE, HTTPHC5Impl.getHttpVersionPolicy("HTTP/2", "HTTP/1.1")); } @Test @@ -96,6 +96,27 @@ void usesHttp2WhenSelected() throws Exception { } } + @Test + void fallsBackToHttp11WhenServerDoesNotSupportHttp2() throws Exception { + WireMockServer server = new WireMockServer(WireMockConfiguration.wireMockConfig() + .dynamicHttpsPort() + .http2TlsDisabled(true)); + try { + server.start(); + server.stubFor(get(urlEqualTo("/fallback")).willReturn(aResponse().withStatus(200))); + HTTPSamplerBase sampler = newSampler(); + sampler.setHttpVersion("HTTP/2"); + + HTTPSampleResult result = sampler.sample( + new URL("https://localhost:" + server.httpsPort() + "/fallback"), HTTPConstants.GET, false, 1); + + assertEquals("200", result.getResponseCode()); + assertEquals("HTTP/1.1", result.getResponseHeaders().substring(0, "HTTP/1.1".length())); + } finally { + server.stop(); + } + } + @Test void usesHttp2WithProxy() throws Exception { WireMockServer server = createServer(); From 1adb9f02653fef5a96d0aea655d64ca1b9abd102 Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Fri, 24 Jul 2026 17:25:10 +0200 Subject: [PATCH 12/80] Update dependencies to replace `httpcore5-h2` with `httpcore5` in `HTTPHC5Impl` and Gradle configurations --- src/bom-thirdparty/build.gradle.kts | 2 +- src/protocol/http/build.gradle.kts | 2 +- .../org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/bom-thirdparty/build.gradle.kts b/src/bom-thirdparty/build.gradle.kts index 0d28723da8b..f819a868318 100644 --- a/src/bom-thirdparty/build.gradle.kts +++ b/src/bom-thirdparty/build.gradle.kts @@ -107,7 +107,7 @@ dependencies { because("User might still rely on commons-text") } api("org.apache.httpcomponents.client5:httpclient5:5.6.4") - api("org.apache.httpcomponents.core5:httpcore5-h2:5.3.4") + api("org.apache.httpcomponents.core5:httpcore5:5.3.4") api("org.apache.httpcomponents:httpasyncclient:4.1.5") api("org.apache.httpcomponents:httpclient:4.5.14") api("org.apache.httpcomponents:httpcore-nio:4.4.16") diff --git a/src/protocol/http/build.gradle.kts b/src/protocol/http/build.gradle.kts index 2b99a8bbfc2..dd937692498 100644 --- a/src/protocol/http/build.gradle.kts +++ b/src/protocol/http/build.gradle.kts @@ -62,7 +62,7 @@ dependencies { } implementation("dnsjava:dnsjava") implementation("org.apache.httpcomponents.client5:httpclient5") - implementation("org.apache.httpcomponents.core5:httpcore5-h2") + implementation("org.apache.httpcomponents.core5:httpcore5") implementation("org.apache.httpcomponents:httpmime") implementation("org.apache.httpcomponents:httpcore") implementation("org.brotli:dec") diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java index 30c1a97b55c..9bd94ea910d 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java @@ -43,7 +43,6 @@ import org.apache.hc.client5.http.async.methods.SimpleHttpResponse; import org.apache.hc.client5.http.auth.AuthScope; import org.apache.hc.client5.http.auth.UsernamePasswordCredentials; -import org.apache.hc.client5.http.classic.ExecChain; import org.apache.hc.client5.http.classic.ExecChainHandler; import org.apache.hc.client5.http.config.ConnectionConfig; import org.apache.hc.client5.http.config.RequestConfig; @@ -230,6 +229,7 @@ protected HTTPSampleResult sample(URL url, String method, boolean areFollowingRe } if (request != null) { result.setRequestHeaders(getRequestHeaders(request)); + result.setSentBytes(calculateSentBytes(request)); } return errorResult(e, result); } finally { From 9d0e26a1dc0826405a7fe87204adc7faea35c85c Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Fri, 24 Jul 2026 17:41:23 +0200 Subject: [PATCH 13/80] Add `sentBytes` calculation to `HTTPHC5Impl` and corresponding unit tests for GET and POST requests --- .../protocol/http/sampler/HTTPHC5Impl.java | 77 +++++++++++++++++++ .../http/sampler/TestHTTPHC5Features.java | 42 ++++++++++ 2 files changed, 119 insertions(+) diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java index 9bd94ea910d..0f707f98342 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java @@ -108,12 +108,16 @@ import org.apache.jmeter.util.SSLManager; import org.apache.jorphan.util.JOrphanUtils; import org.apache.jorphan.util.StringUtilities; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * HTTP Sampler using Apache HttpClient 5.x. */ public class HTTPHC5Impl extends HTTPHCAbstractImpl { + private static final Logger log = LoggerFactory.getLogger(HTTPHC5Impl.class); + private static final ThreadLocal> HTTP_CLIENTS = ThreadLocal.withInitial(HashMap::new); @@ -435,6 +439,7 @@ private static int getPort(URL url) { private void updateResult(ClassicHttpResponse response, org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request, HTTPSampleResult result) throws IOException { result.setRequestHeaders(getRequestHeaders(request)); + result.setSentBytes(calculateSentBytes(request)); Header contentType = response.getFirstHeader(HTTPConstants.HEADER_CONTENT_TYPE); if (contentType != null) { result.setContentType(contentType.getValue()); @@ -482,6 +487,78 @@ private static String getRequestHeaders(org.apache.hc.client5.http.classic.metho return headers.toString(); } + private static long calculateSentBytes(org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request) { + if (request == null) { + return 0; + } + long sentBytes = 0; + + String method = request.getMethod(); + String uri = request.getRequestUri(); + if (uri == null) { + uri = ""; + } + org.apache.hc.core5.http.ProtocolVersion version = request.getVersion(); + String versionStr = version != null ? version.toString() : "HTTP/1.1"; + + sentBytes += method.getBytes(Charset.defaultCharset()).length; + sentBytes += 1; + sentBytes += uri.getBytes(Charset.defaultCharset()).length; + sentBytes += 1; + sentBytes += versionStr.getBytes(Charset.defaultCharset()).length; + sentBytes += 2; + + for (Header header : request.getHeaders()) { + String name = header.getName(); + String value = header.getValue(); + if (name != null) { + sentBytes += name.getBytes(Charset.defaultCharset()).length; + sentBytes += 2; + } + if (value != null) { + sentBytes += value.getBytes(Charset.defaultCharset()).length; + } + sentBytes += 2; + } + sentBytes += 2; + + HttpEntity entity = request.getEntity(); + if (entity != null) { + long contentLength = entity.getContentLength(); + if (contentLength >= 0) { + sentBytes += contentLength; + } else if (entity.isRepeatable()) { + CountingOutputStream counter = new CountingOutputStream(); + try { + entity.writeTo(counter); + sentBytes += counter.getCount(); + } catch (IOException e) { + log.debug("Exception measuring entity length", e); + } + } + } + + return sentBytes; + } + + private static class CountingOutputStream extends java.io.OutputStream { + private long count = 0; + + @Override + public void write(int b) { + count++; + } + + @Override + public void write(byte[] b, int off, int len) { + count += len; + } + + long getCount() { + return count; + } + } + private static String getOnlyCookieFromHeaders(org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request) { Header cookie = request.getFirstHeader(HTTPConstants.HEADER_COOKIE); return cookie == null ? "" : cookie.getValue(); diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java index 604bc240593..dc8d509d130 100644 --- a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java +++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java @@ -20,9 +20,11 @@ import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; import static com.github.tomakehurst.wiremock.client.WireMock.get; import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.post; import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.ByteArrayInputStream; import java.io.DataInputStream; @@ -156,6 +158,46 @@ void usesHttp11WhenSelected() throws Exception { } } + @Test + void setsSentBytesCorrectlyForGetRequest() throws Exception { + WireMockServer server = createServer(); + server.start(); + try { + server.stubFor(get(urlEqualTo("/sentBytesGet")).willReturn(aResponse().withStatus(200))); + HTTPSamplerBase sampler = newSampler(); + sampler.setHttpVersion("HTTP/1.1"); + + HTTPSampleResult result = sampler.sample( + new URL(server.url("/sentBytesGet")), HTTPConstants.GET, false, 1); + + assertEquals("200", result.getResponseCode()); + assertTrue(result.getSentBytes() > 0, "sentBytes should be greater than 0"); + } finally { + server.stop(); + } + } + + @Test + void setsSentBytesCorrectlyForPostRequest() throws Exception { + WireMockServer server = createServer(); + server.start(); + try { + server.stubFor(post(urlEqualTo("/sentBytesPost")).willReturn(aResponse().withStatus(200))); + HTTPSamplerBase sampler = newSampler(); + sampler.setHttpVersion("HTTP/1.1"); + sampler.setPostBodyRaw(true); + sampler.addNonEncodedArgument("", "hello world", ""); + + HTTPSampleResult result = sampler.sample( + new URL(server.url("/sentBytesPost")), HTTPConstants.POST, false, 1); + + assertEquals("200", result.getResponseCode()); + assertTrue(result.getSentBytes() > "hello world".length(), "sentBytes should include request line, headers, and body"); + } finally { + server.stop(); + } + } + @Test void sendsConditionalRequestForCachedResource() throws Exception { WireMockServer server = createServer(); From 7a2dd4f5ff6e051c2e9ae3da60cd883e139db13b Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Mon, 27 Jul 2026 13:32:01 +0200 Subject: [PATCH 14/80] Add `sentBytes` calculation to `HTTPJavaImpl` for GET and POST requests, including HTTP/2, with corresponding unit tests --- .../protocol/http/sampler/HTTPJavaImpl.java | 156 +++++++++++++++++- .../http/sampler/TestHTTPJavaFeatures.java | 81 +++++++++ 2 files changed, 234 insertions(+), 3 deletions(-) diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java index 711d3fb6d1d..e276014ce12 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java @@ -34,6 +34,7 @@ import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.ArrayList; import java.util.Collections; @@ -557,6 +558,10 @@ protected HTTPSampleResult sample(URL url, String method, boolean areFollowingRe } } + Map> requestHeaders = null; + Map securityHeaders = Collections.emptyMap(); + byte[] postBodyBytes = null; + try { // Sampling proper - establish the connection and read the response: // Repeatedly try to connect: @@ -565,6 +570,8 @@ protected HTTPSampleResult sample(URL url, String method, boolean areFollowingRe for (; retry < MAX_CONN_RETRIES; retry++) { try { conn = setupConnection(url, method, res); + requestHeaders = new LinkedHashMap<>(conn.getRequestProperties()); + securityHeaders = setConnectionAuthorization(conn, url, getAuthManager()); // Attempt the connection: savedConn = conn; conn.connect(); @@ -593,9 +600,15 @@ protected HTTPSampleResult sample(URL url, String method, boolean areFollowingRe if (method.equals(HTTPConstants.POST)) { String postBody = sendPostData(conn); res.setQueryString(postBody); + if (postBody != null) { + postBodyBytes = getBytes(postBody); + } } else if (method.equals(HTTPConstants.PUT)) { String putBody = sendPutData(conn); res.setQueryString(putBody); + if (putBody != null) { + postBodyBytes = getBytes(putBody); + } } // Request sent. Now get the response: byte[] responseData = readResponse(conn, res); @@ -677,6 +690,8 @@ protected HTTPSampleResult sample(URL url, String method, boolean areFollowingRe cacheManager.saveDetails(conn, res); } + res.setSentBytes(calculateSentBytes(url, method, testElement.getHttpVersion(), requestHeaders, securityHeaders, postBodyBytes)); + res = resultProcessing(areFollowingRedirect, frameDepth, res); log.debug("End : sample"); @@ -685,6 +700,7 @@ protected HTTPSampleResult sample(URL url, String method, boolean areFollowingRe if (res.getEndTime() == 0) { res.sampleEnd(); } + res.setSentBytes(calculateSentBytes(url, method, testElement.getHttpVersion(), requestHeaders, securityHeaders, postBodyBytes)); savedConn = null; // we don't want interrupt to try disconnection again // We don't want to continue using this connection, even if KeepAlive is set if (conn != null) { // May not exist @@ -780,14 +796,17 @@ private HTTPSampleResult sampleHttp2(URL url, String method, boolean areFollowin } } + CapturingHttpURLConnection capturingConn = null; + byte[] requestBodyBytes = new byte[0]; + Map securityHeaders = Collections.emptyMap(); + try { - CapturingHttpURLConnection capturingConn = new CapturingHttpURLConnection(url, method); + capturingConn = new CapturingHttpURLConnection(url, method); setConnectionHeaders(capturingConn, url, getHeaderManager(), getCacheManager()); String cookies = setConnectionCookie(capturingConn, url, getCookieManager()); - Map securityHeaders = setConnectionAuthorization(capturingConn, url, getAuthManager()); + securityHeaders = setConnectionAuthorization(capturingConn, url, getAuthManager()); - byte[] requestBodyBytes = new byte[0]; if (method.equals(HTTPConstants.POST)) { setPostHeaders(capturingConn); String postBody = sendPostData(capturingConn); @@ -885,6 +904,10 @@ private HTTPSampleResult sampleHttp2(URL url, String method, boolean areFollowin cacheManager.saveDetails(response, res); } + res.setSentBytes(calculateSentBytes(url, method, "HTTP/2", + capturingConn != null ? capturingConn.getRequestProperties() : null, + securityHeaders, requestBodyBytes)); + res = resultProcessing(areFollowingRedirect, frameDepth, res); log.debug("End : sampleHttp2"); @@ -893,6 +916,9 @@ private HTTPSampleResult sampleHttp2(URL url, String method, boolean areFollowin if (res.getEndTime() == 0) { res.sampleEnd(); } + res.setSentBytes(calculateSentBytes(url, method, "HTTP/2", + capturingConn != null ? capturingConn.getRequestProperties() : null, + securityHeaders, requestBodyBytes)); return errorResult(e, res); } } @@ -960,6 +986,130 @@ private static boolean isRestrictedHeader(String name) { || "Upgrade".equalsIgnoreCase(name); // $NON-NLS-1$ } + private static long calculateSentBytes( + URL u, + String method, + String version, + Map> requestHeaders, + Map securityHeaders, + byte[] postBodyBytes) { + long sentBytes = 0; + + if (StringUtilities.isBlank(method)) { + method = HTTPConstants.GET; + } + + String uri = u != null ? u.getFile() : ""; + if (StringUtilities.isBlank(uri)) { + uri = "/"; // $NON-NLS-1$ + } + + if (StringUtilities.isBlank(version)) { + version = HTTPConstants.HTTP_1_1; + } + + // Request line: METHOD URI VERSION\r\n + sentBytes += method.getBytes(StandardCharsets.UTF_8).length; + sentBytes += 1; + sentBytes += uri.getBytes(StandardCharsets.UTF_8).length; + sentBytes += 1; + sentBytes += version.getBytes(StandardCharsets.UTF_8).length; + sentBytes += 2; + + // Request headers + if (requestHeaders != null) { + for (Map.Entry> entry : requestHeaders.entrySet()) { + String key = entry.getKey(); + if (key != null) { + List values = entry.getValue(); + if (values != null) { + for (String val : values) { + sentBytes += key.getBytes(StandardCharsets.UTF_8).length; + sentBytes += 2; // ": " + if (val != null) { + sentBytes += val.getBytes(StandardCharsets.UTF_8).length; + } + sentBytes += 2; // "\r\n" + } + } + } + } + } + + if (securityHeaders != null && !securityHeaders.isEmpty()) { + for (Map.Entry secEntry : securityHeaders.entrySet()) { + String secKey = secEntry.getKey(); + String secVal = secEntry.getValue(); + if (secKey != null && !hasHeader(requestHeaders, secKey)) { + sentBytes += secKey.getBytes(StandardCharsets.UTF_8).length; + sentBytes += 2; // ": " + if (secVal != null) { + sentBytes += secVal.getBytes(StandardCharsets.UTF_8).length; + } + sentBytes += 2; // "\r\n" + } + } + } + + // Header/Body separator \r\n + sentBytes += 2; + + // Request body + if (postBodyBytes != null && postBodyBytes.length > 0) { + sentBytes += postBodyBytes.length; + } else if (requestHeaders != null) { + String contentLengthStr = getHeaderValue(requestHeaders, HTTPConstants.HEADER_CONTENT_LENGTH); + if (StringUtilities.isNotEmpty(contentLengthStr)) { + try { + sentBytes += Long.parseLong(contentLengthStr); + } catch (NumberFormatException e) { + log.debug("Could not parse Content-Length header: {}", contentLengthStr, e); + } + } + } + + return sentBytes; + } + + private static boolean hasHeader(Map> headers, String headerName) { + if (headers == null || headerName == null) { + return false; + } + for (String key : headers.keySet()) { + if (headerName.equalsIgnoreCase(key)) { + return true; + } + } + return false; + } + + private static String getHeaderValue(Map> headers, String headerName) { + if (headers == null || headerName == null) { + return null; + } + for (Map.Entry> entry : headers.entrySet()) { + if (headerName.equalsIgnoreCase(entry.getKey())) { + List values = entry.getValue(); + if (values != null && !values.isEmpty()) { + return values.get(0); + } + } + } + return null; + } + + private byte[] getBytes(String postBody) { + String enc = testElement != null ? testElement.getContentEncoding() : null; + if (StringUtilities.isBlank(enc)) { + enc = StandardCharsets.UTF_8.name(); + } + try { + return postBody.getBytes(enc); + } catch (Exception e) { + return postBody.getBytes(StandardCharsets.UTF_8); + } + } + private HttpClient getHttpClient(URL url) { int connectTimeout = getConnectTimeout(); String proxyHost = getProxyHost(); diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java index d3646c86bea..3c6c90538b9 100644 --- a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java +++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java @@ -19,6 +19,7 @@ import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.post; import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -90,6 +91,86 @@ void usesHttp2WithProxy() throws Exception { } } + @Test + void setsSentBytesCorrectlyForGetRequest() throws Exception { + WireMockServer server = createServer(); + server.start(); + try { + server.stubFor(get(urlEqualTo("/sentBytesGet")).willReturn(aResponse().withStatus(200))); + HTTPSamplerBase sampler = newSampler(); + sampler.setHttpVersion("HTTP/1.1"); + + HTTPSampleResult result = sampler.sample( + new URL(server.url("/sentBytesGet")), HTTPConstants.GET, false, 1); + + assertEquals("200", result.getResponseCode()); + assertTrue(result.getSentBytes() > 0, "sentBytes should be greater than 0"); + } finally { + server.stop(); + } + } + + @Test + void setsSentBytesCorrectlyForPostRequest() throws Exception { + WireMockServer server = createServer(); + server.start(); + try { + server.stubFor(post(urlEqualTo("/sentBytesPost")).willReturn(aResponse().withStatus(200))); + HTTPSamplerBase sampler = newSampler(); + sampler.setHttpVersion("HTTP/1.1"); + sampler.setPostBodyRaw(true); + sampler.addNonEncodedArgument("", "hello world", ""); + + HTTPSampleResult result = sampler.sample( + new URL(server.url("/sentBytesPost")), HTTPConstants.POST, false, 1); + + assertEquals("200", result.getResponseCode()); + assertTrue(result.getSentBytes() > "hello world".length(), "sentBytes should include request line, headers, and body"); + } finally { + server.stop(); + } + } + + @Test + void setsSentBytesCorrectlyForHttp2GetRequest() throws Exception { + WireMockServer server = createServer(); + server.start(); + try { + server.stubFor(get(urlEqualTo("/http2SentBytesGet")).willReturn(aResponse().withStatus(200))); + HTTPSamplerBase sampler = newSampler(); + sampler.setHttpVersion("HTTP/2"); + + HTTPSampleResult result = sampler.sample( + new URL(server.url("/http2SentBytesGet")), HTTPConstants.GET, false, 1); + + assertEquals("200", result.getResponseCode()); + assertTrue(result.getSentBytes() > 0, "sentBytes should be greater than 0"); + } finally { + server.stop(); + } + } + + @Test + void setsSentBytesCorrectlyForHttp2PostRequest() throws Exception { + WireMockServer server = createServer(); + server.start(); + try { + server.stubFor(post(urlEqualTo("/http2SentBytesPost")).willReturn(aResponse().withStatus(200))); + HTTPSamplerBase sampler = newSampler(); + sampler.setHttpVersion("HTTP/2"); + sampler.setPostBodyRaw(true); + sampler.addNonEncodedArgument("", "hello world http2", ""); + + HTTPSampleResult result = sampler.sample( + new URL(server.url("/http2SentBytesPost")), HTTPConstants.POST, false, 1); + + assertEquals("200", result.getResponseCode()); + assertTrue(result.getSentBytes() > "hello world http2".length(), "sentBytes should include request line, headers, and body"); + } finally { + server.stop(); + } + } + private static HTTPSamplerBase newSampler() { return HTTPSamplerFactory.newInstance("Java"); } From b4c5ac7c1834456bd56db26638245613a0451a1f Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Mon, 27 Jul 2026 15:19:55 +0200 Subject: [PATCH 15/80] Improve header handling in `HTTPJavaImpl` by preserving security headers (`Authorization`, `Proxy-Authorization`) and adding unit tests for validation --- .../protocol/http/sampler/HTTPJavaImpl.java | 38 ++++++++++----- .../http/sampler/TestHTTPJavaFeatures.java | 48 +++++++++++++++++++ 2 files changed, 74 insertions(+), 12 deletions(-) diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java index e276014ce12..6917455ff09 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java @@ -222,10 +222,10 @@ protected HttpURLConnection setupConnection(URL u, String method, HTTPSampleResu } conn.setRequestMethod(method); - setConnectionHeaders(conn, u, getHeaderManager(), getCacheManager()); + Map securityHeaders = setConnectionHeaders(conn, u, getHeaderManager(), getCacheManager()); String cookies = setConnectionCookie(conn, u, getCookieManager()); - Map securityHeaders = setConnectionAuthorization(conn, u, getAuthManager()); + setConnectionAuthorization(conn, u, getAuthManager(), securityHeaders); if (method.equals(HTTPConstants.POST)) { setPostHeaders(conn); @@ -379,6 +379,11 @@ private static String setConnectionCookie(HttpURLConnection conn, URL u, CookieM return cookieHeader; } + private static boolean isSecurityHeader(String name) { + return name != null && (HTTPConstants.HEADER_AUTHORIZATION.equalsIgnoreCase(name) + || "Proxy-Authorization".equalsIgnoreCase(name)); + } + /** * Extracts all the required headers for that particular URL request and * sets them in the HttpURLConnection passed in @@ -392,11 +397,13 @@ private static String setConnectionCookie(HttpURLConnection conn, URL u, CookieM * the HeaderManager containing all the cookies * for this UrlConfig * @param cacheManager the CacheManager (may be null) + * @return Map of security headers set from HeaderManager */ - private static void setConnectionHeaders(HttpURLConnection conn, URL u, + private static Map setConnectionHeaders(HttpURLConnection conn, URL u, HeaderManager headerManager, CacheManager cacheManager) { // Add all the headers from the HeaderManager Header[] arrayOfHeaders = null; + Map securityHeaders = new LinkedHashMap<>(); if (headerManager != null) { CollectionProperty headers = headerManager.getHeaders(); if (headers != null) { @@ -408,12 +415,16 @@ private static void setConnectionHeaders(HttpURLConnection conn, URL u, String v = header.getValue(); arrayOfHeaders[i++] = header; conn.addRequestProperty(n, v); + if (isSecurityHeader(n)) { + securityHeaders.put(n, v); + } } } } if (cacheManager != null){ cacheManager.setHeaders(conn, arrayOfHeaders, u); } + return securityHeaders; } /** @@ -476,8 +487,10 @@ private static String getFromConnectionHeaders(HttpURLConnection conn, Map entry : securityHeaders.entrySet()) { - hdrs.append(entry.getKey()).append(": ") // $NON-NLS-1$ - .append(entry.getValue()).append("\n"); // $NON-NLS-1$ + if (!hasHeader(requestHeaders, entry.getKey())) { + hdrs.append(entry.getKey()).append(": ") // $NON-NLS-1$ + .append(entry.getValue()).append("\n"); // $NON-NLS-1$ + } } } return hdrs.toString(); @@ -495,9 +508,10 @@ private static String getFromConnectionHeaders(HttpURLConnection conn, MapAuthManager containing all the cookies for * this UrlConfig - * @return String Authorization header value or null if not set + * @param securityHeaders + * Map to collect security headers */ - private static Map setConnectionAuthorization(HttpURLConnection conn, URL u, AuthManager authManager) { + private static void setConnectionAuthorization(HttpURLConnection conn, URL u, AuthManager authManager, Map securityHeaders) { if (authManager != null) { Authorization auth = authManager.getAuthForURL(u); if (auth != null) { @@ -505,10 +519,9 @@ private static Map setConnectionAuthorization(HttpURLConnection conn.setRequestProperty(HTTPConstants.HEADER_AUTHORIZATION, headerValue); // Java hides request properties so we have to // keep trace of it - return Collections.singletonMap(HTTPConstants.HEADER_AUTHORIZATION, headerValue); + securityHeaders.put(HTTPConstants.HEADER_AUTHORIZATION, headerValue); } } - return Collections.emptyMap(); } /** @@ -571,7 +584,8 @@ protected HTTPSampleResult sample(URL url, String method, boolean areFollowingRe try { conn = setupConnection(url, method, res); requestHeaders = new LinkedHashMap<>(conn.getRequestProperties()); - securityHeaders = setConnectionAuthorization(conn, url, getAuthManager()); + securityHeaders = setConnectionHeaders(conn, url, getHeaderManager(), getCacheManager()); + setConnectionAuthorization(conn, url, getAuthManager(), securityHeaders); // Attempt the connection: savedConn = conn; conn.connect(); @@ -803,9 +817,9 @@ private HTTPSampleResult sampleHttp2(URL url, String method, boolean areFollowin try { capturingConn = new CapturingHttpURLConnection(url, method); - setConnectionHeaders(capturingConn, url, getHeaderManager(), getCacheManager()); + securityHeaders = setConnectionHeaders(capturingConn, url, getHeaderManager(), getCacheManager()); String cookies = setConnectionCookie(capturingConn, url, getCookieManager()); - securityHeaders = setConnectionAuthorization(capturingConn, url, getAuthManager()); + setConnectionAuthorization(capturingConn, url, getAuthManager(), securityHeaders); if (method.equals(HTTPConstants.POST)) { setPostHeaders(capturingConn); diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java index 3c6c90538b9..e99d7c7c9b6 100644 --- a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java +++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java @@ -27,6 +27,8 @@ import java.net.URL; +import org.apache.jmeter.protocol.http.control.Header; +import org.apache.jmeter.protocol.http.control.HeaderManager; import org.apache.jmeter.protocol.http.util.HTTPConstants; import org.junit.jupiter.api.Test; @@ -171,6 +173,52 @@ void setsSentBytesCorrectlyForHttp2PostRequest() throws Exception { } } + @Test + void displaysAuthorizationHeaderFromHeaderManagerInHttp11() throws Exception { + WireMockServer server = createServer(); + server.start(); + try { + server.stubFor(get(urlEqualTo("/authHttp11")).willReturn(aResponse().withStatus(200))); + HTTPSamplerBase sampler = newSampler(); + sampler.setHttpVersion("HTTP/1.1"); + HeaderManager headerManager = new HeaderManager(); + headerManager.add(new Header("Authorization", "Bearer my-secret-token")); + sampler.setHeaderManager(headerManager); + + HTTPSampleResult result = sampler.sample( + new URL(server.url("/authHttp11")), HTTPConstants.GET, false, 1); + + assertEquals("200", result.getResponseCode()); + assertTrue(result.getRequestHeaders().contains("Authorization: Bearer my-secret-token"), + "Request headers should contain Authorization header set in HeaderManager"); + } finally { + server.stop(); + } + } + + @Test + void displaysAuthorizationHeaderFromHeaderManagerInHttp2() throws Exception { + WireMockServer server = createServer(); + server.start(); + try { + server.stubFor(get(urlEqualTo("/authHttp2")).willReturn(aResponse().withStatus(200))); + HTTPSamplerBase sampler = newSampler(); + sampler.setHttpVersion("HTTP/2"); + HeaderManager headerManager = new HeaderManager(); + headerManager.add(new Header("Authorization", "Bearer my-secret-token-http2")); + sampler.setHeaderManager(headerManager); + + HTTPSampleResult result = sampler.sample( + new URL(server.url("/authHttp2")), HTTPConstants.GET, false, 1); + + assertEquals("200", result.getResponseCode()); + assertTrue(result.getRequestHeaders().contains("Authorization: Bearer my-secret-token-http2"), + "Request headers should contain Authorization header set in HeaderManager"); + } finally { + server.stop(); + } + } + private static HTTPSamplerBase newSampler() { return HTTPSamplerFactory.newInstance("Java"); } From 0fcf9cfd3e927be348489ad29703462e3cd3619c Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Mon, 27 Jul 2026 16:41:49 +0200 Subject: [PATCH 16/80] Add connect time measurement to `HTTPJavaImpl` and `HTTPHC5Impl` with unit tests --- .../protocol/http/sampler/HTTPHC5Impl.java | 151 +++++++++++++++++- .../protocol/http/sampler/HTTPJavaImpl.java | 1 + .../http/sampler/TestHTTPHC5Features.java | 98 ++++++++++++ .../http/sampler/TestHTTPJavaFeatures.java | 22 +++ 4 files changed, 270 insertions(+), 2 deletions(-) diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java index 0f707f98342..afcb4642ba9 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java @@ -39,6 +39,7 @@ import javax.net.ssl.SSLContext; +import org.apache.hc.client5.http.HttpRoute; import org.apache.hc.client5.http.async.methods.SimpleHttpRequest; import org.apache.hc.client5.http.async.methods.SimpleHttpResponse; import org.apache.hc.client5.http.auth.AuthScope; @@ -63,10 +64,16 @@ import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder; import org.apache.hc.client5.http.impl.nio.PoolingAsyncClientConnectionManagerBuilder; import org.apache.hc.client5.http.impl.routing.DefaultRoutePlanner; +import org.apache.hc.client5.http.io.ConnectionEndpoint; +import org.apache.hc.client5.http.io.HttpClientConnectionManager; +import org.apache.hc.client5.http.io.LeaseRequest; +import org.apache.hc.client5.http.nio.AsyncClientConnectionManager; +import org.apache.hc.client5.http.nio.AsyncConnectionEndpoint; import org.apache.hc.client5.http.protocol.HttpClientContext; import org.apache.hc.client5.http.ssl.ClientTlsStrategyBuilder; import org.apache.hc.client5.http.ssl.NoopHostnameVerifier; import org.apache.hc.client5.http.ssl.TrustAllStrategy; +import org.apache.hc.core5.concurrent.FutureCallback; import org.apache.hc.core5.http.ClassicHttpResponse; import org.apache.hc.core5.http.ContentType; import org.apache.hc.core5.http.Header; @@ -87,8 +94,12 @@ import org.apache.hc.core5.http.message.BasicNameValuePair; import org.apache.hc.core5.http.message.ParserCursor; import org.apache.hc.core5.http.nio.ssl.TlsStrategy; +import org.apache.hc.core5.http.protocol.HttpContext; import org.apache.hc.core5.http2.HttpVersionPolicy; +import org.apache.hc.core5.io.CloseMode; +import org.apache.hc.core5.reactor.ConnectionInitiator; import org.apache.hc.core5.ssl.SSLContexts; +import org.apache.hc.core5.util.TimeValue; import org.apache.hc.core5.util.Timeout; import org.apache.jmeter.protocol.http.control.AuthManager; import org.apache.jmeter.protocol.http.control.Authorization; @@ -99,6 +110,7 @@ import org.apache.jmeter.protocol.http.util.HTTPArgument; import org.apache.jmeter.protocol.http.util.HTTPConstants; import org.apache.jmeter.protocol.http.util.HTTPFileArg; +import org.apache.jmeter.samplers.SampleResult; import org.apache.jmeter.services.FileServer; import org.apache.jmeter.testelement.property.CollectionProperty; import org.apache.jmeter.testelement.property.JMeterProperty; @@ -118,6 +130,9 @@ public class HTTPHC5Impl extends HTTPHCAbstractImpl { private static final Logger log = LoggerFactory.getLogger(HTTPHC5Impl.class); + /** Key used to store the current {@link SampleResult} in the {@link HttpClientContext}. */ + static final String CONTEXT_ATTRIBUTE_SAMPLER_RESULT = "__jmeter.S_R__"; //$NON-NLS-1$ + private static final ThreadLocal> HTTP_CLIENTS = ThreadLocal.withInitial(HashMap::new); @@ -215,6 +230,7 @@ protected HTTPSampleResult sample(URL url, String method, boolean areFollowingRe currentRequest = request; HttpClientKey clientKey = createHttpClientKey(url); HttpClientContext context = createHttpClientContext(url, clientKey, request); + context.setAttribute(CONTEXT_ATTRIBUTE_SAMPLER_RESULT, result); response = clientKey.httpVersionPolicy != HttpVersionPolicy.FORCE_HTTP_1 ? executeHttp2(getHttp2Client(clientKey), request, context) : getClient(clientKey).executeOpen(null, request, context); @@ -598,7 +614,7 @@ private static CloseableHttpClient createClient(HttpClientKey key) { .setConnectTimeout(Timeout.ofMilliseconds(key.connectTimeout)) .build()); } - builder.setConnectionManager(connectionManagerBuilder.build()); + builder.setConnectionManager(new ConnectTimeMeasuringConnectionManager(connectionManagerBuilder.build())); builder.setRoutePlanner(createRoutePlanner(key)); return builder.disableContentCompression() .addExecInterceptorFirst("response-content-encoding", RESPONSE_CONTENT_ENCODING) @@ -622,7 +638,7 @@ private static CloseableHttpAsyncClient createHttp2Client(HttpClientKey key) { .setConnectTimeout(Timeout.ofMilliseconds(key.connectTimeout)) .build()); } - builder.setConnectionManager(connectionManagerBuilder.build()); + builder.setConnectionManager(new ConnectTimeMeasuringAsyncConnectionManager(connectionManagerBuilder.build())); CloseableHttpAsyncClient asyncClient = builder.build(); asyncClient.start(); return asyncClient; @@ -769,6 +785,137 @@ public boolean interrupt() { return request != null; } + private static void recordConnectEnd(HttpContext context) { + SampleResult sample = (SampleResult) context.getAttribute(CONTEXT_ATTRIBUTE_SAMPLER_RESULT); + if (sample != null) { + sample.connectEnd(); + } + } + + /** + * Delegating connection manager that records the connect time of the current sample + * as soon as the connection to the first hop has been established. + */ + static final class ConnectTimeMeasuringConnectionManager implements HttpClientConnectionManager { + + private final HttpClientConnectionManager delegate; + + ConnectTimeMeasuringConnectionManager(HttpClientConnectionManager delegate) { + this.delegate = delegate; + } + + @Override + public LeaseRequest lease(String id, HttpRoute route, Timeout requestTimeout, Object state) { + return delegate.lease(id, route, requestTimeout, state); + } + + @Override + public void release(ConnectionEndpoint endpoint, Object newState, TimeValue validDuration) { + delegate.release(endpoint, newState, validDuration); + } + + @Override + public void connect(ConnectionEndpoint endpoint, TimeValue connectTimeout, HttpContext context) throws IOException { + try { + delegate.connect(endpoint, connectTimeout, context); + } finally { + recordConnectEnd(context); + } + } + + @Override + public void upgrade(ConnectionEndpoint endpoint, HttpContext context) throws IOException { + delegate.upgrade(endpoint, context); + } + + @Override + public void close(CloseMode closeMode) { + delegate.close(closeMode); + } + + @Override + public void close() throws IOException { + delegate.close(); + } + } + + /** + * Delegating asynchronous connection manager that records the connect time of the current + * sample as soon as the connection to the first hop has been established. + */ + private static final class ConnectTimeMeasuringAsyncConnectionManager implements AsyncClientConnectionManager { + + private final AsyncClientConnectionManager delegate; + + private ConnectTimeMeasuringAsyncConnectionManager(AsyncClientConnectionManager delegate) { + this.delegate = delegate; + } + + @Override + public Future lease(String id, HttpRoute route, Object state, Timeout requestTimeout, + FutureCallback callback) { + return delegate.lease(id, route, state, requestTimeout, callback); + } + + @Override + public void release(AsyncConnectionEndpoint endpoint, Object newState, TimeValue validDuration) { + delegate.release(endpoint, newState, validDuration); + } + + @Override + public Future connect(AsyncConnectionEndpoint endpoint, + ConnectionInitiator connectionInitiator, Timeout connectTimeout, Object attachment, + HttpContext context, FutureCallback callback) { + return delegate.connect(endpoint, connectionInitiator, connectTimeout, attachment, context, + new FutureCallback<>() { + @Override + public void completed(AsyncConnectionEndpoint result) { + recordConnectEnd(context); + if (callback != null) { + callback.completed(result); + } + } + + @Override + public void failed(Exception ex) { + recordConnectEnd(context); + if (callback != null) { + callback.failed(ex); + } + } + + @Override + public void cancelled() { + recordConnectEnd(context); + if (callback != null) { + callback.cancelled(); + } + } + }); + } + + @Override + public void upgrade(AsyncConnectionEndpoint endpoint, Object attachment, HttpContext context) { + delegate.upgrade(endpoint, attachment, context); + } + + @Override + public void upgrade(AsyncConnectionEndpoint endpoint, Object attachment, HttpContext context, + FutureCallback callback) { + delegate.upgrade(endpoint, attachment, context, callback); + } + + @Override + public void close(CloseMode closeMode) { + delegate.close(closeMode); + } + + @Override + public void close() throws IOException { + delegate.close(); + } + } + private static final class HttpClientKey { private final String protocol; private final String authority; diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java index 6917455ff09..03f66e49f82 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java @@ -589,6 +589,7 @@ protected HTTPSampleResult sample(URL url, String method, boolean areFollowingRe // Attempt the connection: savedConn = conn; conn.connect(); + res.connectEnd(); break; } catch (BindException e) { if (retry >= MAX_CONN_RETRIES) { diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java index dc8d509d130..7abf55a6bd7 100644 --- a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java +++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java @@ -33,10 +33,20 @@ import java.net.URL; import java.nio.charset.StandardCharsets; +import org.apache.hc.client5.http.HttpRoute; +import org.apache.hc.client5.http.io.ConnectionEndpoint; +import org.apache.hc.client5.http.io.HttpClientConnectionManager; +import org.apache.hc.client5.http.io.LeaseRequest; +import org.apache.hc.client5.http.protocol.HttpClientContext; +import org.apache.hc.core5.http.protocol.HttpContext; import org.apache.hc.core5.http2.HttpVersionPolicy; +import org.apache.hc.core5.io.CloseMode; +import org.apache.hc.core5.util.TimeValue; +import org.apache.hc.core5.util.Timeout; import org.apache.jmeter.protocol.http.control.AuthManager; import org.apache.jmeter.protocol.http.control.CacheManager; import org.apache.jmeter.protocol.http.util.HTTPConstants; +import org.apache.jmeter.samplers.SampleResult; import org.junit.jupiter.api.Test; import com.github.tomakehurst.wiremock.WireMockServer; @@ -262,6 +272,94 @@ void authenticatesWithConfiguredProxyCredentials() throws Exception { } } + @Test + void setsConnectTimeForHttp11() throws Exception { + SampleResult result = new SampleResult(); + result.sampleStart(); + HttpClientContext context = HttpClientContext.create(); + context.setAttribute(HTTPHC5Impl.CONTEXT_ATTRIBUTE_SAMPLER_RESULT, result); + HttpClientConnectionManager connectionManager = + new HTTPHC5Impl.ConnectTimeMeasuringConnectionManager(new SlowConnectingConnectionManager(50)); + + connectionManager.connect(null, null, context); + Thread.sleep(50); + result.sampleEnd(); + + assertTrue(result.getConnectTime() >= 50, + "connectTime should cover the time spent connecting, but was " + result.getConnectTime()); + assertTrue(result.getConnectTime() <= result.getTime(), + "connectTime should not exceed the elapsed time"); + } + + /** Connection manager that only supports {@code connect} and takes a well-known amount of time for it. */ + private static final class SlowConnectingConnectionManager implements HttpClientConnectionManager { + + private final long connectDurationMillis; + + SlowConnectingConnectionManager(long connectDurationMillis) { + this.connectDurationMillis = connectDurationMillis; + } + + @Override + public LeaseRequest lease(String id, HttpRoute route, Timeout requestTimeout, Object state) { + throw new UnsupportedOperationException(); + } + + @Override + public void release(ConnectionEndpoint endpoint, Object newState, TimeValue validDuration) { + throw new UnsupportedOperationException(); + } + + @Override + public void connect(ConnectionEndpoint endpoint, TimeValue connectTimeout, HttpContext context) throws IOException { + try { + Thread.sleep(connectDurationMillis); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException(e); + } + } + + @Override + public void upgrade(ConnectionEndpoint endpoint, HttpContext context) { + throw new UnsupportedOperationException(); + } + + @Override + public void close(CloseMode closeMode) { + // nothing to close + } + + @Override + public void close() { + // nothing to close + } + } + + @Test + void setsConnectTimeForHttp2() throws Exception { + WireMockServer server = new WireMockServer(WireMockConfiguration.wireMockConfig() + .dynamicHttpsPort() + .http2TlsDisabled(false)); + server.start(); + try { + server.stubFor(get(urlEqualTo("/connectTime2")).willReturn(aResponse().withStatus(200))); + HTTPSamplerBase sampler = newSampler(); + sampler.setHttpVersion("HTTP/2"); + + HTTPSampleResult result = sampler.sample( + new URL("https://localhost:" + server.httpsPort() + "/connectTime2"), HTTPConstants.GET, false, 1); + + assertEquals("200", result.getResponseCode()); + assertTrue(result.getConnectTime() > 0, + "connectTime should be greater than 0, but was " + result.getConnectTime()); + assertTrue(result.getConnectTime() <= result.getTime(), + "connectTime should not exceed the elapsed time"); + } finally { + server.stop(); + } + } + private static HTTPSamplerBase newSampler() { return HTTPSamplerFactory.newInstance("HttpClient5"); } diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java index e99d7c7c9b6..b52ad4e2ce0 100644 --- a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java +++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java @@ -219,6 +219,28 @@ void displaysAuthorizationHeaderFromHeaderManagerInHttp2() throws Exception { } } + @Test + void setsConnectTimeForHttp11() throws Exception { + WireMockServer server = new WireMockServer(WireMockConfiguration.wireMockConfig().dynamicHttpsPort()); + server.start(); + try { + server.stubFor(get(urlEqualTo("/connectTime")).willReturn(aResponse().withStatus(200))); + HTTPSamplerBase sampler = newSampler(); + sampler.setHttpVersion("HTTP/1.1"); + + HTTPSampleResult result = sampler.sample( + new URL("https://localhost:" + server.httpsPort() + "/connectTime"), HTTPConstants.GET, false, 1); + + assertEquals("200", result.getResponseCode()); + assertTrue(result.getConnectTime() > 0, + "connectTime should be greater than 0, but was " + result.getConnectTime()); + assertTrue(result.getConnectTime() <= result.getTime(), + "connectTime should not exceed the elapsed time"); + } finally { + server.stop(); + } + } + private static HTTPSamplerBase newSampler() { return HTTPSamplerFactory.newInstance("Java"); } From cf6200722d9f99ad3d389e1258279f1ffb73b06e Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Tue, 28 Jul 2026 09:01:44 +0200 Subject: [PATCH 17/80] Add connect time measurement for HTTP/2 in `HTTPJavaImpl` with unit tests --- .../protocol/http/sampler/HTTPJavaImpl.java | 392 +++++++++++++++++- .../http/sampler/TestHTTPJavaFeatures.java | 116 ++++++ 2 files changed, 501 insertions(+), 7 deletions(-) diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java index 03f66e49f82..28b16f2e6c4 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java @@ -34,7 +34,11 @@ import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; +import java.security.KeyManagementException; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; import java.time.Duration; import java.util.ArrayList; import java.util.Collections; @@ -43,10 +47,27 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.concurrent.Executor; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.BiFunction; import java.util.function.Predicate; import java.util.zip.GZIPInputStream; +import javax.net.ssl.KeyManager; import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLContextSpi; +import javax.net.ssl.SSLEngine; +import javax.net.ssl.SSLEngineResult; +import javax.net.ssl.SSLException; +import javax.net.ssl.SSLParameters; +import javax.net.ssl.SSLServerSocketFactory; +import javax.net.ssl.SSLSession; +import javax.net.ssl.SSLSessionContext; +import javax.net.ssl.SSLSocketFactory; +import javax.net.ssl.TrustManager; import org.apache.jmeter.protocol.http.control.AuthManager; import org.apache.jmeter.protocol.http.control.Authorization; @@ -78,9 +99,16 @@ public class HTTPJavaImpl extends HTTPAbstractImpl { private static final String DEFAULT_HTTP_VERSION = JMeterUtils.getPropDefault("httpclient.version", HTTPConstants.HTTP_1_1); // $NON-NLS-1$ - private static final ThreadLocal> HTTP_2_CLIENTS = + private static final ThreadLocal> HTTP_2_CLIENTS = ThreadLocal.withInitial(HashMap::new); + /** + * Shared daemon thread pool used by the HTTP/2 {@link HttpClient} instances. It allows JMeter to observe + * when a connection has been established, since the client only submits tasks once the TCP connection is up. + */ + private static final ExecutorService HTTP_2_EXECUTOR = + Executors.newCachedThreadPool(new Http2ThreadFactory()); + private static final Logger log = LoggerFactory.getLogger(HTTPJavaImpl.class); static boolean isHttp2(String samplerHttpVersion, String defaultHttpVersion) { @@ -871,10 +899,17 @@ private HTTPSampleResult sampleHttp2(URL url, String method, boolean areFollowin } } - HttpClient client = getHttpClient(url); + Http2Client client = getHttpClient(url); HttpRequest httpRequest = reqBuilder.build(); - HttpResponse response = client.send(httpRequest, HttpResponse.BodyHandlers.ofInputStream()); + HttpResponse response; + ConnectTimeTracker connectTimeTracker = client.connectTimeTracker; + connectTimeTracker.sampleStarted(res); + try { + response = client.httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofInputStream()); + } finally { + connectTimeTracker.sampleFinished(); + } res.latencyEnd(); byte[] responseData = readResponse(response, res); @@ -1125,7 +1160,7 @@ private byte[] getBytes(String postBody) { } } - private HttpClient getHttpClient(URL url) { + private Http2Client getHttpClient(URL url) { int connectTimeout = getConnectTimeout(); String proxyHost = getProxyHost(); int proxyPort = getProxyPortInt(); @@ -1143,6 +1178,14 @@ private HttpClient getHttpClient(URL url) { } catch (Exception e) { log.warn("Problem getting SSLContext for HTTP/2 HttpClient: ", e); // $NON-NLS-1$ } + if (sslContext == null) { + // Use the default context explicitly, so the connect time of the TLS handshake can be measured + try { + sslContext = SSLContext.getDefault(); + } catch (NoSuchAlgorithmException e) { + log.warn("Problem getting default SSLContext for HTTP/2 HttpClient: ", e); // $NON-NLS-1$ + } + } } HttpClientKey key = new HttpClientKey(connectTimeout, proxyHost, proxyPort, @@ -1151,9 +1194,11 @@ private HttpClient getHttpClient(URL url) { return HTTP_2_CLIENTS.get().computeIfAbsent(key, HTTPJavaImpl::createHttpClient); } - private static HttpClient createHttpClient(HttpClientKey key) { + private static Http2Client createHttpClient(HttpClientKey key) { + ConnectTimeTracker connectTimeTracker = new ConnectTimeTracker(); HttpClient.Builder builder = HttpClient.newBuilder() .version(HttpClient.Version.HTTP_2) + .executor(new ConnectTimeMeasuringExecutor(connectTimeTracker)) .followRedirects(key.autoRedirects ? HttpClient.Redirect.NORMAL : HttpClient.Redirect.NEVER); if (key.connectTimeout > 0) { @@ -1177,10 +1222,343 @@ protected PasswordAuthentication getPasswordAuthentication() { } if (key.sslContext != null) { - builder.sslContext(key.sslContext); + builder.sslContext(new ConnectTimeMeasuringSSLContext(key.sslContext, connectTimeTracker)); + } + + return new Http2Client(builder.build(), connectTimeTracker); + } + + /** + * Holder for an HTTP/2 {@link HttpClient} and the tracker which measures the connect time of its connections. + */ + private static final class Http2Client { + private final HttpClient httpClient; + private final ConnectTimeTracker connectTimeTracker; + + Http2Client(HttpClient httpClient, ConnectTimeTracker connectTimeTracker) { + this.httpClient = httpClient; + this.connectTimeTracker = connectTimeTracker; + } + } + + /** + * Records the connect time of the JDK {@link HttpClient} in the current {@link SampleResult}. + *

    + * The JDK client does not expose a hook for connection establishment, so two indicators are used: + * the client submits its first task to the executor once the TCP connection has been established, and + * the wrapped {@code SSLEngine} reports when the TLS handshake has been finished. The TLS handshake + * always wins, since it is the more precise and the later event. + */ + static final class ConnectTimeTracker { + private volatile SampleResult sampleResult; + private volatile boolean connectRecorded; + private volatile boolean handshakeRecorded; + + void sampleStarted(SampleResult result) { + this.connectRecorded = false; + this.handshakeRecorded = false; + this.sampleResult = result; + } + + void sampleFinished() { + this.sampleResult = null; + } + + /** Invoked when the TCP connection has been established. */ + void connectionEstablished() { + SampleResult result = sampleResult; + if (result != null && !connectRecorded && !handshakeRecorded) { + connectRecorded = true; + result.connectEnd(); + } + } + + /** Invoked when the TLS handshake has been finished, it overrides the plain TCP connect time. */ + void handshakeFinished() { + SampleResult result = sampleResult; + if (result != null && !handshakeRecorded) { + handshakeRecorded = true; + connectRecorded = true; + result.connectEnd(); + } + } + } + + /** + * Executor which notifies the {@link ConnectTimeTracker} before delegating to the shared thread pool. + */ + private static final class ConnectTimeMeasuringExecutor implements Executor { + private final ConnectTimeTracker tracker; + + ConnectTimeMeasuringExecutor(ConnectTimeTracker tracker) { + this.tracker = tracker; + } + + @Override + public void execute(Runnable command) { + tracker.connectionEstablished(); + HTTP_2_EXECUTOR.execute(command); + } + } + + private static final class Http2ThreadFactory implements ThreadFactory { + private final AtomicInteger counter = new AtomicInteger(); + + @Override + public Thread newThread(Runnable r) { + Thread thread = new Thread(r, "JMeter-HTTP2-" + counter.incrementAndGet()); // $NON-NLS-1$ + thread.setDaemon(true); + return thread; + } + } + + /** + * {@link SSLContext} which creates {@link SSLEngine}s that report the end of the TLS handshake. + */ + static final class ConnectTimeMeasuringSSLContext extends SSLContext { + ConnectTimeMeasuringSSLContext(SSLContext delegate, ConnectTimeTracker tracker) { + super(new ConnectTimeMeasuringSSLContextSpi(delegate, tracker), delegate.getProvider(), + delegate.getProtocol()); + } + } + + private static final class ConnectTimeMeasuringSSLContextSpi extends SSLContextSpi { + private final SSLContext delegate; + private final ConnectTimeTracker tracker; + + ConnectTimeMeasuringSSLContextSpi(SSLContext delegate, ConnectTimeTracker tracker) { + this.delegate = delegate; + this.tracker = tracker; + } + + @Override + protected void engineInit(KeyManager[] km, TrustManager[] tm, SecureRandom sr) throws KeyManagementException { + delegate.init(km, tm, sr); + } + + @Override + protected SSLSocketFactory engineGetSocketFactory() { + return delegate.getSocketFactory(); + } + + @Override + protected SSLServerSocketFactory engineGetServerSocketFactory() { + return delegate.getServerSocketFactory(); + } + + @Override + protected SSLEngine engineCreateSSLEngine() { + return new ConnectTimeMeasuringSSLEngine(delegate.createSSLEngine(), tracker); + } + + @Override + protected SSLEngine engineCreateSSLEngine(String host, int port) { + return new ConnectTimeMeasuringSSLEngine(delegate.createSSLEngine(host, port), tracker); + } + + @Override + protected SSLSessionContext engineGetServerSessionContext() { + return delegate.getServerSessionContext(); + } + + @Override + protected SSLSessionContext engineGetClientSessionContext() { + return delegate.getClientSessionContext(); + } + + @Override + protected SSLParameters engineGetDefaultSSLParameters() { + return delegate.getDefaultSSLParameters(); + } + + @Override + protected SSLParameters engineGetSupportedSSLParameters() { + return delegate.getSupportedSSLParameters(); + } + } + + /** + * {@link SSLEngine} which delegates all calls and notifies the {@link ConnectTimeTracker} + * as soon as the TLS handshake has been finished. + */ + static final class ConnectTimeMeasuringSSLEngine extends SSLEngine { + private final SSLEngine delegate; + private final ConnectTimeTracker tracker; + + ConnectTimeMeasuringSSLEngine(SSLEngine delegate, ConnectTimeTracker tracker) { + super(delegate.getPeerHost(), delegate.getPeerPort()); + this.delegate = delegate; + this.tracker = tracker; + } + + private void checkHandshakeFinished(SSLEngineResult result) { + if (result.getHandshakeStatus() == SSLEngineResult.HandshakeStatus.FINISHED) { + tracker.handshakeFinished(); + } + } + + @Override + public SSLEngineResult wrap(ByteBuffer[] srcs, int offset, int length, ByteBuffer dst) throws SSLException { + SSLEngineResult result = delegate.wrap(srcs, offset, length, dst); + checkHandshakeFinished(result); + return result; } - return builder.build(); + @Override + public SSLEngineResult unwrap(ByteBuffer src, ByteBuffer[] dsts, int offset, int length) throws SSLException { + SSLEngineResult result = delegate.unwrap(src, dsts, offset, length); + checkHandshakeFinished(result); + return result; + } + + @Override + public Runnable getDelegatedTask() { + return delegate.getDelegatedTask(); + } + + @Override + public void closeInbound() throws SSLException { + delegate.closeInbound(); + } + + @Override + public boolean isInboundDone() { + return delegate.isInboundDone(); + } + + @Override + public void closeOutbound() { + delegate.closeOutbound(); + } + + @Override + public boolean isOutboundDone() { + return delegate.isOutboundDone(); + } + + @Override + public String[] getSupportedCipherSuites() { + return delegate.getSupportedCipherSuites(); + } + + @Override + public String[] getEnabledCipherSuites() { + return delegate.getEnabledCipherSuites(); + } + + @Override + public void setEnabledCipherSuites(String[] suites) { + delegate.setEnabledCipherSuites(suites); + } + + @Override + public String[] getSupportedProtocols() { + return delegate.getSupportedProtocols(); + } + + @Override + public String[] getEnabledProtocols() { + return delegate.getEnabledProtocols(); + } + + @Override + public void setEnabledProtocols(String[] protocols) { + delegate.setEnabledProtocols(protocols); + } + + @Override + public SSLSession getSession() { + return delegate.getSession(); + } + + @Override + public SSLSession getHandshakeSession() { + return delegate.getHandshakeSession(); + } + + @Override + public void beginHandshake() throws SSLException { + delegate.beginHandshake(); + } + + @Override + public SSLEngineResult.HandshakeStatus getHandshakeStatus() { + return delegate.getHandshakeStatus(); + } + + @Override + public void setUseClientMode(boolean mode) { + delegate.setUseClientMode(mode); + } + + @Override + public boolean getUseClientMode() { + return delegate.getUseClientMode(); + } + + @Override + public void setNeedClientAuth(boolean need) { + delegate.setNeedClientAuth(need); + } + + @Override + public boolean getNeedClientAuth() { + return delegate.getNeedClientAuth(); + } + + @Override + public void setWantClientAuth(boolean want) { + delegate.setWantClientAuth(want); + } + + @Override + public boolean getWantClientAuth() { + return delegate.getWantClientAuth(); + } + + @Override + public void setEnableSessionCreation(boolean flag) { + delegate.setEnableSessionCreation(flag); + } + + @Override + public boolean getEnableSessionCreation() { + return delegate.getEnableSessionCreation(); + } + + @Override + public SSLParameters getSSLParameters() { + return delegate.getSSLParameters(); + } + + @Override + public void setSSLParameters(SSLParameters params) { + delegate.setSSLParameters(params); + } + + @Override + public String getApplicationProtocol() { + return delegate.getApplicationProtocol(); + } + + @Override + public String getHandshakeApplicationProtocol() { + return delegate.getHandshakeApplicationProtocol(); + } + + @Override + public void setHandshakeApplicationProtocolSelector(BiFunction, String> selector) { + if (selector == null) { + delegate.setHandshakeApplicationProtocolSelector(null); + } else { + delegate.setHandshakeApplicationProtocolSelector((engine, protocols) -> selector.apply(this, protocols)); + } + } + + @Override + public BiFunction, String> getHandshakeApplicationProtocolSelector() { + return delegate.getHandshakeApplicationProtocolSelector(); + } } private static class HttpClientKey { diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java index b52ad4e2ce0..49301a71df3 100644 --- a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java +++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java @@ -25,11 +25,23 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.net.Socket; +import java.net.URI; import java.net.URL; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.security.cert.X509Certificate; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLEngine; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509ExtendedTrustManager; import org.apache.jmeter.protocol.http.control.Header; import org.apache.jmeter.protocol.http.control.HeaderManager; import org.apache.jmeter.protocol.http.util.HTTPConstants; +import org.apache.jmeter.samplers.SampleResult; import org.junit.jupiter.api.Test; import com.github.tomakehurst.wiremock.WireMockServer; @@ -241,6 +253,110 @@ void setsConnectTimeForHttp11() throws Exception { } } + @Test + void setsConnectTimeForHttp2OverPlainConnection() throws Exception { + WireMockServer server = createServer(); + server.start(); + try { + server.stubFor(get(urlEqualTo("/http2ConnectTime")).willReturn(aResponse().withStatus(200))); + HTTPSamplerBase sampler = newSampler(); + sampler.setHttpVersion("HTTP/2"); + + HTTPSampleResult result = sampler.sample( + new URL(server.url("/http2ConnectTime")), HTTPConstants.GET, false, 1); + + assertEquals("200", result.getResponseCode()); + assertTrue(result.getConnectTime() > 0, + "connectTime should be greater than 0, but was " + result.getConnectTime()); + assertTrue(result.getConnectTime() <= result.getTime(), + "connectTime should not exceed the elapsed time"); + } finally { + server.stop(); + } + } + + @Test + void setsConnectTimeForHttp2OverTls() throws Exception { + WireMockServer server = new WireMockServer( + WireMockConfiguration.wireMockConfig().dynamicHttpsPort().http2TlsDisabled(false)); + server.start(); + try { + server.stubFor(get(urlEqualTo("/http2TlsConnectTime")).willReturn(aResponse().withStatus(200))); + + HTTPJavaImpl.ConnectTimeTracker tracker = new HTTPJavaImpl.ConnectTimeTracker(); + SSLContext sslContext = trustAllContext(); + HttpClient client = HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_2) + .sslContext(new HTTPJavaImpl.ConnectTimeMeasuringSSLContext(sslContext, tracker)) + .build(); + + SampleResult result = new SampleResult(); + result.sampleStart(); + tracker.sampleStarted(result); + HttpResponse response; + try { + response = client.send( + HttpRequest.newBuilder(URI.create( + "https://localhost:" + server.httpsPort() + "/http2TlsConnectTime")).build(), + HttpResponse.BodyHandlers.ofString()); + } finally { + tracker.sampleFinished(); + } + result.sampleEnd(); + + assertEquals(200, response.statusCode()); + assertEquals(HttpClient.Version.HTTP_2, response.version()); + assertTrue(result.getConnectTime() > 0, + "connectTime should be greater than 0, but was " + result.getConnectTime()); + assertTrue(result.getConnectTime() <= result.getTime(), + "connectTime should not exceed the elapsed time"); + } finally { + server.stop(); + } + } + + private static SSLContext trustAllContext() throws Exception { + TrustManager trustAll = new X509ExtendedTrustManager() { + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType) { + // trust everything in the test + } + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType) { + // trust everything in the test + } + + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType, Socket socket) { + // trust everything in the test + } + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType, Socket socket) { + // trust everything in the test + } + + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType, SSLEngine engine) { + // trust everything in the test + } + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType, SSLEngine engine) { + // trust everything in the test + } + + @Override + public X509Certificate[] getAcceptedIssuers() { + return new X509Certificate[0]; + } + }; + SSLContext context = SSLContext.getInstance("TLS"); + context.init(null, new TrustManager[] { trustAll }, null); + return context; + } + private static HTTPSamplerBase newSampler() { return HTTPSamplerFactory.newInstance("Java"); } From 93fcd0adc56ba0f622a7bccd7c9ead8585e44b7f Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Tue, 28 Jul 2026 09:16:11 +0200 Subject: [PATCH 18/80] Add HTTP/2 reason phrase handling in `HTTPJavaImpl` with unit tests --- .../protocol/http/sampler/HTTPJavaImpl.java | 84 ++++++++++++++++++- .../http/sampler/TestHTTPJavaFeatures.java | 25 ++++++ 2 files changed, 108 insertions(+), 1 deletion(-) diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java index 28b16f2e6c4..d6a8591b04c 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java @@ -111,6 +111,88 @@ public class HTTPJavaImpl extends HTTPAbstractImpl { private static final Logger log = LoggerFactory.getLogger(HTTPJavaImpl.class); + /** + * HTTP/2 does not transmit a reason phrase (see RFC 9113, section 8.3.2), so the response message is + * derived from the status code. The phrases are the ones registered in the IANA HTTP status code registry. + */ + private static final Map HTTP_REASON_PHRASES = createReasonPhrases(); + + private static Map createReasonPhrases() { + Map phrases = new HashMap<>(); + phrases.put(100, "Continue"); // $NON-NLS-1$ + phrases.put(101, "Switching Protocols"); // $NON-NLS-1$ + phrases.put(102, "Processing"); // $NON-NLS-1$ + phrases.put(103, "Early Hints"); // $NON-NLS-1$ + phrases.put(200, "OK"); // $NON-NLS-1$ + phrases.put(201, "Created"); // $NON-NLS-1$ + phrases.put(202, "Accepted"); // $NON-NLS-1$ + phrases.put(203, "Non-Authoritative Information"); // $NON-NLS-1$ + phrases.put(204, "No Content"); // $NON-NLS-1$ + phrases.put(205, "Reset Content"); // $NON-NLS-1$ + phrases.put(206, "Partial Content"); // $NON-NLS-1$ + phrases.put(207, "Multi-Status"); // $NON-NLS-1$ + phrases.put(208, "Already Reported"); // $NON-NLS-1$ + phrases.put(226, "IM Used"); // $NON-NLS-1$ + phrases.put(300, "Multiple Choices"); // $NON-NLS-1$ + phrases.put(301, "Moved Permanently"); // $NON-NLS-1$ + phrases.put(302, "Found"); // $NON-NLS-1$ + phrases.put(303, "See Other"); // $NON-NLS-1$ + phrases.put(304, "Not Modified"); // $NON-NLS-1$ + phrases.put(305, "Use Proxy"); // $NON-NLS-1$ + phrases.put(307, "Temporary Redirect"); // $NON-NLS-1$ + phrases.put(308, "Permanent Redirect"); // $NON-NLS-1$ + phrases.put(400, "Bad Request"); // $NON-NLS-1$ + phrases.put(401, "Unauthorized"); // $NON-NLS-1$ + phrases.put(402, "Payment Required"); // $NON-NLS-1$ + phrases.put(403, "Forbidden"); // $NON-NLS-1$ + phrases.put(404, "Not Found"); // $NON-NLS-1$ + phrases.put(405, "Method Not Allowed"); // $NON-NLS-1$ + phrases.put(406, "Not Acceptable"); // $NON-NLS-1$ + phrases.put(407, "Proxy Authentication Required"); // $NON-NLS-1$ + phrases.put(408, "Request Timeout"); // $NON-NLS-1$ + phrases.put(409, "Conflict"); // $NON-NLS-1$ + phrases.put(410, "Gone"); // $NON-NLS-1$ + phrases.put(411, "Length Required"); // $NON-NLS-1$ + phrases.put(412, "Precondition Failed"); // $NON-NLS-1$ + phrases.put(413, "Content Too Large"); // $NON-NLS-1$ + phrases.put(414, "URI Too Long"); // $NON-NLS-1$ + phrases.put(415, "Unsupported Media Type"); // $NON-NLS-1$ + phrases.put(416, "Range Not Satisfiable"); // $NON-NLS-1$ + phrases.put(417, "Expectation Failed"); // $NON-NLS-1$ + phrases.put(421, "Misdirected Request"); // $NON-NLS-1$ + phrases.put(422, "Unprocessable Content"); // $NON-NLS-1$ + phrases.put(423, "Locked"); // $NON-NLS-1$ + phrases.put(424, "Failed Dependency"); // $NON-NLS-1$ + phrases.put(425, "Too Early"); // $NON-NLS-1$ + phrases.put(426, "Upgrade Required"); // $NON-NLS-1$ + phrases.put(428, "Precondition Required"); // $NON-NLS-1$ + phrases.put(429, "Too Many Requests"); // $NON-NLS-1$ + phrases.put(431, "Request Header Fields Too Large"); // $NON-NLS-1$ + phrases.put(451, "Unavailable For Legal Reasons"); // $NON-NLS-1$ + phrases.put(500, "Internal Server Error"); // $NON-NLS-1$ + phrases.put(501, "Not Implemented"); // $NON-NLS-1$ + phrases.put(502, "Bad Gateway"); // $NON-NLS-1$ + phrases.put(503, "Service Unavailable"); // $NON-NLS-1$ + phrases.put(504, "Gateway Timeout"); // $NON-NLS-1$ + phrases.put(505, "HTTP Version Not Supported"); // $NON-NLS-1$ + phrases.put(506, "Variant Also Negotiates"); // $NON-NLS-1$ + phrases.put(507, "Insufficient Storage"); // $NON-NLS-1$ + phrases.put(508, "Loop Detected"); // $NON-NLS-1$ + phrases.put(510, "Not Extended"); // $NON-NLS-1$ + phrases.put(511, "Network Authentication Required"); // $NON-NLS-1$ + return Collections.unmodifiableMap(phrases); + } + + /** + * Returns the reason phrase belonging to the given HTTP status code. + * + * @param statusCode the HTTP status code + * @return the registered reason phrase, or an empty string if the status code is unknown + */ + static String getReasonPhrase(int statusCode) { + return HTTP_REASON_PHRASES.getOrDefault(statusCode, ""); // $NON-NLS-1$ + } + static boolean isHttp2(String samplerHttpVersion, String defaultHttpVersion) { String httpVersion = StringUtilities.isBlank(samplerHttpVersion) ? defaultHttpVersion : samplerHttpVersion; return "HTTP/2".equalsIgnoreCase(httpVersion) || "2".equalsIgnoreCase(httpVersion); // $NON-NLS-1$ $NON-NLS-2$ @@ -921,7 +1003,7 @@ private HTTPSampleResult sampleHttp2(URL url, String method, boolean areFollowin int statusCode = response.statusCode(); res.setResponseCode(Integer.toString(statusCode)); res.setSuccessful(isSuccessCode(statusCode)); - res.setResponseMessage(""); // $NON-NLS-1$ + res.setResponseMessage(getReasonPhrase(statusCode)); String responseHeaders = getResponseHeaders(response); res.setResponseHeaders(responseHeaders); diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java index 49301a71df3..d008a94431b 100644 --- a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java +++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java @@ -85,6 +85,31 @@ void usesHttp2WhenSelected() throws Exception { } } + @Test + void setsResponseMessageForHttp2() throws Exception { + WireMockServer server = createServer(); + server.start(); + try { + server.stubFor(get(urlEqualTo("/http2ResponseMessage")).willReturn(aResponse().withStatus(404))); + HTTPSamplerBase sampler = newSampler(); + sampler.setHttpVersion("HTTP/2"); + + HTTPSampleResult result = sampler.sample( + new URL(server.url("/http2ResponseMessage")), HTTPConstants.GET, false, 1); + + assertEquals("404", result.getResponseCode()); + assertEquals("Not Found", result.getResponseMessage()); + } finally { + server.stop(); + } + } + + @Test + void returnsEmptyReasonPhraseForUnknownStatusCode() { + assertEquals("OK", HTTPJavaImpl.getReasonPhrase(200)); + assertEquals("", HTTPJavaImpl.getReasonPhrase(599)); + } + @Test void usesHttp2WithProxy() throws Exception { WireMockServer server = createServer(); From 62cb493da2c7dcbb2638a2b7eb9ea918d2c508cc Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Tue, 28 Jul 2026 09:52:18 +0200 Subject: [PATCH 19/80] Reorder HTTP version options in GUI components for consistency --- .../apache/jmeter/protocol/http/config/gui/HttpDefaultsGui.java | 2 +- .../jmeter/protocol/http/control/gui/HttpTestSampleGui.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/config/gui/HttpDefaultsGui.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/config/gui/HttpDefaultsGui.java index 79534daa33a..4fdd406acf4 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/config/gui/HttpDefaultsGui.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/config/gui/HttpDefaultsGui.java @@ -79,7 +79,7 @@ public class HttpDefaultsGui extends AbstractConfigGui { private JTextField proxyUser; private JPasswordField proxyPass; private final JComboBox httpImplementation = new JComboBox<>(HTTPSamplerFactory.getImplementations()); - private final JComboBox httpVersion = new JComboBox<>(new String[] {"", "HTTP/1.1", "HTTP/2"}); + private final JComboBox httpVersion = new JComboBox<>(new String[] {"HTTP/1.1", "HTTP/2", ""}); private JTextField connectTimeOut; private JTextField responseTimeOut; diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/gui/HttpTestSampleGui.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/gui/HttpTestSampleGui.java index 5ce2acc909e..7b6150e3b37 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/gui/HttpTestSampleGui.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/gui/HttpTestSampleGui.java @@ -81,7 +81,7 @@ public class HttpTestSampleGui extends AbstractSamplerGui { private JTextField proxyUser; private JPasswordField proxyPass; private final JComboBox httpImplementation = new JComboBox<>(HTTPSamplerFactory.getImplementations()); - private final JComboBox httpVersion = new JComboBox<>(new String[] {"", "HTTP/1.1", "HTTP/2"}); + private final JComboBox httpVersion = new JComboBox<>(new String[] {"HTTP/1.1", "HTTP/2", ""}); private JTextField connectTimeOut; private JTextField responseTimeOut; From 52a36c2a03bfce1388951a55af2b50e93c1971b5 Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Fri, 31 Jul 2026 08:47:39 +0200 Subject: [PATCH 20/80] Implement HTTP/2 multiplexing and configurable protocol settings in HttpClient5 sampler --- bin/jmeter.properties | 36 +++++ .../protocol/http/sampler/HTTPHC5Impl.java | 148 +++++++++++++++++- .../http/sampler/TestHTTPHC5Features.java | 82 ++++++++++ xdocs/changes.xml | 1 + xdocs/usermanual/properties_reference.xml | 42 +++++ 5 files changed, 303 insertions(+), 6 deletions(-) diff --git a/bin/jmeter.properties b/bin/jmeter.properties index 8820915bb25..30170ccd9f0 100644 --- a/bin/jmeter.properties +++ b/bin/jmeter.properties @@ -382,6 +382,42 @@ remote_hosts=127.0.0.1 # Valid values are HTTP/1.1 and HTTP/2 (defaults to HTTP/1.1) #httpclient.version=HTTP/1.1 +# HTTP/2 settings used by the HttpClient5 sampler implementation + +# Multiplex concurrent message exchanges (e.g. parallel downloads of embedded +# resources) over a single HTTP/2 connection +#httpclient5.h2.multiplexing=true + +# Maximum number of HTTP/2 connections kept per target host. With multiplexing +# enabled a single connection usually serves all concurrent requests +#httpclient5.h2.max_connections_per_route=6 + +# Size in bytes of the HPACK dynamic header table announced to the server. +# 0 disables indexing of headers by the server +#httpclient5.h2.header_table_size=8192 + +# Compress the request headers with HPACK +#httpclient5.h2.header_compression=true + +# Maximum number of concurrent streams the client accepts on a connection +#httpclient5.h2.max_concurrent_streams=250 + +# Flow control window in bytes announced per stream +#httpclient5.h2.initial_window_size=65535 + +# Largest frame payload in bytes the client is willing to receive +#httpclient5.h2.max_frame_size=65536 + +# Accept server push. JMeter cannot report pushed resources, so they are +# dropped, therefore push is disabled by default +#httpclient5.h2.push_enabled=false + +# Use HTTP/2 over cleartext (h2c, prior knowledge) when HTTP/2 is selected for +# a http:// URL. Without it such requests fall back to HTTP/1.1, because +# protocol negotiation requires TLS. Only enable it if all plain HTTP targets +# support h2c +#httpclient5.h2.prior_knowledge=false + # Define characters per second > 0 to emulate slow connections #httpclient.socket.http.cps=0 #httpclient.socket.https.cps=0 diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java index afcb4642ba9..aae0f69a7c1 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java @@ -20,6 +20,8 @@ import java.io.ByteArrayOutputStream; import java.io.File; import java.io.IOException; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; import java.net.InetAddress; import java.net.URI; import java.net.URL; @@ -28,13 +30,14 @@ import java.nio.charset.Charset; import java.security.GeneralSecurityException; import java.util.ArrayList; -import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; import javax.net.ssl.SSLContext; @@ -96,7 +99,9 @@ import org.apache.hc.core5.http.nio.ssl.TlsStrategy; import org.apache.hc.core5.http.protocol.HttpContext; import org.apache.hc.core5.http2.HttpVersionPolicy; +import org.apache.hc.core5.http2.config.H2Config; import org.apache.hc.core5.io.CloseMode; +import org.apache.hc.core5.pool.PoolConcurrencyPolicy; import org.apache.hc.core5.reactor.ConnectionInitiator; import org.apache.hc.core5.ssl.SSLContexts; import org.apache.hc.core5.util.TimeValue; @@ -116,6 +121,7 @@ import org.apache.jmeter.testelement.property.JMeterProperty; import org.apache.jmeter.threads.JMeterContextService; import org.apache.jmeter.threads.JMeterVariables; +import org.apache.jmeter.util.JMeterUtils; import org.apache.jmeter.util.JsseSSLManager; import org.apache.jmeter.util.SSLManager; import org.apache.jorphan.util.JOrphanUtils; @@ -134,10 +140,66 @@ public class HTTPHC5Impl extends HTTPHCAbstractImpl { static final String CONTEXT_ATTRIBUTE_SAMPLER_RESULT = "__jmeter.S_R__"; //$NON-NLS-1$ private static final ThreadLocal> HTTP_CLIENTS = - ThreadLocal.withInitial(HashMap::new); + new InheritableThreadLocal<>() { + @Override + protected Map initialValue() { + return new ConcurrentHashMap<>(); + } + }; + /** + * HTTP/2 clients are shared with the threads that download embedded resources in parallel, + * so that concurrent requests to the same host can be multiplexed over a single connection. + */ private static final ThreadLocal> HTTP_2_CLIENTS = - ThreadLocal.withInitial(HashMap::new); + new InheritableThreadLocal<>() { + @Override + protected Map initialValue() { + return new ConcurrentHashMap<>(); + } + }; + + /** Multiplex concurrent message exchanges over a single HTTP/2 connection. */ + private static final boolean HTTP_2_MULTIPLEXING = + JMeterUtils.getPropDefault("httpclient5.h2.multiplexing", true); + + /** Size of the HPACK dynamic header table announced to the server, {@code 0} disables HPACK indexing. */ + private static final int HTTP_2_HEADER_TABLE_SIZE = + JMeterUtils.getPropDefault("httpclient5.h2.header_table_size", H2Config.DEFAULT.getHeaderTableSize()); + + /** Whether HPACK compression of the request headers is used. */ + private static final boolean HTTP_2_HEADER_COMPRESSION = + JMeterUtils.getPropDefault("httpclient5.h2.header_compression", true); + + private static final int HTTP_2_MAX_CONCURRENT_STREAMS = + JMeterUtils.getPropDefault("httpclient5.h2.max_concurrent_streams", H2Config.DEFAULT.getMaxConcurrentStreams()); + + private static final int HTTP_2_INITIAL_WINDOW_SIZE = + JMeterUtils.getPropDefault("httpclient5.h2.initial_window_size", H2Config.DEFAULT.getInitialWindowSize()); + + private static final int HTTP_2_MAX_FRAME_SIZE = + JMeterUtils.getPropDefault("httpclient5.h2.max_frame_size", H2Config.DEFAULT.getMaxFrameSize()); + + /** + * JMeter has no way of reporting pushed resources, so server push is switched off to avoid + * the server wasting bandwidth on responses that are dropped. + */ + private static final boolean HTTP_2_PUSH_ENABLED = + JMeterUtils.getPropDefault("httpclient5.h2.push_enabled", false); + + /** + * Use HTTP/2 without TLS (h2c with prior knowledge) when HTTP/2 is selected for a {@code http://} URL. + * Without prior knowledge such requests silently fall back to HTTP/1.1, as ALPN is unavailable. + */ + private static final boolean HTTP_2_PRIOR_KNOWLEDGE = + JMeterUtils.getPropDefault("httpclient5.h2.prior_knowledge", false); + + private static final int HTTP_2_MAX_CONNECTIONS_PER_ROUTE = + JMeterUtils.getPropDefault("httpclient5.h2.max_connections_per_route", 6); + + private static final H2Config HTTP_2_CONFIG = createHttp2Config(); + + private static final Method MESSAGE_MULTIPLEXING_SETTER = findMessageMultiplexingSetter(); private static final String[] HEADERS_TO_SAVE = {HttpHeaders.CONTENT_LENGTH, HttpHeaders.CONTENT_ENCODING, HttpHeaders.CONTENT_MD5}; @@ -205,6 +267,17 @@ private static TlsStrategy createHttp2TlsStrategy() { } } + static H2Config createHttp2Config() { + return H2Config.custom() + .setHeaderTableSize(HTTP_2_HEADER_TABLE_SIZE) + .setCompressionEnabled(HTTP_2_HEADER_COMPRESSION) + .setMaxConcurrentStreams(HTTP_2_MAX_CONCURRENT_STREAMS) + .setInitialWindowSize(HTTP_2_INITIAL_WINDOW_SIZE) + .setMaxFrameSize(HTTP_2_MAX_FRAME_SIZE) + .setPushEnabled(HTTP_2_PUSH_ENABLED) + .build(); + } + protected HTTPHC5Impl(HTTPSamplerBase testElement) { super(testElement); } @@ -272,7 +345,8 @@ private static org.apache.hc.client5.http.classic.methods.HttpUriRequestBase cre private void setupRequest(URL url, org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request, HTTPSampleResult result, boolean areFollowingRedirect) throws IOException { - HttpVersionPolicy httpVersionPolicy = getHttpVersionPolicy(testElement.getHttpVersion(), HTTP_VERSION); + HttpVersionPolicy httpVersionPolicy = getHttpVersionPolicy(testElement.getHttpVersion(), HTTP_VERSION, + url.getProtocol()); RequestConfig.Builder config = RequestConfig.custom() .setRedirectsEnabled(getAutoRedirects() && !areFollowingRedirect); int responseTimeout = getResponseTimeout(); @@ -622,14 +696,25 @@ private static CloseableHttpClient createClient(HttpClientKey key) { } private static CloseableHttpAsyncClient createHttp2Client(HttpClientKey key) { + boolean multiplexing = HTTP_2_MULTIPLEXING && MESSAGE_MULTIPLEXING_SETTER != null; HttpAsyncClientBuilder builder = HttpAsyncClients.custom() .disableAutomaticRetries() + .setH2Config(HTTP_2_CONFIG) .setRoutePlanner(createRoutePlanner(key)); + if (multiplexing) { + // A connection bound to a user token cannot be shared, and HTTP/2 has no connection scoped state anyway + builder.disableConnectionState(); + } PoolingAsyncClientConnectionManagerBuilder connectionManagerBuilder = PoolingAsyncClientConnectionManagerBuilder.create(); connectionManagerBuilder.setTlsStrategy(HTTP_2_TLS_STRATEGY); connectionManagerBuilder.setDefaultTlsConfig(TlsConfig.custom() .setVersionPolicy(key.httpVersionPolicy) .build()); + if (multiplexing) { + enableMessageMultiplexing(connectionManagerBuilder); + } + connectionManagerBuilder.setPoolConcurrencyPolicy(PoolConcurrencyPolicy.LAX); + connectionManagerBuilder.setMaxConnPerRoute(HTTP_2_MAX_CONNECTIONS_PER_ROUTE); if (key.dnsCacheManager != null) { connectionManagerBuilder.setDnsResolver(createDnsResolver(key.dnsCacheManager)); } @@ -644,6 +729,28 @@ private static CloseableHttpAsyncClient createHttp2Client(HttpClientKey key) { return asyncClient; } + /** + * Looks up {@code PoolingAsyncClientConnectionManagerBuilder#setMessageMultiplexing(boolean)}, + * which is only available from HttpClient 5.5 onwards. JMeter still runs with older HttpClient + * versions, where HTTP/2 requests just cannot share a connection. + */ + private static Method findMessageMultiplexingSetter() { + try { + return PoolingAsyncClientConnectionManagerBuilder.class.getMethod("setMessageMultiplexing", boolean.class); + } catch (NoSuchMethodException e) { + log.info("HTTP/2 message multiplexing is unavailable, HttpClient 5.5 or later is required"); + return null; + } + } + + private static void enableMessageMultiplexing(PoolingAsyncClientConnectionManagerBuilder connectionManagerBuilder) { + try { + MESSAGE_MULTIPLEXING_SETTER.invoke(connectionManagerBuilder, true); + } catch (IllegalAccessException | InvocationTargetException e) { + log.warn("Could not enable HTTP/2 message multiplexing", e); + } + } + @SuppressWarnings("deprecation") // SimpleHttpRequest.copy is required for HttpClient 5.3 compatibility private static ClassicHttpResponse executeHttp2(CloseableHttpAsyncClient client, org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request, HttpClientContext context) @@ -658,7 +765,7 @@ private static ClassicHttpResponse executeHttp2(CloseableHttpAsyncClient client, } Future responseFuture = client.execute(asyncRequest, context, null); try { - return createClassicResponse(responseFuture.get(1, java.util.concurrent.TimeUnit.MINUTES)); + return createClassicResponse(responseFuture.get(getHttp2ExecutionTimeoutMillis(request), TimeUnit.MILLISECONDS)); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new IOException("Interrupted while executing HTTP/2 request", e); @@ -670,6 +777,20 @@ private static ClassicHttpResponse executeHttp2(CloseableHttpAsyncClient client, } } + /** + * The future is only a safety net: HttpClient enforces the configured response timeout itself, + * so this waits a little longer than the sampler is willing to wait for the response. + */ + private static long getHttp2ExecutionTimeoutMillis( + org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request) { + RequestConfig requestConfig = request.getConfig(); + Timeout responseTimeout = requestConfig == null ? null : requestConfig.getResponseTimeout(); + if (responseTimeout == null || responseTimeout.toMilliseconds() <= 0) { + return TimeUnit.MINUTES.toMillis(1); + } + return responseTimeout.toMilliseconds() + TimeUnit.SECONDS.toMillis(5); + } + private static ClassicHttpResponse createClassicResponse(SimpleHttpResponse asyncResponse) { BasicClassicHttpResponse response = new BasicClassicHttpResponse(asyncResponse.getCode(), asyncResponse.getReasonPhrase()); @@ -725,7 +846,8 @@ private HttpClientKey createHttpClientKey(URL url) throws IOException { InetAddress localAddress = getIpSourceAddress(); boolean useDynamicProxy = isDynamicProxy(proxyHost, proxyPort); boolean useStaticProxy = isStaticProxy(url.getHost()); - HttpVersionPolicy httpVersionPolicy = getHttpVersionPolicy(testElement.getHttpVersion(), HTTP_VERSION); + HttpVersionPolicy httpVersionPolicy = getHttpVersionPolicy(testElement.getHttpVersion(), HTTP_VERSION, + url.getProtocol()); if (!useDynamicProxy) { proxyScheme = PROXY_SCHEME; proxyHost = PROXY_HOST; @@ -742,6 +864,20 @@ static HttpVersionPolicy getHttpVersionPolicy(String samplerHttpVersion, String ? HttpVersionPolicy.NEGOTIATE : HttpVersionPolicy.FORCE_HTTP_1; } + /** + * Determines the version policy for a request to the given scheme. Plain HTTP does not support + * ALPN, so HTTP/2 can only be used over {@code http://} when the client assumes that the server + * speaks HTTP/2 (h2c with prior knowledge). + */ + static HttpVersionPolicy getHttpVersionPolicy(String samplerHttpVersion, String defaultHttpVersion, String scheme) { + HttpVersionPolicy policy = getHttpVersionPolicy(samplerHttpVersion, defaultHttpVersion); + if (policy == HttpVersionPolicy.NEGOTIATE && HTTP_2_PRIOR_KNOWLEDGE + && !HTTPConstants.PROTOCOL_HTTPS.equalsIgnoreCase(scheme)) { + return HttpVersionPolicy.FORCE_HTTP_2; + } + return policy; + } + @Override protected void notifyFirstSampleAfterLoopRestart() { JMeterVariables variables = JMeterContextService.getContext().getVariables(); diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java index 7abf55a6bd7..4f208247a56 100644 --- a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java +++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java @@ -32,6 +32,12 @@ import java.io.InputStream; import java.net.URL; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; import org.apache.hc.client5.http.HttpRoute; import org.apache.hc.client5.http.io.ConnectionEndpoint; @@ -40,6 +46,7 @@ import org.apache.hc.client5.http.protocol.HttpClientContext; import org.apache.hc.core5.http.protocol.HttpContext; import org.apache.hc.core5.http2.HttpVersionPolicy; +import org.apache.hc.core5.http2.config.H2Config; import org.apache.hc.core5.io.CloseMode; import org.apache.hc.core5.util.TimeValue; import org.apache.hc.core5.util.Timeout; @@ -71,6 +78,81 @@ void defaultsToHttp11ForUnsupportedHttpVersion() { assertEquals(HttpVersionPolicy.FORCE_HTTP_1, HTTPHC5Impl.getHttpVersionPolicy("HTTP/3", "HTTP/2")); } + @Test + void negotiatesHttp2OverTls() { + assertEquals(HttpVersionPolicy.NEGOTIATE, HTTPHC5Impl.getHttpVersionPolicy("HTTP/2", "HTTP/1.1", "https")); + } + + @Test + void doesNotUsePriorKnowledgeForCleartextByDefault() { + assertEquals(HttpVersionPolicy.NEGOTIATE, HTTPHC5Impl.getHttpVersionPolicy("HTTP/2", "HTTP/1.1", "http")); + } + + @Test + void multiplexesConcurrentRequestsOverASingleHttp2Connection() throws Exception { + WireMockServer server = new WireMockServer(WireMockConfiguration.wireMockConfig() + .dynamicHttpsPort() + .http2TlsDisabled(false)); + server.start(); + try { + server.stubFor(get(urlEqualTo("/multiplexed")) + .willReturn(aResponse().withStatus(200).withFixedDelay(200))); + URL url = new URL("https://localhost:" + server.httpsPort() + "/multiplexed"); + // warm up, so the connection that gets shared by the concurrent samples is already established + HTTPSamplerBase warmUpSampler = newSampler(); + warmUpSampler.setHttpVersion("HTTP/2"); + assertEquals("200", warmUpSampler.sample(url, HTTPConstants.GET, false, 1).getResponseCode()); + + int requests = 4; + ExecutorService executor = Executors.newFixedThreadPool(requests); + try { + List> results = new ArrayList<>(); + for (int i = 0; i < requests; i++) { + results.add(executor.submit(() -> { + HTTPSamplerBase sampler = newSampler(); + sampler.setHttpVersion("HTTP/2"); + return sampler.sample(url, HTTPConstants.GET, false, 1); + })); + } + for (Future result : results) { + HTTPSampleResult sampleResult = result.get(30, TimeUnit.SECONDS); + assertEquals("200", sampleResult.getResponseCode()); + assertEquals("HTTP/2", sampleResult.getResponseHeaders().substring(0, "HTTP/2".length())); + } + } finally { + executor.shutdownNow(); + } + } finally { + server.stop(); + } + } + + @Test + void enablesMessageMultiplexingWithoutRequiringHttpClient55() throws Exception { + byte[] classBytes; + try (InputStream classFile = HTTPHC5Impl.class.getResourceAsStream("HTTPHC5Impl.class")) { + classBytes = classFile.readAllBytes(); + } + assertTrue(new String(classBytes, StandardCharsets.ISO_8859_1).contains("setMessageMultiplexing"), + "HTTP/2 message multiplexing should be enabled"); + assertFalse(hasMethodReference(classBytes, + "org/apache/hc/client5/http/impl/nio/PoolingAsyncClientConnectionManagerBuilder", + "setMessageMultiplexing"), + "setMessageMultiplexing was added in HttpClient 5.5 and must not be linked directly"); + } + + @Test + void appliesHttp2ProtocolSettings() { + H2Config config = HTTPHC5Impl.createHttp2Config(); + + assertEquals(8192, config.getHeaderTableSize(), "HPACK dynamic table size"); + assertTrue(config.isCompressionEnabled(), "HPACK header compression"); + assertEquals(250, config.getMaxConcurrentStreams()); + assertEquals(65535, config.getInitialWindowSize()); + assertEquals(65536, config.getMaxFrameSize()); + assertFalse(config.isPushEnabled(), "server push is dropped by JMeter, so it must not be announced"); + } + @Test void doesNotRequireProtocolUpgradeConfiguration() throws Exception { try (InputStream classFile = HTTPHC5Impl.class.getResourceAsStream("HTTPHC5Impl.class")) { diff --git a/xdocs/changes.xml b/xdocs/changes.xml index 5fd804f83ae..3835a226cd8 100644 --- a/xdocs/changes.xml +++ b/xdocs/changes.xml @@ -95,6 +95,7 @@ Summary

  • 6250Avoid adding "; charset=" automatically to multipart/form-data requests to align behavior with modern HTTP clients.
  • 6080Preserve the original HTTP method when following 307 and 308 redirects according to the HTTP specification. Contributed by LeeJiWon (github.com/dlwldnjs1009)
  • 62676268Add a space between key and value after : in View Results Tree > Sampler result tab for better readability.
  • +
  • Multiplex concurrent HTTP/2 requests of the HttpClient5 sampler implementation over a single connection, and make the HTTP/2 protocol settings (HPACK header table size and compression, maximum concurrent streams, flow control window, maximum frame size, server push and h2c prior knowledge) configurable with the new httpclient5.h2.* properties.

Timers, Assertions, Config, Pre- & Post-Processors

diff --git a/xdocs/usermanual/properties_reference.xml b/xdocs/usermanual/properties_reference.xml index 721a41d46bf..53d9c980c82 100644 --- a/xdocs/usermanual/properties_reference.xml +++ b/xdocs/usermanual/properties_reference.xml @@ -446,6 +446,48 @@ JMETER-SERVER Valid values are HTTP/1.1 and HTTP/2.
Defaults to: HTTP/1.1 + + Multiplex concurrent message exchanges (for example parallel downloads of embedded resources) + over a single HTTP/2 connection.
+ Defaults to: true +
+ + Maximum number of HTTP/2 connections kept per target host. With multiplexing enabled a single + connection usually serves all concurrent requests.
+ Defaults to: 6 +
+ + Size in bytes of the HPACK dynamic header table announced to the server. + 0 disables indexing of the headers by the server.
+ Defaults to: 8192 +
+ + Compress the request headers with HPACK.
+ Defaults to: true +
+ + Maximum number of concurrent streams the client accepts on a single HTTP/2 connection.
+ Defaults to: 250 +
+ + HTTP/2 flow control window in bytes announced per stream.
+ Defaults to: 65535 +
+ + Largest HTTP/2 frame payload in bytes the client is willing to receive.
+ Defaults to: 65536 +
+ + Accept HTTP/2 server push. JMeter cannot report pushed resources, so they are dropped, + therefore push is disabled by default.
+ Defaults to: false +
+ + Use HTTP/2 over cleartext (h2c with prior knowledge) when HTTP/2 is selected for a + http:// URL. Without it such requests fall back to HTTP/1.1, because protocol + negotiation requires TLS. Only enable it if all plain HTTP targets support h2c.
+ Defaults to: false +
Set characters per second to a value greater then zero to emulate slow connections.
Defaults to: 0 From 3eef3f74e116080b4ae6f5e249bc8f0409886f6c Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Fri, 31 Jul 2026 10:09:42 +0200 Subject: [PATCH 21/80] Add configurable User-Agent header handling in HttpClient5 sampler --- bin/jmeter.properties | 3 ++ .../protocol/http/sampler/HTTPHC5Impl.java | 47 +++++++++++++++++++ xdocs/changes.xml | 1 + xdocs/usermanual/properties_reference.xml | 5 ++ 4 files changed, 56 insertions(+) diff --git a/bin/jmeter.properties b/bin/jmeter.properties index 30170ccd9f0..de3c73a64df 100644 --- a/bin/jmeter.properties +++ b/bin/jmeter.properties @@ -418,6 +418,9 @@ remote_hosts=127.0.0.1 # support h2c #httpclient5.h2.prior_knowledge=false +# If true, default HC5 User-Agent will not be added +#httpclient5.default_user_agent_disabled=false + # Define characters per second > 0 to emulate slow connections #httpclient.socket.http.cps=0 #httpclient.socket.https.cps=0 diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java index aae0f69a7c1..27a07217c7b 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java @@ -84,6 +84,7 @@ import org.apache.hc.core5.http.HttpEntity; import org.apache.hc.core5.http.HttpHeaders; import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.HttpRequestInterceptor; import org.apache.hc.core5.http.HttpVersion; import org.apache.hc.core5.http.NameValuePair; import org.apache.hc.core5.http.config.Lookup; @@ -106,6 +107,7 @@ import org.apache.hc.core5.ssl.SSLContexts; import org.apache.hc.core5.util.TimeValue; import org.apache.hc.core5.util.Timeout; +import org.apache.hc.core5.util.VersionInfo; import org.apache.jmeter.protocol.http.control.AuthManager; import org.apache.jmeter.protocol.http.control.Authorization; import org.apache.jmeter.protocol.http.control.CacheManager; @@ -197,6 +199,31 @@ protected Map initialValue() { private static final int HTTP_2_MAX_CONNECTIONS_PER_ROUTE = JMeterUtils.getPropDefault("httpclient5.h2.max_connections_per_route", 6); + /** + * Name of the property that suppresses the {@code User-Agent} header HttpClient sends when the + * test plan does not define one itself. + */ + private static final String DISABLE_DEFAULT_UA_PROPERTY = "httpclient5.default_user_agent_disabled"; + + /** + * {@code User-Agent} JMeter adds to requests without one, so it shows up in the sample result and + * is accounted for in the sent bytes, instead of being added invisibly by HttpClient. + */ + private static final String DEFAULT_USER_AGENT = VersionInfo.getSoftwareInfo("Apache-HttpClient", + "org.apache.hc.client5", org.apache.hc.client5.http.impl.classic.HttpClientBuilder.class); + + /** Remembers whether the request had a {@code User-Agent} header before HttpClient added its own. */ + private static final String CONTEXT_ATTRIBUTE_USER_AGENT_PRESENT = "__jmeter.U_A__"; + + private static final HttpRequestInterceptor RECORD_USER_AGENT_PRESENCE = (request, entity, context) -> + context.setAttribute(CONTEXT_ATTRIBUTE_USER_AGENT_PRESENT, request.containsHeader(HttpHeaders.USER_AGENT)); + + private static final HttpRequestInterceptor REMOVE_DEFAULT_USER_AGENT = (request, entity, context) -> { + if (!Boolean.TRUE.equals(context.getAttribute(CONTEXT_ATTRIBUTE_USER_AGENT_PRESENT))) { + request.removeHeaders(HttpHeaders.USER_AGENT); + } + }; + private static final H2Config HTTP_2_CONFIG = createHttp2Config(); private static final Method MESSAGE_MULTIPLEXING_SETTER = findMessageMultiplexingSetter(); @@ -361,6 +388,7 @@ private void setupRequest(URL url, org.apache.hc.client5.http.classic.methods.Ht request.setVersion(HttpVersion.HTTP_2); } setConnectionHeaders(request, getHeaderManager(), httpVersionPolicy); + setDefaultUserAgent(request); CacheManager cacheManager = getCacheManager(); if (cacheManager != null) { cacheManager.setHeaders(url, request); @@ -478,6 +506,13 @@ private static void setConnectionHeaders(org.apache.hc.client5.http.classic.meth } } + private static void setDefaultUserAgent(org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request) { + if (isDefaultUserAgentDisabled() || request.containsHeader(HttpHeaders.USER_AGENT)) { + return; + } + request.setHeader(HttpHeaders.USER_AGENT, DEFAULT_USER_AGENT); + } + private static String setConnectionCookie(org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request, URL url, CookieManager cookieManager) { if (cookieManager == null) { @@ -673,8 +708,15 @@ private static CloseableHttpAsyncClient getHttp2Client(HttpClientKey key) { return clients.computeIfAbsent(key, HTTPHC5Impl::createHttp2Client); } + private static boolean isDefaultUserAgentDisabled() { + return JMeterUtils.getPropDefault(DISABLE_DEFAULT_UA_PROPERTY, false); + } + private static CloseableHttpClient createClient(HttpClientKey key) { org.apache.hc.client5.http.impl.classic.HttpClientBuilder builder = HttpClients.custom().disableAutomaticRetries(); + if (isDefaultUserAgentDisabled()) { + builder.disableDefaultUserAgent(); + } PoolingHttpClientConnectionManagerBuilder connectionManagerBuilder = PoolingHttpClientConnectionManagerBuilder.create(); connectionManagerBuilder.setDefaultTlsConfig(TlsConfig.custom() .setVersionPolicy(key.httpVersionPolicy) @@ -705,6 +747,11 @@ private static CloseableHttpAsyncClient createHttp2Client(HttpClientKey key) { // A connection bound to a user token cannot be shared, and HTTP/2 has no connection scoped state anyway builder.disableConnectionState(); } + if (isDefaultUserAgentDisabled()) { + // HttpAsyncClientBuilder has no disableDefaultUserAgent, so the generated header is dropped again + builder.addRequestInterceptorFirst(RECORD_USER_AGENT_PRESENCE); + builder.addRequestInterceptorLast(REMOVE_DEFAULT_USER_AGENT); + } PoolingAsyncClientConnectionManagerBuilder connectionManagerBuilder = PoolingAsyncClientConnectionManagerBuilder.create(); connectionManagerBuilder.setTlsStrategy(HTTP_2_TLS_STRATEGY); connectionManagerBuilder.setDefaultTlsConfig(TlsConfig.custom() diff --git a/xdocs/changes.xml b/xdocs/changes.xml index 3835a226cd8..c870be55479 100644 --- a/xdocs/changes.xml +++ b/xdocs/changes.xml @@ -96,6 +96,7 @@ Summary
  • 6080Preserve the original HTTP method when following 307 and 308 redirects according to the HTTP specification. Contributed by LeeJiWon (github.com/dlwldnjs1009)
  • 62676268Add a space between key and value after : in View Results Tree > Sampler result tab for better readability.
  • Multiplex concurrent HTTP/2 requests of the HttpClient5 sampler implementation over a single connection, and make the HTTP/2 protocol settings (HPACK header table size and compression, maximum concurrent streams, flow control window, maximum frame size, server push and h2c prior knowledge) configurable with the new httpclient5.h2.* properties.
  • +
  • Add the default User-Agent header of the HttpClient5 sampler implementation to the request itself, so it shows up in the sample result and in the sent bytes, and allow suppressing it with the new httpclient5.default_user_agent_disabled property, like httpclient4.default_user_agent_disabled does for HttpClient4.
  • Timers, Assertions, Config, Pre- & Post-Processors

    diff --git a/xdocs/usermanual/properties_reference.xml b/xdocs/usermanual/properties_reference.xml index 53d9c980c82..9bf279bab65 100644 --- a/xdocs/usermanual/properties_reference.xml +++ b/xdocs/usermanual/properties_reference.xml @@ -488,6 +488,11 @@ JMETER-SERVER negotiation requires TLS. Only enable it if all plain HTTP targets support h2c.
    Defaults to: false
    + + If true, the default HC5 User-Agent (Apache-HttpClient/X.Y.Z (Java/A.B.C)) will not be added. + A User-Agent header defined in the test plan is still sent.
    + Defaults to: false +
    Set characters per second to a value greater then zero to emulate slow connections.
    Defaults to: 0 From ca4842a7035ad31462efe7e7f1a1a23b5aec1fbf Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Fri, 31 Jul 2026 10:55:29 +0200 Subject: [PATCH 22/80] Enhance HTTP/2 support in Java sampler with multiplexing and configurable settings --- bin/jmeter.properties | 37 +++++ .../protocol/http/sampler/HTTPJavaImpl.java | 134 +++++++++++++--- .../http/sampler/TestHTTPJavaFeatures.java | 148 +++++++++++++++++- xdocs/changes.xml | 1 + xdocs/usermanual/properties_reference.xml | 47 ++++++ 5 files changed, 344 insertions(+), 23 deletions(-) diff --git a/bin/jmeter.properties b/bin/jmeter.properties index de3c73a64df..aacff1c37eb 100644 --- a/bin/jmeter.properties +++ b/bin/jmeter.properties @@ -418,6 +418,43 @@ remote_hosts=127.0.0.1 # support h2c #httpclient5.h2.prior_knowledge=false +# HTTP/2 settings used by the Java sampler implementation. Except for the +# multiplexing they are applied as jdk.httpclient.* system properties, so a +# setting given on the command line always takes precedence + +# Multiplex concurrent message exchanges (e.g. requests of different threads or +# parallel downloads of embedded resources) over a single HTTP/2 connection. +# Requests which start before the first HTTP/2 connection to a host has been +# established still open a connection of their own +#http.java.h2.multiplexing=true + +# Size in bytes of the HPACK dynamic header table announced to the server. +# 0 disables indexing of headers by the server (JDK default: 16384) +#http.java.h2.header_table_size=16384 + +# Maximum number of server initiated (pushed) streams the client accepts on a +# connection (JDK default: 100 with server push enabled, 0 otherwise) +#http.java.h2.max_concurrent_streams=100 + +# Flow control window in bytes announced per stream (JDK default: 16777216) +#http.java.h2.initial_window_size=16777216 + +# Flow control window in bytes announced for the whole connection, it must not +# be smaller than the stream window (JDK default: 67108864) +#http.java.h2.connection_window_size=67108864 + +# Largest frame payload in bytes the client is willing to receive, between +# 16384 and 16777215 (JDK default: 16384) +#http.java.h2.max_frame_size=16384 + +# Seconds an idle HTTP/2 connection is kept in the pool (JDK default: the value +# of jdk.httpclient.keepalive.timeout, which defaults to 30) +#http.java.h2.keep_alive_timeout=30 + +# Accept server push. JMeter cannot report pushed resources, so they are +# dropped, therefore push is disabled by default +#http.java.h2.push_enabled=false + # If true, default HC5 User-Agent will not be added #httpclient5.default_user_agent_disabled=false diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java index d6a8591b04c..d90041aec19 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java @@ -47,6 +47,8 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Properties; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Executor; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -100,7 +102,14 @@ public class HTTPJavaImpl extends HTTPAbstractImpl { JMeterUtils.getPropDefault("httpclient.version", HTTPConstants.HTTP_1_1); // $NON-NLS-1$ private static final ThreadLocal> HTTP_2_CLIENTS = - ThreadLocal.withInitial(HashMap::new); + ThreadLocal.withInitial(ConcurrentHashMap::new); + + /** + * HTTP/2 clients used when multiplexing is enabled. The JDK client sends every exchange of a client + * over a single connection per origin, so one shared instance lets the requests of all JMeter threads + * and of the threads which download embedded resources in parallel share a connection. + */ + private static final Map SHARED_HTTP_2_CLIENTS = new ConcurrentHashMap<>(); /** * Shared daemon thread pool used by the HTTP/2 {@link HttpClient} instances. It allows JMeter to observe @@ -111,6 +120,81 @@ public class HTTPJavaImpl extends HTTPAbstractImpl { private static final Logger log = LoggerFactory.getLogger(HTTPJavaImpl.class); + /** Multiplex concurrent HTTP/2 message exchanges over a single connection per origin. */ + private static final boolean HTTP_2_MULTIPLEXING = + JMeterUtils.getPropDefault("http.java.h2.multiplexing", true); // $NON-NLS-1$ + + /** + * Accept HTTP/2 server push. JMeter has no way of reporting pushed resources, so push is switched off + * to avoid the server wasting bandwidth on responses that are dropped. + */ + private static final String HTTP_2_PUSH_ENABLED_PROPERTY = "http.java.h2.push_enabled"; // $NON-NLS-1$ + + /** System property the JDK client reads to decide whether it announces {@code SETTINGS_ENABLE_PUSH}. */ + private static final String JDK_PUSH_ENABLED_PROPERTY = "jdk.httpclient.enablepush"; // $NON-NLS-1$ + + /** + * Maps the JMeter HTTP/2 properties to the {@code jdk.httpclient.*} system properties, which are the only + * way to configure the HTTP/2 protocol settings of the JDK client. They are read whenever a client is + * built, so they have to be in place before the first HTTP/2 sample is taken. + */ + private static final Map HTTP_2_SYSTEM_PROPERTIES = createHttp2SystemPropertyMapping(); + + static { + applyHttp2SystemProperties(JMeterUtils.getJMeterProperties(), System.getProperties()); + } + + private static Map createHttp2SystemPropertyMapping() { + Map mapping = new LinkedHashMap<>(); + // Size of the HPACK dynamic header table announced to the server, 0 disables HPACK indexing + mapping.put("http.java.h2.header_table_size", "jdk.httpclient.hpack.maxheadertablesize"); // $NON-NLS-1$ $NON-NLS-2$ + // Number of server initiated (pushed) streams the client accepts on a connection + mapping.put("http.java.h2.max_concurrent_streams", "jdk.httpclient.maxstreams"); // $NON-NLS-1$ $NON-NLS-2$ + mapping.put("http.java.h2.initial_window_size", "jdk.httpclient.windowsize"); // $NON-NLS-1$ $NON-NLS-2$ + mapping.put("http.java.h2.connection_window_size", "jdk.httpclient.connectionWindowSize"); // $NON-NLS-1$ $NON-NLS-2$ + mapping.put("http.java.h2.max_frame_size", "jdk.httpclient.maxframesize"); // $NON-NLS-1$ $NON-NLS-2$ + mapping.put("http.java.h2.keep_alive_timeout", "jdk.httpclient.keepalive.timeout.h2"); // $NON-NLS-1$ $NON-NLS-2$ + return Collections.unmodifiableMap(mapping); + } + + /** + * Copies the HTTP/2 settings from the JMeter properties to the system properties of the JDK client. + * Values which are already defined, for example on the command line, are never overwritten. + * + * @param jmeterProperties the JMeter properties, may be {@code null} when they have not been loaded + * @param systemProperties the system properties to configure + */ + static void applyHttp2SystemProperties(Properties jmeterProperties, Properties systemProperties) { + Properties source = jmeterProperties != null ? jmeterProperties : new Properties(); + for (Map.Entry entry : HTTP_2_SYSTEM_PROPERTIES.entrySet()) { + String value = source.getProperty(entry.getKey()); + if (StringUtilities.isNotBlank(value)) { + setUnlessDefined(systemProperties, entry.getValue(), value.trim()); + } + } + boolean pushEnabled = Boolean.parseBoolean(source.getProperty(HTTP_2_PUSH_ENABLED_PROPERTY, "false")); // $NON-NLS-1$ + setUnlessDefined(systemProperties, JDK_PUSH_ENABLED_PROPERTY, pushEnabled ? "1" : "0"); // $NON-NLS-1$ $NON-NLS-2$ + } + + private static void setUnlessDefined(Properties systemProperties, String name, String value) { + String current = systemProperties.getProperty(name); + if (current == null) { + systemProperties.setProperty(name, value); + } else if (!current.equals(value)) { + log.info("Keeping system property {}={}, it takes precedence over the JMeter property", name, current); // $NON-NLS-1$ + } + } + + /** + * Returns the HTTP/2 clients of the current thread, or the shared ones when the exchanges of all threads + * should be multiplexed over a single connection. + * + * @return the map the HTTP/2 clients are cached in + */ + static Map getHttp2Clients() { + return HTTP_2_MULTIPLEXING ? SHARED_HTTP_2_CLIENTS : HTTP_2_CLIENTS.get(); + } + /** * HTTP/2 does not transmit a reason phrase (see RFC 9113, section 8.3.2), so the response message is * derived from the status code. The phrases are the ones registered in the IANA HTTP status code registry. @@ -990,7 +1074,7 @@ private HTTPSampleResult sampleHttp2(URL url, String method, boolean areFollowin try { response = client.httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofInputStream()); } finally { - connectTimeTracker.sampleFinished(); + connectTimeTracker.sampleFinished(res); } res.latencyEnd(); @@ -1273,7 +1357,7 @@ private Http2Client getHttpClient(URL url) { HttpClientKey key = new HttpClientKey(connectTimeout, proxyHost, proxyPort, proxyUser, proxyPass, autoRedirects, sslContext); - return HTTP_2_CLIENTS.get().computeIfAbsent(key, HTTPJavaImpl::createHttpClient); + return getHttp2Clients().computeIfAbsent(key, HTTPJavaImpl::createHttpClient); } private static Http2Client createHttpClient(HttpClientKey key) { @@ -1324,45 +1408,51 @@ private static final class Http2Client { } /** - * Records the connect time of the JDK {@link HttpClient} in the current {@link SampleResult}. + * Records the connect time of the JDK {@link HttpClient} in the {@link SampleResult}s which are in flight. *

    * The JDK client does not expose a hook for connection establishment, so two indicators are used: * the client submits its first task to the executor once the TCP connection has been established, and * the wrapped {@code SSLEngine} reports when the TLS handshake has been finished. The TLS handshake * always wins, since it is the more precise and the later event. + *

    + * A multiplexed client is shared by several threads, so more than one sample can be in flight. Since the + * connection is established for all of them at once, the connect time is recorded in every sample which + * is waiting for it. */ static final class ConnectTimeTracker { - private volatile SampleResult sampleResult; - private volatile boolean connectRecorded; - private volatile boolean handshakeRecorded; + private static final Integer NOTHING_RECORDED = 0; + private static final Integer CONNECT_RECORDED = 1; + private static final Integer HANDSHAKE_RECORDED = 2; + + /** Samples which are currently in flight, together with the connect event recorded for them. */ + private final Map activeSamples = new ConcurrentHashMap<>(); void sampleStarted(SampleResult result) { - this.connectRecorded = false; - this.handshakeRecorded = false; - this.sampleResult = result; + activeSamples.put(result, NOTHING_RECORDED); } - void sampleFinished() { - this.sampleResult = null; + void sampleFinished(SampleResult result) { + activeSamples.remove(result); } /** Invoked when the TCP connection has been established. */ void connectionEstablished() { - SampleResult result = sampleResult; - if (result != null && !connectRecorded && !handshakeRecorded) { - connectRecorded = true; - result.connectEnd(); - } + recordConnectEnd(CONNECT_RECORDED); } /** Invoked when the TLS handshake has been finished, it overrides the plain TCP connect time. */ void handshakeFinished() { - SampleResult result = sampleResult; - if (result != null && !handshakeRecorded) { - handshakeRecorded = true; - connectRecorded = true; + recordConnectEnd(HANDSHAKE_RECORDED); + } + + private void recordConnectEnd(Integer event) { + activeSamples.replaceAll((result, recorded) -> { + if (recorded.intValue() >= event.intValue()) { + return recorded; + } result.connectEnd(); - } + return event; + }); } } diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java index d008a94431b..280eece0b69 100644 --- a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java +++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java @@ -23,6 +23,8 @@ import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; import java.net.Socket; @@ -32,6 +34,15 @@ import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; import javax.net.ssl.SSLContext; import javax.net.ssl.SSLEngine; @@ -110,6 +121,141 @@ void returnsEmptyReasonPhraseForUnknownStatusCode() { assertEquals("", HTTPJavaImpl.getReasonPhrase(599)); } + @Test + void mapsHttp2SettingsToJdkSystemProperties() { + Properties jmeterProperties = new Properties(); + jmeterProperties.setProperty("http.java.h2.header_table_size", "8192"); + jmeterProperties.setProperty("http.java.h2.max_concurrent_streams", "250"); + jmeterProperties.setProperty("http.java.h2.initial_window_size", "65535"); + jmeterProperties.setProperty("http.java.h2.connection_window_size", "1048576"); + jmeterProperties.setProperty("http.java.h2.max_frame_size", "16384"); + jmeterProperties.setProperty("http.java.h2.keep_alive_timeout", "60"); + jmeterProperties.setProperty("http.java.h2.push_enabled", "true"); + Properties systemProperties = new Properties(); + + HTTPJavaImpl.applyHttp2SystemProperties(jmeterProperties, systemProperties); + + assertEquals("8192", systemProperties.getProperty("jdk.httpclient.hpack.maxheadertablesize")); + assertEquals("250", systemProperties.getProperty("jdk.httpclient.maxstreams")); + assertEquals("65535", systemProperties.getProperty("jdk.httpclient.windowsize")); + assertEquals("1048576", systemProperties.getProperty("jdk.httpclient.connectionWindowSize")); + assertEquals("16384", systemProperties.getProperty("jdk.httpclient.maxframesize")); + assertEquals("60", systemProperties.getProperty("jdk.httpclient.keepalive.timeout.h2")); + assertEquals("1", systemProperties.getProperty("jdk.httpclient.enablepush")); + } + + @Test + void disablesServerPushAndKeepsJdkDefaultsWhenNothingIsConfigured() { + Properties systemProperties = new Properties(); + + HTTPJavaImpl.applyHttp2SystemProperties(new Properties(), systemProperties); + + assertEquals("0", systemProperties.getProperty("jdk.httpclient.enablepush")); + assertNull(systemProperties.getProperty("jdk.httpclient.hpack.maxheadertablesize")); + assertNull(systemProperties.getProperty("jdk.httpclient.windowsize")); + } + + @Test + void doesNotOverrideHttp2SettingsGivenOnTheCommandLine() { + Properties jmeterProperties = new Properties(); + jmeterProperties.setProperty("http.java.h2.header_table_size", "8192"); + Properties systemProperties = new Properties(); + systemProperties.setProperty("jdk.httpclient.hpack.maxheadertablesize", "4096"); + systemProperties.setProperty("jdk.httpclient.enablepush", "1"); + + HTTPJavaImpl.applyHttp2SystemProperties(jmeterProperties, systemProperties); + + assertEquals("4096", systemProperties.getProperty("jdk.httpclient.hpack.maxheadertablesize")); + assertEquals("1", systemProperties.getProperty("jdk.httpclient.enablepush")); + } + + @Test + void sharesHttp2ClientsBetweenThreadsForMultiplexing() throws Exception { + Map clientsOfMainThread = HTTPJavaImpl.getHttp2Clients(); + AtomicReference> clientsOfOtherThread = new AtomicReference<>(); + + Thread thread = new Thread(() -> clientsOfOtherThread.set(HTTPJavaImpl.getHttp2Clients())); + thread.start(); + thread.join(); + + assertSame(clientsOfMainThread, clientsOfOtherThread.get(), + "HTTP/2 clients should be shared, so concurrent requests are multiplexed over one connection"); + } + + @Test + void multiplexesConcurrentHttp2RequestsOverOneConnection() throws Exception { + WireMockServer server = createServer(); + server.start(); + try { + server.stubFor(get(urlEqualTo("/multiplexed")) + .willReturn(aResponse().withStatus(200).withFixedDelay(200).withBody("multiplexed"))); + + int requests = 4; + ExecutorService executor = Executors.newFixedThreadPool(requests); + try { + List> results = new ArrayList<>(); + for (int i = 0; i < requests; i++) { + results.add(executor.submit(() -> { + HTTPSamplerBase sampler = newSampler(); + sampler.setHttpVersion("HTTP/2"); + return sampler.sample( + new URL(server.url("/multiplexed")), HTTPConstants.GET, false, 1); + })); + } + for (Future result : results) { + HTTPSampleResult sampleResult = result.get(60, TimeUnit.SECONDS); + assertEquals("200", sampleResult.getResponseCode()); + assertTrue(sampleResult.getResponseHeaders().startsWith("HTTP/2"), + "Response should have been received over HTTP/2, but was " + + sampleResult.getResponseHeaders()); + } + } finally { + executor.shutdownNow(); + } + } finally { + server.stop(); + } + } + + @Test + void recordsConnectTimeForEveryMultiplexedSample() throws Exception { + HTTPJavaImpl.ConnectTimeTracker tracker = new HTTPJavaImpl.ConnectTimeTracker(); + SampleResult first = new SampleResult(); + SampleResult second = new SampleResult(); + first.sampleStart(); + second.sampleStart(); + tracker.sampleStarted(first); + tracker.sampleStarted(second); + + Thread.sleep(5); + tracker.connectionEstablished(); + Thread.sleep(5); + + first.sampleEnd(); + second.sampleEnd(); + tracker.sampleFinished(first); + tracker.sampleFinished(second); + + assertTrue(first.getConnectTime() > 0, "connectTime of the first sample should have been recorded"); + assertTrue(second.getConnectTime() > 0, "connectTime of the second sample should have been recorded"); + assertTrue(first.getConnectTime() <= first.getTime()); + assertTrue(second.getConnectTime() <= second.getTime()); + } + + @Test + void ignoresSamplesWhichAreNoLongerInFlight() { + HTTPJavaImpl.ConnectTimeTracker tracker = new HTTPJavaImpl.ConnectTimeTracker(); + SampleResult result = new SampleResult(); + result.sampleStart(); + tracker.sampleStarted(result); + tracker.sampleFinished(result); + + tracker.connectionEstablished(); + result.sampleEnd(); + + assertEquals(0, result.getConnectTime()); + } + @Test void usesHttp2WithProxy() throws Exception { WireMockServer server = createServer(); @@ -325,7 +471,7 @@ void setsConnectTimeForHttp2OverTls() throws Exception { "https://localhost:" + server.httpsPort() + "/http2TlsConnectTime")).build(), HttpResponse.BodyHandlers.ofString()); } finally { - tracker.sampleFinished(); + tracker.sampleFinished(result); } result.sampleEnd(); diff --git a/xdocs/changes.xml b/xdocs/changes.xml index c870be55479..1e550e28242 100644 --- a/xdocs/changes.xml +++ b/xdocs/changes.xml @@ -96,6 +96,7 @@ Summary

  • 6080Preserve the original HTTP method when following 307 and 308 redirects according to the HTTP specification. Contributed by LeeJiWon (github.com/dlwldnjs1009)
  • 62676268Add a space between key and value after : in View Results Tree > Sampler result tab for better readability.
  • Multiplex concurrent HTTP/2 requests of the HttpClient5 sampler implementation over a single connection, and make the HTTP/2 protocol settings (HPACK header table size and compression, maximum concurrent streams, flow control window, maximum frame size, server push and h2c prior knowledge) configurable with the new httpclient5.h2.* properties.
  • +
  • Multiplex concurrent HTTP/2 requests of the Java sampler implementation over a single connection, and make the HTTP/2 protocol settings (HPACK header table size, maximum concurrent streams, flow control windows, maximum frame size, connection keep alive and server push) configurable with the new http.java.h2.* properties.
  • Add the default User-Agent header of the HttpClient5 sampler implementation to the request itself, so it shows up in the sample result and in the sent bytes, and allow suppressing it with the new httpclient5.default_user_agent_disabled property, like httpclient4.default_user_agent_disabled does for HttpClient4.
  • diff --git a/xdocs/usermanual/properties_reference.xml b/xdocs/usermanual/properties_reference.xml index 9bf279bab65..15b9f557cae 100644 --- a/xdocs/usermanual/properties_reference.xml +++ b/xdocs/usermanual/properties_reference.xml @@ -493,6 +493,53 @@ JMETER-SERVER A User-Agent header defined in the test plan is still sent.
    Defaults to: false
    + + Multiplex concurrent message exchanges (for example the requests of different threads or parallel + downloads of embedded resources) of the Java sampler implementation over a single HTTP/2 + connection per origin. Requests which start before the first HTTP/2 connection to a host has been + established still open a connection of their own.
    + Defaults to: true +
    + + Size in bytes of the HPACK dynamic header table the Java sampler implementation announces to the + server. 0 disables indexing of the headers by the server. It is applied as the + jdk.httpclient.hpack.maxheadertablesize system property.
    + Defaults to the JDK default: 16384 +
    + + Maximum number of server initiated (pushed) streams the Java sampler implementation accepts on a + single HTTP/2 connection. It is applied as the jdk.httpclient.maxstreams system + property.
    + Defaults to the JDK default: 100 with server push enabled, 0 otherwise +
    + + HTTP/2 flow control window in bytes the Java sampler implementation announces per stream. It is + applied as the jdk.httpclient.windowsize system property.
    + Defaults to the JDK default: 16777216 +
    + + HTTP/2 flow control window in bytes the Java sampler implementation announces for the whole + connection, it must not be smaller than the stream window. It is applied as the + jdk.httpclient.connectionWindowSize system property.
    + Defaults to the JDK default: 67108864 +
    + + Largest HTTP/2 frame payload in bytes the Java sampler implementation is willing to receive, + between 16384 and 16777215. It is applied as the + jdk.httpclient.maxframesize system property.
    + Defaults to the JDK default: 16384 +
    + + Seconds an idle HTTP/2 connection of the Java sampler implementation is kept in the pool. It is + applied as the jdk.httpclient.keepalive.timeout.h2 system property.
    + Defaults to the JDK default: the value of jdk.httpclient.keepalive.timeout +
    + + Accept HTTP/2 server push in the Java sampler implementation. JMeter cannot report pushed + resources, so they are dropped, therefore push is disabled by default. It is applied as the + jdk.httpclient.enablepush system property.
    + Defaults to: false +
    Set characters per second to a value greater then zero to emulate slow connections.
    Defaults to: 0 From d0c400306a6a7323dfa27b6a75276aa5a8d9bd50 Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Fri, 31 Jul 2026 11:11:48 +0200 Subject: [PATCH 23/80] Add default User-Agent header handling for Java sampler in HTTP/1.1 and HTTP/2 --- .../protocol/http/sampler/HTTPJavaImpl.java | 35 +++++++++++++++++++ xdocs/changes.xml | 1 + 2 files changed, 36 insertions(+) diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java index d90041aec19..ba08dce4b95 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java @@ -101,6 +101,26 @@ public class HTTPJavaImpl extends HTTPAbstractImpl { private static final String DEFAULT_HTTP_VERSION = JMeterUtils.getPropDefault("httpclient.version", HTTPConstants.HTTP_1_1); // $NON-NLS-1$ + /** Name of the {@code User-Agent} request header. */ + private static final String HEADER_USER_AGENT = "User-Agent"; // $NON-NLS-1$ + + /** + * {@code User-Agent} of {@link HttpURLConnection}, which is used for HTTP/1.1. JMeter adds it to the + * request itself, so it shows up in the sample result and is accounted for in the sent bytes, instead of + * being added invisibly by the JDK. + */ + private static final String HTTP_1_DEFAULT_USER_AGENT = createHttp1DefaultUserAgent(); + + /** {@code User-Agent} of {@link HttpClient}, which is used for HTTP/2. */ + private static final String HTTP_2_DEFAULT_USER_AGENT = + "Java-http-client/" + System.getProperty("java.version"); // $NON-NLS-1$ + + private static String createHttp1DefaultUserAgent() { + String javaAgent = "Java/" + System.getProperty("java.version"); // $NON-NLS-1$ + String agent = System.getProperty("http.agent"); // $NON-NLS-1$ + return agent == null ? javaAgent : agent + " " + javaAgent; + } + private static final ThreadLocal> HTTP_2_CLIENTS = ThreadLocal.withInitial(ConcurrentHashMap::new); @@ -420,6 +440,7 @@ protected HttpURLConnection setupConnection(URL u, String method, HTTPSampleResu String cookies = setConnectionCookie(conn, u, getCookieManager()); setConnectionAuthorization(conn, u, getAuthManager(), securityHeaders); + setDefaultUserAgent(conn, HTTP_1_DEFAULT_USER_AGENT); if (method.equals(HTTPConstants.POST)) { setPostHeaders(conn); @@ -690,6 +711,19 @@ private static String getFromConnectionHeaders(HttpURLConnection conn, MapHttpURLConnection passed in. @@ -1015,6 +1049,7 @@ private HTTPSampleResult sampleHttp2(URL url, String method, boolean areFollowin securityHeaders = setConnectionHeaders(capturingConn, url, getHeaderManager(), getCacheManager()); String cookies = setConnectionCookie(capturingConn, url, getCookieManager()); setConnectionAuthorization(capturingConn, url, getAuthManager(), securityHeaders); + setDefaultUserAgent(capturingConn, HTTP_2_DEFAULT_USER_AGENT); if (method.equals(HTTPConstants.POST)) { setPostHeaders(capturingConn); diff --git a/xdocs/changes.xml b/xdocs/changes.xml index 1e550e28242..743febf8f65 100644 --- a/xdocs/changes.xml +++ b/xdocs/changes.xml @@ -98,6 +98,7 @@ Summary
  • Multiplex concurrent HTTP/2 requests of the HttpClient5 sampler implementation over a single connection, and make the HTTP/2 protocol settings (HPACK header table size and compression, maximum concurrent streams, flow control window, maximum frame size, server push and h2c prior knowledge) configurable with the new httpclient5.h2.* properties.
  • Multiplex concurrent HTTP/2 requests of the Java sampler implementation over a single connection, and make the HTTP/2 protocol settings (HPACK header table size, maximum concurrent streams, flow control windows, maximum frame size, connection keep alive and server push) configurable with the new http.java.h2.* properties.
  • Add the default User-Agent header of the HttpClient5 sampler implementation to the request itself, so it shows up in the sample result and in the sent bytes, and allow suppressing it with the new httpclient5.default_user_agent_disabled property, like httpclient4.default_user_agent_disabled does for HttpClient4.
  • +
  • Add the default User-Agent header of the Java sampler implementation (Java/A.B.C for HTTP/1.1 and Java-http-client/A.B.C for HTTP/2) to the request itself, so the header the JDK sends anyway shows up in the sample result and in the sent bytes.
  • Timers, Assertions, Config, Pre- & Post-Processors

    From 015cc052f0e81b9f8865519971e4ac71a0548c52 Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Fri, 31 Jul 2026 12:00:47 +0200 Subject: [PATCH 24/80] Add HTTP/2 core dependency for enhanced support in HttpClient5 --- src/bom-thirdparty/build.gradle.kts | 1 + src/protocol/http/build.gradle.kts | 3 +++ 2 files changed, 4 insertions(+) diff --git a/src/bom-thirdparty/build.gradle.kts b/src/bom-thirdparty/build.gradle.kts index f819a868318..d6a9dccb687 100644 --- a/src/bom-thirdparty/build.gradle.kts +++ b/src/bom-thirdparty/build.gradle.kts @@ -108,6 +108,7 @@ dependencies { } api("org.apache.httpcomponents.client5:httpclient5:5.6.4") api("org.apache.httpcomponents.core5:httpcore5:5.3.4") + api("org.apache.httpcomponents.core5:httpcore5-h2:5.3.4") api("org.apache.httpcomponents:httpasyncclient:4.1.5") api("org.apache.httpcomponents:httpclient:4.5.14") api("org.apache.httpcomponents:httpcore-nio:4.4.16") diff --git a/src/protocol/http/build.gradle.kts b/src/protocol/http/build.gradle.kts index dd937692498..d2783bebd83 100644 --- a/src/protocol/http/build.gradle.kts +++ b/src/protocol/http/build.gradle.kts @@ -63,6 +63,9 @@ dependencies { implementation("dnsjava:dnsjava") implementation("org.apache.httpcomponents.client5:httpclient5") implementation("org.apache.httpcomponents.core5:httpcore5") + implementation("org.apache.httpcomponents.core5:httpcore5-h2") { + because("HTTPHC5Impl uses H2Config and HttpVersionPolicy from org.apache.hc.core5.http2") + } implementation("org.apache.httpcomponents:httpmime") implementation("org.apache.httpcomponents:httpcore") implementation("org.brotli:dec") From 13032bd954a6e48de2103dbd51be9c785f0fae0b Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Tue, 11 Aug 2026 06:32:35 +0200 Subject: [PATCH 25/80] Update Apache HttpClient 5 and HttpCore 5 dependencies to 5.6.4 and 5.4.3 respectively for improved SSL parameter handling in HTTP/2 --- src/bom-thirdparty/build.gradle.kts | 4 ++-- .../protocol/http/sampler/HTTPHC5Impl.java | 21 +++++++++++++++---- xdocs/changes.xml | 2 ++ 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/src/bom-thirdparty/build.gradle.kts b/src/bom-thirdparty/build.gradle.kts index d6a9dccb687..1566ed1d914 100644 --- a/src/bom-thirdparty/build.gradle.kts +++ b/src/bom-thirdparty/build.gradle.kts @@ -107,8 +107,8 @@ dependencies { because("User might still rely on commons-text") } api("org.apache.httpcomponents.client5:httpclient5:5.6.4") - api("org.apache.httpcomponents.core5:httpcore5:5.3.4") - api("org.apache.httpcomponents.core5:httpcore5-h2:5.3.4") + api("org.apache.httpcomponents.core5:httpcore5:5.4.3") + api("org.apache.httpcomponents.core5:httpcore5-h2:5.4.3") api("org.apache.httpcomponents:httpasyncclient:4.1.5") api("org.apache.httpcomponents:httpclient:4.5.14") api("org.apache.httpcomponents:httpcore-nio:4.4.16") diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java index 27a07217c7b..456381461ce 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java @@ -51,7 +51,6 @@ import org.apache.hc.client5.http.config.ConnectionConfig; import org.apache.hc.client5.http.config.RequestConfig; import org.apache.hc.client5.http.config.TlsConfig; -import org.apache.hc.client5.http.entity.BrotliInputStreamFactory; import org.apache.hc.client5.http.entity.DecompressingEntity; import org.apache.hc.client5.http.entity.DeflateInputStreamFactory; import org.apache.hc.client5.http.entity.GZIPInputStreamFactory; @@ -74,6 +73,7 @@ import org.apache.hc.client5.http.nio.AsyncConnectionEndpoint; import org.apache.hc.client5.http.protocol.HttpClientContext; import org.apache.hc.client5.http.ssl.ClientTlsStrategyBuilder; +import org.apache.hc.client5.http.ssl.HostnameVerificationPolicy; import org.apache.hc.client5.http.ssl.NoopHostnameVerifier; import org.apache.hc.client5.http.ssl.TrustAllStrategy; import org.apache.hc.core5.concurrent.FutureCallback; @@ -128,6 +128,7 @@ import org.apache.jmeter.util.SSLManager; import org.apache.jorphan.util.JOrphanUtils; import org.apache.jorphan.util.StringUtilities; +import org.brotli.dec.BrotliInputStream; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -231,8 +232,16 @@ protected Map initialValue() { private static final String[] HEADERS_TO_SAVE = {HttpHeaders.CONTENT_LENGTH, HttpHeaders.CONTENT_ENCODING, HttpHeaders.CONTENT_MD5}; + // HttpClient 5.6 switched BrotliInputStreamFactory to the optional brotli4j library, so decode "br" + // with the org.brotli:dec library JMeter ships, like HTTPHC4Impl does. + @SuppressWarnings("deprecation") + private static final InputStreamFactory BROTLI = BrotliInputStream::new; + + // The InputStreamFactory-based decoders are deprecated in favour of the @Internal ContentCodecRegistry, + // so keep using them until a public replacement is available. + @SuppressWarnings("deprecation") private static final Lookup CONTENT_DECODERS = RegistryBuilder.create() - .register("br", BrotliInputStreamFactory.getInstance()) + .register("br", BROTLI) .register("gzip", GZIPInputStreamFactory.getInstance()) .register("x-gzip", GZIPInputStreamFactory.getInstance()) .register("deflate", DeflateInputStreamFactory.getInstance()) @@ -240,6 +249,7 @@ protected Map initialValue() { private static final TlsStrategy HTTP_2_TLS_STRATEGY = createHttp2TlsStrategy(); + @SuppressWarnings("deprecation") // DecompressingEntity is superseded by the @Internal ContentCodecRegistry private static final ExecChainHandler RESPONSE_CONTENT_ENCODING = (request, scope, chain) -> { HttpClientContext context = scope.clientContext; RequestConfig requestConfig = context.getRequestConfig(); @@ -281,14 +291,17 @@ protected Map initialValue() { private volatile org.apache.hc.client5.http.classic.methods.HttpUriRequestBase currentRequest; - @SuppressWarnings("deprecation") // buildAsync is unavailable before HttpClient 5.5 private static TlsStrategy createHttp2TlsStrategy() { try { SSLContext sslContext = SSLContexts.custom().loadTrustMaterial(null, TrustAllStrategy.INSTANCE).build(); return ClientTlsStrategyBuilder.create() .setSslContext(sslContext) + // Leave hostname verification to the no-op verifier below. Without CLIENT the policy would + // default to BOTH, and the JSSE built-in endpoint identification would reject the + // self-signed certificates JMeter deliberately accepts when testing. + .setHostVerificationPolicy(HostnameVerificationPolicy.CLIENT) .setHostnameVerifier(NoopHostnameVerifier.INSTANCE) - .build(); + .buildAsync(); } catch (GeneralSecurityException e) { throw new IllegalStateException("Could not create HTTP/2 TLS strategy", e); } diff --git a/xdocs/changes.xml b/xdocs/changes.xml index 743febf8f65..4042df5513b 100644 --- a/xdocs/changes.xml +++ b/xdocs/changes.xml @@ -125,6 +125,8 @@ Summary
  • Update Apache Commons libraries: commons-codec 1.22.1, commons-collections4 4.6.0, commons-io 2.22.0, commons-jexl3 3.7.0, commons-lang3 3.21.0, commons-logging 1.4.0, commons-net 3.13.0, commons-pool2 2.13.1, commons-text 1.15.0.
  • +
  • Update Rhino JavaScript engine to 1.8.0 for JSR-223 JavaScript execution.
  • +
  • Update Apache HttpClient 5 to 5.6.4 and Apache HttpCore 5 to 5.4.3, which corrects the application of SSL parameters in the async TLS upgrade method used by the HttpClient5 sampler implementation for HTTP/2.
  • UI

    From c26a60783b44ece092ecde3568912105dea615d5 Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Tue, 11 Aug 2026 07:46:55 +0200 Subject: [PATCH 26/80] Enhance HTTP/2 response handling by implementing custom decompression and ensuring consistent header reporting across transports --- .../protocol/http/sampler/HTTPHC5Impl.java | 30 ++++++-- .../http/sampler/TestDecompression.java | 70 +++++++++++++++++++ 2 files changed, 94 insertions(+), 6 deletions(-) diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java index 456381461ce..7ef191e536a 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java @@ -249,7 +249,6 @@ protected Map initialValue() { private static final TlsStrategy HTTP_2_TLS_STRATEGY = createHttp2TlsStrategy(); - @SuppressWarnings("deprecation") // DecompressingEntity is superseded by the @Internal ContentCodecRegistry private static final ExecChainHandler RESPONSE_CONTENT_ENCODING = (request, scope, chain) -> { HttpClientContext context = scope.clientContext; RequestConfig requestConfig = context.getRequestConfig(); @@ -257,9 +256,22 @@ protected Map initialValue() { requestConfig = RequestConfig.DEFAULT; } ClassicHttpResponse response = chain.proceed(request, scope); + if (!requestConfig.isContentCompressionEnabled()) { + return response; + } + return decompressResponse(response); + }; + + /** + * Decodes the response body while keeping the {@code Content-Encoding}, {@code Content-Length} and + * {@code Content-MD5} headers, so the sample result still reports what the server actually sent. + * JMeter therefore disables the transparent decompression of HttpClient, which drops those headers, + * and decodes the response itself for both the HTTP/1.1 and the HTTP/2 transport. + */ + @SuppressWarnings("deprecation") // DecompressingEntity is superseded by the @Internal ContentCodecRegistry + private static ClassicHttpResponse decompressResponse(ClassicHttpResponse response) { HttpEntity entity = response.getEntity(); - if (!requestConfig.isContentCompressionEnabled() || entity == null || entity.getContentLength() == 0 - || entity.getContentEncoding() == null) { + if (entity == null || entity.getContentLength() == 0 || entity.getContentEncoding() == null) { return response; } @@ -287,7 +299,7 @@ protected Map initialValue() { } } return response; - }; + } private volatile org.apache.hc.client5.http.classic.methods.HttpUriRequestBase currentRequest; @@ -754,6 +766,10 @@ private static CloseableHttpAsyncClient createHttp2Client(HttpClientKey key) { boolean multiplexing = HTTP_2_MULTIPLEXING && MESSAGE_MULTIPLEXING_SETTER != null; HttpAsyncClientBuilder builder = HttpAsyncClients.custom() .disableAutomaticRetries() + // HttpClient 5.6 added transparent content compression to the async transport. JMeter decodes + // the response itself, so the HTTP/2 sampler reports the same headers as the HTTP/1.1 one and + // does not depend on the optional codec libraries HttpClient detects on the classpath. + .disableContentCompression() .setH2Config(HTTP_2_CONFIG) .setRoutePlanner(createRoutePlanner(key)); if (multiplexing) { @@ -860,9 +876,11 @@ private static ClassicHttpResponse createClassicResponse(SimpleHttpResponse asyn } byte[] responseBody = asyncResponse.getBodyBytes(); if (responseBody != null) { - response.setEntity(new ByteArrayEntity(responseBody, asyncResponse.getContentType())); + response.setEntity(new ByteArrayEntity(responseBody, asyncResponse.getContentType(), + asyncResponse.getFirstHeader(HttpHeaders.CONTENT_ENCODING) == null ? null + : asyncResponse.getFirstHeader(HttpHeaders.CONTENT_ENCODING).getValue())); } - return response; + return decompressResponse(response); } private static DefaultRoutePlanner createRoutePlanner(HttpClientKey key) { diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestDecompression.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestDecompression.java index 82d22bda273..e733f497778 100644 --- a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestDecompression.java +++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestDecompression.java @@ -112,6 +112,76 @@ public void mockServer(String httpImpl, ClientGzip clientGzip, ServerGzip server } } + public static List http2Params() { + List res = new ArrayList<>(); + for (ClientGzip clientGzip : ClientGzip.values()) { + for (ServerGzip serverGzip : ServerGzip.values()) { + res.add(Arguments.of(clientGzip, serverGzip)); + } + } + return res; + } + + /** + * HttpClient5 uses the async transport for HTTP/2, which decompresses responses in a different + * code path than the HTTP/1.1 transport, so both have to report the same headers and body. + */ + @ParameterizedTest + @MethodSource("http2Params") + public void http2(ClientGzip clientGzip, ServerGzip serverGzip) throws MalformedURLException { + WireMockServer server = new WireMockServer(WireMockConfiguration.wireMockConfig() + .dynamicHttpsPort() + .http2TlsDisabled(false) + .gzipDisabled(serverGzip == ServerGzip.NOT_SUPPORTED)); + server.start(); + try { + HTTPSamplerBase http = HTTPSamplerFactory.newInstance(HTTPSamplerFactory.IMPL_HTTP_CLIENT5); + http.setHttpVersion("HTTP/2"); + String expectedResponse = "Hello, 丈, \uD83D\uDE03, and नि"; + HeaderManager hm = new HeaderManager(); + if (clientGzip == ClientGzip.REQUESTED) { + hm.add(new Header("Accept-Encoding", "gzip")); + } + hm.add(new Header("Content-Encoding", "utf-8")); + http.setHeaderManager(hm); + MappingBuilder mappingBuilder = WireMock.get("/gzip"); + if (clientGzip == ClientGzip.REQUESTED) { + mappingBuilder = mappingBuilder.withHeader("Accept-Encoding", WireMock.equalTo("gzip")); + } + server.stubFor( + mappingBuilder + .willReturn( + WireMock.aResponse() + .withBody(expectedResponse) + .withHeader("Content-Type", "text/plain;charset=utf-8") + ) + ); + + HTTPSampleResult res = http.sample( + new URL("https://localhost:" + server.httpsPort() + "/gzip"), "GET", false, 1); + + Assertions.assertAll( + () -> assertEquals(expectedResponse, res.getResponseDataAsString(), "response body"), + () -> { + // HTTP/2 header names are lower case, so compare them ignoring case + if (clientGzip == ClientGzip.NOT_REQUESTED || serverGzip == ServerGzip.NOT_SUPPORTED) { + assertFalse( + StringsKt.contains(res.getResponseHeaders(), "Content-Encoding:", true), + () -> "clientGzip is " + clientGzip + ", so Content-Encoding header should NOT be present" + ); + } else { + assertTrue( + StringsKt.contains(res.getResponseHeaders(), "Content-Encoding: gzip", true), + () -> "clientGzip is " + clientGzip + ", so Content-Encoding: gzip header should be present" + ); + } + } + ); + } finally { + server.stop(); + } + } + private static WireMockServer createServer(Consumer config) { WireMockConfiguration configuration = WireMockConfiguration From 2b0f1491c17d949464f74c18b43a4d83b5078ab0 Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Tue, 11 Aug 2026 13:40:12 +0200 Subject: [PATCH 27/80] Re-validate pooled HTTP/2 connections after inactivity to prevent failures from closed connections --- bin/jmeter.properties | 7 + .../protocol/http/sampler/HTTPHC5Impl.java | 30 ++- .../http/sampler/TestHTTPHC5Features.java | 197 ++++++++++++++++++ xdocs/changes.xml | 5 + xdocs/usermanual/properties_reference.xml | 8 + 5 files changed, 240 insertions(+), 7 deletions(-) diff --git a/bin/jmeter.properties b/bin/jmeter.properties index aacff1c37eb..454ae4744bf 100644 --- a/bin/jmeter.properties +++ b/bin/jmeter.properties @@ -382,6 +382,13 @@ remote_hosts=127.0.0.1 # Valid values are HTTP/1.1 and HTTP/2 (defaults to HTTP/1.1) #httpclient.version=HTTP/1.1 +# Milliseconds of inactivity after which a pooled connection of the HttpClient5 +# sampler implementation is re-validated before it is reused (HTTP/2 PING, or a +# stale check for HTTP/1.1), -1 disables the check. Without it a connection the +# server closed while the thread was idle is reused as it is and fails the +# sample, as JMeter deliberately does not retry requests +#httpclient5.validate_after_inactivity=2000 + # HTTP/2 settings used by the HttpClient5 sampler implementation # Multiplex concurrent message exchanges (e.g. parallel downloads of embedded diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java index 7ef191e536a..9fe5c703293 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java @@ -200,6 +200,17 @@ protected Map initialValue() { private static final int HTTP_2_MAX_CONNECTIONS_PER_ROUTE = JMeterUtils.getPropDefault("httpclient5.h2.max_connections_per_route", 6); + /** + * Milliseconds of inactivity after which a pooled connection is re-validated before it is used + * again, {@code -1} disables the check. JMeter keeps a client per thread and reuses its + * connections across iterations, so a connection the server (or an idle timeout of a load + * balancer) closed in between would otherwise be handed out as-is and fail the next sample. + * HttpClient leaves this undefined by default, which skips the check altogether, and the + * automatic retries that would hide such a failure are deliberately disabled. + */ + private static final long VALIDATE_AFTER_INACTIVITY = + JMeterUtils.getPropDefault("httpclient5.validate_after_inactivity", 2000L); + /** * Name of the property that suppresses the {@code User-Agent} header HttpClient sends when the * test plan does not define one itself. @@ -749,12 +760,12 @@ private static CloseableHttpClient createClient(HttpClientKey key) { if (key.dnsCacheManager != null) { connectionManagerBuilder.setDnsResolver(createDnsResolver(key.dnsCacheManager)); } + ConnectionConfig.Builder connectionConfig = ConnectionConfig.custom() + .setValidateAfterInactivity(TimeValue.ofMilliseconds(VALIDATE_AFTER_INACTIVITY)); if (key.connectTimeout > 0) { - connectionManagerBuilder - .setDefaultConnectionConfig(ConnectionConfig.custom() - .setConnectTimeout(Timeout.ofMilliseconds(key.connectTimeout)) - .build()); + connectionConfig.setConnectTimeout(Timeout.ofMilliseconds(key.connectTimeout)); } + connectionManagerBuilder.setDefaultConnectionConfig(connectionConfig.build()); builder.setConnectionManager(new ConnectTimeMeasuringConnectionManager(connectionManagerBuilder.build())); builder.setRoutePlanner(createRoutePlanner(key)); return builder.disableContentCompression() @@ -794,11 +805,16 @@ private static CloseableHttpAsyncClient createHttp2Client(HttpClientKey key) { if (key.dnsCacheManager != null) { connectionManagerBuilder.setDnsResolver(createDnsResolver(key.dnsCacheManager)); } + // Without this an HTTP/2 connection is leased straight out of the pool, and a connection the + // server closed while the thread was idle only shows up when the request is written to it, + // which fails the sample with a ConnectionClosedException. The check sends an HTTP/2 PING + // and replaces the connection if the peer does not answer. + ConnectionConfig.Builder connectionConfig = ConnectionConfig.custom() + .setValidateAfterInactivity(TimeValue.ofMilliseconds(VALIDATE_AFTER_INACTIVITY)); if (key.connectTimeout > 0) { - connectionManagerBuilder.setDefaultConnectionConfig(ConnectionConfig.custom() - .setConnectTimeout(Timeout.ofMilliseconds(key.connectTimeout)) - .build()); + connectionConfig.setConnectTimeout(Timeout.ofMilliseconds(key.connectTimeout)); } + connectionManagerBuilder.setDefaultConnectionConfig(connectionConfig.build()); builder.setConnectionManager(new ConnectTimeMeasuringAsyncConnectionManager(connectionManagerBuilder.build())); CloseableHttpAsyncClient asyncClient = builder.build(); asyncClient.start(); diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java index 4f208247a56..b63f901cb18 100644 --- a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java +++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java @@ -27,9 +27,14 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.ByteArrayInputStream; +import java.io.Closeable; import java.io.DataInputStream; import java.io.IOException; import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.net.Socket; import java.net.URL; import java.nio.charset.StandardCharsets; import java.util.ArrayList; @@ -38,6 +43,7 @@ import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; import org.apache.hc.client5.http.HttpRoute; import org.apache.hc.client5.http.io.ConnectionEndpoint; @@ -54,6 +60,8 @@ import org.apache.jmeter.protocol.http.control.CacheManager; import org.apache.jmeter.protocol.http.util.HTTPConstants; import org.apache.jmeter.samplers.SampleResult; +import org.apache.jmeter.util.JMeterUtils; +import org.apache.jorphan.util.JOrphanUtils; import org.junit.jupiter.api.Test; import com.github.tomakehurst.wiremock.WireMockServer; @@ -442,6 +450,195 @@ void setsConnectTimeForHttp2() throws Exception { } } + /** + * A pooled HTTP/2 connection that the server dropped while the thread was idle must not be handed + * out as it is. What triggers this is the idle gap between two samples, not the number of requests, + * so the test waits out {@code httpclient5.validate_after_inactivity} between the two samples and + * only then lets the connection die, while the client still believes it is usable. + * + *

    Without the re-validation the second request is written to that connection and the sample + * fails with {@code ConnectionClosedException}, which is what + * {@link #doesNotRevalidatePooledHttp2ConnectionWithoutAnIdleGap()} pins down. + */ + @Test + void revalidatesPooledHttp2ConnectionAfterIdleGap() throws Exception { + WireMockServer server = new WireMockServer(WireMockConfiguration.wireMockConfig() + .dynamicHttpsPort() + .http2TlsDisabled(false)); + server.start(); + try (ConnectionDroppingRelay relay = new ConnectionDroppingRelay(server.httpsPort())) { + server.stubFor(get(urlEqualTo("/idle")).willReturn(aResponse().withStatus(200))); + HTTPSamplerBase sampler = newSampler(); + sampler.setHttpVersion("HTTP/2"); + sampler.setResponseTimeout("30000"); + URL url = new URL("https://localhost:" + relay.getPort() + "/idle"); + + assertEquals("200", sampler.sample(url, HTTPConstants.GET, false, 1).getResponseCode()); + + Thread.sleep(validateAfterInactivityMillis() + 500); + relay.dropConnectionOnNextRequest(); + + // the same sampler on the same thread, so the cached HTTP/2 client and its pool are reused + HTTPSampleResult result = sampler.sample(url, HTTPConstants.GET, false, 1); + + assertEquals("200", result.getResponseCode(), + "the stale pooled connection should have been replaced, but the sample failed with " + + result.getResponseMessage()); + assertEquals(2, relay.getAcceptedConnections(), + "the stale connection should have been discarded and replaced by a new one"); + } finally { + server.stop(); + } + } + + /** + * Counterpart of {@link #revalidatesPooledHttp2ConnectionAfterIdleGap()} which documents the + * behaviour the re-validation fixes: without an idle gap the connection is leased without being + * checked, so the request itself runs into the connection the server has already given up on. + */ + @Test + void doesNotRevalidatePooledHttp2ConnectionWithoutAnIdleGap() throws Exception { + WireMockServer server = new WireMockServer(WireMockConfiguration.wireMockConfig() + .dynamicHttpsPort() + .http2TlsDisabled(false)); + server.start(); + try (ConnectionDroppingRelay relay = new ConnectionDroppingRelay(server.httpsPort())) { + server.stubFor(get(urlEqualTo("/busy")).willReturn(aResponse().withStatus(200))); + HTTPSamplerBase sampler = newSampler(); + sampler.setHttpVersion("HTTP/2"); + sampler.setResponseTimeout("30000"); + URL url = new URL("https://localhost:" + relay.getPort() + "/busy"); + + assertEquals("200", sampler.sample(url, HTTPConstants.GET, false, 1).getResponseCode()); + relay.dropConnectionOnNextRequest(); + + HTTPSampleResult result = sampler.sample(url, HTTPConstants.GET, false, 1); + + assertFalse(result.isSuccessful(), + "a connection reused within the validation interval is not checked, so the request " + + "is expected to run into the dropped connection"); + } finally { + server.stop(); + } + } + + private static long validateAfterInactivityMillis() { + return JMeterUtils.getPropDefault("httpclient5.validate_after_inactivity", 2000L); + } + + /** + * Relays TCP traffic to a backend server and can drop a single relayed connection as soon as the + * client writes to it again, which is how an idle connection closed by the server (or by a load + * balancer) looks to a client that has not noticed the close yet. New connections are relayed as + * usual, so the backend stays reachable. + */ + private static final class ConnectionDroppingRelay implements Closeable { + + private final ServerSocket serverSocket; + private final int backendPort; + private final ExecutorService executor = Executors.newCachedThreadPool(runnable -> { + Thread thread = new Thread(runnable, "relay"); + thread.setDaemon(true); + return thread; + }); + private final AtomicInteger acceptedConnections = new AtomicInteger(); + private volatile RelayedConnection currentConnection; + + ConnectionDroppingRelay(int backendPort) throws IOException { + this.backendPort = backendPort; + this.serverSocket = new ServerSocket(0, 0, InetAddress.getLoopbackAddress()); + executor.execute(this::acceptConnections); + } + + int getPort() { + return serverSocket.getLocalPort(); + } + + int getAcceptedConnections() { + return acceptedConnections.get(); + } + + void dropConnectionOnNextRequest() { + RelayedConnection connection = currentConnection; + if (connection == null) { + throw new IllegalStateException("No connection has been relayed yet"); + } + connection.doomed = true; + } + + private void acceptConnections() { + while (!serverSocket.isClosed()) { + RelayedConnection connection; + try { + Socket client = serverSocket.accept(); + connection = new RelayedConnection(client, + new Socket(InetAddress.getLoopbackAddress(), backendPort)); + } catch (IOException e) { + return; + } + acceptedConnections.incrementAndGet(); + currentConnection = connection; + executor.execute(connection::relayRequests); + executor.execute(connection::relayResponses); + } + } + + @Override + public void close() throws IOException { + serverSocket.close(); + executor.shutdownNow(); + } + } + + private static final class RelayedConnection { + + private final Socket client; + private final Socket backend; + private volatile boolean doomed; + + RelayedConnection(Socket client, Socket backend) { + this.client = client; + this.backend = backend; + } + + void relayRequests() { + try { + InputStream input = client.getInputStream(); + OutputStream output = backend.getOutputStream(); + byte[] buffer = new byte[8192]; + int read; + while ((read = input.read(buffer)) != -1 && !doomed) { + output.write(buffer, 0, read); + output.flush(); + } + } catch (IOException ignored) { // NOSONAR the connection is closed below in any case + // the peer went away, which ends the relaying just as well + } + close(); + } + + void relayResponses() { + try { + InputStream input = backend.getInputStream(); + OutputStream output = client.getOutputStream(); + byte[] buffer = new byte[8192]; + int read; + while ((read = input.read(buffer)) != -1) { + output.write(buffer, 0, read); + output.flush(); + } + } catch (IOException ignored) { // NOSONAR the connection is closed below in any case + // the peer went away, which ends the relaying just as well + } + close(); + } + + private void close() { + JOrphanUtils.closeQuietly(client); + JOrphanUtils.closeQuietly(backend); + } + } + private static HTTPSamplerBase newSampler() { return HTTPSamplerFactory.newInstance("HttpClient5"); } diff --git a/xdocs/changes.xml b/xdocs/changes.xml index 4042df5513b..ef0817f2b5d 100644 --- a/xdocs/changes.xml +++ b/xdocs/changes.xml @@ -145,6 +145,11 @@ Summary

  • 6620Fix report generation paths so dashboard output files are created in the correct location after internal refactoring.
  • 6456Handle malformed percent-encoded URLs gracefully when recording HTTP traffic, logging a warning instead of failing the recording.
  • + +

    HTTP Samplers and Test Script Recorder

    +
      +
    • Re-validate pooled connections of the HttpClient5 sampler implementation after they have been idle, so a connection the server closed in between two samples is replaced instead of failing the next sample with Could not execute HTTP/2 request. The period of inactivity can be configured with the new httpclient5.validate_after_inactivity property.
    • +
    Thanks diff --git a/xdocs/usermanual/properties_reference.xml b/xdocs/usermanual/properties_reference.xml index 15b9f557cae..c9ce02d3f7a 100644 --- a/xdocs/usermanual/properties_reference.xml +++ b/xdocs/usermanual/properties_reference.xml @@ -446,6 +446,14 @@ JMETER-SERVER Valid values are HTTP/1.1 and HTTP/2.
    Defaults to: HTTP/1.1
    + + Milliseconds of inactivity after which a pooled connection of the HttpClient5 sampler + implementation is re-validated before it is reused, using an HTTP/2 PING or a stale + check for HTTP/1.1. -1 disables the check. Without it a connection the server closed + while the thread was idle is reused as it is and fails the sample, as JMeter deliberately does not + retry requests.
    + Defaults to: 2000 +
    Multiplex concurrent message exchanges (for example parallel downloads of embedded resources) over a single HTTP/2 connection.
    From 1a164c9d56c362404cd610a1f545a64c44e79367 Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Wed, 12 Aug 2026 08:50:25 +0200 Subject: [PATCH 28/80] Group and reorganize HTTP/2 sampler changes in `changes.xml` for clarity and update contributors list --- xdocs/changes.xml | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/xdocs/changes.xml b/xdocs/changes.xml index ef0817f2b5d..5307f67273c 100644 --- a/xdocs/changes.xml +++ b/xdocs/changes.xml @@ -95,10 +95,14 @@ Summary
  • 6250Avoid adding "; charset=" automatically to multipart/form-data requests to align behavior with modern HTTP clients.
  • 6080Preserve the original HTTP method when following 307 and 308 redirects according to the HTTP specification. Contributed by LeeJiWon (github.com/dlwldnjs1009)
  • 62676268Add a space between key and value after : in View Results Tree > Sampler result tab for better readability.
  • -
  • Multiplex concurrent HTTP/2 requests of the HttpClient5 sampler implementation over a single connection, and make the HTTP/2 protocol settings (HPACK header table size and compression, maximum concurrent streams, flow control window, maximum frame size, server push and h2c prior knowledge) configurable with the new httpclient5.h2.* properties.
  • -
  • Multiplex concurrent HTTP/2 requests of the Java sampler implementation over a single connection, and make the HTTP/2 protocol settings (HPACK header table size, maximum concurrent streams, flow control windows, maximum frame size, connection keep alive and server push) configurable with the new http.java.h2.* properties.
  • -
  • Add the default User-Agent header of the HttpClient5 sampler implementation to the request itself, so it shows up in the sample result and in the sent bytes, and allow suppressing it with the new httpclient5.default_user_agent_disabled property, like httpclient4.default_user_agent_disabled does for HttpClient4.
  • -
  • Add the default User-Agent header of the Java sampler implementation (Java/A.B.C for HTTP/1.1 and Java-http-client/A.B.C for HTTP/2) to the request itself, so the header the JDK sends anyway shows up in the sample result and in the sent bytes.
  • + + +

    HTTP/2 support for the HttpClient5 and Java sampler implementations:

    +
      +
    • 6742Multiplex concurrent HTTP/2 requests of the HttpClient5 sampler implementation over a single connection, and make the HTTP/2 protocol settings (HPACK header table size and compression, maximum concurrent streams, flow control window, maximum frame size, server push and h2c prior knowledge) configurable with the new httpclient5.h2.* properties. Pooled connections are re-validated after the period of inactivity configured with the new httpclient5.validate_after_inactivity property, so a connection the server closed in between two samples is replaced instead of failing the next sample.
    • +
    • 6742Multiplex concurrent HTTP/2 requests of the Java sampler implementation over a single connection, and make the HTTP/2 protocol settings (HPACK header table size, maximum concurrent streams, flow control windows, maximum frame size, connection keep alive and server push) configurable with the new http.java.h2.* properties.
    • +
    • 6742Add the default User-Agent header of the HttpClient5 sampler implementation to the request itself, so it shows up in the sample result and in the sent bytes, and allow suppressing it with the new httpclient5.default_user_agent_disabled property, like httpclient4.default_user_agent_disabled does for HttpClient4.
    • +
    • 6742Add the default User-Agent header of the Java sampler implementation (Java/A.B.C for HTTP/1.1 and Java-http-client/A.B.C for HTTP/2) to the request itself, so the header the JDK sends anyway shows up in the sample result and in the sent bytes.

    Timers, Assertions, Config, Pre- & Post-Processors

    @@ -126,7 +130,7 @@ Summary commons-jexl3 3.7.0, commons-lang3 3.21.0, commons-logging 1.4.0, commons-net 3.13.0, commons-pool2 2.13.1, commons-text 1.15.0.
  • Update Rhino JavaScript engine to 1.8.0 for JSR-223 JavaScript execution.
  • -
  • Update Apache HttpClient 5 to 5.6.4 and Apache HttpCore 5 to 5.4.3, which corrects the application of SSL parameters in the async TLS upgrade method used by the HttpClient5 sampler implementation for HTTP/2.
  • +
  • 6742Update Apache HttpClient 5 to 5.6.4 and Apache HttpCore 5 to 5.4.3, which corrects the application of SSL parameters in the async TLS upgrade method used by the HttpClient5 sampler implementation for HTTP/2.
  • UI

    @@ -146,10 +150,6 @@ Summary
  • 6456Handle malformed percent-encoded URLs gracefully when recording HTTP traffic, logging a warning instead of failing the recording.
  • -

    HTTP Samplers and Test Script Recorder

    -
      -
    • Re-validate pooled connections of the HttpClient5 sampler implementation after they have been idle, so a connection the server closed in between two samples is replaced instead of failing the next sample with Could not execute HTTP/2 request. The period of inactivity can be configured with the new httpclient5.validate_after_inactivity property.
    • -
    Thanks @@ -161,6 +161,7 @@ Summary
  • Gabriele Coletta (github.com/gdmg92)
  • Patrick Uiterwijk (patrick at puiterwijk.org)
  • Piotr P. Karwasz (github.com/piotrgithub)
  • +
  • Andreas Lind (github.com/andreaslind01)
  • We also thank bug reporters who helped us improve JMeter.

      From d2b51d98169e53740165f6759fcc4d9dac178ffd Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Wed, 12 Aug 2026 13:59:12 +0200 Subject: [PATCH 29/80] Add support for Kerberos authentication in HttpClient5 sampler implementation --- .../protocol/http/sampler/HTTPHC5Impl.java | 238 +++++++++++++++++- .../http/sampler/TestHTTPHC5Features.java | 53 ++++ xdocs/changes.xml | 1 + xdocs/usermanual/component_reference.xml | 2 +- 4 files changed, 280 insertions(+), 14 deletions(-) diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java index 9fe5c703293..c17fccd043b 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java @@ -29,7 +29,11 @@ import java.net.UnknownHostException; import java.nio.charset.Charset; import java.security.GeneralSecurityException; +import java.security.Principal; +import java.security.PrivilegedActionException; +import java.security.PrivilegedExceptionAction; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Map; @@ -41,11 +45,20 @@ import java.util.concurrent.TimeoutException; import javax.net.ssl.SSLContext; +import javax.security.auth.Subject; +import org.apache.hc.client5.http.DnsResolver; import org.apache.hc.client5.http.HttpRoute; +import org.apache.hc.client5.http.SystemDefaultDnsResolver; import org.apache.hc.client5.http.async.methods.SimpleHttpRequest; import org.apache.hc.client5.http.async.methods.SimpleHttpResponse; +import org.apache.hc.client5.http.auth.AuthSchemeFactory; import org.apache.hc.client5.http.auth.AuthScope; +import org.apache.hc.client5.http.auth.Credentials; +import org.apache.hc.client5.http.auth.CredentialsProvider; +import org.apache.hc.client5.http.auth.KerberosConfig; +import org.apache.hc.client5.http.auth.KerberosCredentials; +import org.apache.hc.client5.http.auth.StandardAuthScheme; import org.apache.hc.client5.http.auth.UsernamePasswordCredentials; import org.apache.hc.client5.http.classic.ExecChainHandler; import org.apache.hc.client5.http.config.ConnectionConfig; @@ -61,6 +74,11 @@ import org.apache.hc.client5.http.impl.async.HttpAsyncClientBuilder; import org.apache.hc.client5.http.impl.async.HttpAsyncClients; import org.apache.hc.client5.http.impl.auth.BasicCredentialsProvider; +import org.apache.hc.client5.http.impl.auth.BasicSchemeFactory; +import org.apache.hc.client5.http.impl.auth.BearerSchemeFactory; +import org.apache.hc.client5.http.impl.auth.DigestSchemeFactory; +import org.apache.hc.client5.http.impl.auth.KerberosSchemeFactory; +import org.apache.hc.client5.http.impl.auth.SPNegoSchemeFactory; import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; import org.apache.hc.client5.http.impl.classic.HttpClients; import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder; @@ -129,6 +147,10 @@ import org.apache.jorphan.util.JOrphanUtils; import org.apache.jorphan.util.StringUtilities; import org.brotli.dec.BrotliInputStream; +import org.ietf.jgss.GSSCredential; +import org.ietf.jgss.GSSException; +import org.ietf.jgss.GSSManager; +import org.ietf.jgss.Oid; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -238,6 +260,43 @@ protected Map initialValue() { private static final H2Config HTTP_2_CONFIG = createHttp2Config(); + /** + * Request that the Kerberos credentials of the JMeter user are delegated to the server, so + * that it can act on behalf of the user, see the {@code kerberos.spnego.delegate_cred} + * property. + */ + private static final boolean KERBEROS_DELEGATE_CRED = + JMeterUtils.getPropDefault("kerberos.spnego.delegate_cred", false); + + /** + * HttpClient 5 only offers {@code Bearer}, {@code Digest} and {@code Basic} by default, so the + * negotiation based schemes have to be requested explicitly for a Kerberos authorization. + */ + @SuppressWarnings("deprecation") // The GSS based auth schemes of HttpClient 5 have no replacement yet + private static final List KERBEROS_PREFERRED_AUTH_SCHEMES = + List.of(StandardAuthScheme.SPNEGO, StandardAuthScheme.KERBEROS); + + private static final Oid SPNEGO_OID = createOid("1.3.6.1.5.5.2"); + + private static final Oid KERBEROS_OID = createOid("1.2.840.113554.1.2.2"); + + /** + * Credentials without a principal, telling the negotiation based auth schemes to use the + * credentials of the JAAS {@link Subject} the request is executed with. + */ + private static final Credentials USE_JAAS_CREDENTIALS = new Credentials() { + @Override + public Principal getUserPrincipal() { + return null; + } + + @Override + @SuppressWarnings("deprecation") // Credentials.getPassword is deprecated, but has to be implemented + public char[] getPassword() { + return new char[0]; + } + }; + private static final Method MESSAGE_MULTIPLEXING_SETTER = findMessageMultiplexingSetter(); private static final String[] HEADERS_TO_SAVE = {HttpHeaders.CONTENT_LENGTH, HttpHeaders.CONTENT_ENCODING, @@ -367,9 +426,7 @@ protected HTTPSampleResult sample(URL url, String method, boolean areFollowingRe HttpClientKey clientKey = createHttpClientKey(url); HttpClientContext context = createHttpClientContext(url, clientKey, request); context.setAttribute(CONTEXT_ATTRIBUTE_SAMPLER_RESULT, result); - response = clientKey.httpVersionPolicy != HttpVersionPolicy.FORCE_HTTP_1 - ? executeHttp2(getHttp2Client(clientKey), request, context) - : getClient(clientKey).executeOpen(null, request, context); + response = executeRequest(url, clientKey, request, context); result.sampleEnd(); currentRequest = null; @@ -406,6 +463,37 @@ private static org.apache.hc.client5.http.classic.methods.HttpUriRequestBase cre return new org.apache.hc.client5.http.classic.methods.HttpUriRequestBase(method, uri); } + /** + * Executes the request under the JAAS {@link Subject} of the {@link AuthManager}, if the URL is + * covered by a Kerberos authorization, so that the auth scheme can use its credentials. + */ + private ClassicHttpResponse executeRequest(URL url, HttpClientKey clientKey, + org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request, HttpClientContext context) + throws IOException { + Subject subject = getSubjectForUrl(url); + if (subject == null) { + return doExecuteRequest(clientKey, request, context); + } + try { + return Subject.doAs(subject, (PrivilegedExceptionAction) () -> + doExecuteRequest(clientKey, request, context)); + } catch (PrivilegedActionException e) { + Exception cause = e.getException(); + if (cause instanceof IOException ioException) { + throw ioException; + } + throw new IOException("Could not execute the request with subject " + subject, cause); + } + } + + private static ClassicHttpResponse doExecuteRequest(HttpClientKey clientKey, + org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request, HttpClientContext context) + throws IOException { + return clientKey.httpVersionPolicy != HttpVersionPolicy.FORCE_HTTP_1 + ? executeHttp2(getHttp2Client(clientKey), request, context) + : getClient(clientKey).executeOpen(null, request, context); + } + private void setupRequest(URL url, org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request, HTTPSampleResult result, boolean areFollowingRedirect) throws IOException { HttpVersionPolicy httpVersionPolicy = getHttpVersionPolicy(testElement.getHttpVersion(), HTTP_VERSION, @@ -561,22 +649,36 @@ private static String setConnectionCookie(org.apache.hc.client5.http.classic.met return cookies; } - private HttpClientContext createHttpClientContext(URL url, HttpClientKey key, + HttpClientContext createHttpClientContext(URL url, HttpClientKey key, org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request) { HttpClientContext context = HttpClientContext.create(); BasicCredentialsProvider credentialsProvider = new BasicCredentialsProvider(); - configureTargetCredentials(url, request, credentialsProvider); + Authorization authorization = getAuthorizationForUrl(url); + configureTargetCredentials(url, request, credentialsProvider, authorization); configureProxyCredentials(key, credentialsProvider); - context.setCredentialsProvider(credentialsProvider); + if (isKerberos(authorization)) { + configureKerberos(url, request, context, credentialsProvider); + } else { + context.setCredentialsProvider(credentialsProvider); + } return context; } - private void configureTargetCredentials(URL url, - org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request, - BasicCredentialsProvider credentialsProvider) { + private Authorization getAuthorizationForUrl(URL url) { AuthManager authManager = getAuthManager(); - Authorization authorization = authManager == null ? null : authManager.getAuthForURL(url); - if (authorization == null) { + return authManager == null ? null : authManager.getAuthForURL(url); + } + + private static boolean isKerberos(Authorization authorization) { + return authorization != null && AuthManager.Mechanism.KERBEROS.equals(authorization.getMechanism()); + } + + private static void configureTargetCredentials(URL url, + org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request, + BasicCredentialsProvider credentialsProvider, Authorization authorization) { + if (authorization == null || isKerberos(authorization)) { + // The credentials of a Kerberos authorization are used to log in to the KDC, they must + // not be sent to the server return; } credentialsProvider.setCredentials(new AuthScope(url.getHost(), getPort(url)), @@ -586,6 +688,116 @@ private void configureTargetCredentials(URL url, } } + /** + * Sets up the {@code Negotiate} and {@code Kerberos} auth schemes for a single request, as + * HttpClient 5 neither registers them nor prefers them by default. The credentials are taken + * from the JAAS {@link Subject} the {@link AuthManager} logged the user in with. + */ + @SuppressWarnings("deprecation") // The GSS based auth schemes of HttpClient 5 have no replacement yet + private void configureKerberos(URL url, + org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request, + HttpClientContext context, CredentialsProvider credentialsProvider) { + KerberosConfig kerberosConfig = KerberosConfig.custom() + .setStripPort(isStripPort(url)) + .setUseCanonicalHostname(AuthManager.USE_CANONICAL_HOST_NAME) + .setRequestDelegCreds(KERBEROS_DELEGATE_CRED + ? KerberosConfig.Option.ENABLE : KerberosConfig.Option.DEFAULT) + .build(); + DnsResolver dnsResolver = SystemDefaultDnsResolver.INSTANCE; + context.setAuthSchemeRegistry(RegistryBuilder.create() + .register(StandardAuthScheme.BASIC, BasicSchemeFactory.INSTANCE) + .register(StandardAuthScheme.DIGEST, DigestSchemeFactory.INSTANCE) + .register(StandardAuthScheme.BEARER, BearerSchemeFactory.INSTANCE) + .register(StandardAuthScheme.SPNEGO, new SPNegoSchemeFactory(kerberosConfig, dnsResolver)) + .register(StandardAuthScheme.KERBEROS, new KerberosSchemeFactory(kerberosConfig, dnsResolver)) + .build()); + context.setCredentialsProvider( + new KerberosCredentialsProvider(getSubjectForUrl(url), credentialsProvider)); + RequestConfig requestConfig = request.getConfig() == null ? RequestConfig.DEFAULT : request.getConfig(); + request.setConfig(RequestConfig.copy(requestConfig) + .setTargetPreferredAuthSchemes(KERBEROS_PREFERRED_AUTH_SCHEMES) + .build()); + } + + /** + * IE and Firefox always strip the port from the URL before they construct the SPN, see the + * {@code kerberos.spnego.strip_port} property. + */ + private static boolean isStripPort(URL url) { + if (AuthManager.STRIP_PORT) { + return true; + } + int port = url.getPort(); + return port == HTTPConstants.DEFAULT_HTTP_PORT || port == HTTPConstants.DEFAULT_HTTPS_PORT; + } + + private Subject getSubjectForUrl(URL url) { + AuthManager authManager = getAuthManager(); + return authManager == null ? null : authManager.getSubjectForUrl(url); + } + + private static Oid createOid(String oid) { + try { + return new Oid(oid); + } catch (GSSException e) { + log.warn("Could not create OID {}", oid, e); + return null; + } + } + + /** + * Provides the GSS credentials of a JAAS {@link Subject} to the negotiation based auth schemes. + * The HTTP/2 transport builds the token on an I/O reactor thread, which is not covered by the + * {@link Subject#doAs(Subject, PrivilegedExceptionAction)} the HTTP/1.1 request is executed + * with, so the credentials have to be passed to HttpClient explicitly. + */ + private static final class KerberosCredentialsProvider implements CredentialsProvider { + + private final Subject subject; + private final CredentialsProvider delegate; + private final Map credentialsCache = new HashMap<>(); + + private KerberosCredentialsProvider(Subject subject, CredentialsProvider delegate) { + this.subject = subject; + this.delegate = delegate; + } + + @Override + @SuppressWarnings("deprecation") // The GSS based auth schemes of HttpClient 5 have no replacement yet + public Credentials getCredentials(AuthScope authScope, HttpContext context) { + String schemeName = authScope == null ? null : authScope.getSchemeName(); + if (StandardAuthScheme.SPNEGO.equalsIgnoreCase(schemeName)) { + return getKerberosCredentials(schemeName, SPNEGO_OID); + } + if (StandardAuthScheme.KERBEROS.equalsIgnoreCase(schemeName)) { + return getKerberosCredentials(schemeName, KERBEROS_OID); + } + return delegate.getCredentials(authScope, context); + } + + private Credentials getKerberosCredentials(String schemeName, Oid oid) { + return credentialsCache.computeIfAbsent(schemeName, name -> createKerberosCredentials(oid)); + } + + @SuppressWarnings("deprecation") // KerberosCredentials is deprecated without a replacement + private Credentials createKerberosCredentials(Oid oid) { + if (subject == null || oid == null) { + return USE_JAAS_CREDENTIALS; + } + try { + GSSCredential gssCredential = Subject.doAs(subject, + (PrivilegedExceptionAction) () -> GSSManager.getInstance() + .createCredential(null, GSSCredential.DEFAULT_LIFETIME, oid, + GSSCredential.INITIATE_ONLY)); + return new KerberosCredentials(gssCredential); + } catch (PrivilegedActionException e) { + log.warn("Could not obtain the GSS credentials of subject {}, " + + "falling back to the credentials of the current context", subject, e.getException()); + return USE_JAAS_CREDENTIALS; + } + } + } + private static void configureProxyCredentials(HttpClientKey key, BasicCredentialsProvider credentialsProvider) { if (key.hasProxy && StringUtilities.isNotEmpty(key.proxyUser)) { credentialsProvider.setCredentials(new AuthScope(key.proxyHost, key.proxyPort), @@ -929,7 +1141,7 @@ public String resolveCanonicalHostname(String host) throws UnknownHostException }; } - private HttpClientKey createHttpClientKey(URL url) throws IOException { + HttpClientKey createHttpClientKey(URL url) throws IOException { String proxyScheme = getProxyScheme(); String proxyHost = getProxyHost(); int proxyPort = getProxyPortInt(); @@ -1146,7 +1358,7 @@ public void close() throws IOException { } } - private static final class HttpClientKey { + static final class HttpClientKey { private final String protocol; private final String authority; private final boolean hasProxy; diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java index b63f901cb18..2cdd8e9acfb 100644 --- a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java +++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java @@ -24,6 +24,8 @@ import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; 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; import java.io.ByteArrayInputStream; @@ -46,10 +48,16 @@ import java.util.concurrent.atomic.AtomicInteger; import org.apache.hc.client5.http.HttpRoute; +import org.apache.hc.client5.http.auth.AuthSchemeFactory; +import org.apache.hc.client5.http.auth.AuthScope; +import org.apache.hc.client5.http.auth.StandardAuthScheme; +import org.apache.hc.client5.http.classic.methods.HttpUriRequestBase; import org.apache.hc.client5.http.io.ConnectionEndpoint; import org.apache.hc.client5.http.io.HttpClientConnectionManager; import org.apache.hc.client5.http.io.LeaseRequest; import org.apache.hc.client5.http.protocol.HttpClientContext; +import org.apache.hc.core5.http.HttpHeaders; +import org.apache.hc.core5.http.config.Lookup; import org.apache.hc.core5.http.protocol.HttpContext; import org.apache.hc.core5.http2.HttpVersionPolicy; import org.apache.hc.core5.http2.config.H2Config; @@ -340,6 +348,51 @@ void sendsBasicCredentialsFromAuthorizationManager() throws Exception { } } + @Test + @SuppressWarnings("deprecation") // The GSS based auth schemes of HttpClient 5 have no replacement yet + void sendsNegotiateCredentialsForKerberosAuthorization() throws Exception { + AuthManager authManager = new AuthManager(); + authManager.set(-1, "http://kerberos.example.invalid/", "user", "pass", "", "", AuthManager.Mechanism.KERBEROS); + HTTPSamplerBase sampler = newSampler(); + sampler.setAuthManager(authManager); + HTTPHC5Impl implementation = new HTTPHC5Impl(sampler); + URL url = new URL("http://kerberos.example.invalid/protected"); + HttpUriRequestBase request = new HttpUriRequestBase(HTTPConstants.GET, url.toURI()); + + HttpClientContext context = + implementation.createHttpClientContext(url, implementation.createHttpClientKey(url), request); + + Lookup authSchemes = context.getAuthSchemeRegistry(); + assertNotNull(authSchemes, "the Kerberos auth schemes have to be registered for the request"); + assertNotNull(authSchemes.lookup(StandardAuthScheme.SPNEGO), "Negotiate has to be supported"); + assertNotNull(authSchemes.lookup(StandardAuthScheme.KERBEROS), "Kerberos has to be supported"); + assertEquals(List.of(StandardAuthScheme.SPNEGO, StandardAuthScheme.KERBEROS), + new ArrayList<>(request.getConfig().getTargetPreferredAuthSchemes()), + "HttpClient 5 only prefers Bearer, Digest and Basic by default"); + assertNotNull(context.getCredentialsProvider().getCredentials( + new AuthScope(null, "kerberos.example.invalid", 80, null, StandardAuthScheme.SPNEGO), context), + "the Negotiate scheme needs credentials to authenticate with"); + assertFalse(request.containsHeader(HttpHeaders.AUTHORIZATION), + "the credentials of a Kerberos authorization are only used to log in to the KDC"); + } + + @Test + void keepsDefaultAuthSchemesWithoutKerberosAuthorization() throws Exception { + AuthManager authManager = new AuthManager(); + authManager.set(-1, "http://basic.example.invalid/", "user", "pass", "", "", AuthManager.Mechanism.BASIC); + HTTPSamplerBase sampler = newSampler(); + sampler.setAuthManager(authManager); + HTTPHC5Impl implementation = new HTTPHC5Impl(sampler); + URL url = new URL("http://basic.example.invalid/protected"); + HttpUriRequestBase request = new HttpUriRequestBase(HTTPConstants.GET, url.toURI()); + + HttpClientContext context = + implementation.createHttpClientContext(url, implementation.createHttpClientKey(url), request); + + assertNull(context.getAuthSchemeRegistry(), "the client should use its default auth schemes"); + assertNull(request.getConfig(), "the request configuration should be left alone"); + } + @Test void authenticatesWithConfiguredProxyCredentials() throws Exception { WireMockServer server = createServer(); diff --git a/xdocs/changes.xml b/xdocs/changes.xml index 5307f67273c..6d88c965afd 100644 --- a/xdocs/changes.xml +++ b/xdocs/changes.xml @@ -103,6 +103,7 @@ Summary
    • 6742Multiplex concurrent HTTP/2 requests of the Java sampler implementation over a single connection, and make the HTTP/2 protocol settings (HPACK header table size, maximum concurrent streams, flow control windows, maximum frame size, connection keep alive and server push) configurable with the new http.java.h2.* properties.
    • 6742Add the default User-Agent header of the HttpClient5 sampler implementation to the request itself, so it shows up in the sample result and in the sent bytes, and allow suppressing it with the new httpclient5.default_user_agent_disabled property, like httpclient4.default_user_agent_disabled does for HttpClient4.
    • 6742Add the default User-Agent header of the Java sampler implementation (Java/A.B.C for HTTP/1.1 and Java-http-client/A.B.C for HTTP/2) to the request itself, so the header the JDK sends anyway shows up in the sample result and in the sent bytes.
    • +
    • 6742Support the Kerberos mechanism of the HTTP Authorization Manager in the HttpClient5 sampler implementation. HttpClient 5 neither registers the Negotiate and Kerberos auth schemes nor prefers them by default, so they are now set up per request, and the request is executed with the JAAS subject the Authorization Manager logged the user in with. The kerberos.spnego.strip_port, kerberos.spnego.use_canonical_host_name and kerberos.spnego.delegate_cred properties are honoured as with HttpClient4.

    Timers, Assertions, Config, Pre- & Post-Processors

    diff --git a/xdocs/usermanual/component_reference.xml b/xdocs/usermanual/component_reference.xml index 2303bafc118..b6c15f40e5c 100644 --- a/xdocs/usermanual/component_reference.xml +++ b/xdocs/usermanual/component_reference.xml @@ -3723,7 +3723,7 @@ information for the user named, "jmeter".
    Java
    BASIC
    HttpClient 4
    BASIC, DIGEST and Kerberos
    -
    HttpClient 5
    BASIC and DIGEST
    +
    HttpClient 5
    BASIC, DIGEST and Kerberos
    From 62359472addbc1a4fb104c3f89c0e1e93ed1a5e3 Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Thu, 13 Aug 2026 07:48:21 +0200 Subject: [PATCH 30/80] Restrict HTTP version options by sampler implementation and add "HTTP/2 Strict" support for HttpClient5 --- bin/jmeter.properties | 2 +- .../http/config/gui/HttpDefaultsGui.java | 5 +- .../http/control/gui/HttpTestSampleGui.java | 5 +- .../http/gui/HttpVersionComboBox.java | 116 ++++++++++++++++++ .../protocol/http/sampler/HTTPHC5Impl.java | 8 +- .../protocol/http/sampler/HTTPJavaImpl.java | 4 +- .../http/sampler/HTTPSamplerFactory.java | 25 ++++ .../http/util/HTTPConstantsInterface.java | 12 ++ .../http/gui/TestHttpVersionComboBox.java | 113 +++++++++++++++++ .../http/sampler/TestHTTPHC5Features.java | 12 ++ .../http/sampler/TestHTTPSamplerFactory.java | 16 +++ xdocs/changes.xml | 1 + xdocs/usermanual/component_reference.xml | 8 +- xdocs/usermanual/properties_reference.xml | 3 +- 14 files changed, 321 insertions(+), 9 deletions(-) create mode 100644 src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/gui/HttpVersionComboBox.java create mode 100644 src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/gui/TestHttpVersionComboBox.java diff --git a/bin/jmeter.properties b/bin/jmeter.properties index 454ae4744bf..03d215c40ce 100644 --- a/bin/jmeter.properties +++ b/bin/jmeter.properties @@ -379,7 +379,7 @@ remote_hosts=127.0.0.1 # 0 == no timeout # Set the default HTTP version for HttpClient5 and Java samplers when HTTP Version is empty -# Valid values are HTTP/1.1 and HTTP/2 (defaults to HTTP/1.1) +# Valid values are HTTP/1.1, HTTP/2 and, for HttpClient5 only, HTTP/2 Strict (defaults to HTTP/1.1) #httpclient.version=HTTP/1.1 # Milliseconds of inactivity after which a pooled connection of the HttpClient5 diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/config/gui/HttpDefaultsGui.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/config/gui/HttpDefaultsGui.java index 4fdd406acf4..71bf2ca5e2a 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/config/gui/HttpDefaultsGui.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/config/gui/HttpDefaultsGui.java @@ -37,6 +37,7 @@ import org.apache.jmeter.gui.TestElementMetadata; import org.apache.jmeter.gui.util.HorizontalPanel; import org.apache.jmeter.gui.util.VerticalPanel; +import org.apache.jmeter.protocol.http.gui.HttpVersionComboBox; import org.apache.jmeter.protocol.http.sampler.HTTPSamplerBase; import org.apache.jmeter.protocol.http.sampler.HTTPSamplerBaseSchema; import org.apache.jmeter.protocol.http.sampler.HTTPSamplerFactory; @@ -79,7 +80,7 @@ public class HttpDefaultsGui extends AbstractConfigGui { private JTextField proxyUser; private JPasswordField proxyPass; private final JComboBox httpImplementation = new JComboBox<>(HTTPSamplerFactory.getImplementations()); - private final JComboBox httpVersion = new JComboBox<>(new String[] {"HTTP/1.1", "HTTP/2", ""}); + private final HttpVersionComboBox httpVersion = new HttpVersionComboBox(); private JTextField connectTimeOut; private JTextField responseTimeOut; @@ -171,6 +172,7 @@ public void configure(TestElement el) { HTTPSamplerBaseSchema httpSchema = HTTPSamplerBaseSchema.INSTANCE; sourceIpType.setSelectedIndex(samplerBase.get(httpSchema.getIpSourceType())); httpImplementation.setSelectedItem(samplerBase.getString(httpSchema.getImplementation())); + httpVersion.setImplementation(samplerBase.getString(httpSchema.getImplementation())); httpVersion.setSelectedItem(samplerBase.getString(httpSchema.getHttpVersion())); } @@ -333,6 +335,7 @@ protected final JPanel getImplementationPanel(){ httpImplementation.addItem("");// $NON-NLS-1$ implPanel.add(httpImplementation); implPanel.add(new JLabel(JMeterUtils.getResString("http_version"))); // $NON-NLS-1$ + httpVersion.bindToImplementation(httpImplementation); implPanel.add(httpVersion); return implPanel; } diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/gui/HttpTestSampleGui.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/gui/HttpTestSampleGui.java index 7b6150e3b37..95849db5c41 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/gui/HttpTestSampleGui.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/gui/HttpTestSampleGui.java @@ -37,6 +37,7 @@ import org.apache.jmeter.gui.util.HorizontalPanel; import org.apache.jmeter.gui.util.VerticalPanel; import org.apache.jmeter.protocol.http.config.gui.UrlConfigGui; +import org.apache.jmeter.protocol.http.gui.HttpVersionComboBox; import org.apache.jmeter.protocol.http.sampler.HTTPSamplerBase; import org.apache.jmeter.protocol.http.sampler.HTTPSamplerBaseSchema; import org.apache.jmeter.protocol.http.sampler.HTTPSamplerFactory; @@ -81,7 +82,7 @@ public class HttpTestSampleGui extends AbstractSamplerGui { private JTextField proxyUser; private JPasswordField proxyPass; private final JComboBox httpImplementation = new JComboBox<>(HTTPSamplerFactory.getImplementations()); - private final JComboBox httpVersion = new JComboBox<>(new String[] {"HTTP/1.1", "HTTP/2", ""}); + private final HttpVersionComboBox httpVersion = new HttpVersionComboBox(); private JTextField connectTimeOut; private JTextField responseTimeOut; @@ -136,6 +137,7 @@ public void configure(TestElement element) { if (!isAJP) { sourceIpType.setSelectedIndex(samplerBase.getIpSourceType()); httpImplementation.setSelectedItem(samplerBase.getString(httpSchema.getImplementation())); + httpVersion.setImplementation(samplerBase.getString(httpSchema.getImplementation())); httpVersion.setSelectedItem(samplerBase.getHttpVersion()); } } @@ -355,6 +357,7 @@ protected final JPanel getImplementationPanel(){ httpImplementation.addItem("");// $NON-NLS-1$ implPanel.add(httpImplementation); implPanel.add(new JLabel(JMeterUtils.getResString("http_version"))); // $NON-NLS-1$ + httpVersion.bindToImplementation(httpImplementation); implPanel.add(httpVersion); return implPanel; } diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/gui/HttpVersionComboBox.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/gui/HttpVersionComboBox.java new file mode 100644 index 00000000000..c5800054748 --- /dev/null +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/gui/HttpVersionComboBox.java @@ -0,0 +1,116 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you 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.apache.jmeter.protocol.http.gui; + +import java.awt.Component; +import java.awt.event.ItemEvent; +import java.util.Arrays; +import java.util.Objects; + +import javax.swing.DefaultComboBoxModel; +import javax.swing.DefaultListCellRenderer; +import javax.swing.JComboBox; +import javax.swing.JList; + +import org.apache.jmeter.protocol.http.sampler.HTTPSamplerFactory; +import org.apache.jmeter.protocol.http.util.HTTPConstants; + +/** + * Combo box for the HTTP version of a sampler, offering only the versions the selected + * implementation supports. The items are the values stored in the {@code HTTPSampler.httpVersion} + * property, the rendering spells out how HTTP/2 is applied. + * + * @since 5.7 + */ +public class HttpVersionComboBox extends JComboBox { + + private static final long serialVersionUID = 1L; + + public HttpVersionComboBox() { + super(HTTPSamplerFactory.getHttpVersions("")); + setRenderer(new DefaultListCellRenderer() { + private static final long serialVersionUID = 1L; + + @Override + public Component getListCellRendererComponent(JList list, Object value, int index, + boolean isSelected, boolean cellHasFocus) { + return super.getListCellRendererComponent(list, getLabel(value), index, isSelected, cellHasFocus); + } + }); + } + + private static Object getLabel(Object value) { + if (HTTPConstants.HTTP_VERSION_2.equals(value)) { + // Spelled out, as HTTP/2 falls back to HTTP/1.1 when the server does not support it + return "HTTP/2 Negotiate"; + } + return value; + } + + /** + * Keeps the offered HTTP versions in sync with the implementation selected in the given combo + * box, so combinations an implementation would ignore cannot be selected. + * + * @param httpImplementation combo box holding the implementation of the sampler + */ + public void bindToImplementation(JComboBox httpImplementation) { + httpImplementation.addItemListener(event -> { + if (event.getStateChange() == ItemEvent.SELECTED) { + setImplementation(Objects.toString(event.getItem(), "")); + } + }); + setImplementation(Objects.toString(httpImplementation.getSelectedItem(), "")); + } + + /** + * Restricts the offered HTTP versions to the ones the given implementation supports. + * + * @param implementation implementation name, an empty value refers to the default implementation + */ + public void setImplementation(String implementation) { + String[] versions = HTTPSamplerFactory.getHttpVersions(implementation); + String selected = (String) getSelectedItem(); + if (Arrays.equals(versions, getItems())) { + return; + } + setModel(new DefaultComboBoxModel<>(versions)); + setSelectedItem(Arrays.asList(versions).contains(selected) ? selected : ""); + } + + private String[] getItems() { + String[] items = new String[getItemCount()]; + Arrays.setAll(items, this::getItemAt); + return items; + } + + /** + * {@inheritDoc} + *

    + * A version which the selected implementation does not offer, e.g. one read from a test plan + * saved with an earlier version of JMeter, is added to the model instead of being dropped + * silently. + */ + @Override + public void setSelectedItem(Object item) { + if (item instanceof String version && !Arrays.asList(getItems()).contains(version) + && getModel() instanceof DefaultComboBoxModel) { + ((DefaultComboBoxModel) getModel()).addElement(version); + } + super.setSelectedItem(item); + } +} diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java index c17fccd043b..d9042631664 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java @@ -1166,14 +1166,18 @@ HttpClientKey createHttpClientKey(URL url) throws IOException { static HttpVersionPolicy getHttpVersionPolicy(String samplerHttpVersion, String defaultHttpVersion) { String httpVersion = StringUtilities.isBlank(samplerHttpVersion) ? defaultHttpVersion : samplerHttpVersion; - return "HTTP/2".equals(httpVersion) || "2".equals(httpVersion) + if (HTTPConstants.HTTP_VERSION_2_STRICT.equalsIgnoreCase(httpVersion)) { + return HttpVersionPolicy.FORCE_HTTP_2; + } + return HTTPConstants.HTTP_VERSION_2.equals(httpVersion) || "2".equals(httpVersion) ? HttpVersionPolicy.NEGOTIATE : HttpVersionPolicy.FORCE_HTTP_1; } /** * Determines the version policy for a request to the given scheme. Plain HTTP does not support * ALPN, so HTTP/2 can only be used over {@code http://} when the client assumes that the server - * speaks HTTP/2 (h2c with prior knowledge). + * speaks HTTP/2 (h2c with prior knowledge). {@code HTTP/2 Strict} keeps its policy for every + * scheme, as it requires HTTP/2 either way. */ static HttpVersionPolicy getHttpVersionPolicy(String samplerHttpVersion, String defaultHttpVersion, String scheme) { HttpVersionPolicy policy = getHttpVersionPolicy(samplerHttpVersion, defaultHttpVersion); diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java index ba08dce4b95..7a2671f632a 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java @@ -299,7 +299,9 @@ static String getReasonPhrase(int statusCode) { static boolean isHttp2(String samplerHttpVersion, String defaultHttpVersion) { String httpVersion = StringUtilities.isBlank(samplerHttpVersion) ? defaultHttpVersion : samplerHttpVersion; - return "HTTP/2".equalsIgnoreCase(httpVersion) || "2".equalsIgnoreCase(httpVersion); // $NON-NLS-1$ $NON-NLS-2$ + // java.net.http.HttpClient always negotiates, so a strict HTTP/2 request is negotiated as well + return HTTPConstants.HTTP_VERSION_2.equalsIgnoreCase(httpVersion) || "2".equalsIgnoreCase(httpVersion) + || HTTPConstants.HTTP_VERSION_2_STRICT.equalsIgnoreCase(httpVersion); // $NON-NLS-1$ } private boolean isHttp2() { diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPSamplerFactory.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPSamplerFactory.java index c7781156dc4..30f0d902dcc 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPSamplerFactory.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPSamplerFactory.java @@ -17,6 +17,7 @@ package org.apache.jmeter.protocol.http.sampler; +import org.apache.jmeter.protocol.http.util.HTTPConstants; import org.apache.jmeter.util.JMeterUtils; import org.apache.jorphan.util.StringUtilities; @@ -88,6 +89,30 @@ public static String[] getImplementations(){ return new String[]{IMPL_HTTP_CLIENT4, IMPL_HTTP_CLIENT5, IMPL_JAVA}; } + /** + * Returns the HTTP versions the given implementation can actually use, starting with the empty + * value which leaves the choice to the {@code httpclient.version} property. Implementations + * which ignore the HTTP version of the sampler do not offer HTTP/2 at all, so a combination + * that would be silently dropped cannot be selected in the first place. + * + * @param implementation implementation name, an empty value refers to the default implementation + * @return the selectable values of the {@code HTTPSampler.httpVersion} property + */ + public static String[] getHttpVersions(String implementation) { + String impl = StringUtilities.isBlank(implementation) ? DEFAULT_CLASSNAME : implementation; + if (IMPL_HTTP_CLIENT5.equals(impl)) { + // HttpClient 5 is the only implementation which can require HTTP/2 for the connection + return new String[]{"", HTTPConstants.HTTP_VERSION_1_1, HTTPConstants.HTTP_VERSION_2, + HTTPConstants.HTTP_VERSION_2_STRICT}; + } + if (IMPL_JAVA.equals(impl) || HTTP_SAMPLER_JAVA.equals(impl)) { + // java.net.http.HttpClient always negotiates, it has no API to insist on HTTP/2 + return new String[]{"", HTTPConstants.HTTP_VERSION_1_1, HTTPConstants.HTTP_VERSION_2}; + } + // HttpClient4 (and its aliases) never read the HTTP version of the sampler + return new String[]{"", HTTPConstants.HTTP_VERSION_1_1}; + } + public static HTTPAbstractImpl getImplementation(String impl, HTTPSamplerBase base){ if (HTTPSamplerBase.PROTOCOL_FILE.equals(base.getProtocol())) { return new HTTPFileImpl(base); diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/util/HTTPConstantsInterface.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/util/HTTPConstantsInterface.java index fffb369de34..cf4d7adadd8 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/util/HTTPConstantsInterface.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/util/HTTPConstantsInterface.java @@ -33,6 +33,18 @@ public interface HTTPConstantsInterface { // CHECKSTYLE IGNORE InterfaceIsType String DEFAULT_HTTP_PORT_STRING = "80"; // $NON-NLS-1$ String PROTOCOL_HTTP = "http"; // $NON-NLS-1$ String PROTOCOL_HTTPS = "https"; // $NON-NLS-1$ + /** Value of the sampler {@code HTTPSampler.httpVersion} property which forces HTTP/1.1. */ + String HTTP_VERSION_1_1 = "HTTP/1.1"; // $NON-NLS-1$ + /** + * Value of the sampler {@code HTTPSampler.httpVersion} property which uses HTTP/2 if the server + * agrees to it, falling back to HTTP/1.1 otherwise. + */ + String HTTP_VERSION_2 = "HTTP/2"; // $NON-NLS-1$ + /** + * Value of the sampler {@code HTTPSampler.httpVersion} property which requires HTTP/2, so a + * server that does not support it fails the sample instead of being used with HTTP/1.1. + */ + String HTTP_VERSION_2_STRICT = "HTTP/2 Strict"; // $NON-NLS-1$ String HEAD = "HEAD"; // $NON-NLS-1$ String POST = "POST"; // $NON-NLS-1$ String PUT = "PUT"; // $NON-NLS-1$ diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/gui/TestHttpVersionComboBox.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/gui/TestHttpVersionComboBox.java new file mode 100644 index 00000000000..88027121c52 --- /dev/null +++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/gui/TestHttpVersionComboBox.java @@ -0,0 +1,113 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you 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.apache.jmeter.protocol.http.gui; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import javax.swing.JComboBox; + +import org.apache.jmeter.protocol.http.sampler.HTTPSamplerFactory; +import org.junit.jupiter.api.Test; + +public class TestHttpVersionComboBox { + + private static JComboBox implementationComboBox() { + JComboBox implementation = new JComboBox<>(HTTPSamplerFactory.getImplementations()); + implementation.addItem(""); + return implementation; + } + + private static List items(JComboBox comboBox) { + List items = new ArrayList<>(); + for (int i = 0; i < comboBox.getItemCount(); i++) { + items.add(comboBox.getItemAt(i)); + } + return items; + } + + @Test + void offersTheVersionsOfTheSelectedImplementation() { + JComboBox implementation = implementationComboBox(); + HttpVersionComboBox httpVersion = new HttpVersionComboBox(); + httpVersion.bindToImplementation(implementation); + + implementation.setSelectedItem("HttpClient5"); + assertEquals(Arrays.asList("", "HTTP/1.1", "HTTP/2", "HTTP/2 Strict"), items(httpVersion)); + + implementation.setSelectedItem("Java"); + assertEquals(Arrays.asList("", "HTTP/1.1", "HTTP/2"), items(httpVersion)); + + implementation.setSelectedItem("HttpClient4"); + assertEquals(Arrays.asList("", "HTTP/1.1"), items(httpVersion)); + } + + @Test + void keepsTheSelectionWhenTheNewImplementationSupportsIt() { + JComboBox implementation = implementationComboBox(); + HttpVersionComboBox httpVersion = new HttpVersionComboBox(); + httpVersion.bindToImplementation(implementation); + + implementation.setSelectedItem("HttpClient5"); + httpVersion.setSelectedItem("HTTP/2"); + implementation.setSelectedItem("Java"); + + assertEquals("HTTP/2", httpVersion.getSelectedItem()); + } + + @Test + void resetsTheSelectionWhenTheNewImplementationDoesNotSupportIt() { + JComboBox implementation = implementationComboBox(); + HttpVersionComboBox httpVersion = new HttpVersionComboBox(); + httpVersion.bindToImplementation(implementation); + + implementation.setSelectedItem("HttpClient5"); + httpVersion.setSelectedItem("HTTP/2 Strict"); + implementation.setSelectedItem("Java"); + + assertEquals("", httpVersion.getSelectedItem()); + } + + @Test + void keepsAValueWhichIsNotOffered() { + JComboBox implementation = implementationComboBox(); + HttpVersionComboBox httpVersion = new HttpVersionComboBox(); + httpVersion.bindToImplementation(implementation); + + implementation.setSelectedItem("HttpClient4"); + httpVersion.setSelectedItem("HTTP/2"); + + assertEquals("HTTP/2", httpVersion.getSelectedItem()); + } + + @Test + void dropsAValueWhichIsNotOfferedWhenTheImplementationIsSetAgain() { + HttpVersionComboBox httpVersion = new HttpVersionComboBox(); + + httpVersion.setImplementation("HttpClient4"); + httpVersion.setSelectedItem("HTTP/2"); + assertEquals(Arrays.asList("", "HTTP/1.1", "HTTP/2"), items(httpVersion)); + + httpVersion.setImplementation("HttpClient4"); + assertEquals(Arrays.asList("", "HTTP/1.1"), items(httpVersion)); + assertEquals("", httpVersion.getSelectedItem()); + } +} diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java index 2cdd8e9acfb..10064cf5e7d 100644 --- a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java +++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java @@ -104,6 +104,18 @@ void doesNotUsePriorKnowledgeForCleartextByDefault() { assertEquals(HttpVersionPolicy.NEGOTIATE, HTTPHC5Impl.getHttpVersionPolicy("HTTP/2", "HTTP/1.1", "http")); } + @Test + void requiresHttp2ForStrictHttp2RegardlessOfScheme() { + assertEquals(HttpVersionPolicy.FORCE_HTTP_2, + HTTPHC5Impl.getHttpVersionPolicy("HTTP/2 Strict", "HTTP/1.1")); + assertEquals(HttpVersionPolicy.FORCE_HTTP_2, + HTTPHC5Impl.getHttpVersionPolicy("HTTP/2 Strict", "HTTP/1.1", "https")); + assertEquals(HttpVersionPolicy.FORCE_HTTP_2, + HTTPHC5Impl.getHttpVersionPolicy("HTTP/2 Strict", "HTTP/1.1", "http")); + assertEquals(HttpVersionPolicy.FORCE_HTTP_2, + HTTPHC5Impl.getHttpVersionPolicy("", "HTTP/2 Strict", "https")); + } + @Test void multiplexesConcurrentRequestsOverASingleHttp2Connection() throws Exception { WireMockServer server = new WireMockServer(WireMockConfiguration.wireMockConfig() diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPSamplerFactory.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPSamplerFactory.java index a617f13df1d..fc4387ab795 100644 --- a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPSamplerFactory.java +++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPSamplerFactory.java @@ -42,4 +42,20 @@ void httpClient5IsSelectable() { void unknownImplementationIsRejected() { assertThrows(IllegalArgumentException.class, () -> HTTPSamplerFactory.newInstance("HttpClient6")); } + + @Test + void httpVersionsMatchTheCapabilitiesOfTheImplementation() { + assertEquals(Arrays.asList("", "HTTP/1.1"), + Arrays.asList(HTTPSamplerFactory.getHttpVersions("HttpClient4"))); + assertEquals(Arrays.asList("", "HTTP/1.1", "HTTP/2"), + Arrays.asList(HTTPSamplerFactory.getHttpVersions("Java"))); + assertEquals(Arrays.asList("", "HTTP/1.1", "HTTP/2", "HTTP/2 Strict"), + Arrays.asList(HTTPSamplerFactory.getHttpVersions("HttpClient5"))); + } + + @Test + void httpVersionsOfBlankImplementationAreTheOnesOfTheDefaultImplementation() { + assertEquals(Arrays.asList(HTTPSamplerFactory.getHttpVersions(HTTPSamplerFactory.DEFAULT_CLASSNAME)), + Arrays.asList(HTTPSamplerFactory.getHttpVersions(""))); + } } diff --git a/xdocs/changes.xml b/xdocs/changes.xml index 6d88c965afd..9741ab1984a 100644 --- a/xdocs/changes.xml +++ b/xdocs/changes.xml @@ -104,6 +104,7 @@ Summary

  • 6742Add the default User-Agent header of the HttpClient5 sampler implementation to the request itself, so it shows up in the sample result and in the sent bytes, and allow suppressing it with the new httpclient5.default_user_agent_disabled property, like httpclient4.default_user_agent_disabled does for HttpClient4.
  • 6742Add the default User-Agent header of the Java sampler implementation (Java/A.B.C for HTTP/1.1 and Java-http-client/A.B.C for HTTP/2) to the request itself, so the header the JDK sends anyway shows up in the sample result and in the sent bytes.
  • 6742Support the Kerberos mechanism of the HTTP Authorization Manager in the HttpClient5 sampler implementation. HttpClient 5 neither registers the Negotiate and Kerberos auth schemes nor prefers them by default, so they are now set up per request, and the request is executed with the JAAS subject the Authorization Manager logged the user in with. The kerberos.spnego.strip_port, kerberos.spnego.use_canonical_host_name and kerberos.spnego.delegate_cred properties are honoured as with HttpClient4.
  • +
  • 6742Offer only the HTTP versions the selected implementation can use in the HTTP Version combo box of the HTTP Request sampler and the HTTP Request Defaults: HttpClient4 offers HTTP/1.1 only, as it ignores the HTTP version, Java adds HTTP/2 Negotiate, and HttpClient5 additionally offers the new HTTP/2 Strict value, which only offers h2 during protocol negotiation, so a server without HTTP/2 support fails the sample instead of being used with HTTP/1.1.
  • Timers, Assertions, Config, Pre- & Post-Processors

    diff --git a/xdocs/usermanual/component_reference.xml b/xdocs/usermanual/component_reference.xml index b6c15f40e5c..6cce812ae4d 100644 --- a/xdocs/usermanual/component_reference.xml +++ b/xdocs/usermanual/component_reference.xml @@ -245,8 +245,12 @@ https.default.protocol=SSLv3 Java, HttpClient4, HttpClient5. If not specified (and not defined by HTTP Request Defaults), the default depends on the value of the JMeter property jmeter.httpsampler, failing that, the HttpClient4 implementation is used. - HTTP/1.1 or HTTP/2. Applies to the - HttpClient5 and Java implementations. An empty value uses the httpclient.version property, which + The HTTP versions offered depend on the selected implementation: + HttpClient5 supports HTTP/1.1, HTTP/2 Negotiate (stored as HTTP/2, + falls back to HTTP/1.1 when the server does not offer HTTP/2) and HTTP/2 Strict (stored as + HTTP/2 Strict, requires HTTP/2, so the request fails when the server does not support it), + Java supports HTTP/1.1 and HTTP/2 Negotiate, and HttpClient4 + supports HTTP/1.1 only. An empty value uses the httpclient.version property, which defaults to HTTP/1.1. HTTP, HTTPS or FILE. Default: HTTP GET, POST, HEAD, TRACE, diff --git a/xdocs/usermanual/properties_reference.xml b/xdocs/usermanual/properties_reference.xml index c9ce02d3f7a..3b8870d9e7a 100644 --- a/xdocs/usermanual/properties_reference.xml +++ b/xdocs/usermanual/properties_reference.xml @@ -443,7 +443,8 @@ JMETER-SERVER Set the default HTTP version for HttpClient5 and Java samplers with an empty HTTP Version value.
    - Valid values are HTTP/1.1 and HTTP/2.
    + Valid values are HTTP/1.1, HTTP/2 and, for HttpClient5 only, + HTTP/2 Strict.
    Defaults to: HTTP/1.1
    From 05ad1ec1b1c6e4ab28ab9fd3c1490d3d2153f775 Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Thu, 13 Aug 2026 09:46:46 +0200 Subject: [PATCH 31/80] Add proxy Kerberos/SPNEGO authentication support to HttpClient5 sampler --- .../protocol/http/sampler/HTTPHC5Impl.java | 122 ++++++++++++++---- .../http/sampler/TestHTTPHC5Features.java | 70 ++++++++++ xdocs/changes.xml | 1 + 3 files changed, 170 insertions(+), 23 deletions(-) diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java index d9042631664..b6052f223d4 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java @@ -23,6 +23,7 @@ import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.net.InetAddress; +import java.net.MalformedURLException; import java.net.URI; import java.net.URL; import java.net.URLDecoder; @@ -276,6 +277,16 @@ protected Map initialValue() { private static final List KERBEROS_PREFERRED_AUTH_SCHEMES = List.of(StandardAuthScheme.SPNEGO, StandardAuthScheme.KERBEROS); + /** + * Auth schemes offered to a proxy that can be authenticated with Kerberos. Unlike the target + * schemes these keep the password based schemes as a fallback, so that a proxy which offers + * {@code Negotiate} next to {@code Basic} or {@code Digest} can still be used. + */ + @SuppressWarnings("deprecation") // The GSS based auth schemes of HttpClient 5 have no replacement yet + private static final List KERBEROS_PROXY_PREFERRED_AUTH_SCHEMES = + List.of(StandardAuthScheme.SPNEGO, StandardAuthScheme.KERBEROS, StandardAuthScheme.BEARER, + StandardAuthScheme.DIGEST, StandardAuthScheme.BASIC); + private static final Oid SPNEGO_OID = createOid("1.3.6.1.5.5.2"); private static final Oid KERBEROS_OID = createOid("1.2.840.113554.1.2.2"); @@ -464,13 +475,17 @@ private static org.apache.hc.client5.http.classic.methods.HttpUriRequestBase cre } /** - * Executes the request under the JAAS {@link Subject} of the {@link AuthManager}, if the URL is - * covered by a Kerberos authorization, so that the auth scheme can use its credentials. + * Executes the request under the JAAS {@link Subject} of the {@link AuthManager}, if the URL or + * the proxy is covered by a Kerberos authorization, so that the auth scheme can use its + * credentials. */ private ClassicHttpResponse executeRequest(URL url, HttpClientKey clientKey, org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request, HttpClientContext context) throws IOException { Subject subject = getSubjectForUrl(url); + if (subject == null) { + subject = getSubjectForProxy(clientKey); + } if (subject == null) { return doExecuteRequest(clientKey, request, context); } @@ -656,8 +671,10 @@ HttpClientContext createHttpClientContext(URL url, HttpClientKey key, Authorization authorization = getAuthorizationForUrl(url); configureTargetCredentials(url, request, credentialsProvider, authorization); configureProxyCredentials(key, credentialsProvider); - if (isKerberos(authorization)) { - configureKerberos(url, request, context, credentialsProvider); + boolean kerberosTarget = isKerberos(authorization); + boolean kerberosProxy = isKerberosProxy(key); + if (kerberosTarget || kerberosProxy) { + configureKerberos(url, key, request, context, credentialsProvider, kerberosTarget, kerberosProxy); } else { context.setCredentialsProvider(credentialsProvider); } @@ -666,7 +683,34 @@ HttpClientContext createHttpClientContext(URL url, HttpClientKey key, private Authorization getAuthorizationForUrl(URL url) { AuthManager authManager = getAuthManager(); - return authManager == null ? null : authManager.getAuthForURL(url); + return authManager == null || url == null ? null : authManager.getAuthForURL(url); + } + + /** + * A proxy is authenticated with Kerberos, if the {@link AuthManager} holds a Kerberos + * authorization for it, or if it has no credentials configured at all. Enterprise proxies + * commonly challenge with {@code Proxy-Authenticate: Negotiate}, which can only be answered + * with the Kerberos ticket of the JMeter user, as JMeter has no place to configure proxy + * credentials for a negotiation based scheme. + */ + private boolean isKerberosProxy(HttpClientKey key) { + if (!key.hasProxy) { + return false; + } + return StringUtilities.isEmpty(key.proxyUser) || isKerberos(getAuthorizationForUrl(getProxyUrl(key))); + } + + private static URL getProxyUrl(HttpClientKey key) { + if (!key.hasProxy) { + return null; + } + String scheme = StringUtilities.isEmpty(key.proxyScheme) ? HTTPConstants.PROTOCOL_HTTP : key.proxyScheme; + try { + return new URL(scheme, key.proxyHost, key.proxyPort, ""); + } catch (MalformedURLException e) { + log.debug("Could not build a URL for proxy {}://{}:{}", scheme, key.proxyHost, key.proxyPort, e); + return null; + } } private static boolean isKerberos(Authorization authorization) { @@ -691,12 +735,15 @@ private static void configureTargetCredentials(URL url, /** * Sets up the {@code Negotiate} and {@code Kerberos} auth schemes for a single request, as * HttpClient 5 neither registers them nor prefers them by default. The credentials are taken - * from the JAAS {@link Subject} the {@link AuthManager} logged the user in with. + * from the JAAS {@link Subject} the {@link AuthManager} logged the user in with. The schemes + * are set up for the target, for the proxy, or for both, depending on which of them is + * covered by a Kerberos authorization. */ @SuppressWarnings("deprecation") // The GSS based auth schemes of HttpClient 5 have no replacement yet - private void configureKerberos(URL url, + private void configureKerberos(URL url, HttpClientKey key, org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request, - HttpClientContext context, CredentialsProvider credentialsProvider) { + HttpClientContext context, CredentialsProvider credentialsProvider, + boolean kerberosTarget, boolean kerberosProxy) { KerberosConfig kerberosConfig = KerberosConfig.custom() .setStripPort(isStripPort(url)) .setUseCanonicalHostname(AuthManager.USE_CANONICAL_HOST_NAME) @@ -711,12 +758,24 @@ private void configureKerberos(URL url, .register(StandardAuthScheme.SPNEGO, new SPNegoSchemeFactory(kerberosConfig, dnsResolver)) .register(StandardAuthScheme.KERBEROS, new KerberosSchemeFactory(kerberosConfig, dnsResolver)) .build()); - context.setCredentialsProvider( - new KerberosCredentialsProvider(getSubjectForUrl(url), credentialsProvider)); + context.setCredentialsProvider(new KerberosCredentialsProvider( + kerberosTarget ? getSubjectForUrl(url) : null, + kerberosProxy ? getProxyHost(key) : null, + kerberosProxy ? getSubjectForProxy(key) : null, + credentialsProvider)); RequestConfig requestConfig = request.getConfig() == null ? RequestConfig.DEFAULT : request.getConfig(); - request.setConfig(RequestConfig.copy(requestConfig) - .setTargetPreferredAuthSchemes(KERBEROS_PREFERRED_AUTH_SCHEMES) - .build()); + RequestConfig.Builder builder = RequestConfig.copy(requestConfig); + if (kerberosTarget) { + builder.setTargetPreferredAuthSchemes(KERBEROS_PREFERRED_AUTH_SCHEMES); + } + if (kerberosProxy) { + builder.setProxyPreferredAuthSchemes(KERBEROS_PROXY_PREFERRED_AUTH_SCHEMES); + } + request.setConfig(builder.build()); + } + + private static HttpHost getProxyHost(HttpClientKey key) { + return key.hasProxy ? new HttpHost(key.proxyScheme, key.proxyHost, key.proxyPort) : null; } /** @@ -733,7 +792,11 @@ private static boolean isStripPort(URL url) { private Subject getSubjectForUrl(URL url) { AuthManager authManager = getAuthManager(); - return authManager == null ? null : authManager.getSubjectForUrl(url); + return authManager == null || url == null ? null : authManager.getSubjectForUrl(url); + } + + private Subject getSubjectForProxy(HttpClientKey key) { + return getSubjectForUrl(getProxyUrl(key)); } private static Oid createOid(String oid) { @@ -753,12 +816,17 @@ private static Oid createOid(String oid) { */ private static final class KerberosCredentialsProvider implements CredentialsProvider { - private final Subject subject; + private final Subject targetSubject; + private final HttpHost proxy; + private final Subject proxySubject; private final CredentialsProvider delegate; private final Map credentialsCache = new HashMap<>(); - private KerberosCredentialsProvider(Subject subject, CredentialsProvider delegate) { - this.subject = subject; + private KerberosCredentialsProvider(Subject targetSubject, HttpHost proxy, Subject proxySubject, + CredentialsProvider delegate) { + this.targetSubject = targetSubject; + this.proxy = proxy; + this.proxySubject = proxySubject; this.delegate = delegate; } @@ -767,20 +835,28 @@ private KerberosCredentialsProvider(Subject subject, CredentialsProvider delegat public Credentials getCredentials(AuthScope authScope, HttpContext context) { String schemeName = authScope == null ? null : authScope.getSchemeName(); if (StandardAuthScheme.SPNEGO.equalsIgnoreCase(schemeName)) { - return getKerberosCredentials(schemeName, SPNEGO_OID); + return getKerberosCredentials(schemeName, SPNEGO_OID, authScope); } if (StandardAuthScheme.KERBEROS.equalsIgnoreCase(schemeName)) { - return getKerberosCredentials(schemeName, KERBEROS_OID); + return getKerberosCredentials(schemeName, KERBEROS_OID, authScope); } return delegate.getCredentials(authScope, context); } - private Credentials getKerberosCredentials(String schemeName, Oid oid) { - return credentialsCache.computeIfAbsent(schemeName, name -> createKerberosCredentials(oid)); + private Credentials getKerberosCredentials(String schemeName, Oid oid, AuthScope authScope) { + boolean forProxy = isProxy(authScope); + Subject subject = forProxy ? proxySubject : targetSubject; + return credentialsCache.computeIfAbsent(schemeName + (forProxy ? "@proxy" : "@target"), + name -> createKerberosCredentials(subject, oid)); + } + + private boolean isProxy(AuthScope authScope) { + return proxy != null && proxy.getHostName().equalsIgnoreCase(authScope.getHost()) + && (authScope.getPort() <= 0 || authScope.getPort() == proxy.getPort()); } @SuppressWarnings("deprecation") // KerberosCredentials is deprecated without a replacement - private Credentials createKerberosCredentials(Oid oid) { + private static Credentials createKerberosCredentials(Subject subject, Oid oid) { if (subject == null || oid == null) { return USE_JAAS_CREDENTIALS; } @@ -1115,7 +1191,7 @@ private static DefaultRoutePlanner createRoutePlanner(HttpClientKey key) { return new DefaultRoutePlanner(null) { @Override protected HttpHost determineProxy(HttpHost target, org.apache.hc.core5.http.protocol.HttpContext context) { - return key.hasProxy ? new HttpHost(key.proxyScheme, key.proxyHost, key.proxyPort) : null; + return getProxyHost(key); } @Override diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java index 10064cf5e7d..e9601551938 100644 --- a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java +++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java @@ -405,6 +405,76 @@ void keepsDefaultAuthSchemesWithoutKerberosAuthorization() throws Exception { assertNull(request.getConfig(), "the request configuration should be left alone"); } + @Test + @SuppressWarnings("deprecation") // The GSS based auth schemes of HttpClient 5 have no replacement yet + void negotiatesWithAProxyThatHasNoCredentialsConfigured() throws Exception { + HTTPSamplerBase sampler = newSampler(); + sampler.setProxyHost("proxy.example.invalid"); + sampler.setProxyPortInt("8080"); + HTTPHC5Impl implementation = new HTTPHC5Impl(sampler); + URL url = new URL("http://target.example.invalid/resource"); + HttpUriRequestBase request = new HttpUriRequestBase(HTTPConstants.GET, url.toURI()); + + HttpClientContext context = + implementation.createHttpClientContext(url, implementation.createHttpClientKey(url), request); + + Lookup authSchemes = context.getAuthSchemeRegistry(); + assertNotNull(authSchemes, "the Kerberos auth schemes have to be registered for the request"); + assertNotNull(authSchemes.lookup(StandardAuthScheme.SPNEGO), "Negotiate has to be supported"); + assertEquals(List.of(StandardAuthScheme.SPNEGO, StandardAuthScheme.KERBEROS, StandardAuthScheme.BEARER, + StandardAuthScheme.DIGEST, StandardAuthScheme.BASIC), + new ArrayList<>(request.getConfig().getProxyPreferredAuthSchemes()), + "a proxy challenging with Negotiate has to be answered with a Kerberos token"); + assertNull(request.getConfig().getTargetPreferredAuthSchemes(), + "the target is not covered by a Kerberos authorization"); + assertNotNull(context.getCredentialsProvider().getCredentials( + new AuthScope(null, "proxy.example.invalid", 8080, null, StandardAuthScheme.SPNEGO), context), + "the Negotiate scheme needs credentials to authenticate with"); + } + + @Test + @SuppressWarnings("deprecation") // The GSS based auth schemes of HttpClient 5 have no replacement yet + void negotiatesWithAProxyCoveredByAKerberosAuthorization() throws Exception { + AuthManager authManager = new AuthManager(); + authManager.set(-1, "http://proxy.example.invalid:8080", "user", "pass", "", "", + AuthManager.Mechanism.KERBEROS); + HTTPSamplerBase sampler = newSampler(); + sampler.setAuthManager(authManager); + sampler.setProxyHost("proxy.example.invalid"); + sampler.setProxyPortInt("8080"); + sampler.setProxyUser("user"); + sampler.setProxyPass("pass"); + HTTPHC5Impl implementation = new HTTPHC5Impl(sampler); + URL url = new URL("http://target.example.invalid/resource"); + HttpUriRequestBase request = new HttpUriRequestBase(HTTPConstants.GET, url.toURI()); + + HttpClientContext context = + implementation.createHttpClientContext(url, implementation.createHttpClientKey(url), request); + + assertNotNull(context.getAuthSchemeRegistry(), "the Kerberos auth schemes have to be registered"); + assertEquals(StandardAuthScheme.SPNEGO, + new ArrayList<>(request.getConfig().getProxyPreferredAuthSchemes()).get(0), + "a Kerberos authorization for the proxy has to be preferred over the password based schemes"); + } + + @Test + void keepsDefaultAuthSchemesForAProxyWithCredentials() throws Exception { + HTTPSamplerBase sampler = newSampler(); + sampler.setProxyHost("proxy.example.invalid"); + sampler.setProxyPortInt("8080"); + sampler.setProxyUser("user"); + sampler.setProxyPass("pass"); + HTTPHC5Impl implementation = new HTTPHC5Impl(sampler); + URL url = new URL("http://target.example.invalid/resource"); + HttpUriRequestBase request = new HttpUriRequestBase(HTTPConstants.GET, url.toURI()); + + HttpClientContext context = + implementation.createHttpClientContext(url, implementation.createHttpClientKey(url), request); + + assertNull(context.getAuthSchemeRegistry(), "the client should use its default auth schemes"); + assertNull(request.getConfig(), "the request configuration should be left alone"); + } + @Test void authenticatesWithConfiguredProxyCredentials() throws Exception { WireMockServer server = createServer(); diff --git a/xdocs/changes.xml b/xdocs/changes.xml index 9741ab1984a..7fd1cbb8182 100644 --- a/xdocs/changes.xml +++ b/xdocs/changes.xml @@ -104,6 +104,7 @@ Summary
  • 6742Add the default User-Agent header of the HttpClient5 sampler implementation to the request itself, so it shows up in the sample result and in the sent bytes, and allow suppressing it with the new httpclient5.default_user_agent_disabled property, like httpclient4.default_user_agent_disabled does for HttpClient4.
  • 6742Add the default User-Agent header of the Java sampler implementation (Java/A.B.C for HTTP/1.1 and Java-http-client/A.B.C for HTTP/2) to the request itself, so the header the JDK sends anyway shows up in the sample result and in the sent bytes.
  • 6742Support the Kerberos mechanism of the HTTP Authorization Manager in the HttpClient5 sampler implementation. HttpClient 5 neither registers the Negotiate and Kerberos auth schemes nor prefers them by default, so they are now set up per request, and the request is executed with the JAAS subject the Authorization Manager logged the user in with. The kerberos.spnego.strip_port, kerberos.spnego.use_canonical_host_name and kerberos.spnego.delegate_cred properties are honoured as with HttpClient4.
  • +
  • 6742Answer a Proxy-Authenticate: Negotiate (SPNEGO/Kerberos) challenge of an enterprise proxy in the HttpClient5 sampler implementation, instead of failing the sample with 407 Proxy Authentication Required. The negotiation based auth schemes are now offered to a proxy which has no user name configured, or which is covered by a Kerberos entry of the HTTP Authorization Manager, while a proxy with a configured user name keeps using the password based schemes.
  • 6742Offer only the HTTP versions the selected implementation can use in the HTTP Version combo box of the HTTP Request sampler and the HTTP Request Defaults: HttpClient4 offers HTTP/1.1 only, as it ignores the HTTP version, Java adds HTTP/2 Negotiate, and HttpClient5 additionally offers the new HTTP/2 Strict value, which only offers h2 during protocol negotiation, so a server without HTTP/2 support fails the sample instead of being used with HTTP/1.1.
  • From e436b521067fc76e12959f69e687bb3a9f7d3832 Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Fri, 14 Aug 2026 08:41:35 +0200 Subject: [PATCH 32/80] Update HTTP Request Advanced tab and defaults tab screenshots and adjust figure dimensions in documentation --- .../http-request-defaults-advanced-tab.png | Bin 19213 -> 23310 bytes .../screenshots/http-request-advanced-tab.png | Bin 18573 -> 23861 bytes xdocs/usermanual/component_reference.xml | 4 ++-- 3 files changed, 2 insertions(+), 2 deletions(-) diff --git a/xdocs/images/screenshots/http-config/http-request-defaults-advanced-tab.png b/xdocs/images/screenshots/http-config/http-request-defaults-advanced-tab.png index 6c77fa2c802b4386028343da09113c832192b693..7360856cd613779545f8c2acff4c66592404a16e 100644 GIT binary patch literal 23310 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 literal 19213 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 literal 18573 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 WP2&}xGKE3=BRpOGT-G@yGywo}yRrfR diff --git a/xdocs/usermanual/component_reference.xml b/xdocs/usermanual/component_reference.xml index 6cce812ae4d..cbf095a42d6 100644 --- a/xdocs/usermanual/component_reference.xml +++ b/xdocs/usermanual/component_reference.xml @@ -214,7 +214,7 @@ https.default.protocol=SSLv3 6.1 Handling User Sessions With URL Rewriting for additional configuration steps.

    -
    HTTP Request Advanced config fields
    +
    HTTP Request Advanced config fields
    Screenshot of Control-Panel of GraphQL HTTP Request
    Variables field for GraphQL HTTP Request
    @@ -3919,7 +3919,7 @@ this field value from the HTTP Request Defaults element.

    All port values are treated equally; a sampler that does not specify a port will use the HTTP Request Defaults port, if one is provided. -
    HTTP Request Advanced config fields
    +
    HTTP Request Advanced config fields
    Descriptive name for this element that is shown in the tree. From b874ac9228b6194c2cbb54628424416c51d657c5 Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Fri, 14 Aug 2026 13:09:09 +0200 Subject: [PATCH 33/80] Refactor HTTP version constants usage and improve readability across HTTP samplers and GUI components --- .../jmeter/protocol/http/gui/HttpVersionComboBox.java | 11 ++++++++--- .../jmeter/protocol/http/sampler/HTTPHC5Impl.java | 10 +++++++--- .../jmeter/protocol/http/sampler/HTTPJavaImpl.java | 11 ++++++----- .../protocol/http/util/HTTPConstantsInterface.java | 2 ++ 4 files changed, 23 insertions(+), 11 deletions(-) diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/gui/HttpVersionComboBox.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/gui/HttpVersionComboBox.java index c5800054748..9f5c0c8ed65 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/gui/HttpVersionComboBox.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/gui/HttpVersionComboBox.java @@ -35,12 +35,18 @@ * implementation supports. The items are the values stored in the {@code HTTPSampler.httpVersion} * property, the rendering spells out how HTTP/2 is applied. * - * @since 5.7 + * @since 6.0 */ public class HttpVersionComboBox extends JComboBox { private static final long serialVersionUID = 1L; + /** + * Label of {@link HTTPConstants#HTTP_VERSION_2}, spelled out as HTTP/2 falls back to HTTP/1.1 + * when the server does not support it. + */ + private static final String HTTP_VERSION_2_LABEL = "HTTP/2 Negotiate"; // $NON-NLS-1$ + public HttpVersionComboBox() { super(HTTPSamplerFactory.getHttpVersions("")); setRenderer(new DefaultListCellRenderer() { @@ -56,8 +62,7 @@ public Component getListCellRendererComponent(JList list, Object value, int i private static Object getLabel(Object value) { if (HTTPConstants.HTTP_VERSION_2.equals(value)) { - // Spelled out, as HTTP/2 falls back to HTTP/1.1 when the server does not support it - return "HTTP/2 Negotiate"; + return HTTP_VERSION_2_LABEL; } return value; } diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java index b6052f223d4..5413293e91a 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java @@ -250,6 +250,10 @@ protected Map initialValue() { /** Remembers whether the request had a {@code User-Agent} header before HttpClient added its own. */ private static final String CONTEXT_ATTRIBUTE_USER_AGENT_PRESENT = "__jmeter.U_A__"; + /** Shown instead of the request body when the entity cannot be read a second time. */ + private static final String NON_REPEATABLE_ENTITY_PREVIEW = + ""; // $NON-NLS-1$ + private static final HttpRequestInterceptor RECORD_USER_AGENT_PRESENCE = (request, entity, context) -> context.setAttribute(CONTEXT_ATTRIBUTE_USER_AGENT_PRESENT, request.containsHeader(HttpHeaders.USER_AGENT)); @@ -618,7 +622,7 @@ private List createNameValuePairs(String contentEncoding) throws private static String getEntityPreview(HttpEntity entity, String contentEncoding) throws IOException { if (!entity.isRepeatable()) { - return ""; + return NON_REPEATABLE_ENTITY_PREVIEW; } ByteArrayOutputStream output = new ByteArrayOutputStream(); entity.writeTo(output); @@ -948,7 +952,7 @@ private static long calculateSentBytes(org.apache.hc.client5.http.classic.method uri = ""; } org.apache.hc.core5.http.ProtocolVersion version = request.getVersion(); - String versionStr = version != null ? version.toString() : "HTTP/1.1"; + String versionStr = version != null ? version.toString() : HTTPConstants.HTTP_VERSION_1_1; sentBytes += method.getBytes(Charset.defaultCharset()).length; sentBytes += 1; @@ -1245,7 +1249,7 @@ static HttpVersionPolicy getHttpVersionPolicy(String samplerHttpVersion, String if (HTTPConstants.HTTP_VERSION_2_STRICT.equalsIgnoreCase(httpVersion)) { return HttpVersionPolicy.FORCE_HTTP_2; } - return HTTPConstants.HTTP_VERSION_2.equals(httpVersion) || "2".equals(httpVersion) + return HTTPConstants.HTTP_VERSION_2.equals(httpVersion) ? HttpVersionPolicy.NEGOTIATE : HttpVersionPolicy.FORCE_HTTP_1; } diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java index 7a2671f632a..b7ec3874792 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java @@ -300,8 +300,8 @@ static String getReasonPhrase(int statusCode) { static boolean isHttp2(String samplerHttpVersion, String defaultHttpVersion) { String httpVersion = StringUtilities.isBlank(samplerHttpVersion) ? defaultHttpVersion : samplerHttpVersion; // java.net.http.HttpClient always negotiates, so a strict HTTP/2 request is negotiated as well - return HTTPConstants.HTTP_VERSION_2.equalsIgnoreCase(httpVersion) || "2".equalsIgnoreCase(httpVersion) - || HTTPConstants.HTTP_VERSION_2_STRICT.equalsIgnoreCase(httpVersion); // $NON-NLS-1$ + return HTTPConstants.HTTP_VERSION_2.equalsIgnoreCase(httpVersion) + || HTTPConstants.HTTP_VERSION_2_STRICT.equalsIgnoreCase(httpVersion); } private boolean isHttp2() { @@ -1157,7 +1157,7 @@ private HTTPSampleResult sampleHttp2(URL url, String method, boolean areFollowin cacheManager.saveDetails(response, res); } - res.setSentBytes(calculateSentBytes(url, method, "HTTP/2", + res.setSentBytes(calculateSentBytes(url, method, HTTPConstants.HTTP_2, capturingConn != null ? capturingConn.getRequestProperties() : null, securityHeaders, requestBodyBytes)); @@ -1169,7 +1169,7 @@ private HTTPSampleResult sampleHttp2(URL url, String method, boolean areFollowin if (res.getEndTime() == 0) { res.sampleEnd(); } - res.setSentBytes(calculateSentBytes(url, method, "HTTP/2", + res.setSentBytes(calculateSentBytes(url, method, HTTPConstants.HTTP_2, capturingConn != null ? capturingConn.getRequestProperties() : null, securityHeaders, requestBodyBytes)); return errorResult(e, res); @@ -1209,7 +1209,8 @@ private byte[] readResponse(HttpResponse response, SampleResult res private static String getResponseHeaders(HttpResponse response) { StringBuilder headerBuf = new StringBuilder(); - String versionStr = (response.version() == HttpClient.Version.HTTP_2) ? "HTTP/2" : "HTTP/1.1"; // $NON-NLS-1$ $NON-NLS-2$ + String versionStr = (response.version() == HttpClient.Version.HTTP_2) + ? HTTPConstants.HTTP_2 : HTTPConstants.HTTP_1_1; headerBuf.append(versionStr).append(" ").append(response.statusCode()).append("\n"); // $NON-NLS-1$ $NON-NLS-2$ response.headers().map().forEach((key, values) -> { diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/util/HTTPConstantsInterface.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/util/HTTPConstantsInterface.java index cf4d7adadd8..46e5cd95bf7 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/util/HTTPConstantsInterface.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/util/HTTPConstantsInterface.java @@ -74,6 +74,8 @@ public interface HTTPConstantsInterface { // CHECKSTYLE IGNORE InterfaceIsType String TRANSFER_ENCODING = "transfer-encoding"; // $NON-NLS-1$ String HEADER_CONTENT_ENCODING = "content-encoding"; // $NON-NLS-1$ String HTTP_1_1 = "HTTP/1.1"; // $NON-NLS-1$ + /** Protocol version as it appears on the wire and in the request/response headers of a sample. */ + String HTTP_2 = "HTTP/2"; // $NON-NLS-1$ String HEADER_SET_COOKIE = "set-cookie"; // $NON-NLS-1$ // Brotli compression not supported yet by HC4 4.5.2 , but to be added String ENCODING_BROTLI = "br"; // $NON-NLS-1$ From 9be5a5bf35af2b0aaf110a9b2eae45812e6384fb Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Mon, 17 Aug 2026 07:18:19 +0200 Subject: [PATCH 34/80] Fix HttpClient5 response timing for HTTP/1.1 and HTTP/2 --- .../protocol/http/sampler/HTTPHC5Impl.java | 80 +++++++++++++++++-- .../http/sampler/TestHTTPHC5Features.java | 42 ++++++++++ 2 files changed, 114 insertions(+), 8 deletions(-) diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java index 5413293e91a..4f84d730971 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java @@ -28,6 +28,7 @@ import java.net.URL; import java.net.URLDecoder; import java.net.UnknownHostException; +import java.nio.ByteBuffer; import java.nio.charset.Charset; import java.security.GeneralSecurityException; import java.security.Principal; @@ -53,6 +54,8 @@ import org.apache.hc.client5.http.SystemDefaultDnsResolver; import org.apache.hc.client5.http.async.methods.SimpleHttpRequest; import org.apache.hc.client5.http.async.methods.SimpleHttpResponse; +import org.apache.hc.client5.http.async.methods.SimpleRequestProducer; +import org.apache.hc.client5.http.async.methods.SimpleResponseConsumer; import org.apache.hc.client5.http.auth.AuthSchemeFactory; import org.apache.hc.client5.http.auth.AuthScope; import org.apache.hc.client5.http.auth.Credentials; @@ -98,12 +101,15 @@ import org.apache.hc.core5.concurrent.FutureCallback; import org.apache.hc.core5.http.ClassicHttpResponse; import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.EntityDetails; import org.apache.hc.core5.http.Header; import org.apache.hc.core5.http.HeaderElement; import org.apache.hc.core5.http.HttpEntity; +import org.apache.hc.core5.http.HttpException; import org.apache.hc.core5.http.HttpHeaders; import org.apache.hc.core5.http.HttpHost; import org.apache.hc.core5.http.HttpRequestInterceptor; +import org.apache.hc.core5.http.HttpResponse; import org.apache.hc.core5.http.HttpVersion; import org.apache.hc.core5.http.NameValuePair; import org.apache.hc.core5.http.config.Lookup; @@ -116,6 +122,8 @@ import org.apache.hc.core5.http.message.BasicHeaderValueParser; import org.apache.hc.core5.http.message.BasicNameValuePair; import org.apache.hc.core5.http.message.ParserCursor; +import org.apache.hc.core5.http.nio.AsyncResponseConsumer; +import org.apache.hc.core5.http.nio.CapacityChannel; import org.apache.hc.core5.http.nio.ssl.TlsStrategy; import org.apache.hc.core5.http.protocol.HttpContext; import org.apache.hc.core5.http2.HttpVersionPolicy; @@ -164,6 +172,7 @@ public class HTTPHC5Impl extends HTTPHCAbstractImpl { /** Key used to store the current {@link SampleResult} in the {@link HttpClientContext}. */ static final String CONTEXT_ATTRIBUTE_SAMPLER_RESULT = "__jmeter.S_R__"; //$NON-NLS-1$ + private static final String CONTEXT_ATTRIBUTE_RESPONSE_LATENCY = "__jmeter.S_R_LATENCY__"; //$NON-NLS-1$ private static final ThreadLocal> HTTP_CLIENTS = new InheritableThreadLocal<>() { @@ -442,6 +451,11 @@ protected HTTPSampleResult sample(URL url, String method, boolean areFollowingRe HttpClientContext context = createHttpClientContext(url, clientKey, request); context.setAttribute(CONTEXT_ATTRIBUTE_SAMPLER_RESULT, result); response = executeRequest(url, clientKey, request, context); + readResponse(response, result); + Long responseLatency = (Long) context.getAttribute(CONTEXT_ATTRIBUTE_RESPONSE_LATENCY); + if (responseLatency != null) { + result.setLatency(responseLatency); + } result.sampleEnd(); currentRequest = null; @@ -889,29 +903,30 @@ private static int getPort(URL url) { return url.getPort() == -1 ? url.getDefaultPort() : url.getPort(); } - private void updateResult(ClassicHttpResponse response, - org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request, HTTPSampleResult result) throws IOException { - result.setRequestHeaders(getRequestHeaders(request)); - result.setSentBytes(calculateSentBytes(request)); + private void readResponse(ClassicHttpResponse response, HTTPSampleResult result) throws IOException { Header contentType = response.getFirstHeader(HTTPConstants.HEADER_CONTENT_TYPE); if (contentType != null) { result.setContentType(contentType.getValue()); result.setEncodingAndType(contentType.getValue()); } HttpEntity entity = response.getEntity(); - long bodySize = 0; if (entity != null) { byte[] body = readResponse(result, entity.getContent(), entity.getContentLength()); result.setResponseData(body); - bodySize = body.length; + result.setBodySize((long) body.length); } + } + + private void updateResult(ClassicHttpResponse response, + org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request, HTTPSampleResult result) { + result.setRequestHeaders(getRequestHeaders(request)); + result.setSentBytes(calculateSentBytes(request)); int statusCode = response.getCode(); result.setResponseCode(Integer.toString(statusCode)); result.setResponseMessage(response.getReasonPhrase()); result.setSuccessful(isSuccessCode(statusCode)); result.setResponseHeaders(getResponseHeaders(response)); result.setHeadersSize(result.getResponseHeaders().length()); - result.setBodySize(bodySize); if (result.isRedirect()) { Header location = response.getFirstHeader(HTTPConstants.HEADER_LOCATION); if (location != null) { @@ -1147,7 +1162,8 @@ private static ClassicHttpResponse executeHttp2(CloseableHttpAsyncClient client, requestEntity.getContentType() == null ? ContentType.DEFAULT_BINARY : ContentType.parse(requestEntity.getContentType())); } - Future responseFuture = client.execute(asyncRequest, context, null); + Future responseFuture = client.execute(SimpleRequestProducer.create(asyncRequest), + new LatencyMeasuringResponseConsumer(), context, null); try { return createClassicResponse(responseFuture.get(getHttp2ExecutionTimeoutMillis(request), TimeUnit.MILLISECONDS)); } catch (InterruptedException e) { @@ -1191,6 +1207,54 @@ private static ClassicHttpResponse createClassicResponse(SimpleHttpResponse asyn return decompressResponse(response); } + /** + * Captures HTTP/2 latency when the final response headers arrive. The response body is buffered + * asynchronously, so its later copy through {@link HTTPSamplerBase#readResponse} must not replace this value. + */ + private static final class LatencyMeasuringResponseConsumer implements AsyncResponseConsumer { + + private final SimpleResponseConsumer delegate = SimpleResponseConsumer.create(); + + @Override + public void consumeResponse(HttpResponse response, EntityDetails entityDetails, HttpContext context, + FutureCallback resultCallback) throws HttpException, IOException { + SampleResult sampleResult = (SampleResult) context.getAttribute(CONTEXT_ATTRIBUTE_SAMPLER_RESULT); + sampleResult.latencyEnd(); + context.setAttribute(CONTEXT_ATTRIBUTE_RESPONSE_LATENCY, sampleResult.getLatency()); + delegate.consumeResponse(response, entityDetails, context, resultCallback); + } + + @Override + public void informationResponse(HttpResponse response, HttpContext context) throws HttpException, IOException { + delegate.informationResponse(response, context); + } + + @Override + public void updateCapacity(CapacityChannel capacityChannel) throws IOException { + delegate.updateCapacity(capacityChannel); + } + + @Override + public void consume(ByteBuffer src) throws IOException { + delegate.consume(src); + } + + @Override + public void streamEnd(List trailers) throws HttpException, IOException { + delegate.streamEnd(trailers); + } + + @Override + public void failed(Exception cause) { + delegate.failed(cause); + } + + @Override + public void releaseResources() { + delegate.releaseResources(); + } + } + private static DefaultRoutePlanner createRoutePlanner(HttpClientKey key) { return new DefaultRoutePlanner(null) { @Override diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java index e9601551938..37cac56051c 100644 --- a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java +++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java @@ -218,6 +218,28 @@ void usesHttp2WhenSelected() throws Exception { } } + @Test + void recordsHttp2LatencyBeforeDribbledResponseBodyCompletes() throws Exception { + WireMockServer server = new WireMockServer(WireMockConfiguration.wireMockConfig() + .dynamicHttpsPort() + .http2TlsDisabled(false)); + server.start(); + try { + server.stubFor(get(urlEqualTo("/http2dribbled")) + .willReturn(aResponse().withStatus(200).withBody(new byte[10_000]).withChunkedDribbleDelay(5, 500))); + HTTPSamplerBase sampler = newSampler(); + sampler.setHttpVersion("HTTP/2"); + + HTTPSampleResult result = sampler.sample( + new URL("https://localhost:" + server.httpsPort() + "/http2dribbled"), HTTPConstants.GET, false, 1); + + assertTrue(result.getTime() - result.getLatency() >= 250, + "HTTP/2 latency should exclude the dribbled response body"); + } finally { + server.stop(); + } + } + @Test void fallsBackToHttp11WhenServerDoesNotSupportHttp2() throws Exception { WireMockServer server = new WireMockServer(WireMockConfiguration.wireMockConfig() @@ -278,6 +300,26 @@ void usesHttp11WhenSelected() throws Exception { } } + @Test + void latencyDoesNotExceedElapsedTimeWhenResponseBodyIsDribbled() throws Exception { + WireMockServer server = createServer(); + server.start(); + try { + server.stubFor(get(urlEqualTo("/dribbled")) + .willReturn(aResponse().withStatus(200).withBody(new byte[10_000]).withChunkedDribbleDelay(5, 500))); + HTTPSamplerBase sampler = newSampler(); + sampler.setHttpVersion("HTTP/1.1"); + + HTTPSampleResult result = sampler.sample( + new URL(server.url("/dribbled")), HTTPConstants.GET, false, 1); + + assertTrue(result.getLatency() <= result.getTime(), + "latency should not exceed elapsed time"); + } finally { + server.stop(); + } + } + @Test void setsSentBytesCorrectlyForGetRequest() throws Exception { WireMockServer server = createServer(); From 179c3a648d5764109e19cd26cd1c321dd0cec2d1 Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Mon, 17 Aug 2026 07:35:33 +0200 Subject: [PATCH 35/80] fix(http): install JsseSSLManager TLS strategies for HC5 HTTP/1.1 and HTTP/2 --- .../protocol/http/sampler/HTTPHC5Impl.java | 43 ++++++++++++++----- .../http/sampler/TestHTTPHC5Features.java | 20 +++++++++ 2 files changed, 52 insertions(+), 11 deletions(-) diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java index 4f84d730971..9999a6f42b9 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java @@ -97,7 +97,7 @@ import org.apache.hc.client5.http.ssl.ClientTlsStrategyBuilder; import org.apache.hc.client5.http.ssl.HostnameVerificationPolicy; import org.apache.hc.client5.http.ssl.NoopHostnameVerifier; -import org.apache.hc.client5.http.ssl.TrustAllStrategy; +import org.apache.hc.client5.http.ssl.TlsSocketStrategy; import org.apache.hc.core5.concurrent.FutureCallback; import org.apache.hc.core5.http.ClassicHttpResponse; import org.apache.hc.core5.http.ContentType; @@ -131,7 +131,6 @@ import org.apache.hc.core5.io.CloseMode; import org.apache.hc.core5.pool.PoolConcurrencyPolicy; import org.apache.hc.core5.reactor.ConnectionInitiator; -import org.apache.hc.core5.ssl.SSLContexts; import org.apache.hc.core5.util.TimeValue; import org.apache.hc.core5.util.Timeout; import org.apache.hc.core5.util.VersionInfo; @@ -326,6 +325,15 @@ public char[] getPassword() { private static final String[] HEADERS_TO_SAVE = {HttpHeaders.CONTENT_LENGTH, HttpHeaders.CONTENT_ENCODING, HttpHeaders.CONTENT_MD5}; + private static final String[] SOCKET_PROTOCOL_ARRAY = + JMeterUtils.getArrayPropDefault("https.socket.protocols", null); + + private static final String[] SOCKET_CIPHER_ARRAY = + JMeterUtils.getArrayPropDefault("https.socket.ciphers", null); + + private static final String[] CIPHER_SUITE_ARRAY = + JMeterUtils.getArrayPropDefault("https.cipherSuites", SOCKET_CIPHER_ARRAY); + // HttpClient 5.6 switched BrotliInputStreamFactory to the optional brotli4j library, so decode "br" // with the org.brotli:dec library JMeter ships, like HTTPHC4Impl does. @SuppressWarnings("deprecation") @@ -341,8 +349,6 @@ public char[] getPassword() { .register("deflate", DeflateInputStreamFactory.getInstance()) .build(); - private static final TlsStrategy HTTP_2_TLS_STRATEGY = createHttp2TlsStrategy(); - private static final ExecChainHandler RESPONSE_CONTENT_ENCODING = (request, scope, chain) -> { HttpClientContext context = scope.clientContext; RequestConfig requestConfig = context.getRequestConfig(); @@ -397,22 +403,36 @@ private static ClassicHttpResponse decompressResponse(ClassicHttpResponse respon private volatile org.apache.hc.client5.http.classic.methods.HttpUriRequestBase currentRequest; - private static TlsStrategy createHttp2TlsStrategy() { + private static ClientTlsStrategyBuilder createTlsStrategyBuilder() { try { - SSLContext sslContext = SSLContexts.custom().loadTrustMaterial(null, TrustAllStrategy.INSTANCE).build(); - return ClientTlsStrategyBuilder.create() + SSLContext sslContext = ((JsseSSLManager) SSLManager.getInstance()).getContext(); + ClientTlsStrategyBuilder builder = ClientTlsStrategyBuilder.create() .setSslContext(sslContext) // Leave hostname verification to the no-op verifier below. Without CLIENT the policy would // default to BOTH, and the JSSE built-in endpoint identification would reject the // self-signed certificates JMeter deliberately accepts when testing. .setHostVerificationPolicy(HostnameVerificationPolicy.CLIENT) - .setHostnameVerifier(NoopHostnameVerifier.INSTANCE) - .buildAsync(); + .setHostnameVerifier(NoopHostnameVerifier.INSTANCE); + if (SOCKET_PROTOCOL_ARRAY != null) { + builder.setTlsVersions(SOCKET_PROTOCOL_ARRAY); + } + if (CIPHER_SUITE_ARRAY != null) { + builder.setCiphers(CIPHER_SUITE_ARRAY); + } + return builder; } catch (GeneralSecurityException e) { - throw new IllegalStateException("Could not create HTTP/2 TLS strategy", e); + throw new IllegalStateException("Could not create TLS strategy", e); } } + private static TlsSocketStrategy createTlsSocketStrategy() { + return createTlsStrategyBuilder().buildClassic(); + } + + private static TlsStrategy createTlsStrategy() { + return createTlsStrategyBuilder().buildAsync(); + } + static H2Config createHttp2Config() { return H2Config.custom() .setHeaderTableSize(HTTP_2_HEADER_TABLE_SIZE) @@ -1061,6 +1081,7 @@ private static CloseableHttpClient createClient(HttpClientKey key) { builder.disableDefaultUserAgent(); } PoolingHttpClientConnectionManagerBuilder connectionManagerBuilder = PoolingHttpClientConnectionManagerBuilder.create(); + connectionManagerBuilder.setTlsSocketStrategy(createTlsSocketStrategy()); connectionManagerBuilder.setDefaultTlsConfig(TlsConfig.custom() .setVersionPolicy(key.httpVersionPolicy) .build()); @@ -1100,7 +1121,7 @@ private static CloseableHttpAsyncClient createHttp2Client(HttpClientKey key) { builder.addRequestInterceptorLast(REMOVE_DEFAULT_USER_AGENT); } PoolingAsyncClientConnectionManagerBuilder connectionManagerBuilder = PoolingAsyncClientConnectionManagerBuilder.create(); - connectionManagerBuilder.setTlsStrategy(HTTP_2_TLS_STRATEGY); + connectionManagerBuilder.setTlsStrategy(createTlsStrategy()); connectionManagerBuilder.setDefaultTlsConfig(TlsConfig.custom() .setVersionPolicy(key.httpVersionPolicy) .build()); diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java index 37cac56051c..4ecbbce679b 100644 --- a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java +++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java @@ -300,6 +300,26 @@ void usesHttp11WhenSelected() throws Exception { } } + @Test + void usesJMeterSslContextForHttp11() throws Exception { + WireMockServer server = new WireMockServer(WireMockConfiguration.wireMockConfig() + .dynamicHttpsPort() + .http2TlsDisabled(true)); + server.start(); + try { + server.stubFor(get(urlEqualTo("/https11")).willReturn(aResponse().withStatus(200))); + HTTPSamplerBase sampler = newSampler(); + + HTTPSampleResult result = sampler.sample( + new URL("https://localhost:" + server.httpsPort() + "/https11"), HTTPConstants.GET, false, 1); + + assertEquals("200", result.getResponseCode()); + assertEquals("HTTP/1.1", result.getResponseHeaders().substring(0, "HTTP/1.1".length())); + } finally { + server.stop(); + } + } + @Test void latencyDoesNotExceedElapsedTimeWhenResponseBodyIsDribbled() throws Exception { WireMockServer server = createServer(); From 928c2c856502e1d3e1506c9087893dd7e91e705f Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Mon, 17 Aug 2026 07:46:45 +0200 Subject: [PATCH 36/80] Fix duplicate HTTP/1.1 headers in Java sampler --- .../protocol/http/sampler/HTTPJavaImpl.java | 30 +++++++++++++++---- .../http/sampler/TestHTTPJavaFeatures.java | 25 ++++++++++++++++ 2 files changed, 49 insertions(+), 6 deletions(-) diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java index b7ec3874792..b2286f9c2ae 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java @@ -95,6 +95,24 @@ * */ public class HTTPJavaImpl extends HTTPAbstractImpl { + protected static final class ConnectionSetup { + private final HttpURLConnection connection; + private final Map securityHeaders; + + ConnectionSetup(HttpURLConnection connection, Map securityHeaders) { + this.connection = connection; + this.securityHeaders = securityHeaders; + } + + public HttpURLConnection getConnection() { + return connection; + } + + public Map getSecurityHeaders() { + return securityHeaders; + } + } + private static final boolean OBEY_CONTENT_LENGTH = JMeterUtils.getPropDefault("httpsampler.obey_contentlength", false); // $NON-NLS-1$ @@ -376,11 +394,11 @@ private String sendPutData(URLConnection connection) throws IOException { * GET, POST etc * @param res * sample result to save request infos to - * @return HttpURLConnection ready for .connect + * @return connection and security headers ready for .connect * @throws IOException * if an I/O Exception occurs */ - protected HttpURLConnection setupConnection(URL u, String method, HTTPSampleResult res) throws IOException { + protected ConnectionSetup setupConnection(URL u, String method, HTTPSampleResult res) throws IOException { SSLManager sslmgr = null; if (HTTPConstants.PROTOCOL_HTTPS.equalsIgnoreCase(u.getProtocol())) { try { @@ -461,7 +479,7 @@ protected HttpURLConnection setupConnection(URL u, String method, HTTPSampleResu } } - return conn; + return new ConnectionSetup(conn, securityHeaders); } /** @@ -812,10 +830,10 @@ protected HTTPSampleResult sample(URL url, String method, boolean areFollowingRe // Start with -1 so tries at least once, and retries at most MAX_CONN_RETRIES times for (; retry < MAX_CONN_RETRIES; retry++) { try { - conn = setupConnection(url, method, res); + ConnectionSetup connectionSetup = setupConnection(url, method, res); + conn = connectionSetup.getConnection(); requestHeaders = new LinkedHashMap<>(conn.getRequestProperties()); - securityHeaders = setConnectionHeaders(conn, url, getHeaderManager(), getCacheManager()); - setConnectionAuthorization(conn, url, getAuthManager(), securityHeaders); + securityHeaders = connectionSetup.getSecurityHeaders(); // Attempt the connection: savedConn = conn; conn.connect(); diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java index 280eece0b69..67e7ec11e53 100644 --- a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java +++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java @@ -18,7 +18,9 @@ package org.apache.jmeter.protocol.http.sampler; import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor; import static com.github.tomakehurst.wiremock.client.WireMock.post; import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -379,6 +381,29 @@ void displaysAuthorizationHeaderFromHeaderManagerInHttp11() throws Exception { } } + @Test + void sendsHeaderManagerHeaderOnceForHttp11() throws Exception { + WireMockServer server = createServer(); + server.start(); + try { + server.stubFor(get(urlEqualTo("/headerOnce")).willReturn(aResponse().withStatus(200))); + HTTPSamplerBase sampler = newSampler(); + sampler.setHttpVersion("HTTP/1.1"); + HeaderManager headerManager = new HeaderManager(); + headerManager.add(new Header("Accept", "application/json")); + sampler.setHeaderManager(headerManager); + + HTTPSampleResult result = sampler.sample( + new URL(server.url("/headerOnce")), HTTPConstants.GET, false, 1); + + assertEquals("200", result.getResponseCode()); + server.verify(1, getRequestedFor(urlEqualTo("/headerOnce")) + .withHeader("Accept", equalTo("application/json"))); + } finally { + server.stop(); + } + } + @Test void displaysAuthorizationHeaderFromHeaderManagerInHttp2() throws Exception { WireMockServer server = createServer(); From 66524a4717ff9601e6eb9ac75522b9fac6092861 Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Mon, 17 Aug 2026 08:29:47 +0200 Subject: [PATCH 37/80] Prevent HTTPJavaImpl HTTP/2 client cache leak --- .../http/sampler/HTTPAbstractImpl.java | 4 + .../protocol/http/sampler/HTTPJavaImpl.java | 136 ++++++++++++++---- .../http/sampler/HTTPSamplerProxy.java | 8 ++ .../http/sampler/TestHTTPJavaFeatures.java | 58 +++++++- 4 files changed, 175 insertions(+), 31 deletions(-) diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPAbstractImpl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPAbstractImpl.java index 9c724a01727..34e696853e1 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPAbstractImpl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPAbstractImpl.java @@ -103,6 +103,10 @@ protected HTTPAbstractImpl(HTTPSamplerBase testElement){ protected void threadFinished() { } + // Allows HTTPSamplerProxy to call testEnded; subclasses can override if necessary + protected void testEnded() { + } + // Allows HTTPSamplerProxy to call notifyFirstSampleAfterLoopRestart; subclasses can override if necessary protected void notifyFirstSampleAfterLoopRestart() { } diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java index b2286f9c2ae..6137ee19290 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java @@ -21,6 +21,7 @@ import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; +import java.lang.reflect.Method; import java.net.Authenticator; import java.net.BindException; import java.net.HttpURLConnection; @@ -54,6 +55,7 @@ import java.util.concurrent.Executors; import java.util.concurrent.ThreadFactory; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; import java.util.function.BiFunction; import java.util.function.Predicate; import java.util.zip.GZIPInputStream; @@ -149,15 +151,13 @@ private static String createHttp1DefaultUserAgent() { */ private static final Map SHARED_HTTP_2_CLIENTS = new ConcurrentHashMap<>(); - /** - * Shared daemon thread pool used by the HTTP/2 {@link HttpClient} instances. It allows JMeter to observe - * when a connection has been established, since the client only submits tasks once the TCP connection is up. - */ - private static final ExecutorService HTTP_2_EXECUTOR = - Executors.newCachedThreadPool(new Http2ThreadFactory()); + private static final AtomicReference HTTP_2_EXECUTOR = new AtomicReference<>(); private static final Logger log = LoggerFactory.getLogger(HTTPJavaImpl.class); + // Available since Java 21; on older JVMs the client is released once it is no longer referenced. + private static final Method HTTP_CLIENT_SHUTDOWN = findHttpClientShutdownMethod(); + /** Multiplex concurrent HTTP/2 message exchanges over a single connection per origin. */ private static final boolean HTTP_2_MULTIPLEXING = JMeterUtils.getPropDefault("http.java.h2.multiplexing", true); // $NON-NLS-1$ @@ -223,16 +223,71 @@ private static void setUnlessDefined(Properties systemProperties, String name, S } } - /** - * Returns the HTTP/2 clients of the current thread, or the shared ones when the exchanges of all threads - * should be multiplexed over a single connection. - * - * @return the map the HTTP/2 clients are cached in - */ static Map getHttp2Clients() { return HTTP_2_MULTIPLEXING ? SHARED_HTTP_2_CLIENTS : HTTP_2_CLIENTS.get(); } + /** Returns the thread pool of the HTTP/2 clients, it is created when the first client needs it. */ + private static ExecutorService http2Executor() { + ExecutorService executor = HTTP_2_EXECUTOR.get(); + if (executor != null) { + return executor; + } + ExecutorService created = Executors.newCachedThreadPool(new Http2ThreadFactory()); + if (HTTP_2_EXECUTOR.compareAndSet(null, created)) { + return created; + } + created.shutdownNow(); + return HTTP_2_EXECUTOR.get(); + } + + private static Method findHttpClientShutdownMethod() { + for (String name : new String[] { "shutdownNow", "close" }) { // $NON-NLS-1$ $NON-NLS-2$ + try { + return HttpClient.class.getMethod(name); + } catch (NoSuchMethodException e) { // NOSONAR Java 17 cannot close an HttpClient explicitly + log.debug("HttpClient has no {}() method", name); // $NON-NLS-1$ + } + } + return null; + } + + /** Releases the resources of the client, in particular its selector thread and its connections. */ + private static void closeQuietly(Http2Client client) { + if (client == null || HTTP_CLIENT_SHUTDOWN == null) { + return; + } + try { + HTTP_CLIENT_SHUTDOWN.invoke(client.httpClient); + } catch (Exception e) { // NOSONAR closing must never fail a sample + log.debug("Problem closing HTTP/2 HttpClient", e); // $NON-NLS-1$ + } + } + + private static void closeHttp2Clients(Map clients) { + synchronized (clients) { + for (Http2Client client : clients.values()) { + closeQuietly(client); + } + clients.clear(); + } + } + + @Override + protected void threadFinished() { + closeHttp2Clients(HTTP_2_CLIENTS.get()); + HTTP_2_CLIENTS.remove(); + } + + @Override + protected void testEnded() { + closeHttp2Clients(SHARED_HTTP_2_CLIENTS); + ExecutorService executor = HTTP_2_EXECUTOR.getAndSet(null); + if (executor != null) { + executor.shutdown(); + } + } + /** * HTTP/2 does not transmit a reason phrase (see RFC 9113, section 8.3.2), so the response message is * derived from the status code. The phrases are the ones registered in the IANA HTTP status code registry. @@ -1382,7 +1437,7 @@ private byte[] getBytes(String postBody) { } } - private Http2Client getHttpClient(URL url) { + Http2Client getHttpClient(URL url) { int connectTimeout = getConnectTimeout(); String proxyHost = getProxyHost(); int proxyPort = getProxyPortInt(); @@ -1411,12 +1466,29 @@ private Http2Client getHttpClient(URL url) { } HttpClientKey key = new HttpClientKey(connectTimeout, proxyHost, proxyPort, - proxyUser, proxyPass, autoRedirects, sslContext); - - return getHttp2Clients().computeIfAbsent(key, HTTPJavaImpl::createHttpClient); + proxyUser, proxyPass, autoRedirects, sslContext != null); + + Map clients = getHttp2Clients(); + Http2Client client = clients.get(key); + if (client != null && (HTTP_2_MULTIPLEXING || client.usesSslContext(sslContext))) { + // The SSLContext must not be part of the key, it does not implement equals(), and JMeter hands out + // a new instance per thread and after every reset, which would make the cache grow without bound. + // When the clients are shared, the SSLContext of the first caller is kept, so all threads keep on + // multiplexing their exchanges over the same connection. + return client; + } + synchronized (clients) { + client = clients.get(key); + if (client != null && (HTTP_2_MULTIPLEXING || client.usesSslContext(sslContext))) { + return client; + } + Http2Client created = createHttpClient(key, sslContext); + closeQuietly(clients.put(key, created)); + return created; + } } - private static Http2Client createHttpClient(HttpClientKey key) { + private static Http2Client createHttpClient(HttpClientKey key, SSLContext sslContext) { ConnectTimeTracker connectTimeTracker = new ConnectTimeTracker(); HttpClient.Builder builder = HttpClient.newBuilder() .version(HttpClient.Version.HTTP_2) @@ -1443,23 +1515,29 @@ protected PasswordAuthentication getPasswordAuthentication() { } } - if (key.sslContext != null) { - builder.sslContext(new ConnectTimeMeasuringSSLContext(key.sslContext, connectTimeTracker)); + if (sslContext != null) { + builder.sslContext(new ConnectTimeMeasuringSSLContext(sslContext, connectTimeTracker)); } - return new Http2Client(builder.build(), connectTimeTracker); + return new Http2Client(builder.build(), connectTimeTracker, sslContext); } /** * Holder for an HTTP/2 {@link HttpClient} and the tracker which measures the connect time of its connections. */ - private static final class Http2Client { + static final class Http2Client { private final HttpClient httpClient; private final ConnectTimeTracker connectTimeTracker; + private final SSLContext sslContext; - Http2Client(HttpClient httpClient, ConnectTimeTracker connectTimeTracker) { + Http2Client(HttpClient httpClient, ConnectTimeTracker connectTimeTracker, SSLContext sslContext) { this.httpClient = httpClient; this.connectTimeTracker = connectTimeTracker; + this.sslContext = sslContext; + } + + boolean usesSslContext(SSLContext other) { + return sslContext == other; } } @@ -1525,7 +1603,7 @@ private static final class ConnectTimeMeasuringExecutor implements Executor { @Override public void execute(Runnable command) { tracker.connectionEstablished(); - HTTP_2_EXECUTOR.execute(command); + http2Executor().execute(command); } } @@ -1796,18 +1874,18 @@ private static class HttpClientKey { private final String proxyUser; private final String proxyPass; private final boolean autoRedirects; - private final SSLContext sslContext; + private final boolean secure; HttpClientKey(int connectTimeout, String proxyHost, int proxyPort, String proxyUser, String proxyPass, boolean autoRedirects, - SSLContext sslContext) { + boolean secure) { this.connectTimeout = connectTimeout; this.proxyHost = proxyHost; this.proxyPort = proxyPort; this.proxyUser = proxyUser; this.proxyPass = proxyPass; this.autoRedirects = autoRedirects; - this.sslContext = sslContext; + this.secure = secure; } @Override @@ -1821,15 +1899,15 @@ public boolean equals(Object o) { return connectTimeout == that.connectTimeout && proxyPort == that.proxyPort && autoRedirects == that.autoRedirects + && secure == that.secure && Objects.equals(proxyHost, that.proxyHost) && Objects.equals(proxyUser, that.proxyUser) - && Objects.equals(proxyPass, that.proxyPass) - && Objects.equals(sslContext, that.sslContext); + && Objects.equals(proxyPass, that.proxyPass); } @Override public int hashCode() { - return Objects.hash(connectTimeout, proxyHost, proxyPort, proxyUser, proxyPass, autoRedirects, sslContext); + return Objects.hash(connectTimeout, proxyHost, proxyPort, proxyUser, proxyPass, autoRedirects, secure); } } diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPSamplerProxy.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPSamplerProxy.java index f7106e77032..30d3d72ebf8 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPSamplerProxy.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPSamplerProxy.java @@ -76,6 +76,14 @@ public void threadFinished(){ } } + @Override + public void testEnded() { + super.testEnded(); + if (impl != null) { + impl.testEnded(); // Forward to sampler, so it can release resources shared by all threads + } + } + @Override public boolean interrupt() { if (impl != null) { diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java index 67e7ec11e53..ffbad86d2f7 100644 --- a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java +++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java @@ -25,6 +25,7 @@ import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotSame; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -37,6 +38,7 @@ import java.net.http.HttpResponse; import java.security.cert.X509Certificate; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Properties; @@ -55,6 +57,8 @@ import org.apache.jmeter.protocol.http.control.HeaderManager; import org.apache.jmeter.protocol.http.util.HTTPConstants; import org.apache.jmeter.samplers.SampleResult; +import org.apache.jmeter.util.JsseSSLManager; +import org.apache.jmeter.util.SSLManager; import org.junit.jupiter.api.Test; import com.github.tomakehurst.wiremock.WireMockServer; @@ -511,8 +515,58 @@ void setsConnectTimeForHttp2OverTls() throws Exception { } } - private static SSLContext trustAllContext() throws Exception { - TrustManager trustAll = new X509ExtendedTrustManager() { + @Test + void doesNotCacheAnAdditionalHttp2ClientWhenTheSslContextIsReset() throws Exception { + HTTPSamplerBase sampler = newSampler(); + sampler.setHttpVersion("HTTP/2"); + HTTPJavaImpl impl = new HTTPJavaImpl(sampler); + Map clients = HTTPJavaImpl.getHttp2Clients(); + URL url = new URL("https://localhost:1234/http2SslReset"); + try { + JsseSSLManager sslManager = (JsseSSLManager) SSLManager.getInstance(); + HTTPJavaImpl.Http2Client first = impl.getHttpClient(url); + Map clientsAfterFirstSample = new HashMap<>(clients); + SSLContext contextOfFirstSample = sslManager.getContext(); + + // Happens on every thread group iteration when "same user on next iteration" is switched off + sslManager.resetContext(); + HTTPJavaImpl.Http2Client second = impl.getHttpClient(url); + + assertNotSame(contextOfFirstSample, sslManager.getContext(), + "resetContext() should hand out a new SSLContext, otherwise the test proves nothing"); + assertFalse(clientsAfterFirstSample.isEmpty(), "an HTTP/2 client should have been cached"); + assertSame(first, second, "the cached HTTP/2 client should be reused after the SSLContext was reset"); + assertEquals(clientsAfterFirstSample, new HashMap(clients), + "a new SSLContext must not add another HttpClient to the cache, that would leak clients"); + } finally { + impl.testEnded(); + } + } + + @Test + void closesSharedHttp2ClientsWhenTheTestEnds() throws Exception { + WireMockServer server = createServer(); + server.start(); + HTTPSamplerBase sampler = newSampler(); + try { + server.stubFor(get(urlEqualTo("/http2TestEnded")).willReturn(aResponse().withStatus(200))); + sampler.setHttpVersion("HTTP/2"); + + HTTPSampleResult result = sampler.sample( + new URL(server.url("/http2TestEnded")), HTTPConstants.GET, false, 1); + assertEquals("200", result.getResponseCode()); + assertFalse(HTTPJavaImpl.getHttp2Clients().isEmpty(), "the sample should have cached an HTTP/2 client"); + + sampler.testEnded(); + + assertTrue(HTTPJavaImpl.getHttp2Clients().isEmpty(), + "HTTP/2 clients should be released when the test ends"); + } finally { + server.stop(); + } + } + + private static SSLContext trustAllContext() throws Exception { TrustManager trustAll = new X509ExtendedTrustManager() { @Override public void checkClientTrusted(X509Certificate[] chain, String authType) { // trust everything in the test From 23aeb7db662638f41281c46bc70fb58daf08deec Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Mon, 17 Aug 2026 09:07:05 +0200 Subject: [PATCH 38/80] Update expected release jars Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/dist/src/dist/expected_release_jars.csv | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/dist/src/dist/expected_release_jars.csv b/src/dist/src/dist/expected_release_jars.csv index d8c8ce37dfa..08dc0e0871d 100644 --- a/src/dist/src/dist/expected_release_jars.csv +++ b/src/dist/src/dist/expected_release_jars.csv @@ -69,8 +69,11 @@ 2403,hamcrest-core-3.0.jar 181512,httpasyncclient-4.1.5.jar 785639,httpclient-4.5.14.jar +1057058,httpclient5-5.6.4.jar 327891,httpcore-4.4.16.jar 368896,httpcore-nio-4.4.16.jar +955121,httpcore5-5.4.3.jar +263675,httpcore5-h2-5.4.3.jar 41727,httpmime-4.5.14.jar 84211,jackson-annotations-2.22.jar 594776,jackson-core-2.22.3.jar From 6bdff24c5e77d54c0d02b82862dfc1c37392ccd1 Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Mon, 17 Aug 2026 09:33:17 +0200 Subject: [PATCH 39/80] Add cancellation support for HTTP/2 requests in HTTPJavaImpl and HTTPHC5Impl samplers, including interrupt handling and corresponding tests --- .../protocol/http/sampler/HTTPHC5Impl.java | 47 +++++++++---------- .../protocol/http/sampler/HTTPJavaImpl.java | 42 ++++++++--------- .../http/sampler/TestHTTPHC5Features.java | 33 +++++++++++++ .../http/sampler/TestHTTPJavaFeatures.java | 30 ++++++++++++ 4 files changed, 107 insertions(+), 45 deletions(-) diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java index 9999a6f42b9..88ba33adbd4 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java @@ -43,8 +43,6 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; import javax.net.ssl.SSLContext; import javax.security.auth.Subject; @@ -402,6 +400,8 @@ private static ClassicHttpResponse decompressResponse(ClassicHttpResponse respon } private volatile org.apache.hc.client5.http.classic.methods.HttpUriRequestBase currentRequest; + private volatile Future currentResponseFuture; + private volatile boolean interruptRequested; private static ClientTlsStrategyBuilder createTlsStrategyBuilder() { try { @@ -466,6 +466,7 @@ protected HTTPSampleResult sample(URL url, String method, boolean areFollowingRe } } + interruptRequested = false; currentRequest = request; HttpClientKey clientKey = createHttpClientKey(url); HttpClientContext context = createHttpClientContext(url, clientKey, request); @@ -539,7 +540,7 @@ private ClassicHttpResponse executeRequest(URL url, HttpClientKey clientKey, } } - private static ClassicHttpResponse doExecuteRequest(HttpClientKey clientKey, + private ClassicHttpResponse doExecuteRequest(HttpClientKey clientKey, org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request, HttpClientContext context) throws IOException { return clientKey.httpVersionPolicy != HttpVersionPolicy.FORCE_HTTP_1 @@ -1172,7 +1173,7 @@ private static void enableMessageMultiplexing(PoolingAsyncClientConnectionManage } @SuppressWarnings("deprecation") // SimpleHttpRequest.copy is required for HttpClient 5.3 compatibility - private static ClassicHttpResponse executeHttp2(CloseableHttpAsyncClient client, + private ClassicHttpResponse executeHttp2(CloseableHttpAsyncClient client, org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request, HttpClientContext context) throws IOException { SimpleHttpRequest asyncRequest = SimpleHttpRequest.copy(request); @@ -1185,33 +1186,23 @@ private static ClassicHttpResponse executeHttp2(CloseableHttpAsyncClient client, } Future responseFuture = client.execute(SimpleRequestProducer.create(asyncRequest), new LatencyMeasuringResponseConsumer(), context, null); + currentResponseFuture = responseFuture; + if (interruptRequested) { + responseFuture.cancel(true); + } try { - return createClassicResponse(responseFuture.get(getHttp2ExecutionTimeoutMillis(request), TimeUnit.MILLISECONDS)); + return createClassicResponse(responseFuture.get()); } catch (InterruptedException e) { + responseFuture.cancel(true); Thread.currentThread().interrupt(); throw new IOException("Interrupted while executing HTTP/2 request", e); - } catch (TimeoutException e) { - responseFuture.cancel(true); - throw new IOException("Timed out while executing HTTP/2 request", e); } catch (ExecutionException e) { throw new IOException("Could not execute HTTP/2 request", e.getCause()); + } finally { + currentResponseFuture = null; } } - /** - * The future is only a safety net: HttpClient enforces the configured response timeout itself, - * so this waits a little longer than the sampler is willing to wait for the response. - */ - private static long getHttp2ExecutionTimeoutMillis( - org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request) { - RequestConfig requestConfig = request.getConfig(); - Timeout responseTimeout = requestConfig == null ? null : requestConfig.getResponseTimeout(); - if (responseTimeout == null || responseTimeout.toMilliseconds() <= 0) { - return TimeUnit.MINUTES.toMillis(1); - } - return responseTimeout.toMilliseconds() + TimeUnit.SECONDS.toMillis(5); - } - private static ClassicHttpResponse createClassicResponse(SimpleHttpResponse asyncResponse) { BasicClassicHttpResponse response = new BasicClassicHttpResponse(asyncResponse.getCode(), asyncResponse.getReasonPhrase()); @@ -1389,11 +1380,19 @@ private static void closeThreadLocalClients() { @Override public boolean interrupt() { org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request = currentRequest; + Future responseFuture = currentResponseFuture; + if (request != null || responseFuture != null) { + interruptRequested = true; + } + currentRequest = null; + currentResponseFuture = null; if (request != null) { - currentRequest = null; request.cancel(); } - return request != null; + if (responseFuture != null) { + responseFuture.cancel(true); + } + return request != null || responseFuture != null; } private static void recordConnectEnd(HttpContext context) { diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java index 6137ee19290..930bbd99572 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java @@ -49,6 +49,7 @@ import java.util.Map; import java.util.Objects; import java.util.Properties; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Executor; import java.util.concurrent.ExecutorService; @@ -395,6 +396,7 @@ private boolean isHttp2() { private transient PostWriter postOrPutWriter; private volatile HttpURLConnection savedConn; + private volatile CompletableFuture> currentResponseFuture; protected HTTPJavaImpl(HTTPSamplerBase base) { super(base); @@ -1087,11 +1089,16 @@ private static void saveConnectionCookies(HttpURLConnection conn, URL u, CookieM @Override public boolean interrupt() { HttpURLConnection conn = savedConn; + CompletableFuture> responseFuture = currentResponseFuture; + savedConn = null; + currentResponseFuture = null; if (conn != null) { - savedConn = null; conn.disconnect(); } - return conn != null; + if (responseFuture != null) { + responseFuture.cancel(true); + } + return conn != null || responseFuture != null; } private HTTPSampleResult sampleHttp2(URL url, String method, boolean areFollowingRedirect, int frameDepth) { @@ -1099,33 +1106,26 @@ private HTTPSampleResult sampleHttp2(URL url, String method, boolean areFollowin log.debug("Start : sampleHttp2 {}, method {}, followingRedirect {}, depth {}", url, method, areFollowingRedirect, frameDepth); } - HTTPSampleResult res = new HTTPSampleResult(); configureSampleLabel(res, url); res.setURL(url); res.setHTTPMethod(method); - res.sampleStart(); - final CacheManager cacheManager = getCacheManager(); if (cacheManager != null && HTTPConstants.GET.equalsIgnoreCase(method)) { if (cacheManager.inCache(url, getHeaders(getHeaderManager()))) { return updateSampleResultForResourceInCache(res); } } - CapturingHttpURLConnection capturingConn = null; byte[] requestBodyBytes = new byte[0]; Map securityHeaders = Collections.emptyMap(); - try { capturingConn = new CapturingHttpURLConnection(url, method); - securityHeaders = setConnectionHeaders(capturingConn, url, getHeaderManager(), getCacheManager()); String cookies = setConnectionCookie(capturingConn, url, getCookieManager()); setConnectionAuthorization(capturingConn, url, getAuthManager(), securityHeaders); setDefaultUserAgent(capturingConn, HTTP_2_DEFAULT_USER_AGENT); - if (method.equals(HTTPConstants.POST)) { setPostHeaders(capturingConn); String postBody = sendPostData(capturingConn); @@ -1137,17 +1137,14 @@ private HTTPSampleResult sampleHttp2(URL url, String method, boolean areFollowin res.setQueryString(putBody); requestBodyBytes = capturingConn.getCapturedBytes(); } - res.setRequestHeaders(getAllHeadersExceptCookie(capturingConn, securityHeaders)); if (StringUtilities.isNotEmpty(cookies)) { res.setCookies(cookies); } else { res.setCookies(getOnlyCookieFromHeaders(capturingConn, securityHeaders)); } - URI uri = url.toURI(); HttpRequest.Builder reqBuilder = HttpRequest.newBuilder(uri); - if (method.equalsIgnoreCase(HTTPConstants.POST) || method.equalsIgnoreCase(HTTPConstants.PUT) || method.equalsIgnoreCase(HTTPConstants.PATCH)) { reqBuilder.method(method, HttpRequest.BodyPublishers.ofByteArray(requestBodyBytes)); @@ -1158,12 +1155,10 @@ private HTTPSampleResult sampleHttp2(URL url, String method, boolean areFollowin } else { reqBuilder.method(method, HttpRequest.BodyPublishers.noBody()); } - int rto = getResponseTimeout(); if (rto > 0) { reqBuilder.timeout(Duration.ofMillis(rto)); } - Map> props = capturingConn.getRequestProperties(); for (Map.Entry> entry : props.entrySet()) { String headerName = entry.getKey(); @@ -1174,15 +1169,24 @@ private HTTPSampleResult sampleHttp2(URL url, String method, boolean areFollowin reqBuilder.header(headerName, value); } } - Http2Client client = getHttpClient(url); HttpRequest httpRequest = reqBuilder.build(); - HttpResponse response; ConnectTimeTracker connectTimeTracker = client.connectTimeTracker; connectTimeTracker.sampleStarted(res); try { - response = client.httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofInputStream()); + CompletableFuture> responseFuture = + client.httpClient.sendAsync(httpRequest, HttpResponse.BodyHandlers.ofInputStream()); + currentResponseFuture = responseFuture; + try { + response = responseFuture.get(); + } catch (InterruptedException e) { + responseFuture.cancel(true); + Thread.currentThread().interrupt(); + throw e; + } finally { + currentResponseFuture = null; + } } finally { connectTimeTracker.sampleFinished(res); } @@ -1193,21 +1197,17 @@ private HTTPSampleResult sampleHttp2(URL url, String method, boolean areFollowin res.sampleEnd(); res.setResponseData(responseData); - int statusCode = response.statusCode(); res.setResponseCode(Integer.toString(statusCode)); res.setSuccessful(isSuccessCode(statusCode)); res.setResponseMessage(getReasonPhrase(statusCode)); - String responseHeaders = getResponseHeaders(response); res.setResponseHeaders(responseHeaders); - String ct = response.headers().firstValue(HTTPConstants.HEADER_CONTENT_TYPE).orElse(null); if (ct != null) { res.setContentType(ct); res.setEncodingAndType(ct); } - if (res.isRedirect()) { String location = response.headers().firstValue(HTTPConstants.HEADER_LOCATION).orElse(null); if (location != null) { diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java index 4ecbbce679b..c07823687a6 100644 --- a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java +++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java @@ -218,6 +218,39 @@ void usesHttp2WhenSelected() throws Exception { } } + @Test + void interruptsPendingHttp2Request() throws Exception { + WireMockServer server = new WireMockServer(WireMockConfiguration.wireMockConfig() + .dynamicHttpsPort() + .http2TlsDisabled(false)); + server.start(); + ExecutorService executor = Executors.newSingleThreadExecutor(); + try { + server.stubFor(get(urlEqualTo("/interrupted")).willReturn(aResponse().withFixedDelay(10_000))); + HTTPSamplerBase sampler = newSampler(); + sampler.setHttpVersion("HTTP/2"); + HTTPHC5Impl implementation = new HTTPHC5Impl(sampler); + + Future sample = executor.submit(() -> implementation.sample( + new URL("https://localhost:" + server.httpsPort() + "/interrupted"), + HTTPConstants.GET, false, 1)); + + boolean interrupted = false; + for (int attempt = 0; attempt < 100 && !interrupted; attempt++) { + interrupted = implementation.interrupt(); + if (!interrupted) { + Thread.sleep(10); + } + } + + assertTrue(interrupted, "the HTTP/2 response future should be cancellable"); + sample.get(5, TimeUnit.SECONDS); + } finally { + executor.shutdownNow(); + server.stop(); + } + } + @Test void recordsHttp2LatencyBeforeDribbledResponseBodyCompletes() throws Exception { WireMockServer server = new WireMockServer(WireMockConfiguration.wireMockConfig() diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java index ffbad86d2f7..2091bac7854 100644 --- a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java +++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java @@ -102,6 +102,36 @@ void usesHttp2WhenSelected() throws Exception { } } + @Test + void interruptsPendingHttp2Request() throws Exception { + WireMockServer server = createServer(); + server.start(); + ExecutorService executor = Executors.newSingleThreadExecutor(); + try { + server.stubFor(get(urlEqualTo("/interrupted")).willReturn(aResponse().withFixedDelay(10_000))); + HTTPSamplerBase sampler = newSampler(); + sampler.setHttpVersion("HTTP/2"); + HTTPJavaImpl implementation = new HTTPJavaImpl(sampler); + + Future sample = executor.submit(() -> implementation.sample( + new URL(server.url("/interrupted")), HTTPConstants.GET, false, 1)); + + boolean interrupted = false; + for (int attempt = 0; attempt < 100 && !interrupted; attempt++) { + interrupted = implementation.interrupt(); + if (!interrupted) { + Thread.sleep(10); + } + } + + assertTrue(interrupted, "the HTTP/2 response future should be cancellable"); + sample.get(5, TimeUnit.SECONDS); + } finally { + executor.shutdownNow(); + server.stop(); + } + } + @Test void setsResponseMessageForHttp2() throws Exception { WireMockServer server = createServer(); From 87960eee2a3f2965a61d4f4bca0ebb0a5cd0a384 Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Mon, 17 Aug 2026 12:26:19 +0200 Subject: [PATCH 40/80] Stream HTTP/2 bodies instead of buffering them, and stop throttling downloads Both HTTP/2 sampler paths materialized entire bodies in heap: HTTPHC5Impl converted the request entity with EntityUtils.toByteArray() and held the response as a byte array (copied again into a ByteArrayEntity), while HTTPJavaImpl captured uploads into a ByteArrayOutputStream before building BodyPublishers.ofByteArray(). Large uploads died with OutOfMemoryError where HTTP/1.1 succeeded, and httpsampler.max_bytes_to_store_per_request could not help downloads because it is applied only after the bytes exist. Requests now stream: HTTPHC5Impl uses AsyncRequestBuilder with a FileEntityProducer over a new memory-first SpillOutputStream (256 KB in heap, then a temp file), and HTTPJavaImpl publishes with BodyPublishers.ofFile. Direct file uploads stream with no copy at all via PathEntity. Responses now truncate at the source: the async response consumer stores only max_bytes_to_store_per_request bytes while still counting the full body, so the bytes beyond the limit never reach the heap. Truncation is skipped when the body is content-encoded (a partial gzip stream cannot be decoded) and when MD5 or recording needs the whole body; the limit is resolved on the sampler thread because the I/O reactor threads cannot see JMeter's ThreadLocal context. That gives the same peak heap usage as the HTTP/1.1 transport, which reads and truncates the body as it arrives. Separately, HTTP/2 downloads were about three times slower than the same request over HTTP/1.1 or with the Java implementation, because httpclient5.h2.initial_window_size defaulted to the 64 kB minimum the HTTP/2 specification mandates. The stream flow control window caps the throughput of a response at the window divided by the round trip time, no matter how much bandwidth is available. JMeter now announces the same 16 MB window the JDK client uses, which is already the default of the Java implementation. On a 100 MB download this cut the time from 15 s to 4 s. The connection level window needs no configuration, as HttpCore maximizes it on its own. Also fixed along the way: * multipart previews materialized the whole uploaded file in heap * response bodies were dropped when the server sent no Content-Type * gzip HTTP/2 responses were not decoded * spilled upload files were deleted between internal redirect attempts * setBodySize() reported the truncated length rather than the wire length Extract Http2CapturingHttpURLConnection from HTTPJavaImpl and add SpillOutputStream; commons-io is not a dependency of this module. --- bin/jmeter.properties | 8 +- .../protocol/http/sampler/HTTPHC5Impl.java | 389 ++++++++++++++---- .../protocol/http/sampler/HTTPJavaImpl.java | 138 ++----- .../http/sampler/HTTPSamplerBase.java | 12 + .../Http2CapturingHttpURLConnection.java | 146 +++++++ .../http/sampler/SpillOutputStream.java | 148 +++++++ .../http/sampler/TestHTTPHC5Features.java | 125 +++++- .../http/sampler/TestHTTPJavaFeatures.java | 37 +- xdocs/usermanual/properties_reference.xml | 8 +- 9 files changed, 832 insertions(+), 179 deletions(-) create mode 100644 src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/Http2CapturingHttpURLConnection.java create mode 100644 src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/SpillOutputStream.java diff --git a/bin/jmeter.properties b/bin/jmeter.properties index 03d215c40ce..03db07611fc 100644 --- a/bin/jmeter.properties +++ b/bin/jmeter.properties @@ -409,8 +409,12 @@ remote_hosts=127.0.0.1 # Maximum number of concurrent streams the client accepts on a connection #httpclient5.h2.max_concurrent_streams=250 -# Flow control window in bytes announced per stream -#httpclient5.h2.initial_window_size=65535 +# Flow control window in bytes announced per stream. It caps how many bytes the +# server may send before it has to wait for a window update, so the throughput +# of a single response cannot exceed this window divided by the round trip +# time. The HTTP/2 default of 65535 throttles downloads over links with any +# noticeable latency +#httpclient5.h2.initial_window_size=16777216 # Largest frame payload in bytes the client is willing to receive #httpclient5.h2.max_frame_size=65536 diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java index 88ba33adbd4..c9131dd5a42 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java @@ -20,6 +20,7 @@ import java.io.ByteArrayOutputStream; import java.io.File; import java.io.IOException; +import java.io.OutputStream; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.net.InetAddress; @@ -30,6 +31,9 @@ import java.net.UnknownHostException; import java.nio.ByteBuffer; import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; import java.security.GeneralSecurityException; import java.security.Principal; import java.security.PrivilegedActionException; @@ -50,10 +54,7 @@ import org.apache.hc.client5.http.DnsResolver; import org.apache.hc.client5.http.HttpRoute; import org.apache.hc.client5.http.SystemDefaultDnsResolver; -import org.apache.hc.client5.http.async.methods.SimpleHttpRequest; -import org.apache.hc.client5.http.async.methods.SimpleHttpResponse; -import org.apache.hc.client5.http.async.methods.SimpleRequestProducer; -import org.apache.hc.client5.http.async.methods.SimpleResponseConsumer; +import org.apache.hc.client5.http.async.methods.AbstractBinResponseConsumer; import org.apache.hc.client5.http.auth.AuthSchemeFactory; import org.apache.hc.client5.http.auth.AuthScope; import org.apache.hc.client5.http.auth.Credentials; @@ -71,6 +72,7 @@ import org.apache.hc.client5.http.entity.GZIPInputStreamFactory; import org.apache.hc.client5.http.entity.InputStreamFactory; import org.apache.hc.client5.http.entity.UrlEncodedFormEntity; +import org.apache.hc.client5.http.entity.mime.FileBody; import org.apache.hc.client5.http.entity.mime.MultipartEntityBuilder; import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient; import org.apache.hc.client5.http.impl.async.HttpAsyncClientBuilder; @@ -99,7 +101,6 @@ import org.apache.hc.core5.concurrent.FutureCallback; import org.apache.hc.core5.http.ClassicHttpResponse; import org.apache.hc.core5.http.ContentType; -import org.apache.hc.core5.http.EntityDetails; import org.apache.hc.core5.http.Header; import org.apache.hc.core5.http.HeaderElement; import org.apache.hc.core5.http.HttpEntity; @@ -113,16 +114,16 @@ import org.apache.hc.core5.http.config.Lookup; import org.apache.hc.core5.http.config.RegistryBuilder; import org.apache.hc.core5.http.io.entity.ByteArrayEntity; -import org.apache.hc.core5.http.io.entity.EntityUtils; import org.apache.hc.core5.http.io.entity.FileEntity; import org.apache.hc.core5.http.io.entity.StringEntity; import org.apache.hc.core5.http.message.BasicClassicHttpResponse; import org.apache.hc.core5.http.message.BasicHeaderValueParser; import org.apache.hc.core5.http.message.BasicNameValuePair; import org.apache.hc.core5.http.message.ParserCursor; -import org.apache.hc.core5.http.nio.AsyncResponseConsumer; -import org.apache.hc.core5.http.nio.CapacityChannel; +import org.apache.hc.core5.http.nio.entity.BasicAsyncEntityProducer; +import org.apache.hc.core5.http.nio.entity.FileEntityProducer; import org.apache.hc.core5.http.nio.ssl.TlsStrategy; +import org.apache.hc.core5.http.nio.support.BasicRequestProducer; import org.apache.hc.core5.http.protocol.HttpContext; import org.apache.hc.core5.http2.HttpVersionPolicy; import org.apache.hc.core5.http2.config.H2Config; @@ -170,6 +171,7 @@ public class HTTPHC5Impl extends HTTPHCAbstractImpl { /** Key used to store the current {@link SampleResult} in the {@link HttpClientContext}. */ static final String CONTEXT_ATTRIBUTE_SAMPLER_RESULT = "__jmeter.S_R__"; //$NON-NLS-1$ private static final String CONTEXT_ATTRIBUTE_RESPONSE_LATENCY = "__jmeter.S_R_LATENCY__"; //$NON-NLS-1$ + private static final String CONTEXT_ATTRIBUTE_RESPONSE_BODY_SIZE = "__jmeter.S_R_BODY_SIZE__"; //$NON-NLS-1$ private static final ThreadLocal> HTTP_CLIENTS = new InheritableThreadLocal<>() { @@ -206,8 +208,18 @@ protected Map initialValue() { private static final int HTTP_2_MAX_CONCURRENT_STREAMS = JMeterUtils.getPropDefault("httpclient5.h2.max_concurrent_streams", H2Config.DEFAULT.getMaxConcurrentStreams()); + /** + * HTTP/2 flow control window announced per stream. It caps how many bytes the server may send + * before it has to wait for a window update, so the throughput of a single response can never + * exceed this window divided by the round trip time. The default of HttpClient is the 64 kB the + * HTTP/2 specification mandates, which throttles a download over a link with any noticeable + * latency to a fraction of the available bandwidth. JMeter therefore announces the same 16 MB + * the JDK client uses by default, see {@code http.java.h2.initial_window_size}. The window is + * only a promise to the server, the body is consumed as it arrives and not buffered up to this + * size. + */ private static final int HTTP_2_INITIAL_WINDOW_SIZE = - JMeterUtils.getPropDefault("httpclient5.h2.initial_window_size", H2Config.DEFAULT.getInitialWindowSize()); + JMeterUtils.getPropDefault("httpclient5.h2.initial_window_size", 16 * 1024 * 1024); private static final int HTTP_2_MAX_FRAME_SIZE = JMeterUtils.getPropDefault("httpclient5.h2.max_frame_size", H2Config.DEFAULT.getMaxFrameSize()); @@ -229,6 +241,15 @@ protected Map initialValue() { private static final int HTTP_2_MAX_CONNECTIONS_PER_ROUTE = JMeterUtils.getPropDefault("httpclient5.h2.max_connections_per_route", 6); + /** + * Number of bytes of an HTTP/2 request body that are buffered in memory before the body is + * spilled to a temporary file. Keeping the common small bodies in heap avoids a temporary file + * per sample, while a larger body is streamed from disk so that a big upload is never + * materialized as a byte array. + */ + private static final int HTTP_2_BODY_SPILL_THRESHOLD = + JMeterUtils.getPropDefault("httpclient5.h2.body_spill_threshold", 256 * 1024); + /** * Milliseconds of inactivity after which a pooled connection is re-validated before it is used * again, {@code -1} disables the check. JMeter keeps a client per thread and reuses its @@ -369,7 +390,18 @@ public char[] getPassword() { @SuppressWarnings("deprecation") // DecompressingEntity is superseded by the @Internal ContentCodecRegistry private static ClassicHttpResponse decompressResponse(ClassicHttpResponse response) { HttpEntity entity = response.getEntity(); - if (entity == null || entity.getContentLength() == 0 || entity.getContentEncoding() == null) { + if (entity == null || entity.getContentLength() == 0) { + return response; + } + + String contentEncoding = entity.getContentEncoding(); + if (contentEncoding == null) { + Header encodingHeader = response.getFirstHeader(HttpHeaders.CONTENT_ENCODING); + if (encodingHeader != null) { + contentEncoding = encodingHeader.getValue(); + } + } + if (contentEncoding == null) { return response; } @@ -377,7 +409,6 @@ private static ClassicHttpResponse decompressResponse(ClassicHttpResponse respon for (int i = 0; i < HEADERS_TO_SAVE.length; i++) { headersToSave[i] = response.getHeaders(HEADERS_TO_SAVE[i]); } - String contentEncoding = entity.getContentEncoding(); HeaderElement[] codecs = BasicHeaderValueParser.INSTANCE.parseElements(contentEncoding, new ParserCursor(0, contentEncoding.length())); for (HeaderElement codec : codecs) { @@ -400,7 +431,7 @@ private static ClassicHttpResponse decompressResponse(ClassicHttpResponse respon } private volatile org.apache.hc.client5.http.classic.methods.HttpUriRequestBase currentRequest; - private volatile Future currentResponseFuture; + private volatile Future currentResponseFuture; private volatile boolean interruptRequested; private static ClientTlsStrategyBuilder createTlsStrategyBuilder() { @@ -472,7 +503,7 @@ protected HTTPSampleResult sample(URL url, String method, boolean areFollowingRe HttpClientContext context = createHttpClientContext(url, clientKey, request); context.setAttribute(CONTEXT_ATTRIBUTE_SAMPLER_RESULT, result); response = executeRequest(url, clientKey, request, context); - readResponse(response, result); + readResponse(response, result, context); Long responseLatency = (Long) context.getAttribute(CONTEXT_ATTRIBUTE_RESPONSE_LATENCY); if (responseLatency != null) { result.setLatency(responseLatency); @@ -605,20 +636,23 @@ private String setupRequestEntity(org.apache.hc.client5.http.classic.methods.Htt builder.addTextBody(argument.getName(), argument.getValue(), contentType); } } + List fileBodies = new ArrayList<>(files.length); for (HTTPFileArg file : files) { File resolvedFile = FileServer.getFileServer().getResolvedFile(file.getPath()); ContentType contentType = StringUtilities.isNotEmpty(file.getMimeType()) ? ContentType.parse(file.getMimeType()) : ContentType.DEFAULT_BINARY; - builder.addBinaryBody(file.getParamName(), resolvedFile, contentType, file.getName()); + ViewableFileBody fileBody = new ViewableFileBody(resolvedFile, contentType, file.getName()); + fileBodies.add(fileBody); + builder.addPart(file.getParamName(), fileBody); } entity = builder.build(); - requestData = getEntityPreview(entity, contentEncoding); + requestData = getMultipartPreview(entity, contentEncoding, fileBodies); } else if (!hasArguments() && getSendFileAsPostBody()) { HTTPFileArg file = files[0]; if (request.getFirstHeader(HTTPConstants.HEADER_CONTENT_TYPE) == null && StringUtilities.isNotEmpty(file.getMimeType())) { request.setHeader(HTTPConstants.HEADER_CONTENT_TYPE, file.getMimeType()); } - entity = new FileEntity(FileServer.getFileServer().getResolvedFile(file.getPath()), null); + entity = new PathEntity(FileServer.getFileServer().getResolvedFile(file.getPath()), null); requestData = ""; } else if (getSendParameterValuesAsPostBody()) { StringBuilder body = new StringBuilder(); @@ -664,6 +698,49 @@ private static String getEntityPreview(HttpEntity entity, String contentEncoding return output.toString(contentEncoding); } + /** + * Renders the multipart body for the sample result with the file contents replaced by a + * placeholder, so that uploading a large file does not have to be held in memory just to be + * shown in the request view. + */ + private static String getMultipartPreview(HttpEntity entity, String contentEncoding, + List fileBodies) throws IOException { + for (ViewableFileBody fileBody : fileBodies) { + fileBody.hideFileData = true; + } + try { + return getEntityPreview(entity, contentEncoding); + } finally { + for (ViewableFileBody fileBody : fileBodies) { + fileBody.hideFileData = false; + } + } + } + + /** + * File part of a multipart body that can write a placeholder instead of the file contents, + * so the body can be rendered for the sample result without reading the file into memory. + */ + private static final class ViewableFileBody extends FileBody { + private static final byte[] CONTENTS_OMITTED = + "".getBytes(StandardCharsets.UTF_8); // $NON-NLS-1$ + + private boolean hideFileData; + + private ViewableFileBody(File file, ContentType contentType, String filename) { + super(file, contentType, filename); + } + + @Override + public void writeTo(OutputStream out) throws IOException { + if (hideFileData) { + out.write(CONTENTS_OMITTED); + } else { + super.writeTo(out); + } + } + } + private static void setConnectionHeaders(org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request, HeaderManager headerManager, HttpVersionPolicy httpVersionPolicy) { if (headerManager == null) { @@ -924,7 +1001,8 @@ private static int getPort(URL url) { return url.getPort() == -1 ? url.getDefaultPort() : url.getPort(); } - private void readResponse(ClassicHttpResponse response, HTTPSampleResult result) throws IOException { + private void readResponse(ClassicHttpResponse response, HTTPSampleResult result, HttpContext context) + throws IOException { Header contentType = response.getFirstHeader(HTTPConstants.HEADER_CONTENT_TYPE); if (contentType != null) { result.setContentType(contentType.getValue()); @@ -934,7 +1012,10 @@ private void readResponse(ClassicHttpResponse response, HTTPSampleResult result) if (entity != null) { byte[] body = readResponse(result, entity.getContent(), entity.getContentLength()); result.setResponseData(body); - result.setBodySize((long) body.length); + // The HTTP/2 transport counts the body while it streams it, so a truncated response + // still reports the number of bytes the server actually sent + Long bodySize = (Long) context.getAttribute(CONTEXT_ATTRIBUTE_RESPONSE_BODY_SIZE); + result.setBodySize(bodySize != null ? bodySize : (long) body.length); } } @@ -1172,98 +1253,254 @@ private static void enableMessageMultiplexing(PoolingAsyncClientConnectionManage } } - @SuppressWarnings("deprecation") // SimpleHttpRequest.copy is required for HttpClient 5.3 compatibility private ClassicHttpResponse executeHttp2(CloseableHttpAsyncClient client, org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request, HttpClientContext context) throws IOException { - SimpleHttpRequest asyncRequest = SimpleHttpRequest.copy(request); - asyncRequest.setConfig(request.getConfig()); - HttpEntity requestEntity = request.getEntity(); - if (requestEntity != null) { - asyncRequest.setBody(EntityUtils.toByteArray(requestEntity), - requestEntity.getContentType() == null ? ContentType.DEFAULT_BINARY - : ContentType.parse(requestEntity.getContentType())); - } - Future responseFuture = client.execute(SimpleRequestProducer.create(asyncRequest), - new LatencyMeasuringResponseConsumer(), context, null); - currentResponseFuture = responseFuture; - if (interruptRequested) { - responseFuture.cancel(true); - } + RequestBody requestBody = createRequestBody(request); try { - return createClassicResponse(responseFuture.get()); - } catch (InterruptedException e) { - responseFuture.cancel(true); - Thread.currentThread().interrupt(); - throw new IOException("Interrupted while executing HTTP/2 request", e); - } catch (ExecutionException e) { - throw new IOException("Could not execute HTTP/2 request", e.getCause()); + Future responseFuture = client.execute(requestBody.producer, + new LatencyMeasuringResponseConsumer( + (SampleResult) context.getAttribute(CONTEXT_ATTRIBUTE_SAMPLER_RESULT), context, + getMaxBytesToStore()), + context, null); + currentResponseFuture = responseFuture; + if (interruptRequested) { + responseFuture.cancel(true); + } + try { + return responseFuture.get(); + } catch (InterruptedException e) { + responseFuture.cancel(true); + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while executing HTTP/2 request", e); + } catch (ExecutionException e) { + throw new IOException("Could not execute HTTP/2 request", e.getCause()); + } finally { + currentResponseFuture = null; + } } finally { - currentResponseFuture = null; + // The exchange, including any redirect HttpClient followed itself, is over by now, so + // the spilled request body is not needed anymore + deleteQuietly(requestBody.temporaryFile); } } - private static ClassicHttpResponse createClassicResponse(SimpleHttpResponse asyncResponse) { - BasicClassicHttpResponse response = new BasicClassicHttpResponse(asyncResponse.getCode(), - asyncResponse.getReasonPhrase()); - response.setVersion(asyncResponse.getVersion()); - for (Header header : asyncResponse.getHeaders()) { - response.addHeader(header); + /** + * Number of response body bytes worth keeping, {@code 0} meaning all of them. This has to be + * determined on the sampler thread, as the recording flag lives in a thread local the HTTP/2 + * I/O reactor threads do not see. Truncation is also given up when an MD5 digest is requested, + * because that is computed over the whole body. + */ + private long getMaxBytesToStore() { + if (testElement.useMD5() || JMeterContextService.getContext().isRecording()) { + return 0; } - byte[] responseBody = asyncResponse.getBodyBytes(); - if (responseBody != null) { - response.setEntity(new ByteArrayEntity(responseBody, asyncResponse.getContentType(), - asyncResponse.getFirstHeader(HttpHeaders.CONTENT_ENCODING) == null ? null - : asyncResponse.getFirstHeader(HttpHeaders.CONTENT_ENCODING).getValue())); + return Math.max(0, HTTPSamplerBase.getMaxBytesToStorePerRequest()); + } + + /** A request body producer together with the temporary file it streams from, if any. */ + private static final class RequestBody { + private final BasicRequestProducer producer; + private final Path temporaryFile; + + private RequestBody(BasicRequestProducer producer, Path temporaryFile) { + this.producer = producer; + this.temporaryFile = temporaryFile; } - return decompressResponse(response); } /** - * Captures HTTP/2 latency when the final response headers arrive. The response body is buffered - * asynchronously, so its later copy through {@link HTTPSamplerBase#readResponse} must not replace this value. + * Builds the producer that streams the request body to the server. A file that is sent as the + * body is streamed straight from disk, other bodies are buffered in memory and only spilled to + * a temporary file when they exceed {@link #HTTP_2_BODY_SPILL_THRESHOLD}, so that a large + * upload is never materialized as a byte array. */ - private static final class LatencyMeasuringResponseConsumer implements AsyncResponseConsumer { + private static RequestBody createRequestBody( + org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request) throws IOException { + HttpEntity entity = request.getEntity(); + if (entity == null) { + return new RequestBody(new BasicRequestProducer(request, null), null); + } - private final SimpleResponseConsumer delegate = SimpleResponseConsumer.create(); + // A null content type leaves the Content-Type header to the request, like the HTTP/1.1 transport does + ContentType contentType = entity.getContentType() == null ? null + : ContentType.parse(entity.getContentType()); + if (entity instanceof PathEntity pathEntity) { + return new RequestBody( + new BasicRequestProducer(request, new FileEntityProducer(pathEntity.getFile(), contentType)), null); + } + + SpillOutputStream spillOutput = + new SpillOutputStream(HTTP_2_BODY_SPILL_THRESHOLD, "jmeter-http2-request-", ".tmp"); + try { + entity.writeTo(spillOutput); + spillOutput.close(); + if (spillOutput.isSpilled()) { + Path temporaryFile = spillOutput.getTempFile(); + return new RequestBody(new BasicRequestProducer(request, + new FileEntityProducer(temporaryFile.toFile(), contentType)), temporaryFile); + } + return new RequestBody(new BasicRequestProducer(request, + new BasicAsyncEntityProducer(spillOutput.toByteArray(), contentType)), null); + } catch (IOException e) { + spillOutput.releaseResources(); + throw e; + } + } + + /** + * Streams the HTTP/2 response body and captures the latency when the final response headers + * arrive. + *

    + * The body has to be buffered until the exchange is complete, because the asynchronous + * transport hands it to the sampler thread as a whole. When + * {@code httpsampler.max_bytes_to_store_per_request} limits what is kept, the body is truncated + * right here, so the bytes beyond the limit are counted and dropped instead of being buffered + * only for {@link HTTPSamplerBase#readResponse} to discard them. That gives the same peak heap + * usage as the HTTP/1.1 transport, which reads and truncates the body as it arrives. + */ + private static final class LatencyMeasuringResponseConsumer extends AbstractBinResponseConsumer { + /** Buffer size used when the size of the body is unknown. */ + private static final int DEFAULT_BUFFER_SIZE = 8 * 1024; + /** + * Upper bound for the buffer that is allocated upfront from the announced content length, + * so that a bogus or hostile {@code Content-Length} cannot exhaust the heap before a single + * byte of the body has been received. A larger body grows the buffer as it arrives. + */ + private static final int MAX_INITIAL_BUFFER_SIZE = 16 * 1024 * 1024; + + private final SampleResult sampleResult; + private final HttpContext context; + /** Bytes to keep of the body, {@code 0} keeps all of them. */ + private final long maxBytesToStore; + private BasicClassicHttpResponse response; + private ByteArrayOutputStream body; + private ContentType contentType; + private long storeLimit; + private long totalBodyBytes; + + private LatencyMeasuringResponseConsumer(SampleResult sampleResult, HttpContext context, + long maxBytesToStore) { + this.sampleResult = sampleResult; + this.context = context; + this.maxBytesToStore = maxBytesToStore; + } @Override - public void consumeResponse(HttpResponse response, EntityDetails entityDetails, HttpContext context, - FutureCallback resultCallback) throws HttpException, IOException { - SampleResult sampleResult = (SampleResult) context.getAttribute(CONTEXT_ATTRIBUTE_SAMPLER_RESULT); + protected void start(HttpResponse response, ContentType contentType) throws HttpException, IOException { sampleResult.latencyEnd(); context.setAttribute(CONTEXT_ATTRIBUTE_RESPONSE_LATENCY, sampleResult.getLatency()); - delegate.consumeResponse(response, entityDetails, context, resultCallback); + this.response = new BasicClassicHttpResponse(response.getCode(), response.getReasonPhrase()); + this.response.setVersion(response.getVersion()); + for (Header header : response.getHeaders()) { + this.response.addHeader(header); + } + this.contentType = contentType; + // An encoded body has to be kept in full, as it can only be decoded from its first byte on + storeLimit = response.containsHeader(HttpHeaders.CONTENT_ENCODING) ? 0 : maxBytesToStore; + // The body is buffered whether or not the server sent a Content-Type header, as a + // response without one still has to be reported with its body + body = new ByteArrayOutputStream(initialBufferSize(response)); + } + + /** + * Size of the buffer the body is collected in, derived from the announced content length + * and the number of bytes that are kept of it, to avoid repeatedly growing and copying the + * buffer while a large body is received. + */ + private int initialBufferSize(HttpResponse response) { + long expected = -1; + String contentLength = getHeaderValue(response, HttpHeaders.CONTENT_LENGTH); + if (contentLength != null) { + try { + expected = Long.parseLong(contentLength.trim()); + } catch (NumberFormatException e) { + log.debug("Ignoring malformed Content-Length header {}", contentLength); + } + } + if (storeLimit > 0) { + expected = expected < 0 ? storeLimit : Math.min(expected, storeLimit); + } + if (expected <= DEFAULT_BUFFER_SIZE) { + return DEFAULT_BUFFER_SIZE; + } + return (int) Math.min(expected, MAX_INITIAL_BUFFER_SIZE); } + /** + * Amount of data the consumer accepts before the flow control window is replenished. This + * is the HTTP/2 window the connection was configured with, as the body is streamed straight + * into the buffer and a smaller increment would only throttle large downloads. + */ @Override - public void informationResponse(HttpResponse response, HttpContext context) throws HttpException, IOException { - delegate.informationResponse(response, context); + protected int capacityIncrement() { + return HTTP_2_INITIAL_WINDOW_SIZE; } @Override - public void updateCapacity(CapacityChannel capacityChannel) throws IOException { - delegate.updateCapacity(capacityChannel); + protected void data(ByteBuffer src, boolean endOfStream) throws IOException { + int available = src.remaining(); + totalBodyBytes += available; + int toStore = available; + if (storeLimit > 0) { + long remainingCapacity = storeLimit - (totalBodyBytes - available); + toStore = (int) Math.max(0, Math.min(available, remainingCapacity)); + } + if (body != null && toStore > 0) { + if (src.hasArray()) { + body.write(src.array(), src.arrayOffset() + src.position(), toStore); + } else { + byte[] bytes = new byte[toStore]; + src.duplicate().get(bytes); + body.write(bytes, 0, toStore); + } + } + src.position(src.limit()); } @Override - public void consume(ByteBuffer src) throws IOException { - delegate.consume(src); + protected ClassicHttpResponse buildResult() { + if (body != null) { + response.setEntity(new ByteArrayEntity(body.toByteArray(), contentType, + getHeaderValue(response, HttpHeaders.CONTENT_ENCODING))); + body = null; + } + // The body may have been truncated, so report what the server actually sent + context.setAttribute(CONTEXT_ATTRIBUTE_RESPONSE_BODY_SIZE, totalBodyBytes); + return decompressResponse(response); } @Override - public void streamEnd(List trailers) throws HttpException, IOException { - delegate.streamEnd(trailers); + public void releaseResources() { + body = null; } + } - @Override - public void failed(Exception cause) { - delegate.failed(cause); + private static class PathEntity extends FileEntity { + private final File file; + + PathEntity(File file, ContentType contentType) { + super(file, contentType); + this.file = file; } - @Override - public void releaseResources() { - delegate.releaseResources(); + File getFile() { + return file; + } + } + + private static String getHeaderValue(HttpResponse response, String name) { + Header header = response.getFirstHeader(name); + return header == null ? null : header.getValue(); + } + + private static void deleteQuietly(Path file) { + if (file != null) { + try { + Files.deleteIfExists(file); + } catch (IOException e) { + log.warn("Could not delete temporary HTTP/2 body file {}", file, e); + } } } @@ -1380,7 +1617,7 @@ private static void closeThreadLocalClients() { @Override public boolean interrupt() { org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request = currentRequest; - Future responseFuture = currentResponseFuture; + Future responseFuture = currentResponseFuture; if (request != null || responseFuture != null) { interruptRequested = true; } diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java index 930bbd99572..bd5ebe4c329 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java @@ -17,7 +17,6 @@ package org.apache.jmeter.protocol.http.sampler; -import java.io.ByteArrayOutputStream; import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; @@ -37,6 +36,7 @@ import java.net.http.HttpResponse; import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; +import java.nio.file.Path; import java.security.KeyManagementException; import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; @@ -1117,11 +1117,13 @@ private HTTPSampleResult sampleHttp2(URL url, String method, boolean areFollowin return updateSampleResultForResourceInCache(res); } } - CapturingHttpURLConnection capturingConn = null; - byte[] requestBodyBytes = new byte[0]; + Http2CapturingHttpURLConnection capturingConn = null; + Path requestBody = null; + byte[] postBodyBytes = null; + long requestBodyLength = -1; Map securityHeaders = Collections.emptyMap(); try { - capturingConn = new CapturingHttpURLConnection(url, method); + capturingConn = new Http2CapturingHttpURLConnection(url, method); securityHeaders = setConnectionHeaders(capturingConn, url, getHeaderManager(), getCacheManager()); String cookies = setConnectionCookie(capturingConn, url, getCookieManager()); setConnectionAuthorization(capturingConn, url, getAuthManager(), securityHeaders); @@ -1130,12 +1132,17 @@ private HTTPSampleResult sampleHttp2(URL url, String method, boolean areFollowin setPostHeaders(capturingConn); String postBody = sendPostData(capturingConn); res.setQueryString(postBody); - requestBodyBytes = capturingConn.getCapturedBytes(); } else if (method.equals(HTTPConstants.PUT)) { setPutHeaders(capturingConn); String putBody = sendPutData(capturingConn); res.setQueryString(putBody); - requestBodyBytes = capturingConn.getCapturedBytes(); + } + capturingConn.finishCapture(); + requestBodyLength = capturingConn.getCapturedBodyLength(); + if (capturingConn.isSpilled()) { + requestBody = capturingConn.getCapturedBody(); + } else { + postBodyBytes = capturingConn.getCapturedByteArray(); } res.setRequestHeaders(getAllHeadersExceptCookie(capturingConn, securityHeaders)); if (StringUtilities.isNotEmpty(cookies)) { @@ -1147,7 +1154,15 @@ private HTTPSampleResult sampleHttp2(URL url, String method, boolean areFollowin HttpRequest.Builder reqBuilder = HttpRequest.newBuilder(uri); if (method.equalsIgnoreCase(HTTPConstants.POST) || method.equalsIgnoreCase(HTTPConstants.PUT) || method.equalsIgnoreCase(HTTPConstants.PATCH)) { - reqBuilder.method(method, HttpRequest.BodyPublishers.ofByteArray(requestBodyBytes)); + HttpRequest.BodyPublisher publisher; + if (requestBody != null) { + publisher = HttpRequest.BodyPublishers.ofFile(requestBody); + } else if (postBodyBytes != null) { + publisher = HttpRequest.BodyPublishers.ofByteArray(postBodyBytes); + } else { + publisher = HttpRequest.BodyPublishers.noBody(); + } + reqBuilder.method(method, publisher); } else if (method.equalsIgnoreCase(HTTPConstants.GET)) { reqBuilder.GET(); } else if (method.equalsIgnoreCase(HTTPConstants.DELETE)) { @@ -1232,7 +1247,7 @@ private HTTPSampleResult sampleHttp2(URL url, String method, boolean areFollowin res.setSentBytes(calculateSentBytes(url, method, HTTPConstants.HTTP_2, capturingConn != null ? capturingConn.getRequestProperties() : null, - securityHeaders, requestBodyBytes)); + securityHeaders, requestBodyLength)); res = resultProcessing(areFollowingRedirect, frameDepth, res); @@ -1244,8 +1259,12 @@ private HTTPSampleResult sampleHttp2(URL url, String method, boolean areFollowin } res.setSentBytes(calculateSentBytes(url, method, HTTPConstants.HTTP_2, capturingConn != null ? capturingConn.getRequestProperties() : null, - securityHeaders, requestBodyBytes)); + securityHeaders, requestBodyLength)); return errorResult(e, res); + } finally { + if (capturingConn != null) { + capturingConn.deleteCapturedBody(); + } } } @@ -1320,6 +1339,17 @@ private static long calculateSentBytes( Map> requestHeaders, Map securityHeaders, byte[] postBodyBytes) { + return calculateSentBytes(u, method, version, requestHeaders, securityHeaders, + postBodyBytes == null ? -1 : postBodyBytes.length); + } + + private static long calculateSentBytes( + URL u, + String method, + String version, + Map> requestHeaders, + Map securityHeaders, + long postBodyLength) { long sentBytes = 0; if (StringUtilities.isBlank(method)) { @@ -1382,8 +1412,8 @@ private static long calculateSentBytes( sentBytes += 2; // Request body - if (postBodyBytes != null && postBodyBytes.length > 0) { - sentBytes += postBodyBytes.length; + if (postBodyLength >= 0) { + sentBytes += postBodyLength; } else if (requestHeaders != null) { String contentLengthStr = getHeaderValue(requestHeaders, HTTPConstants.HEADER_CONTENT_LENGTH); if (StringUtilities.isNotEmpty(contentLengthStr)) { @@ -1911,90 +1941,4 @@ public int hashCode() { } } - private static class CapturingHttpURLConnection extends HttpURLConnection { - private final Map> requestProperties = new LinkedHashMap<>(); - private final ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); - - CapturingHttpURLConnection(URL url, String method) { - super(url); - this.method = method; - } - - @Override - public void setRequestProperty(String key, String value) { - if (key == null) { - return; - } - List list = new ArrayList<>(); - list.add(value); - requestProperties.put(key, list); - } - - @Override - public void addRequestProperty(String key, String value) { - if (key == null) { - return; - } - requestProperties.computeIfAbsent(key, k -> new ArrayList<>()).add(value); - } - - @Override - public String getRequestProperty(String key) { - if (key == null) { - return null; - } - List values = requestProperties.get(key); - if (values == null || values.isEmpty()) { - for (Map.Entry> entry : requestProperties.entrySet()) { - if (key.equalsIgnoreCase(entry.getKey())) { - values = entry.getValue(); - break; - } - } - } - return (values != null && !values.isEmpty()) ? values.get(0) : null; - } - - @Override - public Map> getRequestProperties() { - return Collections.unmodifiableMap(requestProperties); - } - - @Override - public java.io.OutputStream getOutputStream() throws IOException { - return outputStream; - } - - byte[] getCapturedBytes() { - return outputStream.toByteArray(); - } - - @Override - public void connect() throws IOException { - } - - @Override - public void disconnect() { - } - - @Override - public boolean usingProxy() { - return false; - } - - @Override - public String getHeaderField(int n) { - return null; - } - - @Override - public String getHeaderFieldKey(int n) { - return null; - } - - @Override - public String getHeaderField(String name) { - return null; - } - } } diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPSamplerBase.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPSamplerBase.java index ee307623677..c2919025416 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPSamplerBase.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPSamplerBase.java @@ -202,6 +202,18 @@ public abstract class HTTPSamplerBase extends AbstractSampler private static final int MAX_BYTES_TO_STORE_PER_REQUEST = JMeterUtils.getPropDefault("httpsampler.max_bytes_to_store_per_request", 0); // $NON-NLS-1$ // default value: 0 don't truncate + /** + * Maximum number of bytes of a response body that are kept in the sample result, {@code 0} + * meaning that the body is stored in full. Transports that read the body themselves can use + * this to stop buffering early, instead of materializing a body that + * {@link #readResponse(SampleResult, InputStream, long)} would truncate anyway. + * + * @return the configured limit in bytes + */ + static int getMaxBytesToStorePerRequest() { + return MAX_BYTES_TO_STORE_PER_REQUEST; + } + private static final int MAX_BUFFER_SIZE = JMeterUtils.getPropDefault("httpsampler.max_buffer_size", 65 * 1024); // $NON-NLS-1$ diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/Http2CapturingHttpURLConnection.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/Http2CapturingHttpURLConnection.java new file mode 100644 index 00000000000..0de59819f02 --- /dev/null +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/Http2CapturingHttpURLConnection.java @@ -0,0 +1,146 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you 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.apache.jmeter.protocol.http.sampler; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Captures the URLConnection-based post writer output in a temporary file for the JDK HTTP/2 client. + */ +final class Http2CapturingHttpURLConnection extends HttpURLConnection { + private final Map> requestProperties = new LinkedHashMap<>(); + private SpillOutputStream spillOutput; + + Http2CapturingHttpURLConnection(URL url, String method) { + super(url); + this.method = method; + } + + @Override + public void setRequestProperty(String key, String value) { + if (key == null) { + return; + } + List list = new ArrayList<>(); + list.add(value); + requestProperties.put(key, list); + } + + @Override + public void addRequestProperty(String key, String value) { + if (key != null) { + requestProperties.computeIfAbsent(key, k -> new ArrayList<>()).add(value); + } + } + + @Override + public String getRequestProperty(String key) { + if (key == null) { + return null; + } + List values = requestProperties.get(key); + if (values == null || values.isEmpty()) { + for (Map.Entry> entry : requestProperties.entrySet()) { + if (key.equalsIgnoreCase(entry.getKey())) { + values = entry.getValue(); + break; + } + } + } + return values != null && !values.isEmpty() ? values.get(0) : null; + } + + @Override + public Map> getRequestProperties() { + return Collections.unmodifiableMap(requestProperties); + } + + @Override + public OutputStream getOutputStream() throws IOException { + if (spillOutput == null) { + spillOutput = new SpillOutputStream(256 * 1024, "jmeter-http2-request-", ".tmp"); + } + return spillOutput; + } + + /** Flushes and closes the capture buffer, so the captured body can be read back safely. */ + void finishCapture() throws IOException { + if (spillOutput != null) { + spillOutput.close(); + } + } + + boolean isSpilled() { + return spillOutput != null && spillOutput.isSpilled(); + } + + Path getCapturedBody() { + return spillOutput != null ? spillOutput.getTempFile() : null; + } + + byte[] getCapturedByteArray() { + return spillOutput != null ? spillOutput.toByteArray() : null; + } + + long getCapturedBodyLength() { + return spillOutput == null ? -1 : spillOutput.getLength(); + } + + void deleteCapturedBody() { + if (spillOutput != null) { + spillOutput.releaseResources(); + } + } + + @Override + public void connect() { + } + + @Override + public void disconnect() { + } + + @Override + public boolean usingProxy() { + return false; + } + + @Override + public String getHeaderField(int n) { + return null; + } + + @Override + public String getHeaderFieldKey(int n) { + return null; + } + + @Override + public String getHeaderField(String name) { + return null; + } +} diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/SpillOutputStream.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/SpillOutputStream.java new file mode 100644 index 00000000000..ce9220319ab --- /dev/null +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/SpillOutputStream.java @@ -0,0 +1,148 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you 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.apache.jmeter.protocol.http.sampler; + +import java.io.BufferedOutputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * An output stream that buffers data in memory up to a threshold, and spills to a temporary file + * on disk if the threshold is exceeded. Small bodies therefore never touch the disk, while large + * ones are streamed off heap instead of being materialized as a byte array. + */ +final class SpillOutputStream extends OutputStream { + private static final Logger log = LoggerFactory.getLogger(SpillOutputStream.class); + + /** Size of the buffer used for the writes that go to the temporary file. */ + private static final int FILE_BUFFER_SIZE = 32 * 1024; + + private final int threshold; + private final String tempFilePrefix; + private final String tempFileSuffix; + + private ByteArrayOutputStream memoryBuffer; + private Path tempFile; + private OutputStream fileOutputStream; + private long totalBytesWritten; + private boolean closed; + + SpillOutputStream(int threshold, String tempFilePrefix, String tempFileSuffix) { + this.threshold = threshold; + this.tempFilePrefix = tempFilePrefix; + this.tempFileSuffix = tempFileSuffix; + this.memoryBuffer = new ByteArrayOutputStream(Math.min(threshold, 8192)); + } + + @Override + public void write(int b) throws IOException { + checkSpill(1); + if (fileOutputStream != null) { + fileOutputStream.write(b); + } else { + memoryBuffer.write(b); + } + totalBytesWritten++; + } + + @Override + public void write(byte[] b, int off, int len) throws IOException { + if (len <= 0) { + return; + } + checkSpill(len); + if (fileOutputStream != null) { + fileOutputStream.write(b, off, len); + } else { + memoryBuffer.write(b, off, len); + } + totalBytesWritten += len; + } + + private void checkSpill(int len) throws IOException { + if (fileOutputStream == null && (totalBytesWritten + len) > threshold) { + tempFile = Files.createTempFile(tempFilePrefix, tempFileSuffix); + fileOutputStream = new BufferedOutputStream(Files.newOutputStream(tempFile), FILE_BUFFER_SIZE); + memoryBuffer.writeTo(fileOutputStream); + memoryBuffer = null; + } + } + + boolean isSpilled() { + return tempFile != null; + } + + /** + * The buffered bytes, only meaningful while the stream has not spilled to disk. + * + * @see #isSpilled() + */ + byte[] toByteArray() { + return memoryBuffer != null ? memoryBuffer.toByteArray() : new byte[0]; + } + + Path getTempFile() { + return tempFile; + } + + /** Number of bytes written so far, regardless of whether they are held in memory or on disk. */ + long getLength() { + return totalBytesWritten; + } + + @Override + public void flush() throws IOException { + if (fileOutputStream != null) { + fileOutputStream.flush(); + } + } + + @Override + public void close() throws IOException { + if (closed) { + return; + } + closed = true; + if (fileOutputStream != null) { + fileOutputStream.close(); + } + } + + /** Closes the stream and removes the temporary file, if the body was spilled to disk. */ + void releaseResources() { + try { + close(); + } catch (IOException e) { + log.debug("Could not close the buffer of a spilled body", e); + } + if (tempFile != null) { + try { + Files.deleteIfExists(tempFile); + } catch (IOException e) { + log.warn("Could not delete temporary body file {}", tempFile, e); + } + tempFile = null; + } + } +} diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java index c07823687a6..1d514203706 100644 --- a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java +++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java @@ -21,7 +21,9 @@ import static com.github.tomakehurst.wiremock.client.WireMock.get; import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor; import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor; import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +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; @@ -39,6 +41,8 @@ import java.net.Socket; import java.net.URL; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.ArrayList; import java.util.List; import java.util.concurrent.ExecutorService; @@ -67,6 +71,7 @@ import org.apache.jmeter.protocol.http.control.AuthManager; import org.apache.jmeter.protocol.http.control.CacheManager; import org.apache.jmeter.protocol.http.util.HTTPConstants; +import org.apache.jmeter.protocol.http.util.HTTPFileArg; import org.apache.jmeter.samplers.SampleResult; import org.apache.jmeter.util.JMeterUtils; import org.apache.jorphan.util.JOrphanUtils; @@ -176,7 +181,8 @@ void appliesHttp2ProtocolSettings() { assertEquals(8192, config.getHeaderTableSize(), "HPACK dynamic table size"); assertTrue(config.isCompressionEnabled(), "HPACK header compression"); assertEquals(250, config.getMaxConcurrentStreams()); - assertEquals(65535, config.getInitialWindowSize()); + assertEquals(16 * 1024 * 1024, config.getInitialWindowSize(), + "the 64 kB default of HTTP/2 throttles a download to that window per round trip"); assertEquals(65536, config.getMaxFrameSize()); assertFalse(config.isPushEnabled(), "server push is dropped by JMeter, so it must not be announced"); } @@ -218,6 +224,123 @@ void usesHttp2WhenSelected() throws Exception { } } + @Test + void uploadsMultipartFileOverHttp2WithoutBufferingItInMemory() throws Exception { + WireMockServer server = new WireMockServer(WireMockConfiguration.wireMockConfig() + .dynamicHttpsPort() + .http2TlsDisabled(false)); + Path upload = Files.createTempFile("jmeter-http2-upload-", ".bin"); + try { + Files.write(upload, new byte[1_000_000]); + server.start(); + server.stubFor(post(urlEqualTo("/upload")).willReturn(aResponse().withStatus(200))); + HTTPSamplerBase sampler = newSampler(); + sampler.setHttpVersion("HTTP/2"); + sampler.setMethod(HTTPConstants.POST); + sampler.setDoMultipart(true); + sampler.setHTTPFiles(new HTTPFileArg[] { + new HTTPFileArg(upload.toString(), "upload", "application/octet-stream") }); + + HTTPSampleResult result = sampler.sample( + new URL("https://localhost:" + server.httpsPort() + "/upload"), HTTPConstants.POST, false, 1); + + assertEquals("200", result.getResponseCode()); + assertEquals("HTTP/2", result.getResponseHeaders().substring(0, "HTTP/2".length())); + assertTrue(result.getSentBytes() > 1_000_000, + () -> "the whole file should have been sent, but only " + result.getSentBytes() + " bytes were"); + // The request view must not hold the file contents, otherwise a large upload is in heap twice + assertTrue(result.getQueryString().contains(""), + () -> "file contents should be omitted from the request view: " + result.getQueryString()); + assertTrue(result.getQueryString().length() < 10_000, + () -> "request view should not contain the file, but is " + + result.getQueryString().length() + " characters long"); + server.verify(postRequestedFor(urlEqualTo("/upload"))); + } finally { + Files.deleteIfExists(upload); + server.stop(); + } + } + + @Test + void resendsSpilledHttp2RequestBodyOnRedirect() throws Exception { + WireMockServer server = new WireMockServer(WireMockConfiguration.wireMockConfig() + .dynamicHttpsPort() + .http2TlsDisabled(false)); + Path upload = Files.createTempFile("jmeter-http2-upload-", ".bin"); + try { + Files.write(upload, new byte[1_000_000]); + server.start(); + server.stubFor(post(urlEqualTo("/redirect")).willReturn( + aResponse().withStatus(307).withHeader(HTTPConstants.HEADER_LOCATION, "/upload"))); + server.stubFor(post(urlEqualTo("/upload")).willReturn(aResponse().withStatus(200))); + HTTPSamplerBase sampler = newSampler(); + sampler.setHttpVersion("HTTP/2"); + sampler.setMethod(HTTPConstants.POST); + sampler.setAutoRedirects(true); + sampler.setDoMultipart(true); + sampler.setHTTPFiles(new HTTPFileArg[] { + new HTTPFileArg(upload.toString(), "upload", "application/octet-stream") }); + + HTTPSampleResult result = sampler.sample( + new URL("https://localhost:" + server.httpsPort() + "/redirect"), HTTPConstants.POST, false, 1); + + assertEquals("200", result.getResponseCode(), result.getResponseMessage()); + server.verify(postRequestedFor(urlEqualTo("/upload"))); + } finally { + Files.deleteIfExists(upload); + server.stop(); + } + } + + @Test + void readsHttp2ResponseWithoutContentTypeHeader() throws Exception { + WireMockServer server = new WireMockServer(WireMockConfiguration.wireMockConfig() + .dynamicHttpsPort() + .http2TlsDisabled(false)); + try { + server.start(); + server.stubFor(get(urlEqualTo("/no-content-type")) + .willReturn(aResponse().withStatus(200).withBody("no content type here"))); + HTTPSamplerBase sampler = newSampler(); + sampler.setHttpVersion("HTTP/2"); + + HTTPSampleResult result = sampler.sample( + new URL("https://localhost:" + server.httpsPort() + "/no-content-type"), + HTTPConstants.GET, false, 1); + + assertEquals("200", result.getResponseCode()); + assertEquals("no content type here", result.getResponseDataAsString()); + } finally { + server.stop(); + } + } + + @Test + void readsHttp2ResponseThatIsLargerThanTheInMemoryBuffer() throws Exception { + WireMockServer server = new WireMockServer(WireMockConfiguration.wireMockConfig() + .dynamicHttpsPort() + .http2TlsDisabled(false)); + try { + server.start(); + byte[] body = new byte[2_000_000]; + for (int i = 0; i < body.length; i++) { + body[i] = (byte) i; + } + server.stubFor(get(urlEqualTo("/large")).willReturn(aResponse().withStatus(200).withBody(body))); + HTTPSamplerBase sampler = newSampler(); + sampler.setHttpVersion("HTTP/2"); + + HTTPSampleResult result = sampler.sample( + new URL("https://localhost:" + server.httpsPort() + "/large"), HTTPConstants.GET, false, 1); + + assertEquals("200", result.getResponseCode()); + assertArrayEquals(body, result.getResponseData()); + assertEquals(body.length, result.getBodySizeAsLong()); + } finally { + server.stop(); + } + } + @Test void interruptsPendingHttp2Request() throws Exception { WireMockServer server = new WireMockServer(WireMockConfiguration.wireMockConfig() diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java index 2091bac7854..d10685db88e 100644 --- a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java +++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java @@ -22,6 +22,7 @@ import static com.github.tomakehurst.wiremock.client.WireMock.get; import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor; import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor; import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -36,6 +37,8 @@ import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.nio.file.Files; +import java.nio.file.Path; import java.security.cert.X509Certificate; import java.util.ArrayList; import java.util.HashMap; @@ -56,6 +59,7 @@ import org.apache.jmeter.protocol.http.control.Header; import org.apache.jmeter.protocol.http.control.HeaderManager; import org.apache.jmeter.protocol.http.util.HTTPConstants; +import org.apache.jmeter.protocol.http.util.HTTPFileArg; import org.apache.jmeter.samplers.SampleResult; import org.apache.jmeter.util.JsseSSLManager; import org.apache.jmeter.util.SSLManager; @@ -95,13 +99,44 @@ void usesHttp2WhenSelected() throws Exception { HTTPSampleResult result = sampler.sample( new URL(server.url("/http2")), HTTPConstants.GET, false, 1); - assertEquals("200", result.getResponseCode()); + assertEquals("200", result.getResponseCode(), result.getResponseMessage()); assertEquals("HTTP/2", result.getResponseHeaders().substring(0, "HTTP/2".length())); } finally { server.stop(); } } + @Test + void uploadsMultipartFileOverHttp2() throws Exception { + WireMockServer server = createServer(); + Path upload = Files.createTempFile("jmeter-http2-upload-", ".bin"); + try { + Files.write(upload, new byte[1_000_000]); + server.start(); + server.stubFor(post(urlEqualTo("/upload")).willReturn(aResponse().withStatus(200))); + HTTPSamplerBase sampler = newSampler(); + sampler.setHttpVersion("HTTP/2"); + sampler.setMethod(HTTPConstants.POST); + sampler.setDoMultipart(true); + sampler.setHTTPFiles(new HTTPFileArg[] { + new HTTPFileArg(upload.toString(), "upload", "application/octet-stream") }); + + HTTPSampleResult result = sampler.sample( + new URL(server.url("/upload")), HTTPConstants.POST, false, 1); + + assertEquals("200", result.getResponseCode(), result.getResponseMessage()); + assertTrue(result.getSentBytes() > 1_000_000, + () -> "the whole file should have been sent, but only " + result.getSentBytes() + " bytes were"); + // The request view must not hold the file contents, otherwise a large upload is in heap twice + assertTrue(result.getQueryString().contains(""), + () -> "file contents should be omitted from the request view: " + result.getQueryString()); + server.verify(postRequestedFor(urlEqualTo("/upload"))); + } finally { + Files.deleteIfExists(upload); + server.stop(); + } + } + @Test void interruptsPendingHttp2Request() throws Exception { WireMockServer server = createServer(); diff --git a/xdocs/usermanual/properties_reference.xml b/xdocs/usermanual/properties_reference.xml index 3b8870d9e7a..bf5aae7973c 100644 --- a/xdocs/usermanual/properties_reference.xml +++ b/xdocs/usermanual/properties_reference.xml @@ -479,8 +479,12 @@ JMETER-SERVER Defaults to: 250 - HTTP/2 flow control window in bytes announced per stream.
    - Defaults to: 65535 + HTTP/2 flow control window in bytes announced per stream. It caps how many bytes the server may + send before it has to wait for a window update, so the throughput of a single response cannot + exceed this window divided by the round trip time. The HTTP/2 default of 65535 + throttles downloads over links with any noticeable latency, therefore JMeter announces the same + window as the Java sampler implementation.
    + Defaults to: 16777216
    Largest HTTP/2 frame payload in bytes the client is willing to receive.
    From 96118c660c30af838cf37517154680c101fcdf88 Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Mon, 17 Aug 2026 13:41:26 +0200 Subject: [PATCH 41/80] Enhance HTTP/2 redirect handling: update effective URL after redirects and ensure location header presence --- .../protocol/http/sampler/HTTPHC5Impl.java | 33 ++++++++- .../http/sampler/TestHTTPHC5Features.java | 68 +++++++++++++++++++ 2 files changed, 99 insertions(+), 2 deletions(-) diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java index c9131dd5a42..7610e5b5066 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java @@ -26,6 +26,7 @@ import java.net.InetAddress; import java.net.MalformedURLException; import java.net.URI; +import java.net.URISyntaxException; import java.net.URL; import java.net.URLDecoder; import java.net.UnknownHostException; @@ -53,6 +54,7 @@ import org.apache.hc.client5.http.DnsResolver; import org.apache.hc.client5.http.HttpRoute; +import org.apache.hc.client5.http.RouteInfo; import org.apache.hc.client5.http.SystemDefaultDnsResolver; import org.apache.hc.client5.http.async.methods.AbstractBinResponseConsumer; import org.apache.hc.client5.http.auth.AuthSchemeFactory; @@ -94,10 +96,12 @@ import org.apache.hc.client5.http.nio.AsyncClientConnectionManager; import org.apache.hc.client5.http.nio.AsyncConnectionEndpoint; import org.apache.hc.client5.http.protocol.HttpClientContext; +import org.apache.hc.client5.http.protocol.RedirectLocations; import org.apache.hc.client5.http.ssl.ClientTlsStrategyBuilder; import org.apache.hc.client5.http.ssl.HostnameVerificationPolicy; import org.apache.hc.client5.http.ssl.NoopHostnameVerifier; import org.apache.hc.client5.http.ssl.TlsSocketStrategy; +import org.apache.hc.client5.http.utils.URIUtils; import org.apache.hc.core5.concurrent.FutureCallback; import org.apache.hc.core5.http.ClassicHttpResponse; import org.apache.hc.core5.http.ContentType; @@ -512,6 +516,7 @@ protected HTTPSampleResult sample(URL url, String method, boolean areFollowingRe currentRequest = null; updateResult(response, request, result); + updateUrlAfterAutoRedirects(url, context, result); if (cacheManager != null) { cacheManager.saveDetails(response, result); } @@ -1031,9 +1036,33 @@ private void updateResult(ClassicHttpResponse response, result.setHeadersSize(result.getResponseHeaders().length()); if (result.isRedirect()) { Header location = response.getFirstHeader(HTTPConstants.HEADER_LOCATION); - if (location != null) { - result.setRedirectLocation(location.getValue()); + if (location == null) { // HTTP protocol violation, but avoids NPE + throw new IllegalArgumentException( + "Missing location header in redirect for " + request.getMethod() + " " + request.getRequestUri()); } + result.setRedirectLocation(location.getValue()); + } + } + + /** + * When HttpClient followed the redirects on its own, the sampled URL is the one of the last + * request, not the one the sampler started with. The cookie and the cache manager as well as the + * listeners need the effective URL. + */ + private void updateUrlAfterAutoRedirects(URL url, HttpClientContext context, HTTPSampleResult result) { + if (!getAutoRedirects()) { + return; + } + RedirectLocations redirectLocations = context.getRedirectLocations(); + if (redirectLocations == null || redirectLocations.size() == 0) { + return; + } + RouteInfo route = context.getHttpRoute(); + HttpHost target = route != null ? route.getTargetHost() : null; + try { + result.setURL(URIUtils.resolve(url.toURI(), target, redirectLocations.getAll()).toURL()); + } catch (URISyntaxException | MalformedURLException e) { + log.warn("Could not resolve the effective URL after redirects for {}", url, e); } } diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java index 1d514203706..718dbbcbe5f 100644 --- a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java +++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java @@ -70,6 +70,7 @@ import org.apache.hc.core5.util.Timeout; import org.apache.jmeter.protocol.http.control.AuthManager; import org.apache.jmeter.protocol.http.control.CacheManager; +import org.apache.jmeter.protocol.http.control.CookieManager; import org.apache.jmeter.protocol.http.util.HTTPConstants; import org.apache.jmeter.protocol.http.util.HTTPFileArg; import org.apache.jmeter.samplers.SampleResult; @@ -557,6 +558,73 @@ void sendsConditionalRequestForCachedResource() throws Exception { } } + @Test + void updatesSampleUrlAfterAutomaticRedirect() throws Exception { + WireMockServer server = createServer(); + server.start(); + try { + server.stubFor(get(urlEqualTo("/start")).willReturn( + aResponse().withStatus(302).withHeader(HTTPConstants.HEADER_LOCATION, "/target/page"))); + server.stubFor(get(urlEqualTo("/target/page")).willReturn(aResponse().withStatus(200))); + HTTPSamplerBase sampler = newSampler(); + sampler.setAutoRedirects(true); + + HTTPSampleResult result = sampler.sample( + new URL(server.url("/start")), HTTPConstants.GET, false, 1); + + assertEquals("200", result.getResponseCode()); + assertEquals(server.url("/target/page"), result.getUrlAsString()); + } finally { + server.stop(); + } + } + + @Test + void storesCookiesOfTheRedirectTargetAgainstTheRedirectUrl() throws Exception { + WireMockServer server = createServer(); + server.start(); + try { + server.stubFor(get(urlEqualTo("/login")).willReturn( + aResponse().withStatus(302).withHeader(HTTPConstants.HEADER_LOCATION, "/session/page"))); + server.stubFor(get(urlEqualTo("/session/page")).willReturn( + aResponse().withStatus(200).withHeader(HTTPConstants.HEADER_SET_COOKIE, "sid=42"))); + CookieManager cookieManager = new CookieManager(); + cookieManager.testStarted(); + HTTPSamplerBase sampler = newSampler(); + sampler.setCookieManager(cookieManager); + sampler.setAutoRedirects(true); + + HTTPSampleResult result = sampler.sample( + new URL(server.url("/login")), HTTPConstants.GET, false, 1); + + assertEquals("200", result.getResponseCode()); + // the default path of the cookie is derived from the URL it was received from + assertEquals("sid=42", cookieManager.getCookieHeaderForURL(new URL(server.url("/session/page")))); + assertNull(cookieManager.getCookieHeaderForURL(new URL(server.url("/elsewhere")))); + } finally { + server.stop(); + } + } + + @Test + void failsRedirectWithoutLocationHeader() throws Exception { + WireMockServer server = createServer(); + server.start(); + try { + server.stubFor(get(urlEqualTo("/noLocation")).willReturn(aResponse().withStatus(302))); + HTTPSamplerBase sampler = newSampler(); + + HTTPSampleResult result = sampler.sample( + new URL(server.url("/noLocation")), HTTPConstants.GET, false, 1); + + assertFalse(result.isSuccessful()); + assertTrue(result.getResponseCode().contains(IllegalArgumentException.class.getName()), + "Expected an IllegalArgumentException, but got " + result.getResponseCode()); + } finally { + server.stop(); + } + } + @Test void sendsBasicCredentialsFromAuthorizationManager() throws Exception { WireMockServer server = createServer(); From 167eb69efcd7b727a29dba499676553a64b4b762 Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Mon, 17 Aug 2026 14:15:43 +0200 Subject: [PATCH 42/80] Implement connection time tracking for HTTP/2 requests: add ConnectTimeTracker to measure connection establishment duration and update sample results accordingly --- .../http/sampler/ConnectTimeTracker.java | 429 ++++++++++++++++++ .../protocol/http/sampler/HTTPJavaImpl.java | 337 +------------- .../http/sampler/TestHTTPJavaFeatures.java | 120 ++++- 3 files changed, 531 insertions(+), 355 deletions(-) create mode 100644 src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/ConnectTimeTracker.java diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/ConnectTimeTracker.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/ConnectTimeTracker.java new file mode 100644 index 00000000000..b196aa18933 --- /dev/null +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/ConnectTimeTracker.java @@ -0,0 +1,429 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you 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.apache.jmeter.protocol.http.sampler; + +import java.net.URL; +import java.net.http.HttpClient; +import java.nio.ByteBuffer; +import java.security.KeyManagementException; +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.BiFunction; + +import javax.net.ssl.KeyManager; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLContextSpi; +import javax.net.ssl.SSLEngine; +import javax.net.ssl.SSLEngineResult; +import javax.net.ssl.SSLException; +import javax.net.ssl.SSLParameters; +import javax.net.ssl.SSLServerSocketFactory; +import javax.net.ssl.SSLSession; +import javax.net.ssl.SSLSessionContext; +import javax.net.ssl.SSLSocketFactory; +import javax.net.ssl.TrustManager; + +import org.apache.jmeter.samplers.SampleResult; + +/** + * Measures how long it takes to establish the connections of a JDK {@link HttpClient} and reports the + * result in the {@link SampleResult}s which are waiting for them. + *

    + * The JDK client does not expose a hook for connection establishment, so the wrapped {@code SSLContext} + * is used instead: it creates one {@code SSLEngine} per connection, and that engine reports when the TLS + * handshake of its connection has been finished. Only the samples which are waiting for a connection to + * the origin of that very connection are attached to it, so a sample of another origin is never affected, + * and a sample which is served by an already established connection keeps a connect time of {@code 0}. + * Plain text connections carry no {@code SSLEngine}, so their connect time cannot be measured at all. + *

    + * When multiplexing is enabled, a client is shared by all JMeter threads, so the measurement happens on + * the client threads, but the value is only written to the {@link SampleResult} by the thread which owns + * the sample. + */ +final class ConnectTimeTracker { + + /** Samples which are currently in flight, together with the connection they are waiting for. */ + private final Map activeSamples = new ConcurrentHashMap<>(); + + /** + * Registers a sample which is about to be sent. + * + * @param result the sample result, it must have been started + * @param origin the origin the request is sent to, see {@link #origin(URL)} + */ + void sampleStarted(SampleResult result, String origin) { + activeSamples.put(result, new PendingSample(result, origin)); + } + + /** + * Unregisters the sample and stores the connect time of the connection it had to wait for, if any. + * This is called by the thread which owns the sample, so the {@link SampleResult} is never written + * by a client thread. + */ + void sampleFinished(SampleResult result) { + PendingSample pending = activeSamples.remove(result); + if (pending != null) { + pending.storeConnectTime(); + } + } + + /** + * Announces that a new connection to {@code origin} is being opened, and attaches the samples which + * are currently waiting for a connection to that origin to it. + * + * @return the connection, which has to be told when it has been established + */ + Connection connectionOpened(String origin) { + List waiting = new ArrayList<>(); + for (PendingSample pending : activeSamples.values()) { + if (pending.waitsFor(origin) && pending.attach()) { + waiting.add(pending); + } + } + return new Connection(waiting); + } + + /** Returns the origin a sample of the given URL connects to. */ + static String origin(URL url) { + int port = url.getPort() != -1 ? url.getPort() : url.getDefaultPort(); + return origin(url.getHost(), port); + } + + /** Returns the origin a connection to the given host and port belongs to. */ + static String origin(String host, int port) { + return (host == null ? "" : host.toLowerCase(Locale.ROOT)) + ":" + port; // $NON-NLS-1$ $NON-NLS-2$ + } + + /** + * A single connection of an HTTP/2 {@link HttpClient}, together with the samples which are waiting for it + * to be established. + */ + static final class Connection { + /** Set to {@code null} once the connection has been established, which also releases the samples. */ + private final AtomicReference> waitingSamples; + + Connection(List waitingSamples) { + this.waitingSamples = new AtomicReference<>(waitingSamples); + } + + /** Invoked when the connection is ready to carry requests, that is when its handshake is done. */ + void established() { + List samples = waitingSamples.getAndSet(null); + if (samples != null) { + for (PendingSample sample : samples) { + sample.connectEnd(); + } + } + } + } + + /** A sample which is in flight, and the connect time measured for it, if it had to open a connection. */ + private static final class PendingSample { + private static final long NOT_MEASURED = -1; + + private final SampleResult result; + private final String origin; + private final AtomicBoolean attached = new AtomicBoolean(); + private volatile long connectTime = NOT_MEASURED; + + PendingSample(SampleResult result, String origin) { + this.result = result; + this.origin = origin; + } + + boolean waitsFor(String otherOrigin) { + return origin.equals(otherOrigin); + } + + /** Attaches the sample to a connection, a sample can only wait for a single connection. */ + boolean attach() { + return attached.compareAndSet(false, true); + } + + /** Measures the connect time, called on a client thread, so the sample itself is not modified. */ + void connectEnd() { + connectTime = Math.max(0, + result.currentTimeInMillis() - result.getStartTime() - result.getIdleTime()); + } + + /** Stores the measured connect time, called on the thread which owns the sample. */ + void storeConnectTime() { + long measured = connectTime; + if (measured != NOT_MEASURED) { + result.setConnectTime(measured); + } + } + } + + /** + * {@link SSLContext} which creates {@link SSLEngine}s that report the end of the TLS handshake. + */ + static final class MeasuringSSLContext extends SSLContext { + MeasuringSSLContext(SSLContext delegate, ConnectTimeTracker tracker) { + super(new MeasuringSSLContextSpi(delegate, tracker), delegate.getProvider(), + delegate.getProtocol()); + } + } + + private static final class MeasuringSSLContextSpi extends SSLContextSpi { + private final SSLContext delegate; + private final ConnectTimeTracker tracker; + + MeasuringSSLContextSpi(SSLContext delegate, ConnectTimeTracker tracker) { + this.delegate = delegate; + this.tracker = tracker; + } + + @Override + protected void engineInit(KeyManager[] km, TrustManager[] tm, SecureRandom sr) throws KeyManagementException { + delegate.init(km, tm, sr); + } + + @Override + protected SSLSocketFactory engineGetSocketFactory() { + return delegate.getSocketFactory(); + } + + @Override + protected SSLServerSocketFactory engineGetServerSocketFactory() { + return delegate.getServerSocketFactory(); + } + + @Override + protected SSLEngine engineCreateSSLEngine() { + // Without a peer the connection cannot be attributed to the samples of an origin, so it is not measured + return delegate.createSSLEngine(); + } + + @Override + protected SSLEngine engineCreateSSLEngine(String host, int port) { + return new MeasuringSSLEngine(delegate.createSSLEngine(host, port), + tracker.connectionOpened(ConnectTimeTracker.origin(host, port))); + } + + @Override + protected SSLSessionContext engineGetServerSessionContext() { + return delegate.getServerSessionContext(); + } + + @Override + protected SSLSessionContext engineGetClientSessionContext() { + return delegate.getClientSessionContext(); + } + + @Override + protected SSLParameters engineGetDefaultSSLParameters() { + return delegate.getDefaultSSLParameters(); + } + + @Override + protected SSLParameters engineGetSupportedSSLParameters() { + return delegate.getSupportedSSLParameters(); + } + } + + /** + * {@link SSLEngine} which delegates all calls and reports the {@link Connection} it belongs to as + * established as soon as its TLS handshake has been finished. + */ + static final class MeasuringSSLEngine extends SSLEngine { + private final SSLEngine delegate; + private final Connection connection; + + MeasuringSSLEngine(SSLEngine delegate, Connection connection) { + super(delegate.getPeerHost(), delegate.getPeerPort()); + this.delegate = delegate; + this.connection = connection; + } + + private void checkHandshakeFinished(SSLEngineResult result) { + if (result.getHandshakeStatus() == SSLEngineResult.HandshakeStatus.FINISHED) { + connection.established(); + } + } + + @Override + public SSLEngineResult wrap(ByteBuffer[] srcs, int offset, int length, ByteBuffer dst) throws SSLException { + SSLEngineResult result = delegate.wrap(srcs, offset, length, dst); + checkHandshakeFinished(result); + return result; + } + + @Override + public SSLEngineResult unwrap(ByteBuffer src, ByteBuffer[] dsts, int offset, int length) throws SSLException { + SSLEngineResult result = delegate.unwrap(src, dsts, offset, length); + checkHandshakeFinished(result); + return result; + } + + @Override + public Runnable getDelegatedTask() { + return delegate.getDelegatedTask(); + } + + @Override + public void closeInbound() throws SSLException { + delegate.closeInbound(); + } + + @Override + public boolean isInboundDone() { + return delegate.isInboundDone(); + } + + @Override + public void closeOutbound() { + delegate.closeOutbound(); + } + + @Override + public boolean isOutboundDone() { + return delegate.isOutboundDone(); + } + + @Override + public String[] getSupportedCipherSuites() { + return delegate.getSupportedCipherSuites(); + } + + @Override + public String[] getEnabledCipherSuites() { + return delegate.getEnabledCipherSuites(); + } + + @Override + public void setEnabledCipherSuites(String[] suites) { + delegate.setEnabledCipherSuites(suites); + } + + @Override + public String[] getSupportedProtocols() { + return delegate.getSupportedProtocols(); + } + + @Override + public String[] getEnabledProtocols() { + return delegate.getEnabledProtocols(); + } + + @Override + public void setEnabledProtocols(String[] protocols) { + delegate.setEnabledProtocols(protocols); + } + + @Override + public SSLSession getSession() { + return delegate.getSession(); + } + + @Override + public SSLSession getHandshakeSession() { + return delegate.getHandshakeSession(); + } + + @Override + public void beginHandshake() throws SSLException { + delegate.beginHandshake(); + } + + @Override + public SSLEngineResult.HandshakeStatus getHandshakeStatus() { + return delegate.getHandshakeStatus(); + } + + @Override + public void setUseClientMode(boolean mode) { + delegate.setUseClientMode(mode); + } + + @Override + public boolean getUseClientMode() { + return delegate.getUseClientMode(); + } + + @Override + public void setNeedClientAuth(boolean need) { + delegate.setNeedClientAuth(need); + } + + @Override + public boolean getNeedClientAuth() { + return delegate.getNeedClientAuth(); + } + + @Override + public void setWantClientAuth(boolean want) { + delegate.setWantClientAuth(want); + } + + @Override + public boolean getWantClientAuth() { + return delegate.getWantClientAuth(); + } + + @Override + public void setEnableSessionCreation(boolean flag) { + delegate.setEnableSessionCreation(flag); + } + + @Override + public boolean getEnableSessionCreation() { + return delegate.getEnableSessionCreation(); + } + + @Override + public SSLParameters getSSLParameters() { + return delegate.getSSLParameters(); + } + + @Override + public void setSSLParameters(SSLParameters params) { + delegate.setSSLParameters(params); + } + + @Override + public String getApplicationProtocol() { + return delegate.getApplicationProtocol(); + } + + @Override + public String getHandshakeApplicationProtocol() { + return delegate.getHandshakeApplicationProtocol(); + } + + @Override + public void setHandshakeApplicationProtocolSelector(BiFunction, String> selector) { + if (selector == null) { + delegate.setHandshakeApplicationProtocolSelector(null); + } else { + delegate.setHandshakeApplicationProtocolSelector((engine, protocols) -> selector.apply(this, protocols)); + } + } + + @Override + public BiFunction, String> getHandshakeApplicationProtocolSelector() { + return delegate.getHandshakeApplicationProtocolSelector(); + } + } +} diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java index bd5ebe4c329..aa9096bad6d 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java @@ -34,12 +34,9 @@ import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; -import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; import java.nio.file.Path; -import java.security.KeyManagementException; import java.security.NoSuchAlgorithmException; -import java.security.SecureRandom; import java.time.Duration; import java.util.ArrayList; import java.util.Collections; @@ -51,28 +48,15 @@ import java.util.Properties; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.Executor; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.ThreadFactory; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; -import java.util.function.BiFunction; import java.util.function.Predicate; import java.util.zip.GZIPInputStream; -import javax.net.ssl.KeyManager; import javax.net.ssl.SSLContext; -import javax.net.ssl.SSLContextSpi; -import javax.net.ssl.SSLEngine; -import javax.net.ssl.SSLEngineResult; -import javax.net.ssl.SSLException; -import javax.net.ssl.SSLParameters; -import javax.net.ssl.SSLServerSocketFactory; -import javax.net.ssl.SSLSession; -import javax.net.ssl.SSLSessionContext; -import javax.net.ssl.SSLSocketFactory; -import javax.net.ssl.TrustManager; import org.apache.jmeter.protocol.http.control.AuthManager; import org.apache.jmeter.protocol.http.control.Authorization; @@ -1188,7 +1172,7 @@ private HTTPSampleResult sampleHttp2(URL url, String method, boolean areFollowin HttpRequest httpRequest = reqBuilder.build(); HttpResponse response; ConnectTimeTracker connectTimeTracker = client.connectTimeTracker; - connectTimeTracker.sampleStarted(res); + connectTimeTracker.sampleStarted(res, ConnectTimeTracker.origin(url)); try { CompletableFuture> responseFuture = client.httpClient.sendAsync(httpRequest, HttpResponse.BodyHandlers.ofInputStream()); @@ -1522,7 +1506,7 @@ private static Http2Client createHttpClient(HttpClientKey key, SSLContext sslCon ConnectTimeTracker connectTimeTracker = new ConnectTimeTracker(); HttpClient.Builder builder = HttpClient.newBuilder() .version(HttpClient.Version.HTTP_2) - .executor(new ConnectTimeMeasuringExecutor(connectTimeTracker)) + .executor(http2Executor()) .followRedirects(key.autoRedirects ? HttpClient.Redirect.NORMAL : HttpClient.Redirect.NEVER); if (key.connectTimeout > 0) { @@ -1546,7 +1530,7 @@ protected PasswordAuthentication getPasswordAuthentication() { } if (sslContext != null) { - builder.sslContext(new ConnectTimeMeasuringSSLContext(sslContext, connectTimeTracker)); + builder.sslContext(new ConnectTimeTracker.MeasuringSSLContext(sslContext, connectTimeTracker)); } return new Http2Client(builder.build(), connectTimeTracker, sslContext); @@ -1571,72 +1555,6 @@ boolean usesSslContext(SSLContext other) { } } - /** - * Records the connect time of the JDK {@link HttpClient} in the {@link SampleResult}s which are in flight. - *

    - * The JDK client does not expose a hook for connection establishment, so two indicators are used: - * the client submits its first task to the executor once the TCP connection has been established, and - * the wrapped {@code SSLEngine} reports when the TLS handshake has been finished. The TLS handshake - * always wins, since it is the more precise and the later event. - *

    - * A multiplexed client is shared by several threads, so more than one sample can be in flight. Since the - * connection is established for all of them at once, the connect time is recorded in every sample which - * is waiting for it. - */ - static final class ConnectTimeTracker { - private static final Integer NOTHING_RECORDED = 0; - private static final Integer CONNECT_RECORDED = 1; - private static final Integer HANDSHAKE_RECORDED = 2; - - /** Samples which are currently in flight, together with the connect event recorded for them. */ - private final Map activeSamples = new ConcurrentHashMap<>(); - - void sampleStarted(SampleResult result) { - activeSamples.put(result, NOTHING_RECORDED); - } - - void sampleFinished(SampleResult result) { - activeSamples.remove(result); - } - - /** Invoked when the TCP connection has been established. */ - void connectionEstablished() { - recordConnectEnd(CONNECT_RECORDED); - } - - /** Invoked when the TLS handshake has been finished, it overrides the plain TCP connect time. */ - void handshakeFinished() { - recordConnectEnd(HANDSHAKE_RECORDED); - } - - private void recordConnectEnd(Integer event) { - activeSamples.replaceAll((result, recorded) -> { - if (recorded.intValue() >= event.intValue()) { - return recorded; - } - result.connectEnd(); - return event; - }); - } - } - - /** - * Executor which notifies the {@link ConnectTimeTracker} before delegating to the shared thread pool. - */ - private static final class ConnectTimeMeasuringExecutor implements Executor { - private final ConnectTimeTracker tracker; - - ConnectTimeMeasuringExecutor(ConnectTimeTracker tracker) { - this.tracker = tracker; - } - - @Override - public void execute(Runnable command) { - tracker.connectionEstablished(); - http2Executor().execute(command); - } - } - private static final class Http2ThreadFactory implements ThreadFactory { private final AtomicInteger counter = new AtomicInteger(); @@ -1648,255 +1566,6 @@ public Thread newThread(Runnable r) { } } - /** - * {@link SSLContext} which creates {@link SSLEngine}s that report the end of the TLS handshake. - */ - static final class ConnectTimeMeasuringSSLContext extends SSLContext { - ConnectTimeMeasuringSSLContext(SSLContext delegate, ConnectTimeTracker tracker) { - super(new ConnectTimeMeasuringSSLContextSpi(delegate, tracker), delegate.getProvider(), - delegate.getProtocol()); - } - } - - private static final class ConnectTimeMeasuringSSLContextSpi extends SSLContextSpi { - private final SSLContext delegate; - private final ConnectTimeTracker tracker; - - ConnectTimeMeasuringSSLContextSpi(SSLContext delegate, ConnectTimeTracker tracker) { - this.delegate = delegate; - this.tracker = tracker; - } - - @Override - protected void engineInit(KeyManager[] km, TrustManager[] tm, SecureRandom sr) throws KeyManagementException { - delegate.init(km, tm, sr); - } - - @Override - protected SSLSocketFactory engineGetSocketFactory() { - return delegate.getSocketFactory(); - } - - @Override - protected SSLServerSocketFactory engineGetServerSocketFactory() { - return delegate.getServerSocketFactory(); - } - - @Override - protected SSLEngine engineCreateSSLEngine() { - return new ConnectTimeMeasuringSSLEngine(delegate.createSSLEngine(), tracker); - } - - @Override - protected SSLEngine engineCreateSSLEngine(String host, int port) { - return new ConnectTimeMeasuringSSLEngine(delegate.createSSLEngine(host, port), tracker); - } - - @Override - protected SSLSessionContext engineGetServerSessionContext() { - return delegate.getServerSessionContext(); - } - - @Override - protected SSLSessionContext engineGetClientSessionContext() { - return delegate.getClientSessionContext(); - } - - @Override - protected SSLParameters engineGetDefaultSSLParameters() { - return delegate.getDefaultSSLParameters(); - } - - @Override - protected SSLParameters engineGetSupportedSSLParameters() { - return delegate.getSupportedSSLParameters(); - } - } - - /** - * {@link SSLEngine} which delegates all calls and notifies the {@link ConnectTimeTracker} - * as soon as the TLS handshake has been finished. - */ - static final class ConnectTimeMeasuringSSLEngine extends SSLEngine { - private final SSLEngine delegate; - private final ConnectTimeTracker tracker; - - ConnectTimeMeasuringSSLEngine(SSLEngine delegate, ConnectTimeTracker tracker) { - super(delegate.getPeerHost(), delegate.getPeerPort()); - this.delegate = delegate; - this.tracker = tracker; - } - - private void checkHandshakeFinished(SSLEngineResult result) { - if (result.getHandshakeStatus() == SSLEngineResult.HandshakeStatus.FINISHED) { - tracker.handshakeFinished(); - } - } - - @Override - public SSLEngineResult wrap(ByteBuffer[] srcs, int offset, int length, ByteBuffer dst) throws SSLException { - SSLEngineResult result = delegate.wrap(srcs, offset, length, dst); - checkHandshakeFinished(result); - return result; - } - - @Override - public SSLEngineResult unwrap(ByteBuffer src, ByteBuffer[] dsts, int offset, int length) throws SSLException { - SSLEngineResult result = delegate.unwrap(src, dsts, offset, length); - checkHandshakeFinished(result); - return result; - } - - @Override - public Runnable getDelegatedTask() { - return delegate.getDelegatedTask(); - } - - @Override - public void closeInbound() throws SSLException { - delegate.closeInbound(); - } - - @Override - public boolean isInboundDone() { - return delegate.isInboundDone(); - } - - @Override - public void closeOutbound() { - delegate.closeOutbound(); - } - - @Override - public boolean isOutboundDone() { - return delegate.isOutboundDone(); - } - - @Override - public String[] getSupportedCipherSuites() { - return delegate.getSupportedCipherSuites(); - } - - @Override - public String[] getEnabledCipherSuites() { - return delegate.getEnabledCipherSuites(); - } - - @Override - public void setEnabledCipherSuites(String[] suites) { - delegate.setEnabledCipherSuites(suites); - } - - @Override - public String[] getSupportedProtocols() { - return delegate.getSupportedProtocols(); - } - - @Override - public String[] getEnabledProtocols() { - return delegate.getEnabledProtocols(); - } - - @Override - public void setEnabledProtocols(String[] protocols) { - delegate.setEnabledProtocols(protocols); - } - - @Override - public SSLSession getSession() { - return delegate.getSession(); - } - - @Override - public SSLSession getHandshakeSession() { - return delegate.getHandshakeSession(); - } - - @Override - public void beginHandshake() throws SSLException { - delegate.beginHandshake(); - } - - @Override - public SSLEngineResult.HandshakeStatus getHandshakeStatus() { - return delegate.getHandshakeStatus(); - } - - @Override - public void setUseClientMode(boolean mode) { - delegate.setUseClientMode(mode); - } - - @Override - public boolean getUseClientMode() { - return delegate.getUseClientMode(); - } - - @Override - public void setNeedClientAuth(boolean need) { - delegate.setNeedClientAuth(need); - } - - @Override - public boolean getNeedClientAuth() { - return delegate.getNeedClientAuth(); - } - - @Override - public void setWantClientAuth(boolean want) { - delegate.setWantClientAuth(want); - } - - @Override - public boolean getWantClientAuth() { - return delegate.getWantClientAuth(); - } - - @Override - public void setEnableSessionCreation(boolean flag) { - delegate.setEnableSessionCreation(flag); - } - - @Override - public boolean getEnableSessionCreation() { - return delegate.getEnableSessionCreation(); - } - - @Override - public SSLParameters getSSLParameters() { - return delegate.getSSLParameters(); - } - - @Override - public void setSSLParameters(SSLParameters params) { - delegate.setSSLParameters(params); - } - - @Override - public String getApplicationProtocol() { - return delegate.getApplicationProtocol(); - } - - @Override - public String getHandshakeApplicationProtocol() { - return delegate.getHandshakeApplicationProtocol(); - } - - @Override - public void setHandshakeApplicationProtocolSelector(BiFunction, String> selector) { - if (selector == null) { - delegate.setHandshakeApplicationProtocolSelector(null); - } else { - delegate.setHandshakeApplicationProtocolSelector((engine, protocols) -> selector.apply(this, protocols)); - } - } - - @Override - public BiFunction, String> getHandshakeApplicationProtocolSelector() { - return delegate.getHandshakeApplicationProtocolSelector(); - } - } - private static class HttpClientKey { private final int connectTimeout; private final String proxyHost; diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java index d10685db88e..11f40cc4501 100644 --- a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java +++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java @@ -32,7 +32,6 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import java.net.Socket; -import java.net.URI; import java.net.URL; import java.net.http.HttpClient; import java.net.http.HttpRequest; @@ -289,17 +288,18 @@ void multiplexesConcurrentHttp2RequestsOverOneConnection() throws Exception { } @Test - void recordsConnectTimeForEveryMultiplexedSample() throws Exception { - HTTPJavaImpl.ConnectTimeTracker tracker = new HTTPJavaImpl.ConnectTimeTracker(); + void recordsConnectTimeForEverySampleWaitingForTheNewConnection() throws Exception { + ConnectTimeTracker tracker = new ConnectTimeTracker(); SampleResult first = new SampleResult(); SampleResult second = new SampleResult(); first.sampleStart(); second.sampleStart(); - tracker.sampleStarted(first); - tracker.sampleStarted(second); + tracker.sampleStarted(first, "example.com:443"); + tracker.sampleStarted(second, "example.com:443"); + ConnectTimeTracker.Connection connection = tracker.connectionOpened("example.com:443"); Thread.sleep(5); - tracker.connectionEstablished(); + connection.established(); Thread.sleep(5); first.sampleEnd(); @@ -313,15 +313,79 @@ void recordsConnectTimeForEveryMultiplexedSample() throws Exception { assertTrue(second.getConnectTime() <= second.getTime()); } + @Test + void doesNotRecordConnectTimeOfAConnectionToAnotherOrigin() throws Exception { + ConnectTimeTracker tracker = new ConnectTimeTracker(); + SampleResult result = new SampleResult(); + result.sampleStart(); + tracker.sampleStarted(result, "a.example:443"); + + Thread.sleep(5); + // Another JMeter thread opens a connection to a different host over the shared client + tracker.connectionOpened("b.example:443").established(); + + result.sampleEnd(); + tracker.sampleFinished(result); + + assertEquals(0, result.getConnectTime(), + "the connect time of another origin must not be reported for this sample"); + } + + @Test + void doesNotRecordConnectTimeForSampleServedByAnEstablishedConnection() throws Exception { + ConnectTimeTracker tracker = new ConnectTimeTracker(); + tracker.connectionOpened("example.com:443").established(); + + SampleResult result = new SampleResult(); + result.sampleStart(); + tracker.sampleStarted(result, "example.com:443"); + Thread.sleep(5); + result.sampleEnd(); + tracker.sampleFinished(result); + + assertEquals(0, result.getConnectTime(), + "a sample multiplexed over an established connection did not connect"); + } + + @Test + void recordsConnectTimeOfOneConnectionPerSampleOnly() throws Exception { + ConnectTimeTracker tracker = new ConnectTimeTracker(); + SampleResult result = new SampleResult(); + result.sampleStart(); + tracker.sampleStarted(result, "example.com:443"); + + Thread.sleep(5); + tracker.connectionOpened("example.com:443").established(); + long firstConnection = result.getConnectTime(); + Thread.sleep(300); + // A connection opened afterwards belongs to another sample, it must not overwrite the measurement + tracker.connectionOpened("example.com:443").established(); + + result.sampleEnd(); + tracker.sampleFinished(result); + + assertEquals(0, firstConnection, "the sample result should only be written by its own thread"); + assertTrue(result.getConnectTime() > 0, "connectTime should have been recorded"); + assertTrue(result.getConnectTime() < 150, + "connectTime should be the one of the first connection, but was " + result.getConnectTime()); + } + + @Test + void derivesTheOriginOfASampleFromItsUrl() throws Exception { + assertEquals("example.com:443", ConnectTimeTracker.origin(new URL("https://EXAMPLE.com/a"))); + assertEquals("example.com:80", ConnectTimeTracker.origin(new URL("http://example.com/a"))); + assertEquals("example.com:8443", ConnectTimeTracker.origin(new URL("https://example.com:8443/a"))); + } + @Test void ignoresSamplesWhichAreNoLongerInFlight() { - HTTPJavaImpl.ConnectTimeTracker tracker = new HTTPJavaImpl.ConnectTimeTracker(); + ConnectTimeTracker tracker = new ConnectTimeTracker(); SampleResult result = new SampleResult(); result.sampleStart(); - tracker.sampleStarted(result); + tracker.sampleStarted(result, "example.com:443"); tracker.sampleFinished(result); - tracker.connectionEstablished(); + tracker.connectionOpened("example.com:443").established(); result.sampleEnd(); assertEquals(0, result.getConnectTime()); @@ -519,7 +583,7 @@ void setsConnectTimeForHttp11() throws Exception { } @Test - void setsConnectTimeForHttp2OverPlainConnection() throws Exception { + void doesNotMeasureConnectTimeForPlainTextHttp2() throws Exception { WireMockServer server = createServer(); server.start(); try { @@ -531,10 +595,11 @@ void setsConnectTimeForHttp2OverPlainConnection() throws Exception { new URL(server.url("/http2ConnectTime")), HTTPConstants.GET, false, 1); assertEquals("200", result.getResponseCode()); - assertTrue(result.getConnectTime() > 0, - "connectTime should be greater than 0, but was " + result.getConnectTime()); - assertTrue(result.getConnectTime() <= result.getTime(), - "connectTime should not exceed the elapsed time"); + // The JDK client only reveals connection establishment through the SSLEngine it creates per + // connection, so the connect time of a plain text connection cannot be measured. Guessing it + // would report the connect time of unrelated samples of the shared, multiplexed client. + assertEquals(0, result.getConnectTime(), + "connect time cannot be measured for plain text HTTP/2, so it must not be guessed"); } finally { server.stop(); } @@ -548,22 +613,21 @@ void setsConnectTimeForHttp2OverTls() throws Exception { try { server.stubFor(get(urlEqualTo("/http2TlsConnectTime")).willReturn(aResponse().withStatus(200))); - HTTPJavaImpl.ConnectTimeTracker tracker = new HTTPJavaImpl.ConnectTimeTracker(); + ConnectTimeTracker tracker = new ConnectTimeTracker(); SSLContext sslContext = trustAllContext(); HttpClient client = HttpClient.newBuilder() .version(HttpClient.Version.HTTP_2) - .sslContext(new HTTPJavaImpl.ConnectTimeMeasuringSSLContext(sslContext, tracker)) + .sslContext(new ConnectTimeTracker.MeasuringSSLContext(sslContext, tracker)) .build(); + URL url = new URL("https://localhost:" + server.httpsPort() + "/http2TlsConnectTime"); + HttpRequest request = HttpRequest.newBuilder(url.toURI()).build(); SampleResult result = new SampleResult(); result.sampleStart(); - tracker.sampleStarted(result); + tracker.sampleStarted(result, ConnectTimeTracker.origin(url)); HttpResponse response; try { - response = client.send( - HttpRequest.newBuilder(URI.create( - "https://localhost:" + server.httpsPort() + "/http2TlsConnectTime")).build(), - HttpResponse.BodyHandlers.ofString()); + response = client.send(request, HttpResponse.BodyHandlers.ofString()); } finally { tracker.sampleFinished(result); } @@ -575,6 +639,20 @@ void setsConnectTimeForHttp2OverTls() throws Exception { "connectTime should be greater than 0, but was " + result.getConnectTime()); assertTrue(result.getConnectTime() <= result.getTime(), "connectTime should not exceed the elapsed time"); + + // The next sample reuses the established connection, so it did not have to connect + SampleResult pooled = new SampleResult(); + pooled.sampleStart(); + tracker.sampleStarted(pooled, ConnectTimeTracker.origin(url)); + try { + assertEquals(200, client.send(request, HttpResponse.BodyHandlers.ofString()).statusCode()); + } finally { + tracker.sampleFinished(pooled); + } + pooled.sampleEnd(); + + assertEquals(0, pooled.getConnectTime(), + "a sample served by an established connection must not report a connect time"); } finally { server.stop(); } From e627f094a16c92aa4a02cb1d2d8a511931ab8278 Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Mon, 17 Aug 2026 16:07:30 +0200 Subject: [PATCH 43/80] Report wire size for compressed HTTP responses, including proper body size and response bytes accounting across HTTP implementations. Update tests to validate compressed body size handling. --- .../protocol/http/sampler/HTTPHC5Impl.java | 53 +++++++- .../http/sampler/TestDecompression.java | 114 ++++++++++++++---- 2 files changed, 140 insertions(+), 27 deletions(-) diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java index 7610e5b5066..f92737c599e 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java @@ -119,6 +119,7 @@ import org.apache.hc.core5.http.config.RegistryBuilder; import org.apache.hc.core5.http.io.entity.ByteArrayEntity; import org.apache.hc.core5.http.io.entity.FileEntity; +import org.apache.hc.core5.http.io.entity.HttpEntityWrapper; import org.apache.hc.core5.http.io.entity.StringEntity; import org.apache.hc.core5.http.message.BasicClassicHttpResponse; import org.apache.hc.core5.http.message.BasicHeaderValueParser; @@ -155,6 +156,7 @@ import org.apache.jmeter.util.JMeterUtils; import org.apache.jmeter.util.JsseSSLManager; import org.apache.jmeter.util.SSLManager; +import org.apache.jorphan.io.CountingInputStream; import org.apache.jorphan.util.JOrphanUtils; import org.apache.jorphan.util.StringUtilities; import org.brotli.dec.BrotliInputStream; @@ -176,6 +178,7 @@ public class HTTPHC5Impl extends HTTPHCAbstractImpl { static final String CONTEXT_ATTRIBUTE_SAMPLER_RESULT = "__jmeter.S_R__"; //$NON-NLS-1$ private static final String CONTEXT_ATTRIBUTE_RESPONSE_LATENCY = "__jmeter.S_R_LATENCY__"; //$NON-NLS-1$ private static final String CONTEXT_ATTRIBUTE_RESPONSE_BODY_SIZE = "__jmeter.S_R_BODY_SIZE__"; //$NON-NLS-1$ + private static final String CONTEXT_ATTRIBUTE_RESPONSE_BODY_COUNTER = "__jmeter.S_R_BODY_COUNTER__"; //$NON-NLS-1$ private static final ThreadLocal> HTTP_CLIENTS = new InheritableThreadLocal<>() { @@ -382,7 +385,7 @@ public char[] getPassword() { if (!requestConfig.isContentCompressionEnabled()) { return response; } - return decompressResponse(response); + return decompressResponse(response, context); }; /** @@ -392,7 +395,7 @@ public char[] getPassword() { * and decodes the response itself for both the HTTP/1.1 and the HTTP/2 transport. */ @SuppressWarnings("deprecation") // DecompressingEntity is superseded by the @Internal ContentCodecRegistry - private static ClassicHttpResponse decompressResponse(ClassicHttpResponse response) { + private static ClassicHttpResponse decompressResponse(ClassicHttpResponse response, HttpContext context) { HttpEntity entity = response.getEntity(); if (entity == null || entity.getContentLength() == 0) { return response; @@ -415,9 +418,16 @@ private static ClassicHttpResponse decompressResponse(ClassicHttpResponse respon } HeaderElement[] codecs = BasicHeaderValueParser.INSTANCE.parseElements(contentEncoding, new ParserCursor(0, contentEncoding.length())); + CountingEntity countingEntity = new CountingEntity(entity); + boolean decompressing = false; for (HeaderElement codec : codecs) { InputStreamFactory decoderFactory = CONTENT_DECODERS.lookup(codec.getName().toLowerCase(Locale.ROOT)); if (decoderFactory != null) { + if (!decompressing) { + response.setEntity(countingEntity); + context.setAttribute(CONTEXT_ATTRIBUTE_RESPONSE_BODY_COUNTER, countingEntity); + decompressing = true; + } response.setEntity(new DecompressingEntity(response.getEntity(), decoderFactory)); response.removeHeaders(HttpHeaders.CONTENT_LENGTH); response.removeHeaders(HttpHeaders.CONTENT_ENCODING); @@ -1018,9 +1028,14 @@ private void readResponse(ClassicHttpResponse response, HTTPSampleResult result, byte[] body = readResponse(result, entity.getContent(), entity.getContentLength()); result.setResponseData(body); // The HTTP/2 transport counts the body while it streams it, so a truncated response - // still reports the number of bytes the server actually sent + // still reports the number of bytes the server actually sent. For HTTP/1.1 a compressed + // body is counted before it is decoded, so the reported size is the one that crossed the + // wire, like HTTPHC4Impl and HTTPJavaImpl report it Long bodySize = (Long) context.getAttribute(CONTEXT_ATTRIBUTE_RESPONSE_BODY_SIZE); - result.setBodySize(bodySize != null ? bodySize : (long) body.length); + CountingEntity bodyCounter = + (CountingEntity) context.getAttribute(CONTEXT_ATTRIBUTE_RESPONSE_BODY_COUNTER); + result.setBodySize(bodySize != null ? bodySize + : bodyCounter != null ? bodyCounter.getBytesRead() : (long) body.length); } } @@ -1033,7 +1048,15 @@ private void updateResult(ClassicHttpResponse response, result.setResponseMessage(response.getReasonPhrase()); result.setSuccessful(isSuccessCode(statusCode)); result.setResponseHeaders(getResponseHeaders(response)); - result.setHeadersSize(result.getResponseHeaders().length()); + // Approximated the way HTTPHC4Impl does it, so both implementations report the same size: + // the condensed headers (without \r), a \r per header, a \r for the status line and the + // final \r\n before the body + long headerBytes = + (long) result.getResponseHeaders().length() + + response.getHeaders().length + + 1L + + 2L; + result.setHeadersSize((int) headerBytes); if (result.isRedirect()) { Header location = response.getFirstHeader(HTTPConstants.HEADER_LOCATION); if (location == null) { // HTTP protocol violation, but avoids NPE @@ -1496,7 +1519,7 @@ protected ClassicHttpResponse buildResult() { } // The body may have been truncated, so report what the server actually sent context.setAttribute(CONTEXT_ATTRIBUTE_RESPONSE_BODY_SIZE, totalBodyBytes); - return decompressResponse(response); + return decompressResponse(response, context); } @Override @@ -1505,6 +1528,24 @@ public void releaseResources() { } } + private static final class CountingEntity extends HttpEntityWrapper { + private CountingInputStream inputStream; + + private CountingEntity(HttpEntity entity) { + super(entity); + } + + @Override + public CountingInputStream getContent() throws IOException { + inputStream = new CountingInputStream(super.getContent()); + return inputStream; + } + + private long getBytesRead() { + return inputStream == null ? 0 : inputStream.getBytesRead(); + } + } + private static class PathEntity extends FileEntity { private final File file; diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestDecompression.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestDecompression.java index e733f497778..e43044d77ef 100644 --- a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestDecompression.java +++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestDecompression.java @@ -21,11 +21,14 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; -import java.net.MalformedURLException; +import java.io.ByteArrayOutputStream; +import java.io.IOException; import java.net.URL; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; import java.util.function.Consumer; +import java.util.zip.GZIPOutputStream; import org.apache.jmeter.protocol.http.control.Header; import org.apache.jmeter.protocol.http.control.HeaderManager; @@ -62,14 +65,65 @@ public static List mockServerParams() { return res; } + public static List implementations() { + List res = new ArrayList<>(); + for (String httpImpl : HTTPSamplerFactory.getImplementations()) { + res.add(Arguments.of(httpImpl)); + } + return res; + } + + /** + * A compressed response must be accounted for with the bytes that crossed the wire, not with the + * size of the decoded body, otherwise switching the implementation of a plan changes the reported + * bytes and any bandwidth SLA by the compression ratio. + */ + @ParameterizedTest + @MethodSource("implementations") + public void reportsCompressedSizeForHighlyCompressibleBody(String httpImpl) throws IOException { + WireMockServer server = createServer(c -> c.gzipDisabled(true)); + server.start(); + try { + String expectedResponse = "a".repeat(400 * 1024); + byte[] responseBody = encodedResponse(expectedResponse, ClientGzip.REQUESTED, ServerGzip.SUPPORTED); + Assertions.assertTrue(responseBody.length * 20L < expectedResponse.length(), + () -> "the gzipped body of " + responseBody.length + + " bytes should be far smaller than the decoded " + expectedResponse.length()); + + HTTPSamplerBase http = HTTPSamplerFactory.newInstance(httpImpl); + HeaderManager hm = new HeaderManager(); + hm.add(new Header("Accept-Encoding", "gzip")); + http.setHeaderManager(hm); + server.stubFor(WireMock.get("/gzip") + .withHeader("Accept-Encoding", WireMock.equalTo("gzip")) + .willReturn(WireMock.aResponse() + .withBody(responseBody) + .withHeader("Content-Type", "text/plain;charset=utf-8") + .withHeader("Content-Length", Long.toString(responseBody.length)) + .withHeader("Content-Encoding", "gzip"))); + + HTTPSampleResult res = http.sample(new URL(server.url("/gzip")), "GET", false, 1); + + Assertions.assertAll( + () -> assertEquals(expectedResponse, res.getResponseDataAsString(), "decoded response body"), + () -> assertEquals(responseBody.length, res.getBodySizeAsLong(), "wire body size"), + () -> assertEquals(responseBody.length + res.getHeadersSize(), res.getBytesAsLong(), + "wire response bytes") + ); + } finally { + server.stop(); + } + } + @ParameterizedTest @MethodSource("mockServerParams") - public void mockServer(String httpImpl, ClientGzip clientGzip, ServerGzip serverGzip) throws MalformedURLException { - WireMockServer server = createServer(c -> c.gzipDisabled(serverGzip == ServerGzip.NOT_SUPPORTED)); + public void mockServer(String httpImpl, ClientGzip clientGzip, ServerGzip serverGzip) throws IOException { + WireMockServer server = createServer(c -> c.gzipDisabled(true)); server.start(); try { HTTPSamplerBase http = HTTPSamplerFactory.newInstance(httpImpl); String expectedResponse = "Hello, 丈, \uD83D\uDE03, and नि"; + byte[] responseBody = encodedResponse(expectedResponse, clientGzip, serverGzip); HeaderManager hm = new HeaderManager(); if (clientGzip == ClientGzip.REQUESTED) { hm.add(new Header("Accept-Encoding", "gzip")); @@ -80,19 +134,21 @@ public void mockServer(String httpImpl, ClientGzip clientGzip, ServerGzip server if (clientGzip == ClientGzip.REQUESTED) { mappingBuilder = mappingBuilder.withHeader("Accept-Encoding", WireMock.equalTo("gzip")); } - server.stubFor( - mappingBuilder - .willReturn( - WireMock.aResponse() - .withBody(expectedResponse) - .withHeader("Content-Type", "text/plain;charset=utf-8") - ) - ); + var response = WireMock.aResponse() + .withBody(responseBody) + .withHeader("Content-Type", "text/plain;charset=utf-8") + .withHeader("Content-Length", Long.toString(responseBody.length)); + if (clientGzip == ClientGzip.REQUESTED && serverGzip == ServerGzip.SUPPORTED) { + response.withHeader("Content-Encoding", "gzip"); + } + server.stubFor(mappingBuilder.willReturn(response)); HTTPSampleResult res = http.sample(new URL(server.url("/gzip")), "GET", false, 1); Assertions.assertAll( () -> assertEquals(expectedResponse, res.getResponseDataAsString(), "response body"), + () -> assertEquals(responseBody.length + res.getHeadersSize(), res.getBytesAsLong(), + "wire response bytes"), () -> { if (clientGzip == ClientGzip.NOT_REQUESTED || serverGzip == ServerGzip.NOT_SUPPORTED) { assertFalse( @@ -128,16 +184,17 @@ public static List http2Params() { */ @ParameterizedTest @MethodSource("http2Params") - public void http2(ClientGzip clientGzip, ServerGzip serverGzip) throws MalformedURLException { + public void http2(ClientGzip clientGzip, ServerGzip serverGzip) throws IOException { WireMockServer server = new WireMockServer(WireMockConfiguration.wireMockConfig() .dynamicHttpsPort() .http2TlsDisabled(false) - .gzipDisabled(serverGzip == ServerGzip.NOT_SUPPORTED)); + .gzipDisabled(true)); server.start(); try { HTTPSamplerBase http = HTTPSamplerFactory.newInstance(HTTPSamplerFactory.IMPL_HTTP_CLIENT5); http.setHttpVersion("HTTP/2"); String expectedResponse = "Hello, 丈, \uD83D\uDE03, and नि"; + byte[] responseBody = encodedResponse(expectedResponse, clientGzip, serverGzip); HeaderManager hm = new HeaderManager(); if (clientGzip == ClientGzip.REQUESTED) { hm.add(new Header("Accept-Encoding", "gzip")); @@ -148,20 +205,22 @@ public void http2(ClientGzip clientGzip, ServerGzip serverGzip) throws Malformed if (clientGzip == ClientGzip.REQUESTED) { mappingBuilder = mappingBuilder.withHeader("Accept-Encoding", WireMock.equalTo("gzip")); } - server.stubFor( - mappingBuilder - .willReturn( - WireMock.aResponse() - .withBody(expectedResponse) - .withHeader("Content-Type", "text/plain;charset=utf-8") - ) - ); + var response = WireMock.aResponse() + .withBody(responseBody) + .withHeader("Content-Type", "text/plain;charset=utf-8") + .withHeader("Content-Length", Long.toString(responseBody.length)); + if (clientGzip == ClientGzip.REQUESTED && serverGzip == ServerGzip.SUPPORTED) { + response.withHeader("Content-Encoding", "gzip"); + } + server.stubFor(mappingBuilder.willReturn(response)); HTTPSampleResult res = http.sample( new URL("https://localhost:" + server.httpsPort() + "/gzip"), "GET", false, 1); Assertions.assertAll( () -> assertEquals(expectedResponse, res.getResponseDataAsString(), "response body"), + () -> assertEquals(responseBody.length + res.getHeadersSize(), res.getBytesAsLong(), + "wire response bytes"), () -> { // HTTP/2 header names are lower case, so compare them ignoring case if (clientGzip == ClientGzip.NOT_REQUESTED || serverGzip == ServerGzip.NOT_SUPPORTED) { @@ -190,4 +249,17 @@ private static WireMockServer createServer(Consumer confi config.accept(configuration); return new WireMockServer(configuration); } + + private static byte[] encodedResponse(String response, ClientGzip clientGzip, ServerGzip serverGzip) + throws IOException { + byte[] bytes = response.getBytes(StandardCharsets.UTF_8); + if (clientGzip == ClientGzip.NOT_REQUESTED || serverGzip == ServerGzip.NOT_SUPPORTED) { + return bytes; + } + ByteArrayOutputStream output = new ByteArrayOutputStream(); + try (GZIPOutputStream gzip = new GZIPOutputStream(output)) { + gzip.write(bytes); + } + return output.toByteArray(); + } } From a0e118700a5e1acdc8fa57fd1db8a250f3f43aed Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Mon, 17 Aug 2026 16:43:32 +0200 Subject: [PATCH 44/80] Refactor HTTPJavaImpl to count sent bytes for POST/PUT requests; introduce CountingOutputHttpURLConnection to track request body size accurately. Update sent bytes calculation and add Content-Length header when missing. Add tests for large file uploads. --- .../CountingOutputHttpURLConnection.java | 176 ++++++++++++++++++ .../protocol/http/sampler/HTTPJavaImpl.java | 65 +++---- .../http/sampler/TestHTTPJavaFeatures.java | 31 +++ 3 files changed, 236 insertions(+), 36 deletions(-) create mode 100644 src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/CountingOutputHttpURLConnection.java diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/CountingOutputHttpURLConnection.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/CountingOutputHttpURLConnection.java new file mode 100644 index 00000000000..e51f7294e19 --- /dev/null +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/CountingOutputHttpURLConnection.java @@ -0,0 +1,176 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you 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.apache.jmeter.protocol.http.sampler; + +import java.io.FilterOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.List; +import java.util.Map; + +/** + * Delegating {@link HttpURLConnection} that counts the bytes actually written to the request body. + *

    + * It is used to compute the sent bytes of a sample, since the {@code String} returned by + * {@link PostWriter#sendPostData(java.net.URLConnection, HTTPSamplerBase)} is only a human readable + * representation of the request body (e.g. file contents are replaced with a placeholder). + */ +final class CountingOutputHttpURLConnection extends HttpURLConnection { + private final HttpURLConnection delegate; + private CountingOutputStream countingOutput; + + CountingOutputHttpURLConnection(HttpURLConnection delegate) { + super(delegate.getURL()); + this.delegate = delegate; + } + + /** + * @return the number of bytes written to the request body, or {@code -1} if the body was never opened + */ + long getBytesWritten() { + return countingOutput == null ? -1 : countingOutput.getCount(); + } + + @Override + public OutputStream getOutputStream() throws IOException { + if (countingOutput == null) { + countingOutput = new CountingOutputStream(delegate.getOutputStream()); + } + return countingOutput; + } + + @Override + public InputStream getInputStream() throws IOException { + return delegate.getInputStream(); + } + + @Override + public void setRequestProperty(String key, String value) { + delegate.setRequestProperty(key, value); + } + + @Override + public void addRequestProperty(String key, String value) { + delegate.addRequestProperty(key, value); + } + + @Override + public String getRequestProperty(String key) { + return delegate.getRequestProperty(key); + } + + @Override + public Map> getRequestProperties() { + return delegate.getRequestProperties(); + } + + @Override + public void setDoOutput(boolean doOutput) { + delegate.setDoOutput(doOutput); + } + + @Override + public boolean getDoOutput() { + return delegate.getDoOutput(); + } + + @Override + public void setDoInput(boolean doInput) { + delegate.setDoInput(doInput); + } + + @Override + public boolean getDoInput() { + return delegate.getDoInput(); + } + + @Override + public URL getURL() { + return delegate.getURL(); + } + + @Override + public String getRequestMethod() { + return delegate.getRequestMethod(); + } + + @Override + public String getHeaderField(String name) { + return delegate.getHeaderField(name); + } + + @Override + public String getHeaderField(int n) { + return delegate.getHeaderField(n); + } + + @Override + public String getHeaderFieldKey(int n) { + return delegate.getHeaderFieldKey(n); + } + + @Override + public Map> getHeaderFields() { + return delegate.getHeaderFields(); + } + + @Override + public void connect() throws IOException { + delegate.connect(); + } + + @Override + public void disconnect() { + delegate.disconnect(); + } + + @Override + public boolean usingProxy() { + return delegate.usingProxy(); + } + + /** + * Counts the bytes written to the wrapped stream. + */ + private static final class CountingOutputStream extends FilterOutputStream { + private long count; + + CountingOutputStream(OutputStream out) { + super(out); + } + + long getCount() { + return count; + } + + @Override + public void write(int b) throws IOException { + out.write(b); + count++; + } + + @Override + public void write(byte[] b, int off, int len) throws IOException { + out.write(b, off, len); + count += len; + } + } +} diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java index aa9096bad6d..1f9d96b6ab6 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java @@ -862,7 +862,7 @@ protected HTTPSampleResult sample(URL url, String method, boolean areFollowingRe Map> requestHeaders = null; Map securityHeaders = Collections.emptyMap(); - byte[] postBodyBytes = null; + long postBodyLength = -1; try { // Sampling proper - establish the connection and read the response: @@ -901,18 +901,16 @@ protected HTTPSampleResult sample(URL url, String method, boolean areFollowingRe throw new BindException(); } // Nice, we've got a connection. Finish sending the request: - if (method.equals(HTTPConstants.POST)) { - String postBody = sendPostData(conn); - res.setQueryString(postBody); - if (postBody != null) { - postBodyBytes = getBytes(postBody); - } - } else if (method.equals(HTTPConstants.PUT)) { - String putBody = sendPutData(conn); - res.setQueryString(putBody); - if (putBody != null) { - postBodyBytes = getBytes(putBody); - } + if (method.equals(HTTPConstants.POST) || method.equals(HTTPConstants.PUT)) { + // The returned body is only a display representation (file contents are replaced with + // a placeholder), so the bytes actually written to the connection are counted instead + CountingOutputHttpURLConnection countingConn = new CountingOutputHttpURLConnection(conn); + String body = method.equals(HTTPConstants.POST) + ? sendPostData(countingConn) + : sendPutData(countingConn); + res.setQueryString(body); + postBodyLength = countingConn.getBytesWritten(); + addMissingContentLengthHeader(requestHeaders, postBodyLength); } // Request sent. Now get the response: byte[] responseData = readResponse(conn, res); @@ -994,7 +992,7 @@ protected HTTPSampleResult sample(URL url, String method, boolean areFollowingRe cacheManager.saveDetails(conn, res); } - res.setSentBytes(calculateSentBytes(url, method, testElement.getHttpVersion(), requestHeaders, securityHeaders, postBodyBytes)); + res.setSentBytes(calculateSentBytes(url, method, testElement.getHttpVersion(), requestHeaders, securityHeaders, postBodyLength)); res = resultProcessing(areFollowingRedirect, frameDepth, res); @@ -1004,7 +1002,7 @@ protected HTTPSampleResult sample(URL url, String method, boolean areFollowingRe if (res.getEndTime() == 0) { res.sampleEnd(); } - res.setSentBytes(calculateSentBytes(url, method, testElement.getHttpVersion(), requestHeaders, securityHeaders, postBodyBytes)); + res.setSentBytes(calculateSentBytes(url, method, testElement.getHttpVersion(), requestHeaders, securityHeaders, postBodyLength)); savedConn = null; // we don't want interrupt to try disconnection again // We don't want to continue using this connection, even if KeepAlive is set if (conn != null) { // May not exist @@ -1316,15 +1314,22 @@ private static boolean isRestrictedHeader(String name) { || "Upgrade".equalsIgnoreCase(name); // $NON-NLS-1$ } - private static long calculateSentBytes( - URL u, - String method, - String version, - Map> requestHeaders, - Map securityHeaders, - byte[] postBodyBytes) { - return calculateSentBytes(u, method, version, requestHeaders, securityHeaders, - postBodyBytes == null ? -1 : postBodyBytes.length); + /** + * Adds the {@code Content-Length} header to the captured request headers when it is missing. + *

    + * {@link HttpURLConnection} silently drops restricted headers such as {@code Content-Length} from + * {@link HttpURLConnection#getRequestProperties()}, even though it does send them on the wire. + * + * @param requestHeaders headers captured from the connection, may be {@code null} + * @param bodyLength number of bytes written to the request body, or {@code -1} if unknown + */ + private static void addMissingContentLengthHeader(Map> requestHeaders, long bodyLength) { + if (requestHeaders == null || bodyLength < 0 + || hasHeader(requestHeaders, HTTPConstants.HEADER_CONTENT_LENGTH)) { + return; + } + requestHeaders.put(HTTPConstants.HEADER_CONTENT_LENGTH, + Collections.singletonList(Long.toString(bodyLength))); } private static long calculateSentBytes( @@ -1439,18 +1444,6 @@ private static String getHeaderValue(Map> headers, String h return null; } - private byte[] getBytes(String postBody) { - String enc = testElement != null ? testElement.getContentEncoding() : null; - if (StringUtilities.isBlank(enc)) { - enc = StandardCharsets.UTF_8.name(); - } - try { - return postBody.getBytes(enc); - } catch (Exception e) { - return postBody.getBytes(StandardCharsets.UTF_8); - } - } - Http2Client getHttpClient(URL url) { int connectTimeout = getConnectTimeout(); String proxyHost = getProxyHost(); diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java index 11f40cc4501..4662c9b1f1c 100644 --- a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java +++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java @@ -136,6 +136,37 @@ void uploadsMultipartFileOverHttp2() throws Exception { } } + @Test + void countsUploadedFileInSentBytesOverHttp11() throws Exception { + WireMockServer server = createServer(); + Path upload = Files.createTempFile("jmeter-http1-upload-", ".bin"); + try { + Files.write(upload, new byte[1_000_000]); + server.start(); + server.stubFor(post(urlEqualTo("/upload")).willReturn(aResponse().withStatus(200))); + HTTPSamplerBase sampler = newSampler(); + sampler.setHttpVersion(HTTPConstants.HTTP_1_1); + sampler.setMethod(HTTPConstants.POST); + sampler.setDoMultipart(true); + sampler.setHTTPFiles(new HTTPFileArg[] { + new HTTPFileArg(upload.toString(), "upload", "application/octet-stream") }); + + HTTPSampleResult result = sampler.sample( + new URL(server.url("/upload")), HTTPConstants.POST, false, 1); + + assertEquals("200", result.getResponseCode(), result.getResponseMessage()); + assertTrue(result.getSentBytes() > 1_000_000, + () -> "the whole file should have been sent, but only " + result.getSentBytes() + " bytes were"); + // The request view must not hold the file contents, otherwise a large upload is in heap twice + assertTrue(result.getQueryString().contains(""), + () -> "file contents should be omitted from the request view: " + result.getQueryString()); + server.verify(postRequestedFor(urlEqualTo("/upload"))); + } finally { + Files.deleteIfExists(upload); + server.stop(); + } + } + @Test void interruptsPendingHttp2Request() throws Exception { WireMockServer server = createServer(); From a59e347583977661e53c0dfa1c9b2308c06882b2 Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Tue, 18 Aug 2026 07:25:19 +0200 Subject: [PATCH 45/80] Apply HTTP/2 system properties lazily in HTTPJavaImpl - Remove eager static initializer in `HTTPJavaImpl` that mutated global JVM system properties on class loading. - Apply HTTP/2 `jdk.httpclient.*` system properties lazily and idempotently when the first HTTP/2 client instance is created. - Skip setting `jdk.httpclient.enablepush` when `http.java.h2.push_enabled` is not explicitly defined in JMeter properties, preserving JDK defaults for other JVM components. - Update `TestHTTPJavaFeatures` to verify JDK defaults remain intact when properties are unset. --- .../protocol/http/sampler/HTTPJavaImpl.java | 23 +++++++++++++++---- .../http/sampler/TestHTTPJavaFeatures.java | 15 ++++++++++-- 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java index 1f9d96b6ab6..565724bc94b 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java @@ -163,9 +163,7 @@ private static String createHttp1DefaultUserAgent() { */ private static final Map HTTP_2_SYSTEM_PROPERTIES = createHttp2SystemPropertyMapping(); - static { - applyHttp2SystemProperties(JMeterUtils.getJMeterProperties(), System.getProperties()); - } + private static volatile boolean http2SystemPropertiesApplied; private static Map createHttp2SystemPropertyMapping() { Map mapping = new LinkedHashMap<>(); @@ -180,6 +178,17 @@ private static Map createHttp2SystemPropertyMapping() { return Collections.unmodifiableMap(mapping); } + private static void applyHttp2SystemPropertiesLazily() { + if (!http2SystemPropertiesApplied) { + synchronized (HTTPJavaImpl.class) { + if (!http2SystemPropertiesApplied) { + applyHttp2SystemProperties(JMeterUtils.getJMeterProperties(), System.getProperties()); + http2SystemPropertiesApplied = true; + } + } + } + } + /** * Copies the HTTP/2 settings from the JMeter properties to the system properties of the JDK client. * Values which are already defined, for example on the command line, are never overwritten. @@ -195,8 +204,11 @@ static void applyHttp2SystemProperties(Properties jmeterProperties, Properties s setUnlessDefined(systemProperties, entry.getValue(), value.trim()); } } - boolean pushEnabled = Boolean.parseBoolean(source.getProperty(HTTP_2_PUSH_ENABLED_PROPERTY, "false")); // $NON-NLS-1$ - setUnlessDefined(systemProperties, JDK_PUSH_ENABLED_PROPERTY, pushEnabled ? "1" : "0"); // $NON-NLS-1$ $NON-NLS-2$ + String pushProperty = source.getProperty(HTTP_2_PUSH_ENABLED_PROPERTY); + if (StringUtilities.isNotBlank(pushProperty)) { + boolean pushEnabled = Boolean.parseBoolean(pushProperty.trim()); + setUnlessDefined(systemProperties, JDK_PUSH_ENABLED_PROPERTY, pushEnabled ? "1" : "0"); // $NON-NLS-1$ $NON-NLS-2$ + } } private static void setUnlessDefined(Properties systemProperties, String name, String value) { @@ -1496,6 +1508,7 @@ Http2Client getHttpClient(URL url) { } private static Http2Client createHttpClient(HttpClientKey key, SSLContext sslContext) { + applyHttp2SystemPropertiesLazily(); ConnectTimeTracker connectTimeTracker = new ConnectTimeTracker(); HttpClient.Builder builder = HttpClient.newBuilder() .version(HttpClient.Version.HTTP_2) diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java index 4662c9b1f1c..b178c652ab4 100644 --- a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java +++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java @@ -246,16 +246,27 @@ void mapsHttp2SettingsToJdkSystemProperties() { } @Test - void disablesServerPushAndKeepsJdkDefaultsWhenNothingIsConfigured() { + void keepsJdkDefaultsWhenNothingIsConfigured() { Properties systemProperties = new Properties(); HTTPJavaImpl.applyHttp2SystemProperties(new Properties(), systemProperties); - assertEquals("0", systemProperties.getProperty("jdk.httpclient.enablepush")); + assertNull(systemProperties.getProperty("jdk.httpclient.enablepush")); assertNull(systemProperties.getProperty("jdk.httpclient.hpack.maxheadertablesize")); assertNull(systemProperties.getProperty("jdk.httpclient.windowsize")); } + @Test + void disablesServerPushWhenConfigured() { + Properties jmeterProperties = new Properties(); + jmeterProperties.setProperty("http.java.h2.push_enabled", "false"); + Properties systemProperties = new Properties(); + + HTTPJavaImpl.applyHttp2SystemProperties(jmeterProperties, systemProperties); + + assertEquals("0", systemProperties.getProperty("jdk.httpclient.enablepush")); + } + @Test void doesNotOverrideHttp2SettingsGivenOnTheCommandLine() { Properties jmeterProperties = new Properties(); From 57e486782dd449336d3dfb1ace59b7ac01f5457b Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Tue, 18 Aug 2026 08:01:33 +0200 Subject: [PATCH 46/80] Fix HTTP/2 response stream leak in HTTPJavaImpl when obey_contentlength is enabled Wrap the HTTP/2 response body stream in a try-with-resources block in HTTPJavaImpl#readResponse(HttpResponse, SampleResult). Previously, when httpsampler.obey_contentlength was set to true and the server returned Content-Length: 0, the method returned early without closing the response stream, leaking JDK HTTP/2 streams. --- .../protocol/http/sampler/HTTPJavaImpl.java | 47 ++++---- .../http/sampler/TestHTTPJavaFeatures.java | 114 ++++++++++++++++++ 2 files changed, 138 insertions(+), 23 deletions(-) diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java index 565724bc94b..f5ba2306419 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java @@ -1262,34 +1262,35 @@ private HTTPSampleResult sampleHttp2(URL url, String method, boolean areFollowin } } - private byte[] readResponse(HttpResponse response, SampleResult res) throws IOException { - InputStream in = response.body(); - if (in == null) { - return NULL_BA; - } + byte[] readResponse(HttpResponse response, SampleResult res) throws IOException { + try (InputStream in = response.body()) { + if (in == null) { + return NULL_BA; + } - boolean gzipped = response.headers().firstValue(HTTPConstants.HEADER_CONTENT_ENCODING) - .map(HTTPConstants.ENCODING_GZIP::equalsIgnoreCase) - .orElse(false); + boolean gzipped = response.headers().firstValue(HTTPConstants.HEADER_CONTENT_ENCODING) + .map(HTTPConstants.ENCODING_GZIP::equalsIgnoreCase) + .orElse(false); - long contentLength = response.headers().firstValueAsLong(HTTPConstants.HEADER_CONTENT_LENGTH).orElse(-1L); + long contentLength = response.headers().firstValueAsLong(HTTPConstants.HEADER_CONTENT_LENGTH).orElse(-1L); - if (contentLength == 0 && OBEY_CONTENT_LENGTH) { - log.info("Content-Length: 0, not reading http-body"); - res.setResponseHeaders(getResponseHeaders(response)); - res.latencyEnd(); - return NULL_BA; - } + if (contentLength == 0 && OBEY_CONTENT_LENGTH) { + log.info("Content-Length: 0, not reading http-body"); + res.setResponseHeaders(getResponseHeaders(response)); + res.latencyEnd(); + return NULL_BA; + } - CountingInputStream instream = new CountingInputStream(in); - InputStream stream = gzipped ? new GZIPInputStream(instream) : instream; + CountingInputStream instream = new CountingInputStream(in); + InputStream stream = gzipped ? new GZIPInputStream(instream) : instream; - try { - byte[] responseData = readResponse(res, stream, contentLength); - res.setBodySize(instream.getBytesRead()); - return responseData; - } finally { - instream.close(); + try { + byte[] responseData = readResponse(res, stream, contentLength); + res.setBodySize(instream.getBytesRead()); + return responseData; + } finally { + instream.close(); + } } } diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java index b178c652ab4..ebe43713f9e 100644 --- a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java +++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java @@ -31,11 +31,17 @@ import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; import java.net.Socket; +import java.net.URI; import java.net.URL; import java.net.http.HttpClient; +import java.net.http.HttpHeaders; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.security.cert.X509Certificate; @@ -43,15 +49,18 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Properties; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; import javax.net.ssl.SSLContext; import javax.net.ssl.SSLEngine; +import javax.net.ssl.SSLSession; import javax.net.ssl.TrustManager; import javax.net.ssl.X509ExtendedTrustManager; @@ -751,6 +760,111 @@ void closesSharedHttp2ClientsWhenTheTestEnds() throws Exception { } } + @Test + void closesResponseBodyStreamWhenReadingResponse() throws Exception { + HTTPSamplerBase sampler = newSampler(); + HTTPJavaImpl impl = new HTTPJavaImpl(sampler); + AtomicBoolean closed = new AtomicBoolean(false); + InputStream bodyStream = new ByteArrayInputStream("response body".getBytes(StandardCharsets.UTF_8)) { + @Override + public void close() throws IOException { + closed.set(true); + super.close(); + } + }; + + HttpResponse response = createStubResponse(bodyStream, Map.of("Content-Length", List.of("13"))); + + SampleResult result = new SampleResult(); + result.sampleStart(); + byte[] responseData = impl.readResponse(response, result); + + assertEquals("response body", new String(responseData, StandardCharsets.UTF_8)); + assertTrue(closed.get(), "response body stream should be closed"); + } + + @Test + void closesResponseBodyStreamWhenContentLengthIsZero() throws Exception { + HTTPSamplerBase sampler = newSampler(); + HTTPJavaImpl impl = new HTTPJavaImpl(sampler); + AtomicBoolean closed = new AtomicBoolean(false); + InputStream bodyStream = new ByteArrayInputStream(new byte[0]) { + @Override + public void close() throws IOException { + closed.set(true); + super.close(); + } + }; + + HttpResponse response = createStubResponse(bodyStream, Map.of("Content-Length", List.of("0"))); + + SampleResult result = new SampleResult(); + result.sampleStart(); + byte[] responseData = impl.readResponse(response, result); + + assertEquals(0, responseData.length); + assertTrue(closed.get(), "response body stream should be closed by try-with-resources"); + } + + @Test + void returnsEmptyByteArrayWhenResponseBodyIsNull() throws Exception { + HTTPSamplerBase sampler = newSampler(); + HTTPJavaImpl impl = new HTTPJavaImpl(sampler); + + HttpResponse response = createStubResponse(null, Map.of()); + + SampleResult result = new SampleResult(); + result.sampleStart(); + byte[] responseData = impl.readResponse(response, result); + + assertEquals(0, responseData.length); + } + + private static HttpResponse createStubResponse( + InputStream bodyStream, Map> headerMap) { + return new HttpResponse<>() { + @Override + public int statusCode() { + return 200; + } + + @Override + public HttpRequest request() { + return null; + } + + @Override + public Optional> previousResponse() { + return Optional.empty(); + } + + @Override + public HttpHeaders headers() { + return HttpHeaders.of(headerMap, (k, v) -> true); + } + + @Override + public InputStream body() { + return bodyStream; + } + + @Override + public Optional sslSession() { + return Optional.empty(); + } + + @Override + public URI uri() { + return URI.create("http://localhost/"); + } + + @Override + public HttpClient.Version version() { + return HttpClient.Version.HTTP_2; + } + }; + } + private static SSLContext trustAllContext() throws Exception { TrustManager trustAll = new X509ExtendedTrustManager() { @Override public void checkClientTrusted(X509Certificate[] chain, String authType) { From 9147156bc4dff839c520d72d7cac21c1c01e834d Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Tue, 18 Aug 2026 10:10:29 +0200 Subject: [PATCH 47/80] Strengthen HTTP/2 tests and fix sent-bytes accounting in HTTPHC5Impl Replace tests that could not fail with ones that pin down the behaviour they name: - Drop the reflective setMessageMultiplexing() lookup, httpclient5 is pinned at 5.6.4, and remove the three tests that parsed the compiled class file to check for it. - Assert the exact sent bytes against WireMock's request journal. This uncovered that calculateSentBytes() ignored the headers the protocol layer writes itself, so Host, Content-Type, Content-Encoding and Content-Length/Transfer-Encoding are now accounted for as well. - Count the accepted TCP connections in both multiplexing tests instead of only checking four HTTP/2 200s. The relay that does this moved to a shared TcpRelay test helper. - Assert JMeter's own HTTP/2 settings and the property overrides they exist for, instead of re-asserting the H2Config defaults of httpcore5. - Close the clients both suites create, shorten the re-validation wait from 2.5 s to 0.5 s and drop a pointless sleep. --- .../protocol/http/sampler/HTTPHC5Impl.java | 219 ++++++------ .../protocol/http/sampler/HTTPJavaImpl.java | 5 + .../protocol/http/sampler/TcpRelay.java | 146 ++++++++ .../http/sampler/TestHTTPHC5Features.java | 329 +++++------------- .../http/sampler/TestHTTPJavaFeatures.java | 37 +- 5 files changed, 387 insertions(+), 349 deletions(-) create mode 100644 src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TcpRelay.java diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java index f92737c599e..92cb3c7c380 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java @@ -21,8 +21,6 @@ import java.io.File; import java.io.IOException; import java.io.OutputStream; -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Method; import java.net.InetAddress; import java.net.MalformedURLException; import java.net.URI; @@ -45,6 +43,7 @@ import java.util.Locale; import java.util.Map; import java.util.Objects; +import java.util.Properties; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; @@ -133,6 +132,7 @@ import org.apache.hc.core5.http2.HttpVersionPolicy; import org.apache.hc.core5.http2.config.H2Config; import org.apache.hc.core5.io.CloseMode; +import org.apache.hc.core5.net.URIAuthority; import org.apache.hc.core5.pool.PoolConcurrencyPolicy; import org.apache.hc.core5.reactor.ConnectionInitiator; import org.apache.hc.core5.util.TimeValue; @@ -204,39 +204,17 @@ protected Map initialValue() { private static final boolean HTTP_2_MULTIPLEXING = JMeterUtils.getPropDefault("httpclient5.h2.multiplexing", true); - /** Size of the HPACK dynamic header table announced to the server, {@code 0} disables HPACK indexing. */ - private static final int HTTP_2_HEADER_TABLE_SIZE = - JMeterUtils.getPropDefault("httpclient5.h2.header_table_size", H2Config.DEFAULT.getHeaderTableSize()); - - /** Whether HPACK compression of the request headers is used. */ - private static final boolean HTTP_2_HEADER_COMPRESSION = - JMeterUtils.getPropDefault("httpclient5.h2.header_compression", true); - - private static final int HTTP_2_MAX_CONCURRENT_STREAMS = - JMeterUtils.getPropDefault("httpclient5.h2.max_concurrent_streams", H2Config.DEFAULT.getMaxConcurrentStreams()); - - /** - * HTTP/2 flow control window announced per stream. It caps how many bytes the server may send - * before it has to wait for a window update, so the throughput of a single response can never - * exceed this window divided by the round trip time. The default of HttpClient is the 64 kB the - * HTTP/2 specification mandates, which throttles a download over a link with any noticeable - * latency to a fraction of the available bandwidth. JMeter therefore announces the same 16 MB - * the JDK client uses by default, see {@code http.java.h2.initial_window_size}. The window is - * only a promise to the server, the body is consumed as it arrives and not buffered up to this - * size. - */ - private static final int HTTP_2_INITIAL_WINDOW_SIZE = - JMeterUtils.getPropDefault("httpclient5.h2.initial_window_size", 16 * 1024 * 1024); - - private static final int HTTP_2_MAX_FRAME_SIZE = - JMeterUtils.getPropDefault("httpclient5.h2.max_frame_size", H2Config.DEFAULT.getMaxFrameSize()); - /** - * JMeter has no way of reporting pushed resources, so server push is switched off to avoid - * the server wasting bandwidth on responses that are dropped. + * HTTP/2 flow control window announced per stream when the {@code httpclient5.h2.initial_window_size} + * property does not say otherwise. It caps how many bytes the server may send before it has to wait + * for a window update, so the throughput of a single response can never exceed this window divided by + * the round trip time. The default of HttpClient is the 64 kB the HTTP/2 specification mandates, which + * throttles a download over a link with any noticeable latency to a fraction of the available + * bandwidth. JMeter therefore announces the same 16 MB the JDK client uses by default, see + * {@code http.java.h2.initial_window_size}. The window is only a promise to the server, the body is + * consumed as it arrives and not buffered up to this size. */ - private static final boolean HTTP_2_PUSH_ENABLED = - JMeterUtils.getPropDefault("httpclient5.h2.push_enabled", false); + static final int DEFAULT_HTTP_2_INITIAL_WINDOW_SIZE = 16 * 1024 * 1024; /** * Use HTTP/2 without TLS (h2c with prior knowledge) when HTTP/2 is selected for a {@code http://} URL. @@ -257,15 +235,8 @@ protected Map initialValue() { private static final int HTTP_2_BODY_SPILL_THRESHOLD = JMeterUtils.getPropDefault("httpclient5.h2.body_spill_threshold", 256 * 1024); - /** - * Milliseconds of inactivity after which a pooled connection is re-validated before it is used - * again, {@code -1} disables the check. JMeter keeps a client per thread and reuses its - * connections across iterations, so a connection the server (or an idle timeout of a load - * balancer) closed in between would otherwise be handed out as-is and fail the next sample. - * HttpClient leaves this undefined by default, which skips the check altogether, and the - * automatic retries that would hide such a failure are deliberately disabled. - */ - private static final long VALIDATE_AFTER_INACTIVITY = + /** Milliseconds of inactivity after which a pooled connection is re-validated, visible for testing. */ + static long validateAfterInactivityMillis = JMeterUtils.getPropDefault("httpclient5.validate_after_inactivity", 2000L); /** @@ -346,8 +317,6 @@ public char[] getPassword() { } }; - private static final Method MESSAGE_MULTIPLEXING_SETTER = findMessageMultiplexingSetter(); - private static final String[] HEADERS_TO_SAVE = {HttpHeaders.CONTENT_LENGTH, HttpHeaders.CONTENT_ENCODING, HttpHeaders.CONTENT_MD5}; @@ -479,16 +448,57 @@ private static TlsStrategy createTlsStrategy() { } static H2Config createHttp2Config() { + return createHttp2Config(JMeterUtils.getJMeterProperties()); + } + + static H2Config createHttp2Config(Properties properties) { return H2Config.custom() - .setHeaderTableSize(HTTP_2_HEADER_TABLE_SIZE) - .setCompressionEnabled(HTTP_2_HEADER_COMPRESSION) - .setMaxConcurrentStreams(HTTP_2_MAX_CONCURRENT_STREAMS) - .setInitialWindowSize(HTTP_2_INITIAL_WINDOW_SIZE) - .setMaxFrameSize(HTTP_2_MAX_FRAME_SIZE) - .setPushEnabled(HTTP_2_PUSH_ENABLED) + // size of the HPACK dynamic header table announced to the server, 0 disables HPACK indexing + .setHeaderTableSize(intProperty(properties, "httpclient5.h2.header_table_size", + H2Config.DEFAULT.getHeaderTableSize())) + .setCompressionEnabled(booleanProperty(properties, "httpclient5.h2.header_compression", true)) + .setMaxConcurrentStreams(intProperty(properties, "httpclient5.h2.max_concurrent_streams", + H2Config.DEFAULT.getMaxConcurrentStreams())) + .setInitialWindowSize(intProperty(properties, "httpclient5.h2.initial_window_size", + DEFAULT_HTTP_2_INITIAL_WINDOW_SIZE)) + .setMaxFrameSize(intProperty(properties, "httpclient5.h2.max_frame_size", + H2Config.DEFAULT.getMaxFrameSize())) + // JMeter has no way of reporting pushed resources, so server push is switched off to avoid + // the server wasting bandwidth on responses that are dropped + .setPushEnabled(booleanProperty(properties, "httpclient5.h2.push_enabled", false)) .build(); } + private static int intProperty(Properties properties, String name, int defaultValue) { + String value = properties != null ? properties.getProperty(name) : null; + if (value == null || value.isBlank()) { + return defaultValue; + } + try { + return Integer.parseInt(value.trim()); + } catch (NumberFormatException e) { + log.warn("Ignoring property {}, {} is not a number", name, value); + return defaultValue; + } + } + + private static boolean booleanProperty(Properties properties, String name, boolean defaultValue) { + String value = properties != null ? properties.getProperty(name) : null; + return value == null || value.isBlank() ? defaultValue : Boolean.parseBoolean(value.trim()); + } + + /** + * Time of inactivity after which a pooled connection is re-validated before it is used again, + * a negative value disables the check. JMeter keeps a client per thread and reuses its + * connections across iterations, so a connection the server (or an idle timeout of a load + * balancer) closed in between would otherwise be handed out as-is and fail the next sample. + * HttpClient leaves this undefined by default, which skips the check altogether, and the + * automatic retries that would hide such a failure are deliberately disabled. + */ + private static TimeValue validateAfterInactivity() { + return TimeValue.ofMilliseconds(validateAfterInactivityMillis); + } + protected HTTPHC5Impl(HTTPSamplerBase testElement) { super(testElement); } @@ -1113,35 +1123,17 @@ private static long calculateSentBytes(org.apache.hc.client5.http.classic.method if (request == null) { return 0; } - long sentBytes = 0; - - String method = request.getMethod(); - String uri = request.getRequestUri(); - if (uri == null) { - uri = ""; - } + String uri = request.getRequestUri() != null ? request.getRequestUri() : ""; org.apache.hc.core5.http.ProtocolVersion version = request.getVersion(); String versionStr = version != null ? version.toString() : HTTPConstants.HTTP_VERSION_1_1; - - sentBytes += method.getBytes(Charset.defaultCharset()).length; - sentBytes += 1; - sentBytes += uri.getBytes(Charset.defaultCharset()).length; - sentBytes += 1; - sentBytes += versionStr.getBytes(Charset.defaultCharset()).length; - sentBytes += 2; + // the request line, with a space between its three parts and the CRLF that terminates it + long sentBytes = length(request.getMethod()) + 1 + length(uri) + 1 + length(versionStr) + 2; for (Header header : request.getHeaders()) { - String name = header.getName(); - String value = header.getValue(); - if (name != null) { - sentBytes += name.getBytes(Charset.defaultCharset()).length; - sentBytes += 2; - } - if (value != null) { - sentBytes += value.getBytes(Charset.defaultCharset()).length; - } - sentBytes += 2; + sentBytes += headerLength(header.getName(), header.getValue()); } + sentBytes += implicitHeaderLength(request); + // the empty line that separates the headers from the body sentBytes += 2; HttpEntity entity = request.getEntity(); @@ -1163,6 +1155,47 @@ private static long calculateSentBytes(org.apache.hc.client5.http.classic.method return sentBytes; } + /** + * Length of the headers the protocol layer adds when it writes the request, so that they are + * accounted for although they are not part of the request object JMeter built. + */ + private static long implicitHeaderLength(org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request) { + long length = 0; + URIAuthority authority = request.getAuthority(); + if (authority != null && !request.containsHeader(HttpHeaders.HOST)) { + length += headerLength(HttpHeaders.HOST, authority.toString()); + } + HttpEntity entity = request.getEntity(); + if (entity == null) { + return length; + } + if (entity.getContentType() != null && !request.containsHeader(HttpHeaders.CONTENT_TYPE)) { + length += headerLength(HttpHeaders.CONTENT_TYPE, entity.getContentType()); + } + if (entity.getContentEncoding() != null && !request.containsHeader(HttpHeaders.CONTENT_ENCODING)) { + length += headerLength(HttpHeaders.CONTENT_ENCODING, entity.getContentEncoding()); + } + if (request.containsHeader(HttpHeaders.CONTENT_LENGTH) || request.containsHeader(HttpHeaders.TRANSFER_ENCODING)) { + return length; + } + long contentLength = entity.getContentLength(); + if (contentLength >= 0) { + length += headerLength(HttpHeaders.CONTENT_LENGTH, Long.toString(contentLength)); + } else if (entity.isChunked()) { + length += headerLength(HttpHeaders.TRANSFER_ENCODING, "chunked"); + } + return length; + } + + /** Length of a header line, that is {@code name: value} and the CRLF that terminates it. */ + private static long headerLength(String name, String value) { + return length(name) + 2 + length(value) + 2; + } + + private static long length(String text) { + return text == null ? 0 : text.getBytes(StandardCharsets.ISO_8859_1).length; + } + private static class CountingOutputStream extends java.io.OutputStream { private long count = 0; @@ -1223,7 +1256,7 @@ private static CloseableHttpClient createClient(HttpClientKey key) { connectionManagerBuilder.setDnsResolver(createDnsResolver(key.dnsCacheManager)); } ConnectionConfig.Builder connectionConfig = ConnectionConfig.custom() - .setValidateAfterInactivity(TimeValue.ofMilliseconds(VALIDATE_AFTER_INACTIVITY)); + .setValidateAfterInactivity(validateAfterInactivity()); if (key.connectTimeout > 0) { connectionConfig.setConnectTimeout(Timeout.ofMilliseconds(key.connectTimeout)); } @@ -1236,7 +1269,6 @@ private static CloseableHttpClient createClient(HttpClientKey key) { } private static CloseableHttpAsyncClient createHttp2Client(HttpClientKey key) { - boolean multiplexing = HTTP_2_MULTIPLEXING && MESSAGE_MULTIPLEXING_SETTER != null; HttpAsyncClientBuilder builder = HttpAsyncClients.custom() .disableAutomaticRetries() // HttpClient 5.6 added transparent content compression to the async transport. JMeter decodes @@ -1245,7 +1277,7 @@ private static CloseableHttpAsyncClient createHttp2Client(HttpClientKey key) { .disableContentCompression() .setH2Config(HTTP_2_CONFIG) .setRoutePlanner(createRoutePlanner(key)); - if (multiplexing) { + if (HTTP_2_MULTIPLEXING) { // A connection bound to a user token cannot be shared, and HTTP/2 has no connection scoped state anyway builder.disableConnectionState(); } @@ -1259,8 +1291,8 @@ private static CloseableHttpAsyncClient createHttp2Client(HttpClientKey key) { connectionManagerBuilder.setDefaultTlsConfig(TlsConfig.custom() .setVersionPolicy(key.httpVersionPolicy) .build()); - if (multiplexing) { - enableMessageMultiplexing(connectionManagerBuilder); + if (HTTP_2_MULTIPLEXING) { + connectionManagerBuilder.setMessageMultiplexing(true); } connectionManagerBuilder.setPoolConcurrencyPolicy(PoolConcurrencyPolicy.LAX); connectionManagerBuilder.setMaxConnPerRoute(HTTP_2_MAX_CONNECTIONS_PER_ROUTE); @@ -1272,7 +1304,7 @@ private static CloseableHttpAsyncClient createHttp2Client(HttpClientKey key) { // which fails the sample with a ConnectionClosedException. The check sends an HTTP/2 PING // and replaces the connection if the peer does not answer. ConnectionConfig.Builder connectionConfig = ConnectionConfig.custom() - .setValidateAfterInactivity(TimeValue.ofMilliseconds(VALIDATE_AFTER_INACTIVITY)); + .setValidateAfterInactivity(validateAfterInactivity()); if (key.connectTimeout > 0) { connectionConfig.setConnectTimeout(Timeout.ofMilliseconds(key.connectTimeout)); } @@ -1283,28 +1315,6 @@ private static CloseableHttpAsyncClient createHttp2Client(HttpClientKey key) { return asyncClient; } - /** - * Looks up {@code PoolingAsyncClientConnectionManagerBuilder#setMessageMultiplexing(boolean)}, - * which is only available from HttpClient 5.5 onwards. JMeter still runs with older HttpClient - * versions, where HTTP/2 requests just cannot share a connection. - */ - private static Method findMessageMultiplexingSetter() { - try { - return PoolingAsyncClientConnectionManagerBuilder.class.getMethod("setMessageMultiplexing", boolean.class); - } catch (NoSuchMethodException e) { - log.info("HTTP/2 message multiplexing is unavailable, HttpClient 5.5 or later is required"); - return null; - } - } - - private static void enableMessageMultiplexing(PoolingAsyncClientConnectionManagerBuilder connectionManagerBuilder) { - try { - MESSAGE_MULTIPLEXING_SETTER.invoke(connectionManagerBuilder, true); - } catch (IllegalAccessException | InvocationTargetException e) { - log.warn("Could not enable HTTP/2 message multiplexing", e); - } - } - private ClassicHttpResponse executeHttp2(CloseableHttpAsyncClient client, org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request, HttpClientContext context) throws IOException { @@ -1486,7 +1496,7 @@ private int initialBufferSize(HttpResponse response) { */ @Override protected int capacityIncrement() { - return HTTP_2_INITIAL_WINDOW_SIZE; + return HTTP_2_CONFIG.getInitialWindowSize(); } @Override @@ -1671,7 +1681,8 @@ protected void threadFinished() { closeThreadLocalClients(); } - private static void closeThreadLocalClients() { + /** Closes the clients of the current thread, which releases their connections and I/O threads. */ + static void closeThreadLocalClients() { Map clients = HTTP_CLIENTS.get(); for (CloseableHttpClient client : clients.values()) { JOrphanUtils.closeQuietly(client); diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java index f5ba2306419..d7b3659555c 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java @@ -278,6 +278,11 @@ protected void threadFinished() { @Override protected void testEnded() { + releaseSharedHttp2Resources(); + } + + /** Closes the clients shared by all threads and shuts down the thread pool they use. */ + static void releaseSharedHttp2Resources() { closeHttp2Clients(SHARED_HTTP_2_CLIENTS); ExecutorService executor = HTTP_2_EXECUTOR.getAndSet(null); if (executor != null) { diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TcpRelay.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TcpRelay.java new file mode 100644 index 00000000000..9346c3fa3ae --- /dev/null +++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TcpRelay.java @@ -0,0 +1,146 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you 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.apache.jmeter.protocol.http.sampler; + +import java.io.Closeable; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; + +import org.apache.jorphan.util.JOrphanUtils; + +/** + * Relays TCP traffic to a backend server and counts the connections the client opens, which is how a + * test tells apart requests multiplexed over one connection from requests that each got their own. + * + *

    It can also drop a single relayed connection as soon as the client writes to it again, which is + * what an idle connection closed by the server (or by a load balancer) looks like to a client that has + * not noticed the close yet. New connections are relayed as usual, so the backend stays reachable. + */ +final class TcpRelay implements Closeable { + + private final ServerSocket serverSocket; + private final int backendPort; + private final ExecutorService executor = Executors.newCachedThreadPool(runnable -> { + Thread thread = new Thread(runnable, "relay"); + thread.setDaemon(true); + return thread; + }); + private final AtomicInteger acceptedConnections = new AtomicInteger(); + private volatile RelayedConnection currentConnection; + + TcpRelay(int backendPort) throws IOException { + this.backendPort = backendPort; + this.serverSocket = new ServerSocket(0, 0, InetAddress.getLoopbackAddress()); + executor.execute(this::acceptConnections); + } + + int getPort() { + return serverSocket.getLocalPort(); + } + + int getAcceptedConnections() { + return acceptedConnections.get(); + } + + void dropConnectionOnNextRequest() { + RelayedConnection connection = currentConnection; + if (connection == null) { + throw new IllegalStateException("No connection has been relayed yet"); + } + connection.doomed = true; + } + + private void acceptConnections() { + while (!serverSocket.isClosed()) { + RelayedConnection connection; + try { + Socket client = serverSocket.accept(); + connection = new RelayedConnection(client, + new Socket(InetAddress.getLoopbackAddress(), backendPort)); + } catch (IOException e) { + return; + } + acceptedConnections.incrementAndGet(); + currentConnection = connection; + executor.execute(connection::relayRequests); + executor.execute(connection::relayResponses); + } + } + + @Override + public void close() throws IOException { + serverSocket.close(); + executor.shutdownNow(); + } + + private static final class RelayedConnection { + + private final Socket client; + private final Socket backend; + private volatile boolean doomed; + + RelayedConnection(Socket client, Socket backend) { + this.client = client; + this.backend = backend; + } + + void relayRequests() { + try { + InputStream input = client.getInputStream(); + OutputStream output = backend.getOutputStream(); + byte[] buffer = new byte[8192]; + int read; + while ((read = input.read(buffer)) != -1 && !doomed) { + output.write(buffer, 0, read); + output.flush(); + } + } catch (IOException ignored) { // NOSONAR the connection is closed below in any case + // the peer went away, which ends the relaying just as well + } + close(); + } + + void relayResponses() { + try { + InputStream input = backend.getInputStream(); + OutputStream output = client.getOutputStream(); + byte[] buffer = new byte[8192]; + int read; + while ((read = input.read(buffer)) != -1) { + output.write(buffer, 0, read); + output.flush(); + } + } catch (IOException ignored) { // NOSONAR the connection is closed below in any case + // the peer went away, which ends the relaying just as well + } + close(); + } + + private void close() { + JOrphanUtils.closeQuietly(client); + JOrphanUtils.closeQuietly(backend); + } + } +} diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java index 718dbbcbe5f..e3e460fb625 100644 --- a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java +++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java @@ -18,6 +18,7 @@ package org.apache.jmeter.protocol.http.sampler; import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.anyRequestedFor; import static com.github.tomakehurst.wiremock.client.WireMock.get; import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor; import static com.github.tomakehurst.wiremock.client.WireMock.post; @@ -30,26 +31,19 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; -import java.io.ByteArrayInputStream; -import java.io.Closeable; -import java.io.DataInputStream; import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.net.InetAddress; -import java.net.ServerSocket; -import java.net.Socket; import java.net.URL; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.List; +import java.util.Properties; +import java.util.concurrent.Callable; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicInteger; import org.apache.hc.client5.http.HttpRoute; import org.apache.hc.client5.http.auth.AuthSchemeFactory; @@ -74,16 +68,23 @@ import org.apache.jmeter.protocol.http.util.HTTPConstants; import org.apache.jmeter.protocol.http.util.HTTPFileArg; import org.apache.jmeter.samplers.SampleResult; -import org.apache.jmeter.util.JMeterUtils; -import org.apache.jorphan.util.JOrphanUtils; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import com.github.tomakehurst.wiremock.WireMockServer; import com.github.tomakehurst.wiremock.client.WireMock; import com.github.tomakehurst.wiremock.core.WireMockConfiguration; +import com.github.tomakehurst.wiremock.http.HttpHeader; +import com.github.tomakehurst.wiremock.verification.LoggedRequest; class TestHTTPHC5Features { + @AfterEach + void closeClientsOfThisThread() { + // the clients are cached per thread and keep their connections and I/O reactor threads alive + HTTPHC5Impl.closeThreadLocalClients(); + } + @Test void usesHttpClientVersionWhenSamplerVersionIsEmpty() { assertEquals(HttpVersionPolicy.NEGOTIATE, HTTPHC5Impl.getHttpVersionPolicy("", "HTTP/2")); @@ -128,25 +129,31 @@ void multiplexesConcurrentRequestsOverASingleHttp2Connection() throws Exception .dynamicHttpsPort() .http2TlsDisabled(false)); server.start(); - try { + try (TcpRelay relay = new TcpRelay(server.httpsPort())) { server.stubFor(get(urlEqualTo("/multiplexed")) .willReturn(aResponse().withStatus(200).withFixedDelay(200))); - URL url = new URL("https://localhost:" + server.httpsPort() + "/multiplexed"); + URL url = new URL("https://localhost:" + relay.getPort() + "/multiplexed"); // warm up, so the connection that gets shared by the concurrent samples is already established HTTPSamplerBase warmUpSampler = newSampler(); warmUpSampler.setHttpVersion("HTTP/2"); assertEquals("200", warmUpSampler.sample(url, HTTPConstants.GET, false, 1).getResponseCode()); + assertEquals(1, relay.getAcceptedConnections(), "the warm up should have opened one connection"); int requests = 4; + Callable sample = () -> { + HTTPSamplerBase sampler = newSampler(); + sampler.setHttpVersion("HTTP/2"); + return sampler.sample(url, HTTPConstants.GET, false, 1); + }; ExecutorService executor = Executors.newFixedThreadPool(requests); try { List> results = new ArrayList<>(); - for (int i = 0; i < requests; i++) { - results.add(executor.submit(() -> { - HTTPSamplerBase sampler = newSampler(); - sampler.setHttpVersion("HTTP/2"); - return sampler.sample(url, HTTPConstants.GET, false, 1); - })); + results.add(executor.submit(sample)); + // let the first sample lease the pooled connection before the others ask for one, + // otherwise they race for it and the pool opens a second connection + Thread.sleep(50); + for (int i = 1; i < requests; i++) { + results.add(executor.submit(sample)); } for (Future result : results) { HTTPSampleResult sampleResult = result.get(30, TimeUnit.SECONDS); @@ -156,52 +163,55 @@ void multiplexesConcurrentRequestsOverASingleHttp2Connection() throws Exception } finally { executor.shutdownNow(); } + assertEquals(1, relay.getAcceptedConnections(), + "the concurrent samples should have been multiplexed over the established connection " + + "instead of opening a connection each"); } finally { server.stop(); } } - @Test - void enablesMessageMultiplexingWithoutRequiringHttpClient55() throws Exception { - byte[] classBytes; - try (InputStream classFile = HTTPHC5Impl.class.getResourceAsStream("HTTPHC5Impl.class")) { - classBytes = classFile.readAllBytes(); - } - assertTrue(new String(classBytes, StandardCharsets.ISO_8859_1).contains("setMessageMultiplexing"), - "HTTP/2 message multiplexing should be enabled"); - assertFalse(hasMethodReference(classBytes, - "org/apache/hc/client5/http/impl/nio/PoolingAsyncClientConnectionManagerBuilder", - "setMessageMultiplexing"), - "setMessageMultiplexing was added in HttpClient 5.5 and must not be linked directly"); - } - @Test void appliesHttp2ProtocolSettings() { H2Config config = HTTPHC5Impl.createHttp2Config(); - assertEquals(8192, config.getHeaderTableSize(), "HPACK dynamic table size"); assertTrue(config.isCompressionEnabled(), "HPACK header compression"); - assertEquals(250, config.getMaxConcurrentStreams()); - assertEquals(16 * 1024 * 1024, config.getInitialWindowSize(), + assertEquals(HTTPHC5Impl.DEFAULT_HTTP_2_INITIAL_WINDOW_SIZE, config.getInitialWindowSize(), "the 64 kB default of HTTP/2 throttles a download to that window per round trip"); - assertEquals(65536, config.getMaxFrameSize()); assertFalse(config.isPushEnabled(), "server push is dropped by JMeter, so it must not be announced"); + // the settings JMeter has no opinion on are left at the defaults of HttpClient + assertEquals(H2Config.DEFAULT.getHeaderTableSize(), config.getHeaderTableSize()); + assertEquals(H2Config.DEFAULT.getMaxConcurrentStreams(), config.getMaxConcurrentStreams()); + assertEquals(H2Config.DEFAULT.getMaxFrameSize(), config.getMaxFrameSize()); } @Test - void doesNotRequireProtocolUpgradeConfiguration() throws Exception { - try (InputStream classFile = HTTPHC5Impl.class.getResourceAsStream("HTTPHC5Impl.class")) { - assertFalse(new String(classFile.readAllBytes(), StandardCharsets.ISO_8859_1) - .contains("setProtocolUpgradeEnabled")); - } + void overridesHttp2ProtocolSettingsWithJMeterProperties() { + Properties properties = new Properties(); + properties.setProperty("httpclient5.h2.header_table_size", "4096"); + properties.setProperty("httpclient5.h2.header_compression", "false"); + properties.setProperty("httpclient5.h2.max_concurrent_streams", "42"); + properties.setProperty("httpclient5.h2.initial_window_size", "131072"); + properties.setProperty("httpclient5.h2.max_frame_size", "32768"); + properties.setProperty("httpclient5.h2.push_enabled", "true"); + + H2Config config = HTTPHC5Impl.createHttp2Config(properties); + + assertEquals(4096, config.getHeaderTableSize()); + assertFalse(config.isCompressionEnabled()); + assertEquals(42, config.getMaxConcurrentStreams()); + assertEquals(131072, config.getInitialWindowSize()); + assertEquals(32768, config.getMaxFrameSize()); + assertTrue(config.isPushEnabled()); } @Test - void doesNotRequireHttpAsyncClassicAdapter() throws Exception { - try (InputStream classFile = HTTPHC5Impl.class.getResourceAsStream("HTTPHC5Impl.class")) { - assertFalse(hasMethodReference(classFile.readAllBytes(), - "org/apache/hc/client5/http/impl/async/HttpAsyncClients", "classic")); - } + void keepsHttp2ProtocolSettingsWhenAPropertyIsNotANumber() { + Properties properties = new Properties(); + properties.setProperty("httpclient5.h2.initial_window_size", "not a number"); + + assertEquals(HTTPHC5Impl.DEFAULT_HTTP_2_INITIAL_WINDOW_SIZE, + HTTPHC5Impl.createHttp2Config(properties).getInitialWindowSize()); } @Test @@ -510,7 +520,8 @@ void setsSentBytesCorrectlyForGetRequest() throws Exception { new URL(server.url("/sentBytesGet")), HTTPConstants.GET, false, 1); assertEquals("200", result.getResponseCode()); - assertTrue(result.getSentBytes() > 0, "sentBytes should be greater than 0"); + assertEquals(wireSize(singleRequest(server, "/sentBytesGet")), result.getSentBytes(), + "sentBytes should be the number of bytes the server received"); } finally { server.stop(); } @@ -531,12 +542,37 @@ void setsSentBytesCorrectlyForPostRequest() throws Exception { new URL(server.url("/sentBytesPost")), HTTPConstants.POST, false, 1); assertEquals("200", result.getResponseCode()); - assertTrue(result.getSentBytes() > "hello world".length(), "sentBytes should include request line, headers, and body"); + LoggedRequest request = singleRequest(server, "/sentBytesPost"); + assertArrayEquals("hello world".getBytes(StandardCharsets.UTF_8), request.getBody()); + assertEquals(wireSize(request), result.getSentBytes(), + "sentBytes should cover the request line, the headers and the body"); } finally { server.stop(); } } + /** Returns the only request the server received for the given path. */ + private static LoggedRequest singleRequest(WireMockServer server, String path) { + List requests = server.findAll(anyRequestedFor(urlEqualTo(path))); + assertEquals(1, requests.size(), () -> "expected exactly one request for " + path + ", got " + requests); + return requests.get(0); + } + + /** Number of bytes the given request occupies on an HTTP/1.1 connection. */ + private static long wireSize(LoggedRequest request) { + StringBuilder head = new StringBuilder() + .append(request.getMethod().getName()).append(' ') + .append(request.getUrl()).append(' ') + .append(HTTPConstants.HTTP_VERSION_1_1).append("\r\n"); + for (HttpHeader header : request.getHeaders().all()) { + for (String value : header.values()) { + head.append(header.key()).append(": ").append(value).append("\r\n"); + } + } + head.append("\r\n"); + return head.toString().getBytes(StandardCharsets.ISO_8859_1).length + request.getBody().length; + } + @Test void sendsConditionalRequestForCachedResource() throws Exception { WireMockServer server = createServer(); @@ -793,7 +829,6 @@ void setsConnectTimeForHttp11() throws Exception { new HTTPHC5Impl.ConnectTimeMeasuringConnectionManager(new SlowConnectingConnectionManager(50)); connectionManager.connect(null, null, context); - Thread.sleep(50); result.sampleEnd(); assertTrue(result.getConnectTime() >= 50, @@ -883,11 +918,14 @@ void setsConnectTimeForHttp2() throws Exception { */ @Test void revalidatesPooledHttp2ConnectionAfterIdleGap() throws Exception { + long validateAfterInactivity = 250; + long configuredValidateAfterInactivity = HTTPHC5Impl.validateAfterInactivityMillis; + HTTPHC5Impl.validateAfterInactivityMillis = validateAfterInactivity; WireMockServer server = new WireMockServer(WireMockConfiguration.wireMockConfig() .dynamicHttpsPort() .http2TlsDisabled(false)); server.start(); - try (ConnectionDroppingRelay relay = new ConnectionDroppingRelay(server.httpsPort())) { + try (TcpRelay relay = new TcpRelay(server.httpsPort())) { server.stubFor(get(urlEqualTo("/idle")).willReturn(aResponse().withStatus(200))); HTTPSamplerBase sampler = newSampler(); sampler.setHttpVersion("HTTP/2"); @@ -896,7 +934,7 @@ void revalidatesPooledHttp2ConnectionAfterIdleGap() throws Exception { assertEquals("200", sampler.sample(url, HTTPConstants.GET, false, 1).getResponseCode()); - Thread.sleep(validateAfterInactivityMillis() + 500); + Thread.sleep(validateAfterInactivity * 2); relay.dropConnectionOnNextRequest(); // the same sampler on the same thread, so the cached HTTP/2 client and its pool are reused @@ -908,6 +946,7 @@ void revalidatesPooledHttp2ConnectionAfterIdleGap() throws Exception { assertEquals(2, relay.getAcceptedConnections(), "the stale connection should have been discarded and replaced by a new one"); } finally { + HTTPHC5Impl.validateAfterInactivityMillis = configuredValidateAfterInactivity; server.stop(); } } @@ -923,7 +962,7 @@ void doesNotRevalidatePooledHttp2ConnectionWithoutAnIdleGap() throws Exception { .dynamicHttpsPort() .http2TlsDisabled(false)); server.start(); - try (ConnectionDroppingRelay relay = new ConnectionDroppingRelay(server.httpsPort())) { + try (TcpRelay relay = new TcpRelay(server.httpsPort())) { server.stubFor(get(urlEqualTo("/busy")).willReturn(aResponse().withStatus(200))); HTTPSamplerBase sampler = newSampler(); sampler.setHttpVersion("HTTP/2"); @@ -943,123 +982,6 @@ void doesNotRevalidatePooledHttp2ConnectionWithoutAnIdleGap() throws Exception { } } - private static long validateAfterInactivityMillis() { - return JMeterUtils.getPropDefault("httpclient5.validate_after_inactivity", 2000L); - } - - /** - * Relays TCP traffic to a backend server and can drop a single relayed connection as soon as the - * client writes to it again, which is how an idle connection closed by the server (or by a load - * balancer) looks to a client that has not noticed the close yet. New connections are relayed as - * usual, so the backend stays reachable. - */ - private static final class ConnectionDroppingRelay implements Closeable { - - private final ServerSocket serverSocket; - private final int backendPort; - private final ExecutorService executor = Executors.newCachedThreadPool(runnable -> { - Thread thread = new Thread(runnable, "relay"); - thread.setDaemon(true); - return thread; - }); - private final AtomicInteger acceptedConnections = new AtomicInteger(); - private volatile RelayedConnection currentConnection; - - ConnectionDroppingRelay(int backendPort) throws IOException { - this.backendPort = backendPort; - this.serverSocket = new ServerSocket(0, 0, InetAddress.getLoopbackAddress()); - executor.execute(this::acceptConnections); - } - - int getPort() { - return serverSocket.getLocalPort(); - } - - int getAcceptedConnections() { - return acceptedConnections.get(); - } - - void dropConnectionOnNextRequest() { - RelayedConnection connection = currentConnection; - if (connection == null) { - throw new IllegalStateException("No connection has been relayed yet"); - } - connection.doomed = true; - } - - private void acceptConnections() { - while (!serverSocket.isClosed()) { - RelayedConnection connection; - try { - Socket client = serverSocket.accept(); - connection = new RelayedConnection(client, - new Socket(InetAddress.getLoopbackAddress(), backendPort)); - } catch (IOException e) { - return; - } - acceptedConnections.incrementAndGet(); - currentConnection = connection; - executor.execute(connection::relayRequests); - executor.execute(connection::relayResponses); - } - } - - @Override - public void close() throws IOException { - serverSocket.close(); - executor.shutdownNow(); - } - } - - private static final class RelayedConnection { - - private final Socket client; - private final Socket backend; - private volatile boolean doomed; - - RelayedConnection(Socket client, Socket backend) { - this.client = client; - this.backend = backend; - } - - void relayRequests() { - try { - InputStream input = client.getInputStream(); - OutputStream output = backend.getOutputStream(); - byte[] buffer = new byte[8192]; - int read; - while ((read = input.read(buffer)) != -1 && !doomed) { - output.write(buffer, 0, read); - output.flush(); - } - } catch (IOException ignored) { // NOSONAR the connection is closed below in any case - // the peer went away, which ends the relaying just as well - } - close(); - } - - void relayResponses() { - try { - InputStream input = backend.getInputStream(); - OutputStream output = client.getOutputStream(); - byte[] buffer = new byte[8192]; - int read; - while ((read = input.read(buffer)) != -1) { - output.write(buffer, 0, read); - output.flush(); - } - } catch (IOException ignored) { // NOSONAR the connection is closed below in any case - // the peer went away, which ends the relaying just as well - } - close(); - } - - private void close() { - JOrphanUtils.closeQuietly(client); - JOrphanUtils.closeQuietly(backend); - } - } - private static HTTPSamplerBase newSampler() { return HTTPSamplerFactory.newInstance("HttpClient5"); } @@ -1067,71 +989,4 @@ private static HTTPSamplerBase newSampler() { private static WireMockServer createServer() { return new WireMockServer(WireMockConfiguration.wireMockConfig().dynamicPort()); } - - private static boolean hasMethodReference(byte[] classBytes, String className, String methodName) throws IOException { - try (DataInputStream input = new DataInputStream(new ByteArrayInputStream(classBytes))) { - input.readInt(); - input.readUnsignedShort(); - input.readUnsignedShort(); - int constantPoolCount = input.readUnsignedShort(); - int[] tags = new int[constantPoolCount]; - int[] firstReferences = new int[constantPoolCount]; - int[] secondReferences = new int[constantPoolCount]; - String[] utf8Values = new String[constantPoolCount]; - int i = 1; - while (i < constantPoolCount) { - tags[i] = input.readUnsignedByte(); - switch (tags[i]) { - case 1: - utf8Values[i] = input.readUTF(); - break; - case 3: - case 4: - input.readInt(); - break; - case 5: - case 6: - input.readLong(); - i++; - break; - case 7: - case 8: - case 16: - case 19: - case 20: - firstReferences[i] = input.readUnsignedShort(); - break; - case 9: - case 10: - case 11: - case 12: - case 17: - case 18: - firstReferences[i] = input.readUnsignedShort(); - secondReferences[i] = input.readUnsignedShort(); - break; - case 15: - input.readUnsignedByte(); - firstReferences[i] = input.readUnsignedShort(); - break; - default: - throw new IOException("Unknown class-file constant-pool tag " + tags[i]); - } - i++; - } - for (int methodReferenceIndex = 1; methodReferenceIndex < constantPoolCount; methodReferenceIndex++) { - if (tags[methodReferenceIndex] != 10) { - continue; - } - int classIndex = firstReferences[methodReferenceIndex]; - int nameAndTypeIndex = secondReferences[methodReferenceIndex]; - String referencedClass = utf8Values[firstReferences[classIndex]]; - String referencedMethod = utf8Values[firstReferences[nameAndTypeIndex]]; - if (className.equals(referencedClass) && methodName.equals(referencedMethod)) { - return true; - } - } - return false; - } - } } diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java index ebe43713f9e..aff55de64f1 100644 --- a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java +++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java @@ -51,6 +51,7 @@ import java.util.Map; import java.util.Optional; import java.util.Properties; +import java.util.concurrent.Callable; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; @@ -71,6 +72,7 @@ import org.apache.jmeter.samplers.SampleResult; import org.apache.jmeter.util.JsseSSLManager; import org.apache.jmeter.util.SSLManager; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import com.github.tomakehurst.wiremock.WireMockServer; @@ -78,6 +80,12 @@ class TestHTTPJavaFeatures { + @AfterEach + void closeSharedClients() { + // the HTTP/2 clients are shared between all threads and keep their connections and selector alive + HTTPJavaImpl.releaseSharedHttp2Resources(); + } + @Test void usesHttpClientVersionWhenSamplerVersionIsEmpty() { assertTrue(HTTPJavaImpl.isHttp2("", "HTTP/2")); @@ -307,21 +315,31 @@ void sharesHttp2ClientsBetweenThreadsForMultiplexing() throws Exception { void multiplexesConcurrentHttp2RequestsOverOneConnection() throws Exception { WireMockServer server = createServer(); server.start(); - try { + try (TcpRelay relay = new TcpRelay(server.port())) { server.stubFor(get(urlEqualTo("/multiplexed")) .willReturn(aResponse().withStatus(200).withFixedDelay(200).withBody("multiplexed"))); + URL url = new URL("http://localhost:" + relay.getPort() + "/multiplexed"); + // warm up, so the connection that gets shared by the concurrent samples is already established + HTTPSamplerBase warmUpSampler = newSampler(); + warmUpSampler.setHttpVersion("HTTP/2"); + assertEquals("200", warmUpSampler.sample(url, HTTPConstants.GET, false, 1).getResponseCode()); + assertEquals(1, relay.getAcceptedConnections(), "the warm up should have opened one connection"); int requests = 4; + Callable sample = () -> { + HTTPSamplerBase sampler = newSampler(); + sampler.setHttpVersion("HTTP/2"); + return sampler.sample(url, HTTPConstants.GET, false, 1); + }; ExecutorService executor = Executors.newFixedThreadPool(requests); try { List> results = new ArrayList<>(); - for (int i = 0; i < requests; i++) { - results.add(executor.submit(() -> { - HTTPSamplerBase sampler = newSampler(); - sampler.setHttpVersion("HTTP/2"); - return sampler.sample( - new URL(server.url("/multiplexed")), HTTPConstants.GET, false, 1); - })); + results.add(executor.submit(sample)); + // let the first sample claim the established connection before the others ask for one, + // otherwise they race for it and the client opens a second connection + Thread.sleep(50); + for (int i = 1; i < requests; i++) { + results.add(executor.submit(sample)); } for (Future result : results) { HTTPSampleResult sampleResult = result.get(60, TimeUnit.SECONDS); @@ -333,6 +351,9 @@ void multiplexesConcurrentHttp2RequestsOverOneConnection() throws Exception { } finally { executor.shutdownNow(); } + assertEquals(1, relay.getAcceptedConnections(), + "the concurrent samples should have been multiplexed over the established connection " + + "instead of opening a connection each"); } finally { server.stop(); } From b1b713ce7ec0a9f8149a7f87dfb873f930bbba49 Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Tue, 18 Aug 2026 11:04:22 +0200 Subject: [PATCH 48/80] Localize HTTP/2 Negotiate label in HttpVersionComboBox and sort resource keys - Replace hardcoded "HTTP/2 Negotiate" string in HttpVersionComboBox with `http_version_2_negotiate` resource string - Add `http_version_2_negotiate` to `messages.properties` - Sort `http_version` property alphabetically across all 12 message bundles - Add unit test in `TestHttpVersionComboBox` verifying HTTP/2 label rendering --- .../jmeter/resources/messages.properties | 5 +++-- .../jmeter/resources/messages_de.properties | 2 +- .../jmeter/resources/messages_es.properties | 2 +- .../jmeter/resources/messages_fr.properties | 4 ++-- .../jmeter/resources/messages_ja.properties | 2 +- .../jmeter/resources/messages_ko.properties | 4 ++-- .../jmeter/resources/messages_pl.properties | 2 +- .../resources/messages_pt_BR.properties | 2 +- .../jmeter/resources/messages_tr.properties | 2 +- .../resources/messages_zh_CN.properties | 2 +- .../resources/messages_zh_TW.properties | 2 +- .../http/gui/HttpVersionComboBox.java | 13 ++++++------- .../http/gui/TestHttpVersionComboBox.java | 19 +++++++++++++++++++ 13 files changed, 40 insertions(+), 21 deletions(-) diff --git a/src/core/src/main/resources/org/apache/jmeter/resources/messages.properties b/src/core/src/main/resources/org/apache/jmeter/resources/messages.properties index 3ba59ea9f1f..46d3a28f12e 100644 --- a/src/core/src/main/resources/org/apache/jmeter/resources/messages.properties +++ b/src/core/src/main/resources/org/apache/jmeter/resources/messages.properties @@ -472,12 +472,13 @@ html_assertion_label=HTML Assertion html_assertion_title=HTML Assertion html_extractor_title=CSS Selector Extractor html_extractor_type=CSS Selector Extractor Implementation -http_implementation=Implementation: -http_version=HTTP Version: html_report=Generate HTML report +http_implementation=Implementation: http_response_code=HTTP response code http_url_rewriting_modifier_title=HTTP URL Re-writing Modifier http_user_parameter_modifier=HTTP User Parameter Modifier +http_version=HTTP Version: +http_version_2_negotiate=HTTP/2 Negotiate httpmirror_max_pool_size=Max number of Threads: httpmirror_max_queue_size=Max queue size: httpmirror_settings=Settings diff --git a/src/core/src/main/resources/org/apache/jmeter/resources/messages_de.properties b/src/core/src/main/resources/org/apache/jmeter/resources/messages_de.properties index 17ff43ab91f..962a57dc7cb 100644 --- a/src/core/src/main/resources/org/apache/jmeter/resources/messages_de.properties +++ b/src/core/src/main/resources/org/apache/jmeter/resources/messages_de.properties @@ -16,7 +16,6 @@ # about=Über Apache JMeter -http_version=HTTP-Version\: add=Hinzufügen add_as_child=Als ein Kind hinzufügen add_parameter=Variable hinzufügen @@ -238,6 +237,7 @@ html_assertion_label=HTML Bericht html_assertion_title=Titel des HTML Bericht http_url_rewriting_modifier_title=HTTP URL Re-writing Bezeichner http_user_parameter_modifier=HTTP User Parameter Bezeichner +http_version=HTTP-Version\: httpmirror_title=HTTP Spiegel if_controller_evaluate_all=Für alle Unterelemente auswerten if_controller_label=Bedingung (Javascript) diff --git a/src/core/src/main/resources/org/apache/jmeter/resources/messages_es.properties b/src/core/src/main/resources/org/apache/jmeter/resources/messages_es.properties index dcb50db6a35..195b37a88ed 100644 --- a/src/core/src/main/resources/org/apache/jmeter/resources/messages_es.properties +++ b/src/core/src/main/resources/org/apache/jmeter/resources/messages_es.properties @@ -273,10 +273,10 @@ html_assertion_file=Escribir el reporte JTidy en fichero html_assertion_label=Aserción HTML html_assertion_title=Aserción HTML http_implementation=Implementación HTTP\: -http_version=Versión HTTP\: http_response_code=código de respuesta HTTP http_url_rewriting_modifier_title=Modificador de re-escritura HTTP URL http_user_parameter_modifier=Modificador de Parámetro de Usuario HTTP +http_version=Versión HTTP\: httpmirror_title=Servidor espejo HTTP id_prefix=Prefijo ID id_suffix=Sufijo ID diff --git a/src/core/src/main/resources/org/apache/jmeter/resources/messages_fr.properties b/src/core/src/main/resources/org/apache/jmeter/resources/messages_fr.properties index 209f0a84645..0c65c1c1e64 100644 --- a/src/core/src/main/resources/org/apache/jmeter/resources/messages_fr.properties +++ b/src/core/src/main/resources/org/apache/jmeter/resources/messages_fr.properties @@ -466,12 +466,12 @@ html_assertion_label=Assertion HTML html_assertion_title=Assertion HTML html_extractor_title=Extracteur par Sélecteur CSS html_extractor_type=Implémentation de l'extracteur Sélecteur CSS -http_implementation=Implémentation \: -http_version=Version HTTP \: html_report=Générer le rapport HTML +http_implementation=Implémentation \: http_response_code=Code de réponse HTTP http_url_rewriting_modifier_title=Transcripteur d'URL HTTP http_user_parameter_modifier=Modificateur de paramètre utilisateur HTTP +http_version=Version HTTP \: httpmirror_max_pool_size=Taille maximum du pool d'unités \: httpmirror_max_queue_size=Taille maximum de la file d'attente \: httpmirror_settings=Paramètres diff --git a/src/core/src/main/resources/org/apache/jmeter/resources/messages_ja.properties b/src/core/src/main/resources/org/apache/jmeter/resources/messages_ja.properties index 6fe16f42798..cebe4c51080 100644 --- a/src/core/src/main/resources/org/apache/jmeter/resources/messages_ja.properties +++ b/src/core/src/main/resources/org/apache/jmeter/resources/messages_ja.properties @@ -176,10 +176,10 @@ grouping_store_first_only=各グループの最初のサンプラーだけ保存 header_manager_title=HTTP ヘッダマネージャ headers_stored=ヘッダーマネージャに保存されているヘッダ help=ヘルプ -http_version=HTTP バージョン\: http_response_code=HTTP応答コード http_url_rewriting_modifier_title=HTTP URL-Rewriting 修飾子 http_user_parameter_modifier=HTTPユーザーパラメータの変更 +http_version=HTTP バージョン\: id_prefix=ID接頭辞 id_suffix=ID 接尾辞 if_controller_label=条件 diff --git a/src/core/src/main/resources/org/apache/jmeter/resources/messages_ko.properties b/src/core/src/main/resources/org/apache/jmeter/resources/messages_ko.properties index 1de63f00cd6..f806e0add0e 100644 --- a/src/core/src/main/resources/org/apache/jmeter/resources/messages_ko.properties +++ b/src/core/src/main/resources/org/apache/jmeter/resources/messages_ko.properties @@ -470,12 +470,12 @@ html_assertion_label=HTML Assertion html_assertion_title=HTML Assertion html_extractor_title=CSS Selector 추출기 html_extractor_type=CSS Selector 추출기 구현 -http_implementation=구현\: -http_version=HTTP 버전\: html_report=HTML 보고서 생성 +http_implementation=구현\: http_response_code=HTTP 응답 코드 http_url_rewriting_modifier_title=HTTP URL Re-writing Modifier http_user_parameter_modifier=HTTP User Parameter Modifier +http_version=HTTP 버전\: httpmirror_max_pool_size=최대 쓰레드 개수\: httpmirror_max_queue_size=최대 큐 크기\: httpmirror_settings=설정 diff --git a/src/core/src/main/resources/org/apache/jmeter/resources/messages_pl.properties b/src/core/src/main/resources/org/apache/jmeter/resources/messages_pl.properties index 9d81a10d112..b67830d5f63 100644 --- a/src/core/src/main/resources/org/apache/jmeter/resources/messages_pl.properties +++ b/src/core/src/main/resources/org/apache/jmeter/resources/messages_pl.properties @@ -16,7 +16,6 @@ # about=O programie Apache JMeter -http_version=Wersja HTTP\: action_check_message=Aktualnie jest przeprowadzany test, zatrzymaj albo wyłącz test, aby uruchomić to polecenie action_check_title=Test uruchomiony active_total_threads_tooltip=Uruchamianie wątków / całkowita liczba wątków do uruchomienia @@ -167,6 +166,7 @@ functional_mode=Tryb testów funkcjonalnych (i.e. zapisuj dane wejściowe i wyj functional_mode_explanation=Włączenie trybu testów funkcjonalnych może wyraźnie obniżyć wydajność. help=Pomoc help_node=Co to za węzeł? +http_version=Wersja HTTP\: iterator_num=Liczba powtórzeń\: ja=Japoński jms_client_type=Klient diff --git a/src/core/src/main/resources/org/apache/jmeter/resources/messages_pt_BR.properties b/src/core/src/main/resources/org/apache/jmeter/resources/messages_pt_BR.properties index e278975db23..1b2484adaad 100644 --- a/src/core/src/main/resources/org/apache/jmeter/resources/messages_pt_BR.properties +++ b/src/core/src/main/resources/org/apache/jmeter/resources/messages_pt_BR.properties @@ -261,10 +261,10 @@ html_assertion_file=Escrever relatório do JTidy em arquivo html_assertion_label=Asserção HTML html_assertion_title=Asserção HTML http_implementation=Implementação\: -http_version=Versão HTTP\: http_response_code=Código da Resposta HTTP http_url_rewriting_modifier_title=Modificador de Re-escrita de URL HTTP http_user_parameter_modifier=Modificador de Parâmetros HTTP do Usuário +http_version=Versão HTTP\: httpmirror_title=Servidor Espelho HTTP id_prefix=Prefixo do ID id_suffix=Sufixo do ID diff --git a/src/core/src/main/resources/org/apache/jmeter/resources/messages_tr.properties b/src/core/src/main/resources/org/apache/jmeter/resources/messages_tr.properties index a18b1fffbf4..0f957e13222 100644 --- a/src/core/src/main/resources/org/apache/jmeter/resources/messages_tr.properties +++ b/src/core/src/main/resources/org/apache/jmeter/resources/messages_tr.properties @@ -246,10 +246,10 @@ html_assertion_file=JTidy raporunu dosyaya yaz html_assertion_label=HTML Doğrulama html_assertion_title=HTML Doğrulama http_implementation=Uygulaması\: -http_version=HTTP sürümü\: http_response_code=HTTP cevap kodu http_url_rewriting_modifier_title=HTTP URL Yeniden Yazma Niteleyicisi http_user_parameter_modifier=HTTP Kullanıcı Parametresi Niteleyicisi +http_version=HTTP sürümü\: httpmirror_title=HTTP Ayna Sunucusu id_prefix=ID Öneki id_suffix=ID Soneki diff --git a/src/core/src/main/resources/org/apache/jmeter/resources/messages_zh_CN.properties b/src/core/src/main/resources/org/apache/jmeter/resources/messages_zh_CN.properties index 253e35f83ed..edf799c6622 100644 --- a/src/core/src/main/resources/org/apache/jmeter/resources/messages_zh_CN.properties +++ b/src/core/src/main/resources/org/apache/jmeter/resources/messages_zh_CN.properties @@ -433,10 +433,10 @@ html_assertion_title=HTML断言 html_extractor_title=CSS/JQuery提取器 html_extractor_type=CSS 选择器提取器实现 http_implementation=实现: -http_version=HTTP 版本: http_response_code=HTTP响应代码 http_url_rewriting_modifier_title=HTTP URL 重写修饰符 http_user_parameter_modifier=HTTP 用户参数修饰符 +http_version=HTTP 版本: httpmirror_max_pool_size=最大线程数: httpmirror_max_queue_size=最大队列长度 httpmirror_settings=设置 diff --git a/src/core/src/main/resources/org/apache/jmeter/resources/messages_zh_TW.properties b/src/core/src/main/resources/org/apache/jmeter/resources/messages_zh_TW.properties index 49012192320..2d75190fb03 100644 --- a/src/core/src/main/resources/org/apache/jmeter/resources/messages_zh_TW.properties +++ b/src/core/src/main/resources/org/apache/jmeter/resources/messages_zh_TW.properties @@ -206,10 +206,10 @@ headers_stored=標頭管理員中儲存的標頭資料 help=輔助說明 html_assertion_label=HTML 驗證 html_assertion_title=HTML 驗證 -http_version=HTTP 版本: http_response_code=HTTP 回應代碼 http_url_rewriting_modifier_title=HTTP URL 重導修飾詞 http_user_parameter_modifier=HTTP 使用者參數修飾詞 +http_version=HTTP 版本: id_prefix=ID 前置字串 id_suffix=ID 後置字串 if_controller_label=條件 diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/gui/HttpVersionComboBox.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/gui/HttpVersionComboBox.java index 9f5c0c8ed65..fcd367a0aed 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/gui/HttpVersionComboBox.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/gui/HttpVersionComboBox.java @@ -29,6 +29,7 @@ import org.apache.jmeter.protocol.http.sampler.HTTPSamplerFactory; import org.apache.jmeter.protocol.http.util.HTTPConstants; +import org.apache.jmeter.util.JMeterUtils; /** * Combo box for the HTTP version of a sampler, offering only the versions the selected @@ -41,12 +42,6 @@ public class HttpVersionComboBox extends JComboBox { private static final long serialVersionUID = 1L; - /** - * Label of {@link HTTPConstants#HTTP_VERSION_2}, spelled out as HTTP/2 falls back to HTTP/1.1 - * when the server does not support it. - */ - private static final String HTTP_VERSION_2_LABEL = "HTTP/2 Negotiate"; // $NON-NLS-1$ - public HttpVersionComboBox() { super(HTTPSamplerFactory.getHttpVersions("")); setRenderer(new DefaultListCellRenderer() { @@ -60,9 +55,13 @@ public Component getListCellRendererComponent(JList list, Object value, int i }); } + /** + * Label of {@link HTTPConstants#HTTP_VERSION_2}, spelled out as HTTP/2 falls back to HTTP/1.1 + * when the server does not support it. + */ private static Object getLabel(Object value) { if (HTTPConstants.HTTP_VERSION_2.equals(value)) { - return HTTP_VERSION_2_LABEL; + return JMeterUtils.getResString("http_version_2_negotiate"); // $NON-NLS-1$ } return value; } diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/gui/TestHttpVersionComboBox.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/gui/TestHttpVersionComboBox.java index 88027121c52..63478b0e903 100644 --- a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/gui/TestHttpVersionComboBox.java +++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/gui/TestHttpVersionComboBox.java @@ -22,14 +22,25 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.Locale; import javax.swing.JComboBox; +import javax.swing.JLabel; +import javax.swing.JList; +import javax.swing.ListCellRenderer; import org.apache.jmeter.protocol.http.sampler.HTTPSamplerFactory; +import org.apache.jmeter.util.JMeterUtils; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; public class TestHttpVersionComboBox { + @BeforeAll + static void setUp() { + JMeterUtils.setLocale(Locale.ENGLISH); + } + private static JComboBox implementationComboBox() { JComboBox implementation = new JComboBox<>(HTTPSamplerFactory.getImplementations()); implementation.addItem(""); @@ -110,4 +121,12 @@ void dropsAValueWhichIsNotOfferedWhenTheImplementationIsSetAgain() { assertEquals(Arrays.asList("", "HTTP/1.1"), items(httpVersion)); assertEquals("", httpVersion.getSelectedItem()); } + + @Test + void rendersHttp2AsNegotiate() { + HttpVersionComboBox httpVersion = new HttpVersionComboBox(); + ListCellRenderer renderer = httpVersion.getRenderer(); + JLabel label = (JLabel) renderer.getListCellRendererComponent(new JList<>(), "HTTP/2", 0, false, false); + assertEquals("HTTP/2 Negotiate", label.getText()); + } } From 1dfc30dd1e36a4089676360e042ff4f5029f12b9 Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Tue, 18 Aug 2026 11:45:18 +0200 Subject: [PATCH 49/80] Fix httpVersion property serialization in HttpDefaultsGui and reset combos in clearGui - Normalize blank `httpVersion` and `httpImplementation` selections to `null` in `HttpDefaultsGui` and `HttpTestSampleGui` - Reset implementation and version combo boxes in `clearGui()` for both HTTP GUI components - Add unit tests in `TestHttpDefaultsGui` and `TestHttpTestSampleGui` --- .../http/config/gui/HttpDefaultsGui.java | 10 ++- .../http/control/gui/HttpTestSampleGui.java | 8 +- .../http/config/gui/TestHttpDefaultsGui.java | 83 +++++++++++++++++++ .../control/gui/TestHttpTestSampleGui.java | 34 +++++++- 4 files changed, 129 insertions(+), 6 deletions(-) create mode 100644 src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/config/gui/TestHttpDefaultsGui.java diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/config/gui/HttpDefaultsGui.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/config/gui/HttpDefaultsGui.java index 71bf2ca5e2a..fb944ea228b 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/config/gui/HttpDefaultsGui.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/config/gui/HttpDefaultsGui.java @@ -153,14 +153,20 @@ public void modifyTestElement(TestElement config) { config.removeProperty(httpSchema.getIpSourceType()); } - config.set(httpSchema.getImplementation(), String.valueOf(httpImplementation.getSelectedItem())); - config.set(httpSchema.getHttpVersion(), String.valueOf(httpVersion.getSelectedItem())); + String selectedImplementation = (String) httpImplementation.getSelectedItem(); + config.set(httpSchema.getImplementation(), + StringUtilities.isBlank(selectedImplementation) ? null : selectedImplementation); + String selectedHttpVersion = (String) httpVersion.getSelectedItem(); + config.set(httpSchema.getHttpVersion(), + StringUtilities.isBlank(selectedHttpVersion) ? null : selectedHttpVersion); } @Override public void clearGui() { super.clearGui(); urlConfigGui.clear(); + httpImplementation.setSelectedItem(""); + httpVersion.setSelectedItem(""); } @Override diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/gui/HttpTestSampleGui.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/gui/HttpTestSampleGui.java index 95849db5c41..9786212be6d 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/gui/HttpTestSampleGui.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/gui/HttpTestSampleGui.java @@ -180,10 +180,10 @@ public void modifyTestElement(TestElement sampler) { } else { samplerBase.removeProperty(httpSchema.getIpSourceType()); } - String selectedImplementation = String.valueOf(httpImplementation.getSelectedItem()); + String selectedImplementation = (String) httpImplementation.getSelectedItem(); samplerBase.set(httpSchema.getImplementation(), StringUtilities.isBlank(selectedImplementation) ? null : selectedImplementation); - String selectedHttpVersion = String.valueOf(httpVersion.getSelectedItem()); + String selectedHttpVersion = (String) httpVersion.getSelectedItem(); samplerBase.set(httpSchema.getHttpVersion(), StringUtilities.isBlank(selectedHttpVersion) ? null : selectedHttpVersion); } @@ -400,6 +400,10 @@ public Dimension getPreferredSize() { public void clearGui() { super.clearGui(); urlConfigGui.clear(); + if (!isAJP) { + httpImplementation.setSelectedItem(""); + httpVersion.setSelectedItem(""); + } } private void enableConcurrentDwn() { diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/config/gui/TestHttpDefaultsGui.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/config/gui/TestHttpDefaultsGui.java new file mode 100644 index 00000000000..0a457a0a3b0 --- /dev/null +++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/config/gui/TestHttpDefaultsGui.java @@ -0,0 +1,83 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you 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.apache.jmeter.protocol.http.config.gui; + +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 org.apache.jmeter.config.ConfigTestElement; +import org.apache.jmeter.protocol.http.sampler.HTTPSamplerBaseSchema; +import org.apache.jmeter.protocol.http.util.HTTPConstants; +import org.apache.jmeter.testelement.property.JMeterProperty; +import org.apache.jmeter.testelement.property.NullProperty; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +public class TestHttpDefaultsGui { + private HttpDefaultsGui gui; + + @BeforeEach + public void setUp() { + gui = new HttpDefaultsGui(); + } + + @Test + public void testCreateTestElementDoesNotSetEmptyHttpVersion() { + ConfigTestElement config = (ConfigTestElement) gui.createTestElement(); + JMeterProperty prop = config.getProperty(HTTPSamplerBaseSchema.INSTANCE.getHttpVersion().getName()); + assertInstanceOf(NullProperty.class, prop, "httpVersion property should not be present on default ConfigTestElement"); + } + + @Test + public void testModifyTestElementNormalizesBlankHttpVersionToNull() { + ConfigTestElement config = new ConfigTestElement(); + gui.modifyTestElement(config); + JMeterProperty prop = config.getProperty(HTTPSamplerBaseSchema.INSTANCE.getHttpVersion().getName()); + assertInstanceOf(NullProperty.class, prop, "httpVersion property should not be present when selection is blank"); + } + + @Test + public void testClearGuiResetsHttpVersionAndImplementation() { + ConfigTestElement config = (ConfigTestElement) gui.createTestElement(); + config.set(HTTPSamplerBaseSchema.INSTANCE.getImplementation(), "HttpClient5"); + config.set(HTTPSamplerBaseSchema.INSTANCE.getHttpVersion(), HTTPConstants.HTTP_VERSION_2); + gui.configure(config); + + gui.clearGui(); + + ConfigTestElement clearedConfig = (ConfigTestElement) gui.createTestElement(); + gui.modifyTestElement(clearedConfig); + JMeterProperty versionProp = clearedConfig.getProperty(HTTPSamplerBaseSchema.INSTANCE.getHttpVersion().getName()); + assertInstanceOf(NullProperty.class, versionProp, "httpVersion property should be null/removed after clearGui"); + JMeterProperty implProp = clearedConfig.getProperty(HTTPSamplerBaseSchema.INSTANCE.getImplementation().getName()); + assertInstanceOf(NullProperty.class, implProp, "implementation property should be null/removed after clearGui"); + } + + @Test + public void testModifyTestElementHandlesNullSelectionWithoutSettingLiteralNullString() { + ConfigTestElement config = (ConfigTestElement) gui.createTestElement(); + // Emulate null selection in the comboboxes if any + gui.clearGui(); + gui.modifyTestElement(config); + JMeterProperty versionProp = config.getProperty(HTTPSamplerBaseSchema.INSTANCE.getHttpVersion().getName()); + assertInstanceOf(NullProperty.class, versionProp, "httpVersion property should not be present as 'null' string"); + JMeterProperty implProp = config.getProperty(HTTPSamplerBaseSchema.INSTANCE.getImplementation().getName()); + assertInstanceOf(NullProperty.class, implProp, "implementation property should not be present as 'null' string"); + } +} diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/control/gui/TestHttpTestSampleGui.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/control/gui/TestHttpTestSampleGui.java index 8f45efa147e..96292cb6dee 100644 --- a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/control/gui/TestHttpTestSampleGui.java +++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/control/gui/TestHttpTestSampleGui.java @@ -17,8 +17,14 @@ package org.apache.jmeter.protocol.http.control.gui; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; + import org.apache.jmeter.protocol.http.sampler.HTTPSamplerBase; -import org.junit.jupiter.api.Assertions; +import org.apache.jmeter.protocol.http.sampler.HTTPSamplerBaseSchema; +import org.apache.jmeter.protocol.http.util.HTTPConstants; +import org.apache.jmeter.testelement.property.JMeterProperty; +import org.apache.jmeter.testelement.property.NullProperty; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -37,6 +43,30 @@ public void testCloneSampler() throws Exception { HTTPSamplerBase clonedSampler = (HTTPSamplerBase) sampler.clone(); clonedSampler.setRunningVersion(true); sampler.getArguments().getArgument(0).setValue("new value"); - Assertions.assertEquals("new value", sampler.getArguments().getArgument(0).getValue(), "Sampler didn't clone correctly"); + assertEquals("new value", sampler.getArguments().getArgument(0).getValue(), "Sampler didn't clone correctly"); + } + + @Test + public void testCreateTestElementDoesNotSetEmptyHttpVersion() { + HTTPSamplerBase sampler = (HTTPSamplerBase) gui.createTestElement(); + JMeterProperty prop = sampler.getProperty(HTTPSamplerBaseSchema.INSTANCE.getHttpVersion().getName()); + assertInstanceOf(NullProperty.class, prop, "httpVersion property should not be present on default HTTPSamplerBase"); + } + + @Test + public void testClearGuiResetsHttpVersionAndImplementation() { + HTTPSamplerBase sampler = (HTTPSamplerBase) gui.createTestElement(); + sampler.set(HTTPSamplerBaseSchema.INSTANCE.getImplementation(), "HttpClient5"); + sampler.set(HTTPSamplerBaseSchema.INSTANCE.getHttpVersion(), HTTPConstants.HTTP_VERSION_2); + gui.configure(sampler); + + gui.clearGui(); + + HTTPSamplerBase clearedSampler = (HTTPSamplerBase) gui.createTestElement(); + gui.modifyTestElement(clearedSampler); + JMeterProperty versionProp = clearedSampler.getProperty(HTTPSamplerBaseSchema.INSTANCE.getHttpVersion().getName()); + assertInstanceOf(NullProperty.class, versionProp, "httpVersion property should be null/removed after clearGui"); + JMeterProperty implProp = clearedSampler.getProperty(HTTPSamplerBaseSchema.INSTANCE.getImplementation().getName()); + assertInstanceOf(NullProperty.class, implProp, "implementation property should be null/removed after clearGui"); } } From 5f55e0889d99bff34ea2fb58a9115a60599ab668 Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Tue, 18 Aug 2026 12:03:11 +0200 Subject: [PATCH 50/80] Deduplicate HTTP version constants and match them case-insensitively Remove HTTPConstants.HTTP_2, which duplicated HTTP_VERSION_2. Derive HTTPSamplerBase.HTTP_VERSION from the schema and rename HTTPHCAbstractImpl.HTTP_VERSION to DEFAULT_HTTP_VERSION, so the same simple name no longer means both a property name and its value. HTTPHC5Impl compared "HTTP/2" exactly but "HTTP/2 Strict" ignoring case, so a stored "http/2" fell back to HTTP/1.1 and disagreed with HTTPJavaImpl. Compare both ignoring case and cover it with a test. --- .../apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java | 6 +++--- .../jmeter/protocol/http/sampler/HTTPHCAbstractImpl.java | 3 ++- .../apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java | 6 +++--- .../jmeter/protocol/http/sampler/HTTPSamplerBase.java | 2 +- .../jmeter/protocol/http/util/HTTPConstantsInterface.java | 5 ++--- .../jmeter/protocol/http/sampler/TestHTTPHC5Features.java | 7 +++++++ 6 files changed, 18 insertions(+), 11 deletions(-) diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java index 92cb3c7c380..3b9d3eb899b 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java @@ -606,7 +606,7 @@ private ClassicHttpResponse doExecuteRequest(HttpClientKey clientKey, private void setupRequest(URL url, org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request, HTTPSampleResult result, boolean areFollowingRedirect) throws IOException { - HttpVersionPolicy httpVersionPolicy = getHttpVersionPolicy(testElement.getHttpVersion(), HTTP_VERSION, + HttpVersionPolicy httpVersionPolicy = getHttpVersionPolicy(testElement.getHttpVersion(), DEFAULT_HTTP_VERSION, url.getProtocol()); RequestConfig.Builder config = RequestConfig.custom() .setRedirectsEnabled(getAutoRedirects() && !areFollowingRedirect); @@ -1625,7 +1625,7 @@ HttpClientKey createHttpClientKey(URL url) throws IOException { InetAddress localAddress = getIpSourceAddress(); boolean useDynamicProxy = isDynamicProxy(proxyHost, proxyPort); boolean useStaticProxy = isStaticProxy(url.getHost()); - HttpVersionPolicy httpVersionPolicy = getHttpVersionPolicy(testElement.getHttpVersion(), HTTP_VERSION, + HttpVersionPolicy httpVersionPolicy = getHttpVersionPolicy(testElement.getHttpVersion(), DEFAULT_HTTP_VERSION, url.getProtocol()); if (!useDynamicProxy) { proxyScheme = PROXY_SCHEME; @@ -1642,7 +1642,7 @@ static HttpVersionPolicy getHttpVersionPolicy(String samplerHttpVersion, String if (HTTPConstants.HTTP_VERSION_2_STRICT.equalsIgnoreCase(httpVersion)) { return HttpVersionPolicy.FORCE_HTTP_2; } - return HTTPConstants.HTTP_VERSION_2.equals(httpVersion) + return HTTPConstants.HTTP_VERSION_2.equalsIgnoreCase(httpVersion) ? HttpVersionPolicy.NEGOTIATE : HttpVersionPolicy.FORCE_HTTP_1; } diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHCAbstractImpl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHCAbstractImpl.java index 6bd8882703d..c5c9a4381ca 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHCAbstractImpl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHCAbstractImpl.java @@ -77,7 +77,8 @@ public abstract class HTTPHCAbstractImpl extends HTTPAbstractImpl { protected static final boolean USE_LOOPBACK = JMeterUtils.getPropDefault("httpclient.loopback", false); - protected static final String HTTP_VERSION = JMeterUtils.getPropDefault("httpclient.version", "1.1"); + /** Default value of the sampler {@code HTTPSampler.httpVersion} property, see {@code httpclient.version}. */ + protected static final String DEFAULT_HTTP_VERSION = JMeterUtils.getPropDefault("httpclient.version", "1.1"); // -1 means not defined protected static final int SO_TIMEOUT = JMeterUtils.getPropDefault("httpclient.timeout", -1); diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java index d7b3659555c..51110faf4de 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java @@ -1244,7 +1244,7 @@ private HTTPSampleResult sampleHttp2(URL url, String method, boolean areFollowin cacheManager.saveDetails(response, res); } - res.setSentBytes(calculateSentBytes(url, method, HTTPConstants.HTTP_2, + res.setSentBytes(calculateSentBytes(url, method, HTTPConstants.HTTP_VERSION_2, capturingConn != null ? capturingConn.getRequestProperties() : null, securityHeaders, requestBodyLength)); @@ -1256,7 +1256,7 @@ private HTTPSampleResult sampleHttp2(URL url, String method, boolean areFollowin if (res.getEndTime() == 0) { res.sampleEnd(); } - res.setSentBytes(calculateSentBytes(url, method, HTTPConstants.HTTP_2, + res.setSentBytes(calculateSentBytes(url, method, HTTPConstants.HTTP_VERSION_2, capturingConn != null ? capturingConn.getRequestProperties() : null, securityHeaders, requestBodyLength)); return errorResult(e, res); @@ -1302,7 +1302,7 @@ byte[] readResponse(HttpResponse response, SampleResult res) throws private static String getResponseHeaders(HttpResponse response) { StringBuilder headerBuf = new StringBuilder(); String versionStr = (response.version() == HttpClient.Version.HTTP_2) - ? HTTPConstants.HTTP_2 : HTTPConstants.HTTP_1_1; + ? HTTPConstants.HTTP_VERSION_2 : HTTPConstants.HTTP_VERSION_1_1; headerBuf.append(versionStr).append(" ").append(response.statusCode()).append("\n"); // $NON-NLS-1$ $NON-NLS-2$ response.headers().map().forEach((key, values) -> { diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPSamplerBase.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPSamplerBase.java index c2919025416..553248bd21e 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPSamplerBase.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPSamplerBase.java @@ -160,7 +160,7 @@ public abstract class HTTPSamplerBase extends AbstractSampler public static final String IMPLEMENTATION = "HTTPSampler.implementation"; // $NON-NLS-1$ - public static final String HTTP_VERSION = "HTTPSampler.httpVersion"; // $NON-NLS-1$ + public static final String HTTP_VERSION = HTTPSamplerBaseSchema.INSTANCE.getHttpVersion().getName(); public static final String PATH = "HTTPSampler.path"; // $NON-NLS-1$ diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/util/HTTPConstantsInterface.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/util/HTTPConstantsInterface.java index 46e5cd95bf7..3157fddc049 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/util/HTTPConstantsInterface.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/util/HTTPConstantsInterface.java @@ -37,7 +37,8 @@ public interface HTTPConstantsInterface { // CHECKSTYLE IGNORE InterfaceIsType String HTTP_VERSION_1_1 = "HTTP/1.1"; // $NON-NLS-1$ /** * Value of the sampler {@code HTTPSampler.httpVersion} property which uses HTTP/2 if the server - * agrees to it, falling back to HTTP/1.1 otherwise. + * agrees to it, falling back to HTTP/1.1 otherwise. It is also the protocol version as it appears + * on the wire and in the request/response headers of a sample. */ String HTTP_VERSION_2 = "HTTP/2"; // $NON-NLS-1$ /** @@ -74,8 +75,6 @@ public interface HTTPConstantsInterface { // CHECKSTYLE IGNORE InterfaceIsType String TRANSFER_ENCODING = "transfer-encoding"; // $NON-NLS-1$ String HEADER_CONTENT_ENCODING = "content-encoding"; // $NON-NLS-1$ String HTTP_1_1 = "HTTP/1.1"; // $NON-NLS-1$ - /** Protocol version as it appears on the wire and in the request/response headers of a sample. */ - String HTTP_2 = "HTTP/2"; // $NON-NLS-1$ String HEADER_SET_COOKIE = "set-cookie"; // $NON-NLS-1$ // Brotli compression not supported yet by HC4 4.5.2 , but to be added String ENCODING_BROTLI = "br"; // $NON-NLS-1$ diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java index e3e460fb625..2edea811fb3 100644 --- a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java +++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java @@ -96,6 +96,13 @@ void usesSamplerHttpVersionWhenSpecified() { assertEquals(HttpVersionPolicy.NEGOTIATE, HTTPHC5Impl.getHttpVersionPolicy("HTTP/2", "HTTP/1.1")); } + @Test + void matchesHttpVersionIgnoringCase() { + assertEquals(HttpVersionPolicy.NEGOTIATE, HTTPHC5Impl.getHttpVersionPolicy("http/2", "HTTP/1.1")); + assertEquals(HttpVersionPolicy.NEGOTIATE, HTTPHC5Impl.getHttpVersionPolicy("", "http/2")); + assertEquals(HttpVersionPolicy.FORCE_HTTP_2, HTTPHC5Impl.getHttpVersionPolicy("http/2 strict", "HTTP/1.1")); + } + @Test void defaultsToHttp11ForUnsupportedHttpVersion() { assertEquals(HttpVersionPolicy.FORCE_HTTP_1, HTTPHC5Impl.getHttpVersionPolicy("HTTP/3", "HTTP/2")); From 1faff23fa0107fd0c6ffff6345abb6b7911eac36 Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Tue, 18 Aug 2026 12:30:38 +0200 Subject: [PATCH 51/80] Accept legacy httpclient.version values and fix AJP handling The httpclient.version property now selects HTTP/1.1, HTTP/2 or HTTP/2 Strict, but it historically only took 1.0 and 1.1. Two readers were left behind: AjpSampler compared against the old spellings, so the documented values were wrong for AJP, and HTTPHCAbstractImpl defaulted to "1.1", which is not a recognized version for the HttpClient5 and Java implementations. Read the property through HTTPAbstractImpl.readDefaultHttpVersion(), which maps the legacy values 1.0 and 1.1 to HTTP/1.1, and let AjpSampler accept both 1.0 and HTTP/1.0 for its request line. Document both spellings and the AJP behaviour in jmeter.properties, properties_reference.xml and changes.xml. --- bin/jmeter.properties | 3 + .../protocol/http/sampler/AjpSampler.java | 20 ++++- .../http/sampler/HTTPAbstractImpl.java | 30 +++++++ .../http/sampler/HTTPHCAbstractImpl.java | 2 +- .../protocol/http/sampler/HTTPJavaImpl.java | 3 +- .../http/sampler/TestHttpVersionProperty.java | 86 +++++++++++++++++++ xdocs/changes.xml | 1 + xdocs/usermanual/properties_reference.xml | 6 +- 8 files changed, 145 insertions(+), 6 deletions(-) create mode 100644 src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHttpVersionProperty.java diff --git a/bin/jmeter.properties b/bin/jmeter.properties index 03db07611fc..9e13113a95c 100644 --- a/bin/jmeter.properties +++ b/bin/jmeter.properties @@ -380,6 +380,9 @@ remote_hosts=127.0.0.1 # Set the default HTTP version for HttpClient5 and Java samplers when HTTP Version is empty # Valid values are HTTP/1.1, HTTP/2 and, for HttpClient5 only, HTTP/2 Strict (defaults to HTTP/1.1) +# The legacy values 1.0 and 1.1 are still accepted and both mean HTTP/1.1 for these samplers +# The AJP sampler only reads this property to decide between the legacy value 1.0 (or HTTP/1.0), +# which makes it announce HTTP/1.0, and any other value, which makes it announce HTTP/1.1 #httpclient.version=HTTP/1.1 # Milliseconds of inactivity after which a pooled connection of the HttpClient5 diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/AjpSampler.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/AjpSampler.java index 2a5fe624bc7..00f6cfad2e0 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/AjpSampler.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/AjpSampler.java @@ -58,6 +58,9 @@ public class AjpSampler extends HTTPSamplerBase implements Interruptible { private static final char NEWLINE = '\n'; private static final String COLON_SPACE = ": ";//$NON-NLS-1$ + /** HTTP version sent to the AJP connector when the {@code httpclient.version} property requests HTTP/1.0. */ + private static final String HTTP_1_0 = "HTTP/1.0";//$NON-NLS-1$ + /** * Translates integer codes to request header names */ @@ -177,8 +180,8 @@ private void setupConnection(URL url, } else { setByte((byte)2); } - if(JMeterUtils.getPropDefault("httpclient.version","1.1").equals("1.0")) {//$NON-NLS-1$//$NON-NLS-2$//$NON-NLS-3$ - setString("HTTP/1.0");//$NON-NLS-1$ + if (isHttp10(JMeterUtils.getPropDefault("httpclient.version", HTTPConstants.HTTP_VERSION_1_1))) {//$NON-NLS-1$ + setString(HTTP_1_0); } else { setString(HTTPConstants.HTTP_1_1); } @@ -200,6 +203,19 @@ private void setupConnection(URL url, setByte((byte)0xff); // More general attributes not supported } + /** + * Tells whether the {@code httpclient.version} property requests HTTP/1.0 for the AJP sampler. + * Both the legacy spelling {@code 1.0} and the {@code HTTP/1.0} spelling of the HTTP version + * values used by the other samplers are accepted. + * + * @param httpVersion value of the {@code httpclient.version} property + * @return {@code true} if the request line should announce HTTP/1.0 + */ + static boolean isHttp10(String httpVersion) { + String version = httpVersion == null ? "" : httpVersion.trim(); + return "1.0".equals(version) || HTTP_1_0.equalsIgnoreCase(version);//$NON-NLS-1$ + } + private int getHeaderSize(String method, URL url) { HeaderManager headers = getHeaderManager(); CookieManager cookies = getCookieManager(); diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPAbstractImpl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPAbstractImpl.java index 34e696853e1..13d23050ce6 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPAbstractImpl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPAbstractImpl.java @@ -81,6 +81,9 @@ private enum CachedResourceMode { private static final String RETURN_CUSTOM_STATUS_CODE = JMeterUtils.getProperty("RETURN_CUSTOM_STATUS.code");//$NON-NLS-1$ + /** Name of the property which sets the default HTTP version of the samplers. */ + protected static final String HTTP_VERSION_PROPERTY = "httpclient.version"; // $NON-NLS-1$ + protected static final Predicate ALL_EXCEPT_COOKIE = s -> !HTTPConstants.HEADER_COOKIE.equalsIgnoreCase(s); protected static final Predicate ONLY_COOKIE = s -> HTTPConstants.HEADER_COOKIE.equalsIgnoreCase(s); @@ -93,6 +96,33 @@ private enum CachedResourceMode { protected final HTTPSamplerBase testElement; + /** + * Reads the default HTTP version of the samplers from the {@code httpclient.version} property. + * + * @return the value of the property, normalized by {@link #normalizeHttpVersion(String)}, + * defaulting to {@code HTTP/1.1} + */ + protected static String readDefaultHttpVersion() { + return normalizeHttpVersion( + JMeterUtils.getPropDefault(HTTP_VERSION_PROPERTY, HTTPConstants.HTTP_VERSION_1_1)); + } + + /** + * Maps the legacy values {@code 1.0} and {@code 1.1} of the {@code httpclient.version} property, + * which the AJP sampler still understands, to {@code HTTP/1.1}, so that a configuration written + * for the old meaning of the property keeps selecting HTTP/1.1 instead of an unknown version. + * + * @param httpVersion value of the {@code httpclient.version} property, may be {@code null} + * @return the HTTP version as used by the {@code HTTPSampler.httpVersion} sampler property + */ + static String normalizeHttpVersion(String httpVersion) { + String version = httpVersion == null ? "" : httpVersion.trim(); + if ("1.0".equals(version) || "1.1".equals(version)) { // $NON-NLS-1$ $NON-NLS-2$ + return HTTPConstants.HTTP_VERSION_1_1; + } + return version; + } + protected HTTPAbstractImpl(HTTPSamplerBase testElement){ this.testElement = testElement; } diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHCAbstractImpl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHCAbstractImpl.java index c5c9a4381ca..abd40eaa38f 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHCAbstractImpl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHCAbstractImpl.java @@ -78,7 +78,7 @@ public abstract class HTTPHCAbstractImpl extends HTTPAbstractImpl { protected static final boolean USE_LOOPBACK = JMeterUtils.getPropDefault("httpclient.loopback", false); /** Default value of the sampler {@code HTTPSampler.httpVersion} property, see {@code httpclient.version}. */ - protected static final String DEFAULT_HTTP_VERSION = JMeterUtils.getPropDefault("httpclient.version", "1.1"); + protected static final String DEFAULT_HTTP_VERSION = readDefaultHttpVersion(); // -1 means not defined protected static final int SO_TIMEOUT = JMeterUtils.getPropDefault("httpclient.timeout", -1); diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java index 51110faf4de..a438f1989e2 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java @@ -103,8 +103,7 @@ public Map getSecurityHeaders() { private static final boolean OBEY_CONTENT_LENGTH = JMeterUtils.getPropDefault("httpsampler.obey_contentlength", false); // $NON-NLS-1$ - private static final String DEFAULT_HTTP_VERSION = - JMeterUtils.getPropDefault("httpclient.version", HTTPConstants.HTTP_1_1); // $NON-NLS-1$ + private static final String DEFAULT_HTTP_VERSION = readDefaultHttpVersion(); /** Name of the {@code User-Agent} request header. */ private static final String HEADER_USER_AGENT = "User-Agent"; // $NON-NLS-1$ diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHttpVersionProperty.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHttpVersionProperty.java new file mode 100644 index 00000000000..b51648e1e29 --- /dev/null +++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHttpVersionProperty.java @@ -0,0 +1,86 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you 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.apache.jmeter.protocol.http.sampler; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.apache.jmeter.junit.JMeterTestCase; +import org.apache.jmeter.util.JMeterUtils; +import org.apache.jorphan.test.JMeterSerialTest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +/** + * Tests the handling of the {@code httpclient.version} property, which selects HTTP versions such + * as {@code HTTP/2} for the HttpClient5 and Java samplers, but historically only took the values + * {@code 1.0} and {@code 1.1}, which the AJP sampler still understands. + */ +class TestHttpVersionProperty extends JMeterTestCase implements JMeterSerialTest { + + @AfterEach + void removeProperty() { + JMeterUtils.getJMeterProperties().remove("httpclient.version"); + } + + @Test + void keepsCurrentHttpVersionValues() { + assertEquals("HTTP/1.1", HTTPAbstractImpl.normalizeHttpVersion("HTTP/1.1")); + assertEquals("HTTP/2", HTTPAbstractImpl.normalizeHttpVersion("HTTP/2")); + assertEquals("HTTP/2 Strict", HTTPAbstractImpl.normalizeHttpVersion("HTTP/2 Strict")); + } + + @Test + void mapsLegacyValuesToHttp11() { + assertEquals("HTTP/1.1", HTTPAbstractImpl.normalizeHttpVersion("1.1")); + assertEquals("HTTP/1.1", HTTPAbstractImpl.normalizeHttpVersion("1.0")); + assertEquals("HTTP/1.1", HTTPAbstractImpl.normalizeHttpVersion(" 1.1 ")); + } + + @Test + void defaultsToHttp11() { + assertEquals("HTTP/1.1", HTTPAbstractImpl.readDefaultHttpVersion()); + assertEquals("", HTTPAbstractImpl.normalizeHttpVersion(null)); + } + + @Test + void readsPropertyValue() { + JMeterUtils.setProperty("httpclient.version", "HTTP/2"); + assertEquals("HTTP/2", HTTPAbstractImpl.readDefaultHttpVersion()); + JMeterUtils.setProperty("httpclient.version", "1.1"); + assertEquals("HTTP/1.1", HTTPAbstractImpl.readDefaultHttpVersion()); + } + + @Test + void ajpAcceptsBothHttp10Spellings() { + assertTrue(AjpSampler.isHttp10("1.0")); + assertTrue(AjpSampler.isHttp10("HTTP/1.0")); + assertTrue(AjpSampler.isHttp10("http/1.0")); + } + + @Test + void ajpUsesHttp11ForEveryOtherValue() { + assertFalse(AjpSampler.isHttp10("1.1")); + assertFalse(AjpSampler.isHttp10("HTTP/1.1")); + assertFalse(AjpSampler.isHttp10("HTTP/2")); + assertFalse(AjpSampler.isHttp10("HTTP/2 Strict")); + assertFalse(AjpSampler.isHttp10("")); + assertFalse(AjpSampler.isHttp10(null)); + } +} diff --git a/xdocs/changes.xml b/xdocs/changes.xml index 7fd1cbb8182..567304d7a1c 100644 --- a/xdocs/changes.xml +++ b/xdocs/changes.xml @@ -106,6 +106,7 @@ Summary

  • 6742Support the Kerberos mechanism of the HTTP Authorization Manager in the HttpClient5 sampler implementation. HttpClient 5 neither registers the Negotiate and Kerberos auth schemes nor prefers them by default, so they are now set up per request, and the request is executed with the JAAS subject the Authorization Manager logged the user in with. The kerberos.spnego.strip_port, kerberos.spnego.use_canonical_host_name and kerberos.spnego.delegate_cred properties are honoured as with HttpClient4.
  • 6742Answer a Proxy-Authenticate: Negotiate (SPNEGO/Kerberos) challenge of an enterprise proxy in the HttpClient5 sampler implementation, instead of failing the sample with 407 Proxy Authentication Required. The negotiation based auth schemes are now offered to a proxy which has no user name configured, or which is covered by a Kerberos entry of the HTTP Authorization Manager, while a proxy with a configured user name keeps using the password based schemes.
  • 6742Offer only the HTTP versions the selected implementation can use in the HTTP Version combo box of the HTTP Request sampler and the HTTP Request Defaults: HttpClient4 offers HTTP/1.1 only, as it ignores the HTTP version, Java adds HTTP/2 Negotiate, and HttpClient5 additionally offers the new HTTP/2 Strict value, which only offers h2 during protocol negotiation, so a server without HTTP/2 support fails the sample instead of being used with HTTP/1.1.
  • +
  • 6742Accept the legacy values 1.0 and 1.1 of the httpclient.version property, which now takes the HTTP versions HTTP/1.1, HTTP/2 and HTTP/2 Strict, so an existing configuration keeps selecting HTTP/1.1 for the HttpClient5 and Java sampler implementations. The AJP sampler still announces HTTP/1.0 in its request line for the legacy value 1.0, and now for HTTP/1.0 as well.
  • Timers, Assertions, Config, Pre- & Post-Processors

    diff --git a/xdocs/usermanual/properties_reference.xml b/xdocs/usermanual/properties_reference.xml index bf5aae7973c..7ef4a93c11b 100644 --- a/xdocs/usermanual/properties_reference.xml +++ b/xdocs/usermanual/properties_reference.xml @@ -444,7 +444,11 @@ JMETER-SERVER Set the default HTTP version for HttpClient5 and Java samplers with an empty HTTP Version value.
    Valid values are HTTP/1.1, HTTP/2 and, for HttpClient5 only, - HTTP/2 Strict.
    + HTTP/2 Strict. The legacy values 1.0 and 1.1 are still + accepted and both select HTTP/1.1 for these samplers.
    + The AJP sampler only reads this property to choose the HTTP version of its request line: the + legacy value 1.0 (or HTTP/1.0) announces HTTP/1.0, every + other value announces HTTP/1.1.
    Defaults to: HTTP/1.1
    From aa95e5f7574ee6c8f5ac406c9d33cdb5bce8c6a6 Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Tue, 18 Aug 2026 12:52:10 +0200 Subject: [PATCH 52/80] Document the HttpClient5 sampler implementation and HTTP Version field changes.xml described only the HTTP/2 details and assumed the HttpClient5 implementation already existed, so the headline features of this release were missing: the new sampler implementation itself and the per-sampler HTTP Version field backed by the new HTTPSampler.httpVersion property. Add a changes.xml entry for each, and document HTTP Version in the HTTP Request Defaults properties block of component_reference.xml, which HttpDefaultsGui gained but the reference never listed. Also spell the implementations HttpClient4 and HttpClient5 as the aliases do, instead of the pre-existing HTTPClient4/HTTPClient5 typo, in component_reference.xml and in the properties_reference.xml section title. --- xdocs/changes.xml | 2 ++ xdocs/usermanual/component_reference.xml | 15 +++++++++++---- xdocs/usermanual/properties_reference.xml | 2 +- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/xdocs/changes.xml b/xdocs/changes.xml index 567304d7a1c..01c13bb517d 100644 --- a/xdocs/changes.xml +++ b/xdocs/changes.xml @@ -95,6 +95,8 @@ Summary
  • 6250Avoid adding "; charset=" automatically to multipart/form-data requests to align behavior with modern HTTP clients.
  • 6080Preserve the original HTTP method when following 307 and 308 redirects according to the HTTP specification. Contributed by LeeJiWon (github.com/dlwldnjs1009)
  • 62676268Add a space between key and value after : in View Results Tree > Sampler result tab for better readability.
  • +
  • 6742Add the new HttpClient5 sampler implementation, which is based on Apache HttpComponents HttpClient 5.x and can be selected in the Implementation combo box of the HTTP Request sampler and the HTTP Request Defaults, or with the jmeter.httpsampler property. It supports the features of the existing HttpClient4 implementation (cookie, cache, header and authorization management, proxies, client certificates, WebDav methods, DNS Cache Manager, connection reuse and retries) and adds HTTP/2 support.
  • +
  • 6742Add the HTTP Version combo box (property HTTPSampler.httpVersion) to the HTTP Request sampler and the HTTP Request Defaults, so the HTTP version can be selected per sampler instead of only globally with the httpclient.version property. An empty value keeps using that property, which now defaults to HTTP/1.1.
  • HTTP/2 support for the HttpClient5 and Java sampler implementations:

    diff --git a/xdocs/usermanual/component_reference.xml b/xdocs/usermanual/component_reference.xml index cbf095a42d6..45852fac3af 100644 --- a/xdocs/usermanual/component_reference.xml +++ b/xdocs/usermanual/component_reference.xml @@ -141,8 +141,8 @@ Latency is set to the time it takes to login.
    Java
    uses the HTTP implementation provided by the JVM. This has some limitations in comparison with the HttpClient implementations - see below.
    -
    HTTPClient4
    uses Apache HttpComponents HttpClient 4.x.
    -
    HTTPClient5
    uses Apache HttpComponents HttpClient 5.x.
    +
    HttpClient4
    uses Apache HttpComponents HttpClient 4.x.
    +
    HttpClient5
    uses Apache HttpComponents HttpClient 5.x.
    Blank Value
    does not set implementation on HTTP Samplers, so relies on HTTP Request Defaults if present or on jmeter.httpsampler property defined in jmeter.properties
    @@ -3631,7 +3631,7 @@ By default, a Graphite implementation is provided. DNS Cache Manager is designed for using in the root of Thread Group or Test Plan. Do not place it as child element of particular HTTP Sampler - DNS Cache Manager works only with HTTP requests using HTTPClient4 or HTTPClient5 implementation. + DNS Cache Manager works only with HTTP requests using HttpClient4 or HttpClient5 implementation.

    The DNS Cache Manager element allows to test applications, which have several servers behind load balancers (CDN, etc.), when user receives content from different IP's. By default JMeter uses JVM DNS cache. That's why only one server from the cluster receives load. DNS Cache Manager resolves names for each thread separately each iteration and @@ -3657,7 +3657,7 @@ By default, a Graphite implementation is provided.

    The IP address for the test server will be looked up by using the custom DNS resolver. When none is given, the system DNS resolver will be used.

    -

    Now you can use www.example.com in your HTTPClient4 or HTTPClient5 samplers and the requests will be made against +

    Now you can use www.example.com in your HttpClient4 or HttpClient5 samplers and the requests will be made against a123.another.example.org with all headers set to www.example.com.

    @@ -3930,6 +3930,13 @@ All port values are treated equally; a sampler that does not specify a port will Java, HttpClient4, HttpClient5. If not specified the default depends on the value of the JMeter property jmeter.httpsampler, failing that, the Java implementation is used. + The HTTP versions offered depend on the selected implementation: + HttpClient5 supports HTTP/1.1, HTTP/2 Negotiate (stored as HTTP/2, + falls back to HTTP/1.1 when the server does not offer HTTP/2) and HTTP/2 Strict (stored as + HTTP/2 Strict, requires HTTP/2, so the request fails when the server does not support it), + Java supports HTTP/1.1 and HTTP/2 Negotiate, and HttpClient4 + supports HTTP/1.1 only. An empty value uses the httpclient.version property, which + defaults to HTTP/1.1. HTTP or HTTPS. The encoding to be used for the request. The path to resource (for example, /servlets/myServlet). If the diff --git a/xdocs/usermanual/properties_reference.xml b/xdocs/usermanual/properties_reference.xml index 7ef4a93c11b..e52a11a4bc0 100644 --- a/xdocs/usermanual/properties_reference.xml +++ b/xdocs/usermanual/properties_reference.xml @@ -622,7 +622,7 @@ JMETER-SERVER
    -
    +
    Define a properties file for overriding Apache HttpClient parameters.
    From 5ebd84f9e5a3492ea6023082f691daebe8179b5d Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Tue, 18 Aug 2026 14:07:19 +0200 Subject: [PATCH 53/80] Share the duplicated cookie, header and size helpers between the HTTP sampler implementations HTTPHC5Impl carried its own copy of the HttpClient4 cookie, header and form entity handling, and CountingOutputStream existed a fourth time. Lift the type-independent parts into HTTPHCAbstractImpl behind a HeaderIterable seam, add HTTPMessageSizes for the wire lengths shared with HTTPJavaImpl, and reuse jorphan's CountingOutputStream. Along the way the copies that had drifted are unified: HttpClient5 now strips a default port from a Host header of the Header Manager, multiple Cookie headers are joined, sent bytes are measured with ISO-8859-1, and jorphan's CountingOutputStream counts single byte writes again. --- .../apache/jorphan/io/CountingOutputStream.kt | 3 +- .../jorphan/io/CountingOutputStreamTest.kt | 55 +++++ .../protocol/http/control/AuthManager.java | 2 +- .../CountingOutputHttpURLConnection.java | 32 +-- .../protocol/http/sampler/HTTPHC4Impl.java | 136 ++---------- .../protocol/http/sampler/HTTPHC5Impl.java | 158 ++++---------- .../http/sampler/HTTPHCAbstractImpl.java | 205 ++++++++++++++++++ .../protocol/http/sampler/HTTPJavaImpl.java | 26 +-- .../http/sampler/HTTPMessageSizes.java | 69 ++++++ .../http/sampler/TestHTTPHC5Features.java | 47 ++++ xdocs/changes.xml | 2 + 11 files changed, 446 insertions(+), 289 deletions(-) create mode 100644 src/jorphan/src/test/kotlin/org/apache/jorphan/io/CountingOutputStreamTest.kt create mode 100644 src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPMessageSizes.java diff --git a/src/jorphan/src/main/kotlin/org/apache/jorphan/io/CountingOutputStream.kt b/src/jorphan/src/main/kotlin/org/apache/jorphan/io/CountingOutputStream.kt index 4b175b42d65..4709f00e315 100644 --- a/src/jorphan/src/main/kotlin/org/apache/jorphan/io/CountingOutputStream.kt +++ b/src/jorphan/src/main/kotlin/org/apache/jorphan/io/CountingOutputStream.kt @@ -26,8 +26,6 @@ import java.io.OutputStream * count the total number of bytes written. The byte count can be accessed through * the [bytesWritten] property. The original [OutputStream]'s functionality * (such as writing, flushing, and closing) is preserved. - * - * The byte count is incremented only for the `write` operations that accept a byte array. */ public class CountingOutputStream(private val output: OutputStream) : OutputStream() { public var bytesWritten: Long = 0 @@ -35,6 +33,7 @@ public class CountingOutputStream(private val output: OutputStream) : OutputStre override fun write(b: Int) { output.write(b) + bytesWritten++ } override fun write(b: ByteArray, off: Int, len: Int) { diff --git a/src/jorphan/src/test/kotlin/org/apache/jorphan/io/CountingOutputStreamTest.kt b/src/jorphan/src/test/kotlin/org/apache/jorphan/io/CountingOutputStreamTest.kt new file mode 100644 index 00000000000..d2a0e1075c3 --- /dev/null +++ b/src/jorphan/src/test/kotlin/org/apache/jorphan/io/CountingOutputStreamTest.kt @@ -0,0 +1,55 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you 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.apache.jorphan.io + +import org.junit.jupiter.api.Assertions.assertArrayEquals +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertAll +import java.io.ByteArrayOutputStream + +class CountingOutputStreamTest { + @Test + fun writeByte() { + val target = ByteArrayOutputStream() + val counting = CountingOutputStream(target) + counting.write(42) + counting.write(43) + assertAll( + { assertArrayEquals(byteArrayOf(42, 43), target.toByteArray()) }, + { assertEquals(2L, counting.bytesWritten, "single byte writes should be counted as well") }, + ) + } + + @Test + fun writeArray() { + val target = ByteArrayOutputStream() + val counting = CountingOutputStream(target) + counting.write(byteArrayOf(42, 43)) + counting.write(byteArrayOf(44, 45, 46), 1, 2) + assertAll( + { assertArrayEquals(byteArrayOf(42, 43, 45, 46), target.toByteArray()) }, + { assertEquals(4L, counting.bytesWritten) }, + ) + } + + @Test + fun countsNothingWhenNothingIsWritten() { + assertEquals(0L, CountingOutputStream(ByteArrayOutputStream()).bytesWritten) + } +} diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/AuthManager.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/AuthManager.java index c3c5be458ae..9fbe3f18f68 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/AuthManager.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/AuthManager.java @@ -503,7 +503,7 @@ public void setupCredentials(Authorization auth, URL url, * @param url to be checked * @return true when port should omitted in SPN */ - private static boolean isStripPort(URL url) { + public static boolean isStripPort(URL url) { if (STRIP_PORT) { return true; } diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/CountingOutputHttpURLConnection.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/CountingOutputHttpURLConnection.java index e51f7294e19..360f9b8eae5 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/CountingOutputHttpURLConnection.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/CountingOutputHttpURLConnection.java @@ -17,7 +17,6 @@ package org.apache.jmeter.protocol.http.sampler; -import java.io.FilterOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; @@ -26,6 +25,8 @@ import java.util.List; import java.util.Map; +import org.apache.jorphan.io.CountingOutputStream; + /** * Delegating {@link HttpURLConnection} that counts the bytes actually written to the request body. *

    @@ -46,7 +47,7 @@ final class CountingOutputHttpURLConnection extends HttpURLConnection { * @return the number of bytes written to the request body, or {@code -1} if the body was never opened */ long getBytesWritten() { - return countingOutput == null ? -1 : countingOutput.getCount(); + return countingOutput == null ? -1 : countingOutput.getBytesWritten(); } @Override @@ -146,31 +147,4 @@ public void disconnect() { public boolean usingProxy() { return delegate.usingProxy(); } - - /** - * Counts the bytes written to the wrapped stream. - */ - private static final class CountingOutputStream extends FilterOutputStream { - private long count; - - CountingOutputStream(OutputStream out) { - super(out); - } - - long getCount() { - return count; - } - - @Override - public void write(int b) throws IOException { - out.write(b); - count++; - } - - @Override - public void write(byte[] b, int off, int len) throws IOException { - out.write(b, off, len); - count += len; - } - } } diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC4Impl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC4Impl.java index 569bb164b6a..1d03552b4e0 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC4Impl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC4Impl.java @@ -28,7 +28,6 @@ import java.net.URI; import java.net.URISyntaxException; import java.net.URL; -import java.net.URLDecoder; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.security.GeneralSecurityException; @@ -41,8 +40,6 @@ import java.util.Map; import java.util.Objects; import java.util.concurrent.TimeUnit; -import java.util.function.Predicate; -import java.util.regex.Pattern; import javax.security.auth.Subject; @@ -52,6 +49,7 @@ import org.apache.http.HttpEntity; import org.apache.http.HttpException; import org.apache.http.HttpHost; +import org.apache.http.HttpMessage; import org.apache.http.HttpRequest; import org.apache.http.HttpRequestInterceptor; import org.apache.http.HttpResponse; @@ -156,7 +154,6 @@ import org.apache.jmeter.protocol.http.util.SlowHCPlainConnectionSocketFactory; import org.apache.jmeter.samplers.SampleResult; import org.apache.jmeter.services.FileServer; -import org.apache.jmeter.testelement.property.CollectionProperty; import org.apache.jmeter.testelement.property.JMeterProperty; import org.apache.jmeter.threads.JMeterContextService; import org.apache.jmeter.threads.JMeterVariables; @@ -426,8 +423,6 @@ public void connect(ManagedHttpClientConnection conn, HttpHost host, InetSocketA /** Preemptive Basic Auth */ private static final boolean BASIC_AUTH_PREEMPTIVE = JMeterUtils.getPropDefault("httpclient4.auth.preemptive", true); - private static final Pattern PORT_PATTERN = Pattern.compile("\\d+"); // only used in .matches(), no need for anchors - @SuppressWarnings("UnnecessaryAnonymousClass") private static final ConnectionKeepAliveStrategy IDLE_STRATEGY = new DefaultConnectionKeepAliveStrategy(){ @Override @@ -1367,14 +1362,7 @@ private static void writeHeader(StringBuilder headerBuffer, Header header) { * May be null */ protected String setConnectionCookie(HttpRequest request, URL url, CookieManager cookieManager) { - String cookieHeader = null; - if (cookieManager != null) { - cookieHeader = cookieManager.getCookieHeaderForURL(url); - if (cookieHeader != null) { - request.setHeader(HTTPConstants.HEADER_COOKIE, cookieHeader); - } - } - return cookieHeader; + return setConnectionCookie(url, cookieManager, request::setHeader); } /** @@ -1388,61 +1376,12 @@ protected String setConnectionCookie(HttpRequest request, URL url, CookieManager * @param cacheManager the CacheManager (may be null) */ protected static void setConnectionHeaders(HttpRequestBase request, URL url, HeaderManager headerManager, CacheManager cacheManager) { - if (headerManager != null) { - CollectionProperty headers = headerManager.getHeaders(); - if (headers != null) { - for (JMeterProperty jMeterProperty : headers) { - org.apache.jmeter.protocol.http.control.Header header - = (org.apache.jmeter.protocol.http.control.Header) - jMeterProperty.getObjectValue(); - String headerName = header.getName(); - // Don't allow override of Content-Length - if (!HTTPConstants.HEADER_CONTENT_LENGTH.equalsIgnoreCase(headerName)) { - String headerValue = header.getValue(); - if (HTTPConstants.HEADER_HOST.equalsIgnoreCase(headerName)) { - int port = getPortFromHostHeader(headerValue, url.getPort()); - // remove any port specification - headerValue = headerValue.replaceFirst(":\\d+$", ""); // $NON-NLS-1$ $NON-NLS-2$ - if (port != -1 && port == url.getDefaultPort()) { - port = -1; // no need to specify the port if it is the default - } - if(port == -1) { - request.addHeader(HEADER_HOST, headerValue); - } else { - request.addHeader(HEADER_HOST, headerValue+":"+port); - } - } else { - request.addHeader(headerName, headerValue); - } - } - } - } - } + setConnectionHeaders(headerManager, url, name -> true, request::addHeader); if (cacheManager != null) { cacheManager.setHeaders(url, request); } } - /** - * Get port from the value of the Host header, or return the given - * defaultValue - * - * @param hostHeaderValue value of the http Host header - * @param defaultValue value to be used, when no port could be extracted from - * hostHeaderValue - * @return integer representing the port for the host header - */ - private static int getPortFromHostHeader(String hostHeaderValue, int defaultValue) { - String[] hostParts = hostHeaderValue.split(":"); - if (hostParts.length > 1) { - String portString = hostParts[hostParts.length - 1]; - if (PORT_PATTERN.matcher(portString).matches()) { - return Integer.parseInt(portString); - } - } - return defaultValue; - } - /** * Get all the request headers except Cookie for the HttpRequest * @@ -1450,7 +1389,7 @@ private static int getPortFromHostHeader(String hostHeaderValue, int defaultValu * @return the headers as a string */ private static String getAllHeadersExceptCookie(HttpRequest method) { - return getFromHeadersMatchingPredicate(method, ALL_EXCEPT_COOKIE); + return formatHeaders(headersOf(method), ALL_EXCEPT_COOKIE); } /** @@ -1460,35 +1399,25 @@ private static String getAllHeadersExceptCookie(HttpRequest method) { * @return the headers as a string */ private static String getOnlyCookieFromHeaders(HttpRequest method) { - String cookieHeader= getFromHeadersMatchingPredicate(method, ONLY_COOKIE).trim(); - if(!cookieHeader.isEmpty()) { - return cookieHeader.substring(HTTPConstants.HEADER_COOKIE_IN_REQUEST.length()).trim(); - } - return ""; + return getOnlyCookieFromHeaders(headersOf(method)); } - /** - * Get only cookies from request headers for the HttpRequest + * Adapts the headers of a request or a response to the representation the helpers of + * {@link HTTPHCAbstractImpl} work on. * - * @param method HttpMethod which represents the request - * @return the headers as a string + * @param message request or response, may be {@code null} + * @return the headers of the message */ - private static String getFromHeadersMatchingPredicate(HttpRequest method, Predicate predicate) { - if(method != null) { - // Get all the request headers - StringBuilder hdrs = new StringBuilder(150); - Header[] requestHeaders = method.getAllHeaders(); - for (Header requestHeader : requestHeaders) { - // Get header if it matches predicate - if (predicate.test(requestHeader.getName())) { - writeHeader(hdrs, requestHeader); - } + private static HeaderIterable headersOf(HttpMessage message) { + return action -> { + if (message == null) { + return; } - - return hdrs.toString(); - } - return ""; ////$NON-NLS-1$ + for (Header header : message.getAllHeaders()) { + action.accept(header.getName(), header.getValue()); + } + }; } // Helper class so we can generate request data without dumping entire file contents @@ -1785,39 +1714,14 @@ else if(getSendParameterValuesAsPostBody()) { */ private UrlEncodedFormEntity createUrlEncodedFormEntity(final String urlContentEncoding) throws UnsupportedEncodingException { // It is a normal request, with parameter names and values - // Add the parameters + // Add the parameters, the HttpClient will urlencode them List nvps = new ArrayList<>(); - for (JMeterProperty jMeterProperty: getArguments().getEnabledArguments()) { - HTTPArgument arg = (HTTPArgument) jMeterProperty.getObjectValue(); - // The HTTPClient always urlencodes both name and value, - // so if the argument is already encoded, we have to decode - // it before adding it to the post request - String parameterName = arg.getName(); - if (arg.isSkippable(parameterName)) { - continue; - } - String parameterValue = arg.getValue(); - if (!arg.isAlwaysEncoded()) { - // The value is already encoded by the user - // Must decode the value now, so that when the - // httpclient encodes it, we end up with the same value - // as the user had entered. - parameterName = URLDecoder.decode(parameterName, urlContentEncoding); - parameterValue = URLDecoder.decode(parameterValue, urlContentEncoding); - } - // Add the parameter, httpclient will urlencode it - nvps.add(new BasicNameValuePair(parameterName, parameterValue)); - } + forEachFormParameter(urlContentEncoding, (name, value) -> nvps.add(new BasicNameValuePair(name, value))); return new UrlEncodedFormEntity(nvps, urlContentEncoding); } private static void saveConnectionCookies(HttpResponse method, URL u, CookieManager cookieManager) { - if (cookieManager != null) { - Header[] hdrs = method.getHeaders(HTTPConstants.HEADER_SET_COOKIE); - for (Header hdr : hdrs) { - cookieManager.addCookieFromHeader(hdr.getValue(),u); - } - } + saveConnectionCookies(headersOf(method), u, cookieManager); } @Override diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java index 3b9d3eb899b..66f747bb03a 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java @@ -26,7 +26,6 @@ import java.net.URI; import java.net.URISyntaxException; import java.net.URL; -import java.net.URLDecoder; import java.net.UnknownHostException; import java.nio.ByteBuffer; import java.nio.charset.Charset; @@ -149,7 +148,6 @@ import org.apache.jmeter.protocol.http.util.HTTPFileArg; import org.apache.jmeter.samplers.SampleResult; import org.apache.jmeter.services.FileServer; -import org.apache.jmeter.testelement.property.CollectionProperty; import org.apache.jmeter.testelement.property.JMeterProperty; import org.apache.jmeter.threads.JMeterContextService; import org.apache.jmeter.threads.JMeterVariables; @@ -157,6 +155,7 @@ import org.apache.jmeter.util.JsseSSLManager; import org.apache.jmeter.util.SSLManager; import org.apache.jorphan.io.CountingInputStream; +import org.apache.jorphan.io.CountingOutputStream; import org.apache.jorphan.util.JOrphanUtils; import org.apache.jorphan.util.StringUtilities; import org.brotli.dec.BrotliInputStream; @@ -621,18 +620,18 @@ private void setupRequest(URL url, org.apache.hc.client5.http.classic.methods.Ht } else if (httpVersionPolicy == HttpVersionPolicy.FORCE_HTTP_2) { request.setVersion(HttpVersion.HTTP_2); } - setConnectionHeaders(request, getHeaderManager(), httpVersionPolicy); + setConnectionHeaders(request, url, getHeaderManager(), httpVersionPolicy); setDefaultUserAgent(request); CacheManager cacheManager = getCacheManager(); if (cacheManager != null) { cacheManager.setHeaders(url, request); } - String cookies = setConnectionCookie(request, url, getCookieManager()); + String cookies = setConnectionCookie(url, getCookieManager(), request::setHeader); if (StringUtilities.isNotEmpty(cookies)) { result.setCookies(cookies); } else { - result.setCookies(getOnlyCookieFromHeaders(request)); + result.setCookies(getOnlyCookieFromHeaders(headersOf(request))); } if (canHaveBody(request.getMethod())) { @@ -698,19 +697,7 @@ private String setupRequestEntity(org.apache.hc.client5.http.classic.methods.Htt private List createNameValuePairs(String contentEncoding) throws IOException { List pairs = new ArrayList<>(); - for (JMeterProperty property : getArguments().getEnabledArguments()) { - HTTPArgument argument = (HTTPArgument) property.getObjectValue(); - String name = argument.getName(); - if (argument.isSkippable(name)) { - continue; - } - String value = argument.getValue(); - if (!argument.isAlwaysEncoded()) { - name = URLDecoder.decode(name, contentEncoding); - value = URLDecoder.decode(value, contentEncoding); - } - pairs.add(new BasicNameValuePair(name, value)); - } + forEachFormParameter(contentEncoding, (name, value) -> pairs.add(new BasicNameValuePair(name, value))); return pairs; } @@ -767,23 +754,27 @@ public void writeTo(OutputStream out) throws IOException { } private static void setConnectionHeaders(org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request, - HeaderManager headerManager, HttpVersionPolicy httpVersionPolicy) { - if (headerManager == null) { - return; - } - CollectionProperty headers = headerManager.getHeaders(); - if (headers == null) { - return; - } - for (JMeterProperty property : headers) { - org.apache.jmeter.protocol.http.control.Header header = - (org.apache.jmeter.protocol.http.control.Header) property.getObjectValue(); - if (!HTTPConstants.HEADER_CONTENT_LENGTH.equalsIgnoreCase(header.getName()) - && (httpVersionPolicy == HttpVersionPolicy.FORCE_HTTP_1 - || !HTTPConstants.HEADER_CONNECTION.equalsIgnoreCase(header.getName()))) { - request.addHeader(header.getName(), header.getValue()); + URL url, HeaderManager headerManager, HttpVersionPolicy httpVersionPolicy) { + // HTTP/2 has no Connection header, the request has to be sent without it + setConnectionHeaders(headerManager, url, + name -> httpVersionPolicy == HttpVersionPolicy.FORCE_HTTP_1 + || !HTTPConstants.HEADER_CONNECTION.equalsIgnoreCase(name), + request::addHeader); + } + + /** + * Adapts the headers of a request or a response to the representation the helpers of + * {@link HTTPHCAbstractImpl} work on. + * + * @param message request or response + * @return the headers of the message + */ + private static HeaderIterable headersOf(org.apache.hc.core5.http.MessageHeaders message) { + return action -> { + for (Header header : message.getHeaders()) { + action.accept(header.getName(), header.getValue()); } - } + }; } private static void setDefaultUserAgent(org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request) { @@ -793,18 +784,6 @@ private static void setDefaultUserAgent(org.apache.hc.client5.http.classic.metho request.setHeader(HttpHeaders.USER_AGENT, DEFAULT_USER_AGENT); } - private static String setConnectionCookie(org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request, - URL url, CookieManager cookieManager) { - if (cookieManager == null) { - return null; - } - String cookies = cookieManager.getCookieHeaderForURL(url); - if (cookies != null) { - request.setHeader(HTTPConstants.HEADER_COOKIE, cookies); - } - return cookies; - } - HttpClientContext createHttpClientContext(URL url, HttpClientKey key, org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request) { HttpClientContext context = HttpClientContext.create(); @@ -886,7 +865,7 @@ private void configureKerberos(URL url, HttpClientKey key, HttpClientContext context, CredentialsProvider credentialsProvider, boolean kerberosTarget, boolean kerberosProxy) { KerberosConfig kerberosConfig = KerberosConfig.custom() - .setStripPort(isStripPort(url)) + .setStripPort(AuthManager.isStripPort(url)) .setUseCanonicalHostname(AuthManager.USE_CANONICAL_HOST_NAME) .setRequestDelegCreds(KERBEROS_DELEGATE_CRED ? KerberosConfig.Option.ENABLE : KerberosConfig.Option.DEFAULT) @@ -919,18 +898,6 @@ private static HttpHost getProxyHost(HttpClientKey key) { return key.hasProxy ? new HttpHost(key.proxyScheme, key.proxyHost, key.proxyPort) : null; } - /** - * IE and Firefox always strip the port from the URL before they construct the SPN, see the - * {@code kerberos.spnego.strip_port} property. - */ - private static boolean isStripPort(URL url) { - if (AuthManager.STRIP_PORT) { - return true; - } - int port = url.getPort(); - return port == HTTPConstants.DEFAULT_HTTP_PORT || port == HTTPConstants.DEFAULT_HTTPS_PORT; - } - private Subject getSubjectForUrl(URL url) { AuthManager authManager = getAuthManager(); return authManager == null || url == null ? null : authManager.getSubjectForUrl(url); @@ -1103,20 +1070,12 @@ private static String getResponseHeaders(ClassicHttpResponse response) { StringBuilder headers = new StringBuilder(); headers.append(response.getVersion()).append(' ').append(response.getCode()).append(' ') .append(response.getReasonPhrase()).append('\n'); - for (Header header : response.getHeaders()) { - headers.append(header.getName()).append(": ").append(header.getValue()).append('\n'); - } + headers.append(formatHeaders(headersOf(response), name -> true)); return headers.toString(); } private static String getRequestHeaders(org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request) { - StringBuilder headers = new StringBuilder(); - for (Header header : request.getHeaders()) { - if (ALL_EXCEPT_COOKIE.test(header.getName())) { - headers.append(header.getName()).append(": ").append(header.getValue()).append('\n'); - } - } - return headers.toString(); + return formatHeaders(headersOf(request), ALL_EXCEPT_COOKIE); } private static long calculateSentBytes(org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request) { @@ -1126,15 +1085,13 @@ private static long calculateSentBytes(org.apache.hc.client5.http.classic.method String uri = request.getRequestUri() != null ? request.getRequestUri() : ""; org.apache.hc.core5.http.ProtocolVersion version = request.getVersion(); String versionStr = version != null ? version.toString() : HTTPConstants.HTTP_VERSION_1_1; - // the request line, with a space between its three parts and the CRLF that terminates it - long sentBytes = length(request.getMethod()) + 1 + length(uri) + 1 + length(versionStr) + 2; + long sentBytes = HTTPMessageSizes.requestLineLength(request.getMethod(), uri, versionStr); for (Header header : request.getHeaders()) { - sentBytes += headerLength(header.getName(), header.getValue()); + sentBytes += HTTPMessageSizes.headerLength(header.getName(), header.getValue()); } sentBytes += implicitHeaderLength(request); - // the empty line that separates the headers from the body - sentBytes += 2; + sentBytes += HTTPMessageSizes.EMPTY_LINE; HttpEntity entity = request.getEntity(); if (entity != null) { @@ -1142,10 +1099,10 @@ private static long calculateSentBytes(org.apache.hc.client5.http.classic.method if (contentLength >= 0) { sentBytes += contentLength; } else if (entity.isRepeatable()) { - CountingOutputStream counter = new CountingOutputStream(); + CountingOutputStream counter = new CountingOutputStream(OutputStream.nullOutputStream()); try { entity.writeTo(counter); - sentBytes += counter.getCount(); + sentBytes += counter.getBytesWritten(); } catch (IOException e) { log.debug("Exception measuring entity length", e); } @@ -1163,69 +1120,32 @@ private static long implicitHeaderLength(org.apache.hc.client5.http.classic.meth long length = 0; URIAuthority authority = request.getAuthority(); if (authority != null && !request.containsHeader(HttpHeaders.HOST)) { - length += headerLength(HttpHeaders.HOST, authority.toString()); + length += HTTPMessageSizes.headerLength(HttpHeaders.HOST, authority.toString()); } HttpEntity entity = request.getEntity(); if (entity == null) { return length; } if (entity.getContentType() != null && !request.containsHeader(HttpHeaders.CONTENT_TYPE)) { - length += headerLength(HttpHeaders.CONTENT_TYPE, entity.getContentType()); + length += HTTPMessageSizes.headerLength(HttpHeaders.CONTENT_TYPE, entity.getContentType()); } if (entity.getContentEncoding() != null && !request.containsHeader(HttpHeaders.CONTENT_ENCODING)) { - length += headerLength(HttpHeaders.CONTENT_ENCODING, entity.getContentEncoding()); + length += HTTPMessageSizes.headerLength(HttpHeaders.CONTENT_ENCODING, entity.getContentEncoding()); } if (request.containsHeader(HttpHeaders.CONTENT_LENGTH) || request.containsHeader(HttpHeaders.TRANSFER_ENCODING)) { return length; } long contentLength = entity.getContentLength(); if (contentLength >= 0) { - length += headerLength(HttpHeaders.CONTENT_LENGTH, Long.toString(contentLength)); + length += HTTPMessageSizes.headerLength(HttpHeaders.CONTENT_LENGTH, Long.toString(contentLength)); } else if (entity.isChunked()) { - length += headerLength(HttpHeaders.TRANSFER_ENCODING, "chunked"); + length += HTTPMessageSizes.headerLength(HttpHeaders.TRANSFER_ENCODING, "chunked"); } return length; } - /** Length of a header line, that is {@code name: value} and the CRLF that terminates it. */ - private static long headerLength(String name, String value) { - return length(name) + 2 + length(value) + 2; - } - - private static long length(String text) { - return text == null ? 0 : text.getBytes(StandardCharsets.ISO_8859_1).length; - } - - private static class CountingOutputStream extends java.io.OutputStream { - private long count = 0; - - @Override - public void write(int b) { - count++; - } - - @Override - public void write(byte[] b, int off, int len) { - count += len; - } - - long getCount() { - return count; - } - } - - private static String getOnlyCookieFromHeaders(org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request) { - Header cookie = request.getFirstHeader(HTTPConstants.HEADER_COOKIE); - return cookie == null ? "" : cookie.getValue(); - } - private static void saveConnectionCookies(ClassicHttpResponse response, URL url, CookieManager cookieManager) { - if (cookieManager == null) { - return; - } - for (Header header : response.getHeaders(HTTPConstants.HEADER_SET_COOKIE)) { - cookieManager.addCookieFromHeader(header.getValue(), url); - } + saveConnectionCookies(headersOf(response), url, cookieManager); } private static CloseableHttpClient getClient(HttpClientKey key) { diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHCAbstractImpl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHCAbstractImpl.java index abd40eaa38f..5b2cd7e1775 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHCAbstractImpl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHCAbstractImpl.java @@ -17,15 +17,28 @@ package org.apache.jmeter.protocol.http.sampler; +import java.io.UnsupportedEncodingException; import java.net.InetAddress; +import java.net.URL; +import java.net.URLDecoder; import java.net.UnknownHostException; import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.Set; import java.util.StringTokenizer; +import java.util.function.BiConsumer; +import java.util.function.Predicate; +import java.util.regex.Pattern; import org.apache.jmeter.JMeter; +import org.apache.jmeter.protocol.http.control.CookieManager; +import org.apache.jmeter.protocol.http.control.Header; +import org.apache.jmeter.protocol.http.control.HeaderManager; +import org.apache.jmeter.protocol.http.util.HTTPArgument; +import org.apache.jmeter.protocol.http.util.HTTPConstants; +import org.apache.jmeter.testelement.property.CollectionProperty; +import org.apache.jmeter.testelement.property.JMeterProperty; import org.apache.jmeter.util.JMeterUtils; import org.apache.jorphan.util.StringUtilities; import org.slf4j.Logger; @@ -186,4 +199,196 @@ protected static boolean isStaticProxy(String host){ protected static boolean isNullOrEmptyTrimmed(String value) { return StringUtilities.isBlank(value); } + + /** Matches the port of a {@code Host} header, only used in {@code matches()}, so unanchored. */ + private static final Pattern PORT_PATTERN = Pattern.compile("\\d+"); + + /** + * Iterates the headers of a request or a response, so that the helpers below can be shared by + * the HttpClient implementations although they represent a header with different types. + */ + @FunctionalInterface + protected interface HeaderIterable { + /** + * @param action called with the name and the value of every header, in the order the + * message holds them + */ + void forEach(BiConsumer action); + } + + /** + * Renders the headers matching the predicate the way the sample result reports them, that is + * {@code name: value} per line. + * + * @param headers headers of the request or the response + * @param predicate selects the headers to render by name + * @return the headers as a string, one per line + */ + protected static String formatHeaders(HeaderIterable headers, Predicate predicate) { + StringBuilder result = new StringBuilder(150); + headers.forEach((name, value) -> { + if (predicate.test(name)) { + result.append(name).append(": ").append(value).append('\n'); // $NON-NLS-1$ + } + }); + return result.toString(); + } + + /** + * Extracts the cookies of the {@link CookieManager} that apply to the URL and sets them as the + * {@code Cookie} header of the request. + * + * @param url URL of the request + * @param cookieManager holds the cookies, may be {@code null} + * @param setHeader sets a header of the request, replacing an existing one of that name + * @return the value of the {@code Cookie} header, {@code null} if there is none + */ + protected static String setConnectionCookie(URL url, CookieManager cookieManager, + BiConsumer setHeader) { + if (cookieManager == null) { + return null; + } + String cookieHeader = cookieManager.getCookieHeaderForURL(url); + if (cookieHeader != null) { + setHeader.accept(HTTPConstants.HEADER_COOKIE, cookieHeader); + } + return cookieHeader; + } + + /** + * Reads the cookies the request carries itself, which is how they are reported while recording, + * when the {@link CookieManager} does not handle them. + * + * @param headers headers of the request + * @return the value of the {@code Cookie} header, the values of several such headers joined the + * way a single header would hold them, or an empty string if the request has none + */ + protected static String getOnlyCookieFromHeaders(HeaderIterable headers) { + StringBuilder cookies = new StringBuilder(); + headers.forEach((name, value) -> { + if (HTTPConstants.HEADER_COOKIE.equalsIgnoreCase(name) && StringUtilities.isNotBlank(value)) { + if (cookies.length() > 0) { + cookies.append("; "); // $NON-NLS-1$ + } + cookies.append(value.trim()); + } + }); + return cookies.toString(); + } + + /** + * Hands the {@code Set-Cookie} headers of a response to the {@link CookieManager}. + * + * @param headers headers of the response + * @param url URL the response was received from + * @param cookieManager stores the cookies, may be {@code null} + */ + protected static void saveConnectionCookies(HeaderIterable headers, URL url, CookieManager cookieManager) { + if (cookieManager == null) { + return; + } + headers.forEach((name, value) -> { + if (HTTPConstants.HEADER_SET_COOKIE.equalsIgnoreCase(name)) { + cookieManager.addCookieFromHeader(value, url); + } + }); + } + + /** + * Adds the headers of the {@link HeaderManager} to the request. {@code Content-Length} is never + * added, as it is determined by the request body, and a {@code Host} header is normalized so + * that a port which is the default one for the URL is left out. + * + * @param headerManager holds the headers of the test plan, may be {@code null} + * @param url URL of the request, used to normalize the {@code Host} header + * @param isAllowed additional filter of the implementation, by header name + * @param addHeader adds a header to the request, keeping the ones already set + */ + protected static void setConnectionHeaders(HeaderManager headerManager, URL url, + Predicate isAllowed, BiConsumer addHeader) { + if (headerManager == null) { + return; + } + CollectionProperty headers = headerManager.getHeaders(); + if (headers == null) { + return; + } + for (JMeterProperty property : headers) { + Header header = (Header) property.getObjectValue(); + String headerName = header.getName(); + // Don't allow override of Content-Length + if (HTTPConstants.HEADER_CONTENT_LENGTH.equalsIgnoreCase(headerName) || !isAllowed.test(headerName)) { + continue; + } + if (HTTPConstants.HEADER_HOST.equalsIgnoreCase(headerName)) { + addHeader.accept(HTTPConstants.HEADER_HOST, normalizeHostHeader(header.getValue(), url)); + } else { + addHeader.accept(headerName, header.getValue()); + } + } + } + + /** + * Removes the port from the value of a {@code Host} header when it is the default port of the + * URL, so that the header looks the way a browser would send it. + * + * @param headerValue value of the {@code Host} header + * @param url URL of the request + * @return the value to send + */ + private static String normalizeHostHeader(String headerValue, URL url) { + int port = getPortFromHostHeader(headerValue, url.getPort()); + // remove any port specification + String host = headerValue.replaceFirst(":\\d+$", ""); // $NON-NLS-1$ $NON-NLS-2$ + if (port == -1 || port == url.getDefaultPort()) { + // no need to specify the port if it is the default + return host; + } + return host + ":" + port; // $NON-NLS-1$ + } + + /** + * Get port from the value of the Host header, or return the given defaultValue + * + * @param hostHeaderValue value of the http Host header + * @param defaultValue value to be used, when no port could be extracted from + * hostHeaderValue + * @return integer representing the port for the host header + */ + private static int getPortFromHostHeader(String hostHeaderValue, int defaultValue) { + String[] hostParts = hostHeaderValue.split(":"); + if (hostParts.length > 1) { + String portString = hostParts[hostParts.length - 1]; + if (PORT_PATTERN.matcher(portString).matches()) { + return Integer.parseInt(portString); + } + } + return defaultValue; + } + + /** + * Passes the name and the value of every parameter that goes into a url-encoded form body to + * the consumer. A parameter the user entered already encoded is decoded first, so that the + * encoding HttpClient applies ends up with the value the user had entered. + * + * @param contentEncoding encoding of the request body + * @param consumer called with the name and the value of every parameter + * @throws UnsupportedEncodingException when the content encoding is not supported + */ + protected void forEachFormParameter(String contentEncoding, BiConsumer consumer) + throws UnsupportedEncodingException { + for (JMeterProperty property : getArguments().getEnabledArguments()) { + HTTPArgument argument = (HTTPArgument) property.getObjectValue(); + String name = argument.getName(); + if (argument.isSkippable(name)) { + continue; + } + String value = argument.getValue(); + if (!argument.isAlwaysEncoded()) { + name = URLDecoder.decode(name, contentEncoding); + value = URLDecoder.decode(value, contentEncoding); + } + consumer.accept(name, value); + } + } } diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java index a438f1989e2..21cca9514fe 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java @@ -1356,8 +1356,6 @@ private static long calculateSentBytes( Map> requestHeaders, Map securityHeaders, long postBodyLength) { - long sentBytes = 0; - if (StringUtilities.isBlank(method)) { method = HTTPConstants.GET; } @@ -1372,12 +1370,7 @@ private static long calculateSentBytes( } // Request line: METHOD URI VERSION\r\n - sentBytes += method.getBytes(StandardCharsets.UTF_8).length; - sentBytes += 1; - sentBytes += uri.getBytes(StandardCharsets.UTF_8).length; - sentBytes += 1; - sentBytes += version.getBytes(StandardCharsets.UTF_8).length; - sentBytes += 2; + long sentBytes = HTTPMessageSizes.requestLineLength(method, uri, version); // Request headers if (requestHeaders != null) { @@ -1387,12 +1380,7 @@ private static long calculateSentBytes( List values = entry.getValue(); if (values != null) { for (String val : values) { - sentBytes += key.getBytes(StandardCharsets.UTF_8).length; - sentBytes += 2; // ": " - if (val != null) { - sentBytes += val.getBytes(StandardCharsets.UTF_8).length; - } - sentBytes += 2; // "\r\n" + sentBytes += HTTPMessageSizes.headerLength(key, val); } } } @@ -1402,20 +1390,14 @@ private static long calculateSentBytes( if (securityHeaders != null && !securityHeaders.isEmpty()) { for (Map.Entry secEntry : securityHeaders.entrySet()) { String secKey = secEntry.getKey(); - String secVal = secEntry.getValue(); if (secKey != null && !hasHeader(requestHeaders, secKey)) { - sentBytes += secKey.getBytes(StandardCharsets.UTF_8).length; - sentBytes += 2; // ": " - if (secVal != null) { - sentBytes += secVal.getBytes(StandardCharsets.UTF_8).length; - } - sentBytes += 2; // "\r\n" + sentBytes += HTTPMessageSizes.headerLength(secKey, secEntry.getValue()); } } } // Header/Body separator \r\n - sentBytes += 2; + sentBytes += HTTPMessageSizes.EMPTY_LINE; // Request body if (postBodyLength >= 0) { diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPMessageSizes.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPMessageSizes.java new file mode 100644 index 00000000000..2dc78d43d1c --- /dev/null +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPMessageSizes.java @@ -0,0 +1,69 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you 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.apache.jmeter.protocol.http.sampler; + +import java.nio.charset.StandardCharsets; + +/** + * Sizes of the parts of an HTTP/1.1 request, shared by the sampler implementations so that all of + * them report the same number of sent bytes for the same request. + *

    + * The lengths are the ones the message has on the wire, where the request line and the headers are + * encoded with ISO-8859-1, one byte per character. + */ +final class HTTPMessageSizes { + + /** Length of the empty line that separates the headers from the body. */ + static final long EMPTY_LINE = 2; + + private HTTPMessageSizes() { + // utility class + } + + /** + * Length of the request line, that is {@code METHOD URI VERSION} with a space between its + * three parts and the CRLF that terminates it. + * + * @param method HTTP method of the request + * @param uri request target + * @param version HTTP version of the request + * @return the number of bytes the request line takes on the wire + */ + static long requestLineLength(String method, String uri, String version) { + return length(method) + 1 + length(uri) + 1 + length(version) + 2; + } + + /** + * Length of a header line, that is {@code name: value} and the CRLF that terminates it. + * + * @param name name of the header + * @param value value of the header + * @return the number of bytes the header line takes on the wire + */ + static long headerLength(String name, String value) { + return length(name) + 2 + length(value) + 2; + } + + /** + * @param text text to measure, may be {@code null} + * @return the number of bytes the text takes on the wire, {@code 0} for {@code null} + */ + static long length(String text) { + return text == null ? 0 : text.getBytes(StandardCharsets.ISO_8859_1).length; + } +} diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java index 2edea811fb3..3227a125649 100644 --- a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java +++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java @@ -65,6 +65,8 @@ import org.apache.jmeter.protocol.http.control.AuthManager; import org.apache.jmeter.protocol.http.control.CacheManager; import org.apache.jmeter.protocol.http.control.CookieManager; +import org.apache.jmeter.protocol.http.control.Header; +import org.apache.jmeter.protocol.http.control.HeaderManager; import org.apache.jmeter.protocol.http.util.HTTPConstants; import org.apache.jmeter.protocol.http.util.HTTPFileArg; import org.apache.jmeter.samplers.SampleResult; @@ -989,6 +991,51 @@ void doesNotRevalidatePooledHttp2ConnectionWithoutAnIdleGap() throws Exception { } } + @Test + void sendsHostHeaderOfTheHeaderManagerWithoutItsDefaultPort() throws Exception { + WireMockServer server = createServer(); + server.start(); + try { + server.stubFor(get(urlEqualTo("/host")).willReturn(aResponse().withStatus(200))); + HTTPSamplerBase sampler = newSampler(); + sampler.setHttpVersion("HTTP/1.1"); + HeaderManager headerManager = new HeaderManager(); + headerManager.add(new Header(HTTPConstants.HEADER_HOST, "example.com:80")); + sampler.setHeaderManager(headerManager); + + HTTPSampleResult result = sampler.sample(new URL(server.url("/host")), HTTPConstants.GET, false, 1); + + assertEquals("200", result.getResponseCode()); + assertEquals("example.com", + singleRequest(server, "/host").getHeader(HTTPConstants.HEADER_HOST), + "the default port should be stripped from the Host header, like HttpClient4 does"); + } finally { + server.stop(); + } + } + + @Test + void keepsANonDefaultPortOfTheHostHeaderOfTheHeaderManager() throws Exception { + WireMockServer server = createServer(); + server.start(); + try { + server.stubFor(get(urlEqualTo("/host")).willReturn(aResponse().withStatus(200))); + HTTPSamplerBase sampler = newSampler(); + sampler.setHttpVersion("HTTP/1.1"); + HeaderManager headerManager = new HeaderManager(); + headerManager.add(new Header(HTTPConstants.HEADER_HOST, "example.com:8080")); + sampler.setHeaderManager(headerManager); + + HTTPSampleResult result = sampler.sample(new URL(server.url("/host")), HTTPConstants.GET, false, 1); + + assertEquals("200", result.getResponseCode()); + assertEquals("example.com:8080", + singleRequest(server, "/host").getHeader(HTTPConstants.HEADER_HOST)); + } finally { + server.stop(); + } + } + private static HTTPSamplerBase newSampler() { return HTTPSamplerFactory.newInstance("HttpClient5"); } diff --git a/xdocs/changes.xml b/xdocs/changes.xml index 01c13bb517d..da15d6bcff5 100644 --- a/xdocs/changes.xml +++ b/xdocs/changes.xml @@ -97,6 +97,7 @@ Summary

  • 62676268Add a space between key and value after : in View Results Tree > Sampler result tab for better readability.
  • 6742Add the new HttpClient5 sampler implementation, which is based on Apache HttpComponents HttpClient 5.x and can be selected in the Implementation combo box of the HTTP Request sampler and the HTTP Request Defaults, or with the jmeter.httpsampler property. It supports the features of the existing HttpClient4 implementation (cookie, cache, header and authorization management, proxies, client certificates, WebDav methods, DNS Cache Manager, connection reuse and retries) and adds HTTP/2 support.
  • 6742Add the HTTP Version combo box (property HTTPSampler.httpVersion) to the HTTP Request sampler and the HTTP Request Defaults, so the HTTP version can be selected per sampler instead of only globally with the httpclient.version property. An empty value keeps using that property, which now defaults to HTTP/1.1.
  • +
  • 6742Send a Host header of the HTTP Header Manager without its port when that port is the default one of the URL in the HttpClient5 sampler implementation, as the HttpClient4 implementation does.
  • HTTP/2 support for the HttpClient5 and Java sampler implementations:

    @@ -154,6 +155,7 @@ Summary
  • 5937Remove deprecated Log4j package scanning and configure plugin metadata processing to improve startup time and avoid deprecation warnings. Contributed by Piotr P. Karwasz (github.com/piotrgithub)
  • 6620Fix report generation paths so dashboard output files are created in the correct location after internal refactoring.
  • 6456Handle malformed percent-encoded URLs gracefully when recording HTTP traffic, logging a warning instead of failing the recording.
  • +
  • 6742Count single byte writes in CountingOutputStream as well, so the SMTP Sampler does not under-report the size of the messages it sends.
  • From 379fd0542569b674c2b760c655a43d46ba204dcc Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Tue, 18 Aug 2026 15:14:38 +0200 Subject: [PATCH 54/80] Give CacheManager a single HTTP client neutral header API Replace the per implementation saveDetails/inCache/setHeaders overloads with saveDetails(ResponseHeaderSource, HTTPSampleResult), inCache(URL, RequestHeaderSource) and setHeaders(URL, RequestHeaderSink), so the Vary and cache-control handling lives in one place and the config element no longer compiles against HttpClient 5. The sampler implementations adapt their requests and responses at the call sites, and the HttpClient 4 typed methods stay as deprecated delegators. --- .../protocol/http/control/CacheManager.java | 359 +++++++++--------- .../protocol/http/sampler/HTTPHC4Impl.java | 38 +- .../protocol/http/sampler/HTTPHC5Impl.java | 38 +- .../protocol/http/sampler/HTTPJavaImpl.java | 3 +- .../http/control/TestCacheManagerHC4.java | 2 + .../control/TestCacheManagerHeaderSource.java | 120 ++++++ .../TestCacheManagerThreadIteration.java | 5 + xdocs/changes.xml | 1 + 8 files changed, 379 insertions(+), 187 deletions(-) create mode 100644 src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/control/TestCacheManagerHeaderSource.java diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/CacheManager.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/CacheManager.java index 32509ab81f8..93f5ef3c6f9 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/CacheManager.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/CacheManager.java @@ -29,13 +29,11 @@ import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.function.BiConsumer; -import org.apache.http.Header; -import org.apache.http.HeaderElement; import org.apache.http.HttpResponse; import org.apache.http.client.methods.HttpRequestBase; import org.apache.http.client.utils.DateUtils; -import org.apache.http.message.BasicHeader; import org.apache.jmeter.config.ConfigTestElement; import org.apache.jmeter.engine.event.LoopIterationEvent; import org.apache.jmeter.protocol.http.sampler.HTTPSampleResult; @@ -172,6 +170,76 @@ public String toString() { } } + /** + * Provides read access to the headers of an HTTP response, independently of the + * HTTP client implementation that produced it. + * + * @since 6.0 + */ + @FunctionalInterface + public interface ResponseHeaderSource { + /** + * @param name name of the header (case handling is up to the implementation) + * @return value of the header or {@code null} if the response has no such header + */ + String getHeader(String name); + } + + /** + * Provides read access to the headers of an HTTP request, independently of the + * HTTP client implementation that will send it. + * + * @since 6.0 + */ + @FunctionalInterface + public interface RequestHeaderSource { + /** + * Passes every header of the request, including repeated ones, to the given action. + * + * @param action receives the name and the value of each header + */ + void forEachHeader(BiConsumer action); + } + + /** + * An HTTP request whose headers can be read and to which conditional request headers + * (like {@code If-Modified-Since}) can be added. + * + * @since 6.0 + */ + public interface RequestHeaderSink extends RequestHeaderSource { + /** + * Sets the given header on the request, replacing a previously set one. + * + * @param name name of the header + * @param value value of the header + */ + void setHeader(String name, String value); + } + + /** + * Combines a {@link RequestHeaderSource} and a header setter into a {@link RequestHeaderSink}. + * + * @param headers headers currently present on the request + * @param headerSetter sets a header on the request + * @return sink that reads from {@code headers} and writes through {@code headerSetter} + * @since 6.0 + */ + public static RequestHeaderSink requestHeaderSink(RequestHeaderSource headers, + BiConsumer headerSetter) { + return new RequestHeaderSink() { + @Override + public void forEachHeader(BiConsumer action) { + headers.forEachHeader(action); + } + + @Override + public void setHeader(String name, String value) { + headerSetter.accept(name, value); + } + }; + } + /** * Save the Last-Modified, Etag, and Expires headers if the result is cacheable. * Version for Java implementation. @@ -179,16 +247,31 @@ public String toString() { * @param res result */ public void saveDetails(URLConnection conn, HTTPSampleResult res){ - final String varyHeader = conn.getHeaderField(HTTPConstants.VARY); - if (isCacheable(res, varyHeader)){ - String lastModified = conn.getHeaderField(HTTPConstants.LAST_MODIFIED); - String expires = conn.getHeaderField(HTTPConstants.EXPIRES); - String etag = conn.getHeaderField(HTTPConstants.ETAG); - String url = conn.getURL().toString(); - String cacheControl = conn.getHeaderField(HTTPConstants.CACHE_CONTROL); - String date = conn.getHeaderField(HTTPConstants.DATE); + saveDetails(conn::getHeaderField, res, conn.getURL().toString()); + } + + /** + * Save the Last-Modified, Etag, and Expires headers if the result is cacheable. + * + * @param response headers of the response to extract the cache information from + * @param res result to decide if the result is cacheable + * @since 6.0 + */ + public void saveDetails(ResponseHeaderSource response, HTTPSampleResult res) { + saveDetails(response, res, res.getUrlAsString()); + } + + private void saveDetails(ResponseHeaderSource response, HTTPSampleResult res, String url) { + final String varyHeader = response.getHeader(HTTPConstants.VARY); + if (isCacheable(res, varyHeader)) { + String lastModified = response.getHeader(HTTPConstants.LAST_MODIFIED); + String expires = response.getHeader(HTTPConstants.EXPIRES); + String etag = response.getHeader(HTTPConstants.ETAG); + String cacheControl = response.getHeader(HTTPConstants.CACHE_CONTROL); + String date = response.getHeader(HTTPConstants.DATE); if (anyNotBlank(lastModified, expires, etag, cacheControl)) { - setCache(lastModified, cacheControl, expires, etag, url, date, getVaryHeader(varyHeader, asHeaders(res.getRequestHeaders()))); + setCache(lastModified, cacheControl, expires, etag, url, date, + getVaryHeader(varyHeader, requestHeadersOf(res.getRequestHeaders()))); } } } @@ -202,7 +285,7 @@ private static boolean anyNotBlank(String... values) { return false; } - private static Map.Entry getVaryHeader(String headerName, Header[] reqHeaders) { + private static Map.Entry getVaryHeader(String headerName, RequestHeaderSource reqHeaders) { if (headerName == null) { return null; } @@ -211,87 +294,29 @@ private static Map.Entry getVaryHeader(String headerName, Header for (final String name: names) { values.put(name, new ArrayList<>()); } - for (Header header: reqHeaders) { - if (names.contains(header.getName())) { - log.debug("Found vary value {} for {} in response", header, headerName); - values.get(header.getName()).add(header.getValue()); + reqHeaders.forEachHeader((name, value) -> { + List valuesForName = values.get(name); + if (valuesForName != null) { + log.debug("Found vary value {}: {} for {} in request", name, value, headerName); + valuesForName.add(value); } - } + }); return new AbstractMap.SimpleEntry<>(headerName, values.toString()); } /** * Save the Last-Modified, Etag, and Expires headers if the result is - * cacheable. Version for Apache HttpClient implementation. + * cacheable. Version for Apache HttpClient 4 implementation. * * @param method * {@link HttpResponse} to extract header information from * @param res * result to decide if result is cacheable + * @deprecated use {@link #saveDetails(ResponseHeaderSource, HTTPSampleResult)} instead */ + @Deprecated public void saveDetails(HttpResponse method, HTTPSampleResult res) { - final String varyHeader = getHeader(method, HTTPConstants.VARY); - if (isCacheable(res, varyHeader)){ - String lastModified = getHeader(method ,HTTPConstants.LAST_MODIFIED); - String expires = getHeader(method ,HTTPConstants.EXPIRES); - String etag = getHeader(method ,HTTPConstants.ETAG); - String cacheControl = getHeader(method, HTTPConstants.CACHE_CONTROL); - String date = getHeader(method, HTTPConstants.DATE); - if (anyNotBlank(lastModified, expires, etag, cacheControl)) { - setCache(lastModified, cacheControl, expires, etag, - res.getUrlAsString(), date, getVaryHeader(varyHeader, - asHeaders(res.getRequestHeaders()))); // TODO correct URL? - } - } - } - - /** - * Save the Last-Modified, Etag, and Expires headers if the result is - * cacheable. Version for Apache HttpClient 5 implementation. - * - * @param response response to extract header information from - * @param res result to decide if result is cacheable - */ - public void saveDetails(org.apache.hc.core5.http.ClassicHttpResponse response, HTTPSampleResult res) { - final String varyHeader = getHeader(response, HTTPConstants.VARY); - if (isCacheable(res, varyHeader)) { - String lastModified = getHeader(response, HTTPConstants.LAST_MODIFIED); - String expires = getHeader(response, HTTPConstants.EXPIRES); - String etag = getHeader(response, HTTPConstants.ETAG); - String cacheControl = getHeader(response, HTTPConstants.CACHE_CONTROL); - String date = getHeader(response, HTTPConstants.DATE); - if (anyNotBlank(lastModified, expires, etag, cacheControl)) { - setCache(lastModified, cacheControl, expires, etag, - res.getUrlAsString(), date, getVaryHeader(varyHeader, asHeaders(res.getRequestHeaders()))); - } - } - } - - /** - * Save the Last-Modified, Etag, and Expires headers if the result is - * cacheable. Version for Java HttpClient implementation. - * - * @param response response to extract header information from - * @param res result to decide if result is cacheable - */ - public void saveDetails(java.net.http.HttpResponse response, HTTPSampleResult res) { - final String varyHeader = response.headers().firstValue(HTTPConstants.VARY).orElse(null); - if (isCacheable(res, varyHeader)) { - String lastModified = response.headers().firstValue(HTTPConstants.LAST_MODIFIED).orElse(null); - String expires = response.headers().firstValue(HTTPConstants.EXPIRES).orElse(null); - String etag = response.headers().firstValue(HTTPConstants.ETAG).orElse(null); - String cacheControl = response.headers().firstValue(HTTPConstants.CACHE_CONTROL).orElse(null); - String date = response.headers().firstValue(HTTPConstants.DATE).orElse(null); - if (anyNotBlank(lastModified, expires, etag, cacheControl)) { - setCache(lastModified, cacheControl, expires, etag, - res.getUrlAsString(), date, getVaryHeader(varyHeader, asHeaders(res.getRequestHeaders()))); - } - } - } - - private static String getHeader(org.apache.hc.core5.http.HttpMessage message, String name) { - org.apache.hc.core5.http.Header header = message.getFirstHeader(name); - return header == null ? null : header.getValue(); + saveDetails(responseHeadersOf(method), res); } // helper method to save the cache entry @@ -401,10 +426,12 @@ private static Date calcExpiresDate(String lastModified, String cacheControl, } } - // Apache HttpClient - private static String getHeader(HttpResponse method, String name) { - org.apache.http.Header hdr = method.getLastHeader(name); - return hdr != null ? hdr.getValue() : null; + // Apache HttpClient 4 + private static ResponseHeaderSource responseHeadersOf(HttpResponse response) { + return name -> { + org.apache.http.Header hdr = response.getLastHeader(name); + return hdr != null ? hdr.getValue() : null; + }; } /** @@ -438,48 +465,42 @@ private static boolean isCacheableMethod(HTTPSampleResult res) { *
  • If-Modified-Since
  • *
  • If-None-Match
  • * - * Apache HttpClient version. + * * @param url {@link URL} to look up in cache - * @param request where to set the headers + * @param request request to read the headers from and to set the conditional headers on + * @since 6.0 */ - public void setHeaders(URL url, HttpRequestBase request) { - CacheEntry entry = getEntry(url.toString(), request.getAllHeaders()); - if (log.isDebugEnabled()){ - log.debug("setHeaders for HTTP Method:{}(OAH) URL:{} Entry:{}", request.getMethod(), url.toString(), entry); + public void setHeaders(URL url, RequestHeaderSink request) { + CacheEntry entry = getEntry(url.toString(), request); + if (log.isDebugEnabled()) { + log.debug("setHeaders for URL:{} Entry:{}", url, entry); } - if (entry != null){ + if (entry != null) { final String lastModified = entry.getLastModified(); - if (lastModified != null){ + if (lastModified != null) { request.setHeader(HTTPConstants.IF_MODIFIED_SINCE, lastModified); } final String etag = entry.getEtag(); - if (etag != null){ + if (etag != null) { request.setHeader(HTTPConstants.IF_NONE_MATCH, etag); } } } /** - * Check the cache, and if there is a match, set conditional request headers. - * - * @param url URL to look up in cache - * @param request request where to set the headers + * Check the cache, and if there is a match, set the headers: + *
      + *
    • If-Modified-Since
    • + *
    • If-None-Match
    • + *
    + * Apache HttpClient 4 version. + * @param url {@link URL} to look up in cache + * @param request where to set the headers + * @deprecated use {@link #setHeaders(URL, RequestHeaderSink)} instead */ - public void setHeaders(URL url, org.apache.hc.core5.http.ClassicHttpRequest request) { - CacheEntry entry = getEntry(url.toString(), asHeaders(request.getHeaders())); - if (log.isDebugEnabled()) { - log.debug("setHeaders for HTTP Method:{}(HC5) URL:{} Entry:{}", request.getMethod(), url, entry); - } - if (entry != null) { - String lastModified = entry.getLastModified(); - if (lastModified != null) { - request.setHeader(HTTPConstants.IF_MODIFIED_SINCE, lastModified); - } - String etag = entry.getEtag(); - if (etag != null) { - request.setHeader(HTTPConstants.IF_NONE_MATCH, etag); - } - } + @Deprecated + public void setHeaders(URL url, HttpRequestBase request) { + setHeaders(url, requestHeaderSink(requestHeadersOf(request.getAllHeaders()), request::setHeader)); } /** @@ -494,21 +515,10 @@ public void setHeaders(URL url, org.apache.hc.core5.http.ClassicHttpRequest requ */ public void setHeaders(HttpURLConnection conn, org.apache.jmeter.protocol.http.control.Header[] headers, URL url) { - CacheEntry entry = getEntry(url.toString(), - headers != null ? asHeaders(headers) : new Header[0]); - if (log.isDebugEnabled()){ - log.debug("setHeaders HTTP Method{}(Java) url:{} entry:{}", conn.getRequestMethod(), url.toString(), entry); - } - if (entry != null){ - final String lastModified = entry.getLastModified(); - if (lastModified != null){ - conn.addRequestProperty(HTTPConstants.IF_MODIFIED_SINCE, lastModified); - } - final String etag = entry.getEtag(); - if (etag != null){ - conn.addRequestProperty(HTTPConstants.IF_NONE_MATCH, etag); - } + if (log.isDebugEnabled()) { + log.debug("setHeaders HTTP Method{}(Java) url:{}", conn.getRequestMethod(), url); } + setHeaders(url, requestHeaderSink(requestHeadersOf(headers), conn::addRequestProperty)); } /** @@ -519,7 +529,7 @@ public void setHeaders(HttpURLConnection conn, * {@link URL} to look up in cache * @return true if entry has an expires header and the entry * has not expired, else false - * @deprecated use a version of {@link CacheManager#inCache(URL, Header[])} + * @deprecated use a version of {@link CacheManager#inCache(URL, RequestHeaderSource)} * or * {@link CacheManager#inCache(URL, org.apache.jmeter.protocol.http.control.Header[])} */ @@ -528,76 +538,65 @@ public boolean inCache(URL url) { return entryStillValid(url, getEntry(url.toString(), null)); } - public boolean inCache(URL url, Header[] allHeaders) { + /** + * Check whether the URL has a valid entry for the supplied request headers. + * + * @param url {@link URL} to look up in cache + * @param allHeaders headers of the request that would be sent + * @return {@code true} if the matching entry has not expired + * @since 6.0 + */ + public boolean inCache(URL url, RequestHeaderSource allHeaders) { return entryStillValid(url, getEntry(url.toString(), allHeaders)); } /** - * Check whether the URL has a valid entry for the supplied HttpClient 5 request headers. + * Check whether the URL has a valid entry for the supplied HttpClient 4 request headers. * - * @param url URL to look up in cache + * @param url {@link URL} to look up in cache * @param allHeaders request headers * @return {@code true} if the matching entry has not expired + * @deprecated use {@link #inCache(URL, RequestHeaderSource)} instead */ - public boolean inCache(URL url, org.apache.hc.core5.http.Header[] allHeaders) { - return entryStillValid(url, getEntry(url.toString(), asHeaders(allHeaders))); + @Deprecated + public boolean inCache(URL url, org.apache.http.Header[] allHeaders) { + return inCache(url, requestHeadersOf(allHeaders)); } public boolean inCache(URL url, org.apache.jmeter.protocol.http.control.Header[] allHeaders) { - return entryStillValid(url, getEntry(url.toString(), asHeaders(allHeaders))); + return inCache(url, requestHeadersOf(allHeaders)); } - private static Header[] asHeaders( + private static RequestHeaderSource requestHeadersOf( org.apache.jmeter.protocol.http.control.Header[] allHeaders) { - final List
    result = new ArrayList<>(allHeaders.length); - for (org.apache.jmeter.protocol.http.control.Header header: allHeaders) { - result.add(new HeaderAdapter(header)); + if (allHeaders == null) { + return action -> { /* no headers to iterate over */ }; } - return result.toArray(new Header[result.size()]); - } - - private static Header[] asHeaders(String allHeaders) { - List
    result = new ArrayList<>(); - for (String line: allHeaders.split("\\n")) { - String[] splitted = line.split(": ", 2); - if (splitted.length == 2) { - result.add(new BasicHeader(splitted[0], splitted[1])); + return action -> { + for (org.apache.jmeter.protocol.http.control.Header header : allHeaders) { + action.accept(header.getName(), header.getValue()); } - } - return result.toArray(new Header[result.size()]); + }; } - private static Header[] asHeaders(org.apache.hc.core5.http.Header[] allHeaders) { - Header[] result = new Header[allHeaders.length]; - for (int i = 0; i < allHeaders.length; i++) { - result[i] = new BasicHeader(allHeaders[i].getName(), allHeaders[i].getValue()); - } - return result; + private static RequestHeaderSource requestHeadersOf(String allHeaders) { + return action -> { + for (String line : allHeaders.split("\\n")) { + String[] nameAndValue = line.split(": ", 2); + if (nameAndValue.length == 2) { + action.accept(nameAndValue[0], nameAndValue[1]); + } + } + }; } - private static class HeaderAdapter implements Header { - - private final org.apache.jmeter.protocol.http.control.Header delegate; - - private HeaderAdapter(org.apache.jmeter.protocol.http.control.Header delegate) { - this.delegate = delegate; - } - - @Override - public HeaderElement[] getElements() { - throw new UnsupportedOperationException(); - } - - @Override - public String getName() { - return delegate.getName(); - } - - @Override - public String getValue() { - return delegate.getValue(); - } - + // Apache HttpClient 4 + private static RequestHeaderSource requestHeadersOf(org.apache.http.Header[] allHeaders) { + return action -> { + for (org.apache.http.Header header : allHeaders) { + action.accept(header.getName(), header.getValue()); + } + }; } @SuppressWarnings("JavaUtilDate") @@ -619,9 +618,9 @@ private static boolean entryStillValid(URL url, CacheEntry entry) { return false; } - private CacheEntry getEntry(String url, Header[] headers) { + private CacheEntry getEntry(String url, RequestHeaderSource headers) { CacheEntry entry = getCache().getIfPresent(url); - log.debug("getEntry url:{} entry:{} header:{}", url, entry, headers); + log.debug("getEntry url:{} entry:{}", url, entry); if (entry == null) { log.debug("No entry found for url {}", url); return null; diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC4Impl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC4Impl.java index 1d03552b4e0..e7da9ea7122 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC4Impl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC4Impl.java @@ -623,7 +623,8 @@ protected HTTPSampleResult sample(URL url, String method, res.sampleStart(); final CacheManager cacheManager = getCacheManager(); - if (cacheManager != null && HTTPConstants.GET.equalsIgnoreCase(method) && cacheManager.inCache(url, httpRequest.getAllHeaders())) { + if (cacheManager != null && HTTPConstants.GET.equalsIgnoreCase(method) + && cacheManager.inCache(url, cacheRequestHeaders(httpRequest))) { return updateSampleResultForResourceInCache(res); } CloseableHttpResponse httpResponse = null; @@ -717,7 +718,7 @@ protected HTTPSampleResult sample(URL url, String method, // Save cache information if (cacheManager != null){ - cacheManager.saveDetails(httpResponse, res); + cacheManager.saveDetails(cacheResponseHeaders(httpResponse), res); } // Follow redirects and download page resources if appropriate: @@ -1378,10 +1379,41 @@ protected String setConnectionCookie(HttpRequest request, URL url, CookieManager protected static void setConnectionHeaders(HttpRequestBase request, URL url, HeaderManager headerManager, CacheManager cacheManager) { setConnectionHeaders(headerManager, url, name -> true, request::addHeader); if (cacheManager != null) { - cacheManager.setHeaders(url, request); + cacheManager.setHeaders(url, cacheRequestHeaders(request)); } } + /** + * Adapts the headers of an HttpClient 4 request to the client neutral view used by + * {@link CacheManager}. + * + * @param request request to adapt + * @return view of the request headers that also allows adding conditional headers + */ + private static CacheManager.RequestHeaderSink cacheRequestHeaders(HttpRequestBase request) { + return CacheManager.requestHeaderSink( + action -> { + for (Header header : request.getAllHeaders()) { + action.accept(header.getName(), header.getValue()); + } + }, + request::setHeader); + } + + /** + * Adapts the headers of an HttpClient 4 response to the client neutral view used by + * {@link CacheManager}. + * + * @param response response to adapt + * @return view of the response headers + */ + private static CacheManager.ResponseHeaderSource cacheResponseHeaders(HttpResponse response) { + return name -> { + Header header = response.getLastHeader(name); + return header != null ? header.getValue() : null; + }; + } + /** * Get all the request headers except Cookie for the HttpRequest * diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java index 66f747bb03a..351f4f18dd7 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java @@ -109,6 +109,7 @@ import org.apache.hc.core5.http.HttpException; import org.apache.hc.core5.http.HttpHeaders; import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.HttpRequest; import org.apache.hc.core5.http.HttpRequestInterceptor; import org.apache.hc.core5.http.HttpResponse; import org.apache.hc.core5.http.HttpVersion; @@ -515,7 +516,7 @@ protected HTTPSampleResult sample(URL url, String method, boolean areFollowingRe CacheManager cacheManager = getCacheManager(); if (cacheManager != null && HTTPConstants.GET.equalsIgnoreCase(method)) { - if (cacheManager.inCache(url, request.getHeaders())) { + if (cacheManager.inCache(url, cacheRequestHeaders(request))) { return updateSampleResultForResourceInCache(result); } } @@ -537,7 +538,7 @@ protected HTTPSampleResult sample(URL url, String method, boolean areFollowingRe updateResult(response, request, result); updateUrlAfterAutoRedirects(url, context, result); if (cacheManager != null) { - cacheManager.saveDetails(response, result); + cacheManager.saveDetails(cacheResponseHeaders(response), result); } saveConnectionCookies(response, result.getURL(), getCookieManager()); return resultProcessing(areFollowingRedirect, frameDepth, result); @@ -624,7 +625,7 @@ private void setupRequest(URL url, org.apache.hc.client5.http.classic.methods.Ht setDefaultUserAgent(request); CacheManager cacheManager = getCacheManager(); if (cacheManager != null) { - cacheManager.setHeaders(url, request); + cacheManager.setHeaders(url, cacheRequestHeaders(request)); } String cookies = setConnectionCookie(url, getCookieManager(), request::setHeader); @@ -643,6 +644,37 @@ private static boolean canHaveBody(String method) { return !HTTPConstants.HEAD.equals(method) && !HTTPConstants.TRACE.equals(method); } + /** + * Adapts the headers of an HttpClient 5 request to the client neutral view used by + * {@link CacheManager}. + * + * @param request request to adapt + * @return view of the request headers that also allows adding conditional headers + */ + private static CacheManager.RequestHeaderSink cacheRequestHeaders(HttpRequest request) { + return CacheManager.requestHeaderSink( + action -> { + for (Header header : request.getHeaders()) { + action.accept(header.getName(), header.getValue()); + } + }, + request::setHeader); + } + + /** + * Adapts the headers of an HttpClient 5 response to the client neutral view used by + * {@link CacheManager}. + * + * @param response response to adapt + * @return view of the response headers + */ + private static CacheManager.ResponseHeaderSource cacheResponseHeaders(HttpResponse response) { + return name -> { + Header header = response.getFirstHeader(name); + return header != null ? header.getValue() : null; + }; + } + private String setupRequestEntity(org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request) throws IOException { HTTPFileArg[] files = getHTTPFiles(); String contentEncoding = getContentEncoding(); diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java index 21cca9514fe..d21de425705 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java @@ -1240,7 +1240,8 @@ private HTTPSampleResult sampleHttp2(URL url, String method, boolean areFollowin saveConnectionCookies(response, url, getCookieManager()); if (cacheManager != null) { - cacheManager.saveDetails(response, res); + cacheManager.saveDetails( + name -> response.headers().firstValue(name).orElse(null), res); } res.setSentBytes(calculateSentBytes(url, method, HTTPConstants.HTTP_VERSION_2, diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/control/TestCacheManagerHC4.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/control/TestCacheManagerHC4.java index 58887e5a220..8aa3cc9c7fc 100644 --- a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/control/TestCacheManagerHC4.java +++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/control/TestCacheManagerHC4.java @@ -234,6 +234,7 @@ protected void cacheResult(HTTPSampleResult result) { } @Override + @SuppressWarnings("deprecation") protected void cacheResult(HTTPSampleResult result, boolean hasCachingHeaders) { if (hasCachingHeaders) { this.cacheManager.saveDetails(httpResponse, result); @@ -248,6 +249,7 @@ protected void addRequestHeader(String requestHeader, String value) { } @Override + @SuppressWarnings("deprecation") protected void setRequestHeaders() { this.cacheManager.setHeaders(this.url, this.httpMethod); } diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/control/TestCacheManagerHeaderSource.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/control/TestCacheManagerHeaderSource.java new file mode 100644 index 00000000000..f5179c4f59a --- /dev/null +++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/control/TestCacheManagerHeaderSource.java @@ -0,0 +1,120 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you 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.apache.jmeter.protocol.http.control; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.apache.jmeter.protocol.http.sampler.HTTPSampleResult; +import org.apache.jmeter.protocol.http.util.HTTPConstants; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; + +/** + * Test {@link CacheManager} through its HTTP client neutral API, the one used by the + * HttpClient 5 and the Java HTTP client implementations. + */ +public class TestCacheManagerHeaderSource extends TestCacheManagerBase { + + private List> requestHeaders; + private Map setRequestHeaders; + private String expires; + private String cacheControl; + private String lastModified; + + @Override + @BeforeEach + public void setUp() throws Exception { + super.setUp(); + this.requestHeaders = new ArrayList<>(); + this.setRequestHeaders = new LinkedHashMap<>(); + this.lastModified = this.currentTimeInGMT; + } + + @Override + @AfterEach + public void tearDown() throws Exception { + this.requestHeaders = null; + this.setRequestHeaders = null; + this.expires = null; + this.cacheControl = null; + this.lastModified = null; + super.tearDown(); + } + + @Override + protected void setExpires(String expires) { + this.expires = expires; + } + + @Override + protected void setCacheControl(String cacheControl) { + this.cacheControl = cacheControl; + } + + @Override + protected void setLastModified(String lastModified) { + this.lastModified = lastModified; + } + + @Override + protected void cacheResult(HTTPSampleResult result) throws Exception { + cacheResult(result, true); + } + + @Override + protected void cacheResult(HTTPSampleResult result, boolean hasCachingHeaders) throws Exception { + this.cacheManager.saveDetails(responseHeaders(hasCachingHeaders)::get, result); + } + + private Map responseHeaders(boolean hasCachingHeaders) { + Map headers = new LinkedHashMap<>(); + headers.put(HTTPConstants.DATE, this.currentTimeInGMT); + if (hasCachingHeaders) { + headers.put(HTTPConstants.LAST_MODIFIED, this.lastModified); + headers.put(HTTPConstants.ETAG, EXPECTED_ETAG); + headers.put(HTTPConstants.EXPIRES, this.expires); + headers.put(HTTPConstants.CACHE_CONTROL, this.cacheControl); + } + headers.put(HTTPConstants.VARY, this.vary); + return headers; + } + + @Override + protected void addRequestHeader(String requestHeader, String value) { + this.requestHeaders.add(Map.entry(requestHeader, value)); + } + + @Override + protected void setRequestHeaders() { + this.cacheManager.setHeaders(this.url, CacheManager.requestHeaderSink( + action -> this.requestHeaders.forEach(header -> action.accept(header.getKey(), header.getValue())), + this.setRequestHeaders::put)); + } + + @Override + protected void checkRequestHeader(String requestHeader, String expectedValue) { + assertEquals( + requestHeader + ": " + expectedValue, + requestHeader + ": " + this.setRequestHeaders.get(requestHeader)); + } +} diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/control/TestCacheManagerThreadIteration.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/control/TestCacheManagerThreadIteration.java index 349fe143153..3f9ff0ac587 100644 --- a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/control/TestCacheManagerThreadIteration.java +++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/control/TestCacheManagerThreadIteration.java @@ -253,6 +253,7 @@ protected void setLastModified(String lastModified) { lastModified); } + @SuppressWarnings("deprecation") protected void cacheResult(HTTPSampleResult result) { this.cacheManager.saveDetails(httpResponse, result); } @@ -261,6 +262,7 @@ protected void addRequestHeader(String requestHeader, String value) { this.httpMethod.addHeader(new BasicHeader(requestHeader, value)); } + @SuppressWarnings("deprecation") protected void setRequestHeaders() { this.cacheManager.setHeaders(this.url, this.httpMethod); } @@ -278,6 +280,7 @@ protected CacheManager.CacheEntry getThreadCacheEntry(String url) throws Excepti return getThreadCache().getIfPresent(url); } @Test + @SuppressWarnings("deprecation") public void testCacheControlCleared() throws Exception { this.cacheManager.setUseExpires(true); this.cacheManager.testIterationStart(null); @@ -321,6 +324,7 @@ public void testJmeterVariableWhenThreadIterationIsSameUser() { } @Test + @SuppressWarnings("deprecation") public void testCacheManagerWhenThreadIterationIsANewUser() throws Exception { //Controlled by ThreadGroup jmvars.putObject(SAME_USER, false); @@ -360,6 +364,7 @@ public void testCacheManagerWhenThreadIterationIsANewUser() throws Exception { } @Test + @SuppressWarnings("deprecation") public void testCacheManagerWhenThreadIterationIsSameUser() throws Exception { // Controlled by ThreadGroup jmvars.putObject(SAME_USER, true); diff --git a/xdocs/changes.xml b/xdocs/changes.xml index da15d6bcff5..4517ad9a652 100644 --- a/xdocs/changes.xml +++ b/xdocs/changes.xml @@ -138,6 +138,7 @@ Summary commons-pool2 2.13.1, commons-text 1.15.0.
  • Update Rhino JavaScript engine to 1.8.0 for JSR-223 JavaScript execution.
  • 6742Update Apache HttpClient 5 to 5.6.4 and Apache HttpCore 5 to 5.4.3, which corrects the application of SSL parameters in the async TLS upgrade method used by the HttpClient5 sampler implementation for HTTP/2.
  • +
  • 6742Give the Cache Manager a single HTTP client neutral API: saveDetails(CacheManager.ResponseHeaderSource, HTTPSampleResult), inCache(URL, CacheManager.RequestHeaderSource) and setHeaders(URL, CacheManager.RequestHeaderSink) replace the per implementation overloads, so the Vary and cache control handling exists once and the config element does not depend on a specific HTTP client library. The sampler implementations adapt their requests and responses to those interfaces. The methods taking Apache HttpClient 4 types are deprecated and delegate to the new ones.
  • UI

    From 8996c7430737d8c9a09cc0b1eb8f5d899af3f60c Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Tue, 18 Aug 2026 15:50:15 +0200 Subject: [PATCH 55/80] Give every JMeter thread its own HTTP/2 connection in the Java sampler The Java implementation shared one HttpClient with all threads, so a test drove N virtual users over a single connection and lost connection level effects. The clients are now held in an InheritableThreadLocal, so a thread still multiplexes its own requests and the parallel downloads of its embedded resources over one connection per origin, while each thread connects on its own. The former behaviour is available with the new http.java.h2.share_connections_between_threads property, which replaces http.java.h2.multiplexing and defaults to false. --- bin/jmeter.properties | 18 +++--- .../http/sampler/ConnectTimeTracker.java | 7 ++- .../protocol/http/sampler/HTTPJavaImpl.java | 48 ++++++++++++---- .../http/sampler/TestHTTPJavaFeatures.java | 56 +++++++++++++------ xdocs/changes.xml | 4 +- xdocs/usermanual/component_reference.xml | 20 ++++++- xdocs/usermanual/properties_reference.xml | 18 +++--- 7 files changed, 120 insertions(+), 51 deletions(-) diff --git a/bin/jmeter.properties b/bin/jmeter.properties index 9e13113a95c..a9aa8249555 100644 --- a/bin/jmeter.properties +++ b/bin/jmeter.properties @@ -395,7 +395,8 @@ remote_hosts=127.0.0.1 # HTTP/2 settings used by the HttpClient5 sampler implementation # Multiplex concurrent message exchanges (e.g. parallel downloads of embedded -# resources) over a single HTTP/2 connection +# resources) over a single HTTP/2 connection. The connections are not shared +# between threads, every JMeter thread uses connections of its own #httpclient5.h2.multiplexing=true # Maximum number of HTTP/2 connections kept per target host. With multiplexing @@ -433,14 +434,17 @@ remote_hosts=127.0.0.1 #httpclient5.h2.prior_knowledge=false # HTTP/2 settings used by the Java sampler implementation. Except for the -# multiplexing they are applied as jdk.httpclient.* system properties, so a +# connection sharing they are applied as jdk.httpclient.* system properties, so a # setting given on the command line always takes precedence -# Multiplex concurrent message exchanges (e.g. requests of different threads or -# parallel downloads of embedded resources) over a single HTTP/2 connection. -# Requests which start before the first HTTP/2 connection to a host has been -# established still open a connection of their own -#http.java.h2.multiplexing=true +# Multiplex the HTTP/2 message exchanges of all threads over a single connection +# per origin, instead of giving every thread a connection of its own. It saves +# connections and threads, but a test which drives all virtual users over one +# connection no longer measures connection level effects such as the congestion +# window, the TLS handshake cost or per connection limits of the server. The +# requests of a single thread, including the parallel downloads of its embedded +# resources, are multiplexed over one connection either way +#http.java.h2.share_connections_between_threads=false # Size in bytes of the HPACK dynamic header table announced to the server. # 0 disables indexing of headers by the server (JDK default: 16384) diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/ConnectTimeTracker.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/ConnectTimeTracker.java index b196aa18933..45d92ff6885 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/ConnectTimeTracker.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/ConnectTimeTracker.java @@ -57,9 +57,10 @@ * and a sample which is served by an already established connection keeps a connect time of {@code 0}. * Plain text connections carry no {@code SSLEngine}, so their connect time cannot be measured at all. *

    - * When multiplexing is enabled, a client is shared by all JMeter threads, so the measurement happens on - * the client threads, but the value is only written to the {@link SampleResult} by the thread which owns - * the sample. + * A client is shared by a JMeter thread and the threads which download its embedded resources, and by all + * JMeter threads when {@code http.java.h2.share_connections_between_threads} is enabled, so the measurement + * happens on the client threads, but the value is only written to the {@link SampleResult} by the thread + * which owns the sample. */ final class ConnectTimeTracker { diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java index d21de425705..7b0c544581c 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java @@ -125,13 +125,24 @@ private static String createHttp1DefaultUserAgent() { return agent == null ? javaAgent : agent + " " + javaAgent; } + /** + * HTTP/2 clients of a JMeter thread. The JDK client sends every exchange of a client over a single + * connection per origin, so one client per JMeter thread multiplexes the requests of that thread and + * of the threads which download its embedded resources over one connection, while every JMeter thread + * still uses a connection of its own, like a real user would. + */ private static final ThreadLocal> HTTP_2_CLIENTS = - ThreadLocal.withInitial(ConcurrentHashMap::new); + new InheritableThreadLocal<>() { + @Override + protected Map initialValue() { + return new ConcurrentHashMap<>(); + } + }; /** - * HTTP/2 clients used when multiplexing is enabled. The JDK client sends every exchange of a client - * over a single connection per origin, so one shared instance lets the requests of all JMeter threads - * and of the threads which download embedded resources in parallel share a connection. + * HTTP/2 clients used when the connections are shared between the JMeter threads. One instance for all + * threads means the whole test plan is driven over a single connection per origin, which saves resources + * but hides connection level effects such as the congestion window or per connection server limits. */ private static final Map SHARED_HTTP_2_CLIENTS = new ConcurrentHashMap<>(); @@ -142,9 +153,13 @@ private static String createHttp1DefaultUserAgent() { // Available since Java 21; on older JVMs the client is released once it is no longer referenced. private static final Method HTTP_CLIENT_SHUTDOWN = findHttpClientShutdownMethod(); - /** Multiplex concurrent HTTP/2 message exchanges over a single connection per origin. */ - private static final boolean HTTP_2_MULTIPLEXING = - JMeterUtils.getPropDefault("http.java.h2.multiplexing", true); // $NON-NLS-1$ + /** + * Multiplex the HTTP/2 message exchanges of all JMeter threads over a single connection per origin, + * instead of giving every thread a connection of its own. It is disabled by default, as sharing one + * connection between the virtual users changes what the test measures. + */ + private static final boolean SHARE_HTTP_2_CONNECTIONS_BETWEEN_THREADS = + JMeterUtils.getPropDefault("http.java.h2.share_connections_between_threads", false); // $NON-NLS-1$ /** * Accept HTTP/2 server push. JMeter has no way of reporting pushed resources, so push is switched off @@ -220,7 +235,7 @@ private static void setUnlessDefined(Properties systemProperties, String name, S } static Map getHttp2Clients() { - return HTTP_2_MULTIPLEXING ? SHARED_HTTP_2_CLIENTS : HTTP_2_CLIENTS.get(); + return SHARE_HTTP_2_CONNECTIONS_BETWEEN_THREADS ? SHARED_HTTP_2_CLIENTS : HTTP_2_CLIENTS.get(); } /** Returns the thread pool of the HTTP/2 clients, it is created when the first client needs it. */ @@ -262,15 +277,24 @@ private static void closeQuietly(Http2Client client) { private static void closeHttp2Clients(Map clients) { synchronized (clients) { - for (Http2Client client : clients.values()) { + List closing = new ArrayList<>(clients.values()); + // The map is shared with the threads which download embedded resources, and those threads are + // pooled, so it is emptied before the clients are closed to make sure a thread which serves + // another JMeter thread later on builds a client of its own instead of using a closed one. + clients.clear(); + for (Http2Client client : closing) { closeQuietly(client); } - clients.clear(); } } @Override protected void threadFinished() { + closeThreadLocalHttp2Clients(); + } + + /** Closes the HTTP/2 clients of the current JMeter thread, which releases their connections. */ + static void closeThreadLocalHttp2Clients() { closeHttp2Clients(HTTP_2_CLIENTS.get()); HTTP_2_CLIENTS.remove(); } @@ -1477,7 +1501,7 @@ Http2Client getHttpClient(URL url) { Map clients = getHttp2Clients(); Http2Client client = clients.get(key); - if (client != null && (HTTP_2_MULTIPLEXING || client.usesSslContext(sslContext))) { + if (client != null && (SHARE_HTTP_2_CONNECTIONS_BETWEEN_THREADS || client.usesSslContext(sslContext))) { // The SSLContext must not be part of the key, it does not implement equals(), and JMeter hands out // a new instance per thread and after every reset, which would make the cache grow without bound. // When the clients are shared, the SSLContext of the first caller is kept, so all threads keep on @@ -1486,7 +1510,7 @@ Http2Client getHttpClient(URL url) { } synchronized (clients) { client = clients.get(key); - if (client != null && (HTTP_2_MULTIPLEXING || client.usesSslContext(sslContext))) { + if (client != null && (SHARE_HTTP_2_CONNECTIONS_BETWEEN_THREADS || client.usesSslContext(sslContext))) { return client; } Http2Client created = createHttpClient(key, sslContext); diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java index aff55de64f1..9a8ef153cb2 100644 --- a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java +++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java @@ -81,8 +81,9 @@ class TestHTTPJavaFeatures { @AfterEach - void closeSharedClients() { - // the HTTP/2 clients are shared between all threads and keep their connections and selector alive + void closeHttp2Clients() { + // the HTTP/2 clients keep their connections and their selector thread alive until they are closed + HTTPJavaImpl.closeThreadLocalHttp2Clients(); HTTPJavaImpl.releaseSharedHttp2Resources(); } @@ -299,20 +300,38 @@ void doesNotOverrideHttp2SettingsGivenOnTheCommandLine() { } @Test - void sharesHttp2ClientsBetweenThreadsForMultiplexing() throws Exception { - Map clientsOfMainThread = HTTPJavaImpl.getHttp2Clients(); - AtomicReference> clientsOfOtherThread = new AtomicReference<>(); + void sharesHttp2ClientsWithTheThreadsDownloadingEmbeddedResources() throws Exception { + // JMeter threads are started by a thread which never samples itself, so they do not inherit a map + HTTPJavaImpl.closeThreadLocalHttp2Clients(); + AtomicReference> clientsOfFirstThread = new AtomicReference<>(); + AtomicReference> clientsOfSecondThread = new AtomicReference<>(); + AtomicReference> clientsOfResourceDownloader = new AtomicReference<>(); - Thread thread = new Thread(() -> clientsOfOtherThread.set(HTTPJavaImpl.getHttp2Clients())); - thread.start(); - thread.join(); + Thread firstThread = new Thread(() -> { + clientsOfFirstThread.set(HTTPJavaImpl.getHttp2Clients()); + // the embedded resources are downloaded by threads the JMeter thread creates + Thread downloader = new Thread(() -> clientsOfResourceDownloader.set(HTTPJavaImpl.getHttp2Clients())); + downloader.start(); + try { + downloader.join(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + Thread secondThread = new Thread(() -> clientsOfSecondThread.set(HTTPJavaImpl.getHttp2Clients())); + firstThread.start(); + secondThread.start(); + firstThread.join(); + secondThread.join(); - assertSame(clientsOfMainThread, clientsOfOtherThread.get(), - "HTTP/2 clients should be shared, so concurrent requests are multiplexed over one connection"); + assertSame(clientsOfFirstThread.get(), clientsOfResourceDownloader.get(), + "the downloads of embedded resources should be multiplexed over the connection of their thread"); + assertNotSame(clientsOfFirstThread.get(), clientsOfSecondThread.get(), + "every JMeter thread should use a connection of its own, like a real user would"); } @Test - void multiplexesConcurrentHttp2RequestsOverOneConnection() throws Exception { + void multiplexesConcurrentHttp2RequestsOfOneThreadOverOneConnection() throws Exception { WireMockServer server = createServer(); server.start(); try (TcpRelay relay = new TcpRelay(server.port())) { @@ -331,6 +350,7 @@ void multiplexesConcurrentHttp2RequestsOverOneConnection() throws Exception { sampler.setHttpVersion("HTTP/2"); return sampler.sample(url, HTTPConstants.GET, false, 1); }; + // the threads are created by this thread, like the ones which download embedded resources ExecutorService executor = Executors.newFixedThreadPool(requests); try { List> results = new ArrayList<>(); @@ -739,27 +759,27 @@ void doesNotCacheAnAdditionalHttp2ClientWhenTheSslContextIsReset() throws Except URL url = new URL("https://localhost:1234/http2SslReset"); try { JsseSSLManager sslManager = (JsseSSLManager) SSLManager.getInstance(); - HTTPJavaImpl.Http2Client first = impl.getHttpClient(url); + impl.getHttpClient(url); Map clientsAfterFirstSample = new HashMap<>(clients); SSLContext contextOfFirstSample = sslManager.getContext(); // Happens on every thread group iteration when "same user on next iteration" is switched off sslManager.resetContext(); - HTTPJavaImpl.Http2Client second = impl.getHttpClient(url); + impl.getHttpClient(url); assertNotSame(contextOfFirstSample, sslManager.getContext(), "resetContext() should hand out a new SSLContext, otherwise the test proves nothing"); assertFalse(clientsAfterFirstSample.isEmpty(), "an HTTP/2 client should have been cached"); - assertSame(first, second, "the cached HTTP/2 client should be reused after the SSLContext was reset"); - assertEquals(clientsAfterFirstSample, new HashMap(clients), + assertEquals(clientsAfterFirstSample.keySet(), new HashMap(clients).keySet(), "a new SSLContext must not add another HttpClient to the cache, that would leak clients"); } finally { + impl.threadFinished(); impl.testEnded(); } } @Test - void closesSharedHttp2ClientsWhenTheTestEnds() throws Exception { + void closesHttp2ClientsWhenTheThreadFinishes() throws Exception { WireMockServer server = createServer(); server.start(); HTTPSamplerBase sampler = newSampler(); @@ -772,10 +792,10 @@ void closesSharedHttp2ClientsWhenTheTestEnds() throws Exception { assertEquals("200", result.getResponseCode()); assertFalse(HTTPJavaImpl.getHttp2Clients().isEmpty(), "the sample should have cached an HTTP/2 client"); - sampler.testEnded(); + sampler.threadFinished(); assertTrue(HTTPJavaImpl.getHttp2Clients().isEmpty(), - "HTTP/2 clients should be released when the test ends"); + "HTTP/2 clients should be released when the thread finishes"); } finally { server.stop(); } diff --git a/xdocs/changes.xml b/xdocs/changes.xml index 4517ad9a652..b4561cfe8ed 100644 --- a/xdocs/changes.xml +++ b/xdocs/changes.xml @@ -102,8 +102,8 @@ Summary

    HTTP/2 support for the HttpClient5 and Java sampler implementations:

      -
    • 6742Multiplex concurrent HTTP/2 requests of the HttpClient5 sampler implementation over a single connection, and make the HTTP/2 protocol settings (HPACK header table size and compression, maximum concurrent streams, flow control window, maximum frame size, server push and h2c prior knowledge) configurable with the new httpclient5.h2.* properties. Pooled connections are re-validated after the period of inactivity configured with the new httpclient5.validate_after_inactivity property, so a connection the server closed in between two samples is replaced instead of failing the next sample.
    • -
    • 6742Multiplex concurrent HTTP/2 requests of the Java sampler implementation over a single connection, and make the HTTP/2 protocol settings (HPACK header table size, maximum concurrent streams, flow control windows, maximum frame size, connection keep alive and server push) configurable with the new http.java.h2.* properties.
    • +
    • 6742Multiplex concurrent HTTP/2 requests of a thread of the HttpClient5 sampler implementation, for example the parallel downloads of embedded resources, over a single connection, and make the HTTP/2 protocol settings (HPACK header table size and compression, maximum concurrent streams, flow control window, maximum frame size, server push and h2c prior knowledge) configurable with the new httpclient5.h2.* properties. Pooled connections are re-validated after the period of inactivity configured with the new httpclient5.validate_after_inactivity property, so a connection the server closed in between two samples is replaced instead of failing the next sample.
    • +
    • 6742Multiplex concurrent HTTP/2 requests of a thread of the Java sampler implementation, for example the parallel downloads of embedded resources, over a single connection, and make the HTTP/2 protocol settings (HPACK header table size, maximum concurrent streams, flow control windows, maximum frame size, connection keep alive and server push) configurable with the new http.java.h2.* properties. Every thread uses a connection of its own, as a real user would, unless the new http.java.h2.share_connections_between_threads property is enabled.
    • 6742Add the default User-Agent header of the HttpClient5 sampler implementation to the request itself, so it shows up in the sample result and in the sent bytes, and allow suppressing it with the new httpclient5.default_user_agent_disabled property, like httpclient4.default_user_agent_disabled does for HttpClient4.
    • 6742Add the default User-Agent header of the Java sampler implementation (Java/A.B.C for HTTP/1.1 and Java-http-client/A.B.C for HTTP/2) to the request itself, so the header the JDK sends anyway shows up in the sample result and in the sent bytes.
    • 6742Support the Kerberos mechanism of the HTTP Authorization Manager in the HttpClient5 sampler implementation. HttpClient 5 neither registers the Negotiate and Kerberos auth schemes nor prefers them by default, so they are now set up per request, and the request is executed with the JAAS subject the Authorization Manager logged the user in with. The kerberos.spnego.strip_port, kerberos.spnego.use_canonical_host_name and kerberos.spnego.delegate_cred properties are honoured as with HttpClient4.
    • diff --git a/xdocs/usermanual/component_reference.xml b/xdocs/usermanual/component_reference.xml index 45852fac3af..4e60a172cdf 100644 --- a/xdocs/usermanual/component_reference.xml +++ b/xdocs/usermanual/component_reference.xml @@ -251,7 +251,15 @@ https.default.protocol=SSLv3 HTTP/2 Strict, requires HTTP/2, so the request fails when the server does not support it), Java supports HTTP/1.1 and HTTP/2 Negotiate, and HttpClient4 supports HTTP/1.1 only. An empty value uses the httpclient.version property, which - defaults to HTTP/1.1. + defaults to HTTP/1.1. +

      With HTTP/2 a thread multiplexes its concurrent requests, for example the parallel downloads of + embedded resources, over a single connection per host, but the connections are not shared between + threads, so a test with N threads still opens N connections like + N real users would. Setting the + http.java.h2.share_connections_between_threads property makes the Java + implementation drive all threads over one connection per host, which saves connections and threads, + but the test then no longer measures connection level effects such as the congestion window, the cost + of the TLS handshake or per connection limits of the server.

      HTTP, HTTPS or FILE. Default: HTTP GET, POST, HEAD, TRACE, OPTIONS, PUT, DELETE, PATCH (not supported for @@ -3936,7 +3944,15 @@ All port values are treated equally; a sampler that does not specify a port will HTTP/2 Strict, requires HTTP/2, so the request fails when the server does not support it), Java supports HTTP/1.1 and HTTP/2 Negotiate, and HttpClient4 supports HTTP/1.1 only. An empty value uses the httpclient.version property, which - defaults to HTTP/1.1. + defaults to HTTP/1.1. +

      With HTTP/2 a thread multiplexes its concurrent requests, for example the parallel downloads of + embedded resources, over a single connection per host, but the connections are not shared between + threads, so a test with N threads still opens N connections like + N real users would. Setting the + http.java.h2.share_connections_between_threads property makes the Java + implementation drive all threads over one connection per host, which saves connections and threads, + but the test then no longer measures connection level effects such as the congestion window, the cost + of the TLS handshake or per connection limits of the server.

      HTTP or HTTPS. The encoding to be used for the request. The path to resource (for example, /servlets/myServlet). If the diff --git a/xdocs/usermanual/properties_reference.xml b/xdocs/usermanual/properties_reference.xml index e52a11a4bc0..55416fb6cf7 100644 --- a/xdocs/usermanual/properties_reference.xml +++ b/xdocs/usermanual/properties_reference.xml @@ -461,7 +461,8 @@ JMETER-SERVER Multiplex concurrent message exchanges (for example parallel downloads of embedded resources) - over a single HTTP/2 connection.
      + over a single HTTP/2 connection. The connections are not shared between threads, every JMeter + thread uses connections of its own.
      Defaults to: true
      @@ -510,12 +511,15 @@ JMETER-SERVER A User-Agent header defined in the test plan is still sent.
      Defaults to: false
      - - Multiplex concurrent message exchanges (for example the requests of different threads or parallel - downloads of embedded resources) of the Java sampler implementation over a single HTTP/2 - connection per origin. Requests which start before the first HTTP/2 connection to a host has been - established still open a connection of their own.
      - Defaults to: true + + Multiplex the HTTP/2 message exchanges of all JMeter threads of the Java sampler implementation + over a single connection per origin, instead of giving every thread a connection of its own. It + saves connections and threads, but a test which drives all virtual users over one connection no + longer measures connection level effects such as the congestion window, the cost of the TLS + handshake or per connection limits of the server, therefore it is disabled by default. The + requests of a single thread, including the parallel downloads of its embedded resources, are + multiplexed over one connection either way.
      + Defaults to: false
      Size in bytes of the HPACK dynamic header table the Java sampler implementation announces to the From 59186364d75105e5e0404fbe5866af1800ef202c Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Tue, 18 Aug 2026 16:16:48 +0200 Subject: [PATCH 56/80] Ensure proper cleanup of thread-local HTTP clients by clearing maps before closing clients --- .../jmeter/protocol/http/sampler/HTTPHC5Impl.java | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java index 351f4f18dd7..42959022e16 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java @@ -1635,16 +1635,21 @@ protected void threadFinished() { /** Closes the clients of the current thread, which releases their connections and I/O threads. */ static void closeThreadLocalClients() { + // The maps are shared with the threads which download embedded resources, and those threads are + // pooled, so they are emptied before the clients are closed to make sure a thread which serves + // another JMeter thread later on builds a client of its own instead of using a closed one. Map clients = HTTP_CLIENTS.get(); - for (CloseableHttpClient client : clients.values()) { + List closing = new ArrayList<>(clients.values()); + clients.clear(); + for (CloseableHttpClient client : closing) { JOrphanUtils.closeQuietly(client); } - clients.clear(); Map http2Clients = HTTP_2_CLIENTS.get(); - for (CloseableHttpAsyncClient client : http2Clients.values()) { + List closingHttp2Clients = new ArrayList<>(http2Clients.values()); + http2Clients.clear(); + for (CloseableHttpAsyncClient client : closingHttp2Clients) { JOrphanUtils.closeQuietly(client); } - http2Clients.clear(); } @Override From 9ef408f38a69f1048da6af9ce7186cbd988c82cf Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Tue, 18 Aug 2026 18:14:58 +0200 Subject: [PATCH 57/80] Key HTTP client caches by JMeterContext instead of thread The threads that download embedded resources in parallel are pooled and shared by all JMeter threads, so an InheritableThreadLocal bound them to whichever JMeter thread created them. They kept using that thread's HTTP client, which failed samples of other threads with IOException, "Socket closed" or "Connection pool shut down" as soon as the owning thread finished or started a new iteration. Look the clients up by the JMeterContext the downloader thread adopts from the JMeter thread it works for, in the HttpClient4, HttpClient5 and Java sampler implementations. This keeps HTTP/2 multiplexing between a sample and its embedded resources and stops a thread from closing clients another one is still using. --- .../protocol/http/sampler/HTTPHC4Impl.java | 47 ++-- .../protocol/http/sampler/HTTPHC5Impl.java | 73 +++--- .../protocol/http/sampler/HTTPJavaImpl.java | 55 +++-- .../http/sampler/TestHTTPHC5Features.java | 89 +++++++- .../http/sampler/TestHTTPJavaFeatures.java | 20 +- .../TestHTTPSamplerParallelDownloads.java | 208 ++++++++++++++++++ xdocs/changes.xml | 5 + 7 files changed, 421 insertions(+), 76 deletions(-) create mode 100644 src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPSamplerParallelDownloads.java diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC4Impl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC4Impl.java index e7da9ea7122..1d71675cc4f 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC4Impl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC4Impl.java @@ -35,10 +35,12 @@ import java.security.PrivilegedExceptionAction; import java.util.ArrayList; import java.util.Arrays; -import java.util.HashMap; +import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.WeakHashMap; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeUnit; import javax.security.auth.Subject; @@ -155,6 +157,7 @@ import org.apache.jmeter.samplers.SampleResult; import org.apache.jmeter.services.FileServer; import org.apache.jmeter.testelement.property.JMeterProperty; +import org.apache.jmeter.threads.JMeterContext; import org.apache.jmeter.threads.JMeterContextService; import org.apache.jmeter.threads.JMeterVariables; import org.apache.jmeter.util.JMeterUtils; @@ -509,15 +512,23 @@ public void process(HttpResponse response, HttpContext context) }; /** - * 1 HttpClient instance per combination of (HttpClient,HttpClientKey) + * Clients of every JMeter thread, one per {@link HttpClientKey}, looked up by the + * {@link JMeterContext} of that thread instead of by the thread itself. The threads which + * download embedded resources in parallel are pooled and shared by all JMeter threads, and they + * adopt the context of the JMeter thread they are working for, so looking the clients up by + * context hands them the clients of that thread and keeps a JMeter thread from closing or + * resetting clients another one is still using. + * + *

      The keys are weak, so that a context whose clients are never closed explicitly, like the + * one of a thread of the HTTP(S) Test Script Recorder, does not keep the entry alive forever. */ - private static final ThreadLocal> - HTTPCLIENTS_CACHE_PER_THREAD_AND_HTTPCLIENTKEY = new InheritableThreadLocal<>() { - @Override - protected Map initialValue() { - return new HashMap<>(5); - } - }; + private static final Map> + HTTPCLIENTS_CACHE_PER_THREAD_AND_HTTPCLIENTKEY = Collections.synchronizedMap(new WeakHashMap<>()); + + private static Map getClientsOfCurrentThread() { + return HTTPCLIENTS_CACHE_PER_THREAD_AND_HTTPCLIENTKEY.computeIfAbsent( + JMeterContextService.getContext(), context -> new ConcurrentHashMap<>(5)); + } /** * CONNECTION_SOCKET_FACTORY changes if we want to simulate Slow connection @@ -1041,8 +1052,7 @@ public String toString() { private HttpClientState setupClient(HttpClientKey key, JMeterVariables jMeterVariables, HttpClientContext clientContext) throws GeneralSecurityException { - Map mapHttpClientPerHttpClientKey = - HTTPCLIENTS_CACHE_PER_THREAD_AND_HTTPCLIENTKEY.get(); + Map mapHttpClientPerHttpClientKey = getClientsOfCurrentThread(); clientContext.setAttribute(CONTEXT_ATTRIBUTE_CLIENT_KEY, key); CloseableHttpClient httpClient = null; HttpClientState clientState = null; @@ -1777,13 +1787,18 @@ protected void notifyFirstSampleAfterLoopRestart() { @Override protected void threadFinished() { log.debug("Thread Finished"); - closeThreadLocalConnections(); + closeClientsOfCurrentThread(); } - private static void closeThreadLocalConnections() { - // Does not need to be synchronised, as all access is from same thread - Map - mapHttpClientPerHttpClientKey = HTTPCLIENTS_CACHE_PER_THREAD_AND_HTTPCLIENTKEY.get(); + /** + * Closes the clients of the current JMeter thread. Only the clients of this JMeter thread are + * dropped. The threads which download embedded resources borrow them while they work for this + * thread, and they are done by the time the sample returns, so no client is closed while a + * request is still using it. + */ + private static void closeClientsOfCurrentThread() { + Map mapHttpClientPerHttpClientKey = + HTTPCLIENTS_CACHE_PER_THREAD_AND_HTTPCLIENTKEY.remove(JMeterContextService.getContext()); if (mapHttpClientPerHttpClientKey != null ) { for (HttpClientState clientState : mapHttpClientPerHttpClientKey.values() ) { JOrphanUtils.closeQuietly(clientState.getClient()); diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java index 42959022e16..76e3e4d1320 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java @@ -18,6 +18,7 @@ package org.apache.jmeter.protocol.http.sampler; import java.io.ByteArrayOutputStream; +import java.io.Closeable; import java.io.File; import java.io.IOException; import java.io.OutputStream; @@ -37,12 +38,14 @@ import java.security.PrivilegedActionException; import java.security.PrivilegedExceptionAction; import java.util.ArrayList; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Objects; import java.util.Properties; +import java.util.WeakHashMap; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; @@ -150,6 +153,7 @@ import org.apache.jmeter.samplers.SampleResult; import org.apache.jmeter.services.FileServer; import org.apache.jmeter.testelement.property.JMeterProperty; +import org.apache.jmeter.threads.JMeterContext; import org.apache.jmeter.threads.JMeterContextService; import org.apache.jmeter.threads.JMeterVariables; import org.apache.jmeter.util.JMeterUtils; @@ -180,25 +184,26 @@ public class HTTPHC5Impl extends HTTPHCAbstractImpl { private static final String CONTEXT_ATTRIBUTE_RESPONSE_BODY_SIZE = "__jmeter.S_R_BODY_SIZE__"; //$NON-NLS-1$ private static final String CONTEXT_ATTRIBUTE_RESPONSE_BODY_COUNTER = "__jmeter.S_R_BODY_COUNTER__"; //$NON-NLS-1$ - private static final ThreadLocal> HTTP_CLIENTS = - new InheritableThreadLocal<>() { - @Override - protected Map initialValue() { - return new ConcurrentHashMap<>(); - } - }; + /** + * Clients of every JMeter thread, looked up by the {@link JMeterContext} of that thread instead + * of by the thread itself. The threads that download embedded resources in parallel are pooled + * and shared by all JMeter threads, and they adopt the context of the JMeter thread they are + * working for, so looking the clients up by context hands them the clients of that thread. That + * shares a connection between a sample and its embedded resources, which lets HTTP/2 multiplex + * them, and it keeps a JMeter thread from closing clients another one is still using. + * + *

      The keys are weak, so that a context whose clients are never closed explicitly, like the + * one of a thread of the HTTP(S) Test Script Recorder, does not keep the entry alive forever. + */ + private static final Map> HTTP_CLIENTS = + Collections.synchronizedMap(new WeakHashMap<>()); /** - * HTTP/2 clients are shared with the threads that download embedded resources in parallel, - * so that concurrent requests to the same host can be multiplexed over a single connection. + * HTTP/2 clients of every JMeter thread, see {@link #HTTP_CLIENTS} for how they are shared with + * the threads that download embedded resources in parallel. */ - private static final ThreadLocal> HTTP_2_CLIENTS = - new InheritableThreadLocal<>() { - @Override - protected Map initialValue() { - return new ConcurrentHashMap<>(); - } - }; + private static final Map> HTTP_2_CLIENTS = + Collections.synchronizedMap(new WeakHashMap<>()); /** Multiplex concurrent message exchanges over a single HTTP/2 connection. */ private static final boolean HTTP_2_MULTIPLEXING = @@ -1181,12 +1186,14 @@ private static void saveConnectionCookies(ClassicHttpResponse response, URL url, } private static CloseableHttpClient getClient(HttpClientKey key) { - Map clients = HTTP_CLIENTS.get(); + Map clients = + HTTP_CLIENTS.computeIfAbsent(JMeterContextService.getContext(), context -> new ConcurrentHashMap<>()); return clients.computeIfAbsent(key, HTTPHC5Impl::createClient); } private static CloseableHttpAsyncClient getHttp2Client(HttpClientKey key) { - Map clients = HTTP_2_CLIENTS.get(); + Map clients = + HTTP_2_CLIENTS.computeIfAbsent(JMeterContextService.getContext(), context -> new ConcurrentHashMap<>()); return clients.computeIfAbsent(key, HTTPHC5Impl::createHttp2Client); } @@ -1622,7 +1629,7 @@ protected void notifyFirstSampleAfterLoopRestart() { private static void resetStateIfNeeded() { if (resetStateOnThreadGroupIteration.get()) { - closeThreadLocalClients(); + closeClientsOfCurrentThread(); ((JsseSSLManager) SSLManager.getInstance()).resetContext(); resetStateOnThreadGroupIteration.set(false); } @@ -1630,24 +1637,24 @@ private static void resetStateIfNeeded() { @Override protected void threadFinished() { - closeThreadLocalClients(); + closeClientsOfCurrentThread(); } /** Closes the clients of the current thread, which releases their connections and I/O threads. */ - static void closeThreadLocalClients() { - // The maps are shared with the threads which download embedded resources, and those threads are - // pooled, so they are emptied before the clients are closed to make sure a thread which serves - // another JMeter thread later on builds a client of its own instead of using a closed one. - Map clients = HTTP_CLIENTS.get(); - List closing = new ArrayList<>(clients.values()); - clients.clear(); - for (CloseableHttpClient client : closing) { - JOrphanUtils.closeQuietly(client); + static void closeClientsOfCurrentThread() { + // Only the clients of this JMeter thread are dropped. The threads which download embedded + // resources borrow them while they work for this thread, and they are done by the time the + // sample returns, so no client is closed while a request is still using it. + JMeterContext context = JMeterContextService.getContext(); + closeQuietly(HTTP_CLIENTS.remove(context)); + closeQuietly(HTTP_2_CLIENTS.remove(context)); + } + + private static void closeQuietly(Map clients) { + if (clients == null) { + return; } - Map http2Clients = HTTP_2_CLIENTS.get(); - List closingHttp2Clients = new ArrayList<>(http2Clients.values()); - http2Clients.clear(); - for (CloseableHttpAsyncClient client : closingHttp2Clients) { + for (Closeable client : new ArrayList<>(clients.values())) { JOrphanUtils.closeQuietly(client); } } diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java index 7b0c544581c..c185c106fdb 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java @@ -46,6 +46,7 @@ import java.util.Map; import java.util.Objects; import java.util.Properties; +import java.util.WeakHashMap; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; @@ -68,6 +69,8 @@ import org.apache.jmeter.samplers.SampleResult; import org.apache.jmeter.testelement.property.CollectionProperty; import org.apache.jmeter.testelement.property.JMeterProperty; +import org.apache.jmeter.threads.JMeterContext; +import org.apache.jmeter.threads.JMeterContextService; import org.apache.jmeter.util.JMeterUtils; import org.apache.jmeter.util.JsseSSLManager; import org.apache.jmeter.util.SSLManager; @@ -126,18 +129,22 @@ private static String createHttp1DefaultUserAgent() { } /** - * HTTP/2 clients of a JMeter thread. The JDK client sends every exchange of a client over a single - * connection per origin, so one client per JMeter thread multiplexes the requests of that thread and - * of the threads which download its embedded resources over one connection, while every JMeter thread - * still uses a connection of its own, like a real user would. + * HTTP/2 clients of every JMeter thread, looked up by the {@link JMeterContext} of that thread + * instead of by the thread itself. The JDK client sends every exchange of a client over a single + * connection per origin, so one client per JMeter thread multiplexes the requests of that thread + * and of the threads which download its embedded resources over one connection, while every + * JMeter thread still uses a connection of its own, like a real user would. + * + *

      The threads which download embedded resources in parallel are pooled and shared by all + * JMeter threads, and they adopt the context of the JMeter thread they are working for, so + * looking the clients up by context hands them the clients of that thread and keeps a JMeter + * thread from closing clients another one is still using. + * + *

      The keys are weak, so that a context whose clients are never closed explicitly, like the + * one of a thread of the HTTP(S) Test Script Recorder, does not keep the entry alive forever. */ - private static final ThreadLocal> HTTP_2_CLIENTS = - new InheritableThreadLocal<>() { - @Override - protected Map initialValue() { - return new ConcurrentHashMap<>(); - } - }; + private static final Map> HTTP_2_CLIENTS = + Collections.synchronizedMap(new WeakHashMap<>()); /** * HTTP/2 clients used when the connections are shared between the JMeter threads. One instance for all @@ -235,7 +242,11 @@ private static void setUnlessDefined(Properties systemProperties, String name, S } static Map getHttp2Clients() { - return SHARE_HTTP_2_CONNECTIONS_BETWEEN_THREADS ? SHARED_HTTP_2_CLIENTS : HTTP_2_CLIENTS.get(); + if (SHARE_HTTP_2_CONNECTIONS_BETWEEN_THREADS) { + return SHARED_HTTP_2_CLIENTS; + } + return HTTP_2_CLIENTS.computeIfAbsent(JMeterContextService.getContext(), + context -> new ConcurrentHashMap<>()); } /** Returns the thread pool of the HTTP/2 clients, it is created when the first client needs it. */ @@ -276,11 +287,11 @@ private static void closeQuietly(Http2Client client) { } private static void closeHttp2Clients(Map clients) { + if (clients == null) { + return; + } synchronized (clients) { List closing = new ArrayList<>(clients.values()); - // The map is shared with the threads which download embedded resources, and those threads are - // pooled, so it is emptied before the clients are closed to make sure a thread which serves - // another JMeter thread later on builds a client of its own instead of using a closed one. clients.clear(); for (Http2Client client : closing) { closeQuietly(client); @@ -290,13 +301,17 @@ private static void closeHttp2Clients(Map clients) { @Override protected void threadFinished() { - closeThreadLocalHttp2Clients(); + closeHttp2ClientsOfCurrentThread(); } - /** Closes the HTTP/2 clients of the current JMeter thread, which releases their connections. */ - static void closeThreadLocalHttp2Clients() { - closeHttp2Clients(HTTP_2_CLIENTS.get()); - HTTP_2_CLIENTS.remove(); + /** + * Closes the HTTP/2 clients of the current JMeter thread, which releases their connections. Only + * the clients of this JMeter thread are dropped. The threads which download embedded resources + * borrow them while they work for this thread, and they are done by the time the sample returns, + * so no client is closed while a request is still using it. + */ + static void closeHttp2ClientsOfCurrentThread() { + closeHttp2Clients(HTTP_2_CLIENTS.remove(JMeterContextService.getContext())); } @Override diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java index 3227a125649..85f66a46a6a 100644 --- a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java +++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java @@ -40,6 +40,8 @@ import java.util.List; import java.util.Properties; import java.util.concurrent.Callable; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; @@ -67,9 +69,12 @@ import org.apache.jmeter.protocol.http.control.CookieManager; import org.apache.jmeter.protocol.http.control.Header; import org.apache.jmeter.protocol.http.control.HeaderManager; +import org.apache.jmeter.protocol.http.parser.LagartoBasedHtmlParser; import org.apache.jmeter.protocol.http.util.HTTPConstants; import org.apache.jmeter.protocol.http.util.HTTPFileArg; import org.apache.jmeter.samplers.SampleResult; +import org.apache.jmeter.threads.JMeterContext; +import org.apache.jmeter.threads.JMeterContextService; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; @@ -84,7 +89,7 @@ class TestHTTPHC5Features { @AfterEach void closeClientsOfThisThread() { // the clients are cached per thread and keep their connections and I/O reactor threads alive - HTTPHC5Impl.closeThreadLocalClients(); + HTTPHC5Impl.closeClientsOfCurrentThread(); } @Test @@ -149,7 +154,11 @@ void multiplexesConcurrentRequestsOverASingleHttp2Connection() throws Exception assertEquals(1, relay.getAcceptedConnections(), "the warm up should have opened one connection"); int requests = 4; + // the threads which download embedded resources adopt the JMeterContext of the JMeter + // thread they work for, which is how they end up using its client + JMeterContext jmeterThreadContext = JMeterContextService.getContext(); Callable sample = () -> { + JMeterContextService.replaceContext(jmeterThreadContext); HTTPSamplerBase sampler = newSampler(); sampler.setHttpVersion("HTTP/2"); return sampler.sample(url, HTTPConstants.GET, false, 1); @@ -180,6 +189,84 @@ void multiplexesConcurrentRequestsOverASingleHttp2Connection() throws Exception } } + /** + * The threads which download embedded resources in parallel are pooled and shared by all JMeter + * threads, so they must use the clients of the JMeter thread they are working for. Otherwise + * they keep the clients of the thread that happened to create them, and a request is executed + * on a client another JMeter thread closes at its iteration boundary, which fails the sample + * with an {@link IOException} or leaves it waiting for a response that never arrives. + */ + @Test + @org.junit.jupiter.api.Timeout(120) + void downloadsEmbeddedResourcesWhileOtherThreadsCloseTheirClients() throws Exception { + HTTPSamplerBase.registerParser("text/html", LagartoBasedHtmlParser.class.getName()); + WireMockServer server = new WireMockServer(WireMockConfiguration.wireMockConfig() + .dynamicHttpsPort() + .http2TlsDisabled(false)); + server.start(); + List failures = new CopyOnWriteArrayList<>(); + try { + StringBuilder html = new StringBuilder(""); + for (int i = 0; i < 12; i++) { + html.append(""); + server.stubFor(get(urlEqualTo("/image" + i + ".png")) + .willReturn(aResponse().withStatus(200).withFixedDelay(30).withBody("image" + i))); + } + server.stubFor(get(urlEqualTo("/index.html")).willReturn(aResponse().withStatus(200) + .withHeader("Content-Type", "text/html").withBody(html.append("").toString()))); + URL url = new URL("https://localhost:" + server.httpsPort() + "/index.html"); + + CountDownLatch start = new CountDownLatch(1); + List jmeterThreads = new ArrayList<>(); + for (int i = 0; i < 4; i++) { + Thread jmeterThread = new Thread(() -> { + try { + start.await(); + for (int iteration = 0; iteration < 10; iteration++) { + HTTPSamplerBase sampler = newSampler(); + sampler.setHttpVersion("HTTP/2"); + sampler.setImageParser(true); + sampler.setConcurrentDwn(true); + sampler.setConcurrentPool("6"); + sampler.setRunningVersion(true); + + HTTPSampleResult result = sampler.sample(url, HTTPConstants.GET, false, 0); + + collectFailures(result, failures); + // the clients are dropped at every thread group iteration by default + HTTPHC5Impl.closeClientsOfCurrentThread(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + jmeterThreads.add(jmeterThread); + jmeterThread.start(); + } + start.countDown(); + for (Thread jmeterThread : jmeterThreads) { + jmeterThread.join(); + } + } finally { + server.stop(); + } + assertEquals(List.of(), failures.stream().distinct().toList(), + "the embedded resources of a thread must not be affected by the clients of another one"); + } + + private static void collectFailures(HTTPSampleResult result, List failures) { + if (!"200".equals(result.getResponseCode())) { + failures.add(result.getUrlAsString() + " -> " + result.getResponseCode() + " " + + result.getResponseMessage()); + } + for (SampleResult subResult : result.getSubResults()) { + if (!"200".equals(subResult.getResponseCode())) { + failures.add(subResult.getUrlAsString() + " -> " + subResult.getResponseCode() + " " + + subResult.getResponseMessage()); + } + } + } + @Test void appliesHttp2ProtocolSettings() { H2Config config = HTTPHC5Impl.createHttp2Config(); diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java index 9a8ef153cb2..2c2f8fb41f1 100644 --- a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java +++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java @@ -70,6 +70,8 @@ import org.apache.jmeter.protocol.http.util.HTTPConstants; import org.apache.jmeter.protocol.http.util.HTTPFileArg; import org.apache.jmeter.samplers.SampleResult; +import org.apache.jmeter.threads.JMeterContext; +import org.apache.jmeter.threads.JMeterContextService; import org.apache.jmeter.util.JsseSSLManager; import org.apache.jmeter.util.SSLManager; import org.junit.jupiter.api.AfterEach; @@ -83,7 +85,7 @@ class TestHTTPJavaFeatures { @AfterEach void closeHttp2Clients() { // the HTTP/2 clients keep their connections and their selector thread alive until they are closed - HTTPJavaImpl.closeThreadLocalHttp2Clients(); + HTTPJavaImpl.closeHttp2ClientsOfCurrentThread(); HTTPJavaImpl.releaseSharedHttp2Resources(); } @@ -301,16 +303,19 @@ void doesNotOverrideHttp2SettingsGivenOnTheCommandLine() { @Test void sharesHttp2ClientsWithTheThreadsDownloadingEmbeddedResources() throws Exception { - // JMeter threads are started by a thread which never samples itself, so they do not inherit a map - HTTPJavaImpl.closeThreadLocalHttp2Clients(); AtomicReference> clientsOfFirstThread = new AtomicReference<>(); AtomicReference> clientsOfSecondThread = new AtomicReference<>(); AtomicReference> clientsOfResourceDownloader = new AtomicReference<>(); Thread firstThread = new Thread(() -> { clientsOfFirstThread.set(HTTPJavaImpl.getHttp2Clients()); - // the embedded resources are downloaded by threads the JMeter thread creates - Thread downloader = new Thread(() -> clientsOfResourceDownloader.set(HTTPJavaImpl.getHttp2Clients())); + // the embedded resources are downloaded by pooled threads which adopt the context of the + // JMeter thread they are working for, see HTTPSamplerBase.ASyncSample + JMeterContext contextOfFirstThread = JMeterContextService.getContext(); + Thread downloader = new Thread(() -> { + JMeterContextService.replaceContext(contextOfFirstThread); + clientsOfResourceDownloader.set(HTTPJavaImpl.getHttp2Clients()); + }); downloader.start(); try { downloader.join(); @@ -345,12 +350,15 @@ void multiplexesConcurrentHttp2RequestsOfOneThreadOverOneConnection() throws Exc assertEquals(1, relay.getAcceptedConnections(), "the warm up should have opened one connection"); int requests = 4; + JMeterContext jmeterThreadContext = JMeterContextService.getContext(); Callable sample = () -> { + // the threads which download embedded resources adopt the context of the JMeter thread + JMeterContextService.replaceContext(jmeterThreadContext); HTTPSamplerBase sampler = newSampler(); sampler.setHttpVersion("HTTP/2"); return sampler.sample(url, HTTPConstants.GET, false, 1); }; - // the threads are created by this thread, like the ones which download embedded resources + // the threads are pooled, like the ones which download embedded resources ExecutorService executor = Executors.newFixedThreadPool(requests); try { List> results = new ArrayList<>(); diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPSamplerParallelDownloads.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPSamplerParallelDownloads.java new file mode 100644 index 00000000000..57a80da01c8 --- /dev/null +++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPSamplerParallelDownloads.java @@ -0,0 +1,208 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you 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.apache.jmeter.protocol.http.sampler; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.net.URL; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.apache.jmeter.engine.event.LoopIterationEvent; +import org.apache.jmeter.protocol.http.control.CacheManager; +import org.apache.jmeter.protocol.http.control.CookieManager; +import org.apache.jmeter.protocol.http.control.Header; +import org.apache.jmeter.protocol.http.control.HeaderManager; +import org.apache.jmeter.protocol.http.parser.LagartoBasedHtmlParser; +import org.apache.jmeter.protocol.http.util.HTTPConstants; +import org.apache.jmeter.samplers.SampleResult; +import org.apache.jmeter.testelement.TestElement; +import org.apache.jmeter.threads.JMeterContextService; +import org.apache.jmeter.threads.JMeterVariables; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.core.WireMockConfiguration; + +/** + * The threads which download embedded resources in parallel are pooled and shared by all JMeter + * threads, so a client which is bound to the thread that happens to create such a pooled thread ends + * up being used, and closed, across JMeter threads. + * + *

      These tests warm the pool up from one JMeter thread, which then keeps closing its clients while + * the other JMeter threads sample, as a thread group does when its threads finish or start a new + * iteration, and they check that no sample fails because of a client another JMeter thread closed. + */ +class TestHTTPSamplerParallelDownloads { + + private static final int EMBEDDED_RESOURCES = 12; + private static final int PARALLEL_DOWNLOADS = 6; + private static final int SAMPLING_THREADS = 2; + private static final int ITERATIONS = 12; + + private static WireMockServer server; + + @BeforeAll + static void startServer() { + HTTPSamplerBase.registerParser("text/html", LagartoBasedHtmlParser.class.getName()); + server = new WireMockServer(WireMockConfiguration.wireMockConfig() + .dynamicPort() + .dynamicHttpsPort() + .http2TlsDisabled(false)); + server.start(); + StringBuilder html = new StringBuilder(""); + for (int i = 0; i < EMBEDDED_RESOURCES; i++) { + html.append(""); + server.stubFor(get(urlEqualTo("/image" + i + ".png")) + .willReturn(aResponse().withStatus(200) + .withFixedDelay(100) + .withHeader("Cache-Control", "max-age=600") + .withHeader("ETag", "\"etag" + i + "\"") + .withBody("image" + i))); + } + server.stubFor(get(urlEqualTo("/index.html")) + .willReturn(aResponse().withStatus(200) + .withHeader("Content-Type", "text/html") + .withHeader("Set-Cookie", "session=abc; Path=/") + .withBody(html.append("").toString()))); + } + + @AfterAll + static void stopServer() { + if (server != null) { + server.stop(); + } + } + + @ParameterizedTest(name = "{0} {1} over {2}") + @CsvSource({ + "HttpClient4, HTTP/1.1, http", + "HttpClient5, HTTP/1.1, http", + "HttpClient5, HTTP/2, https", + "Java, HTTP/1.1, http", + "Java, HTTP/2, http", + }) + @Timeout(120) + void downloadsEmbeddedResourcesWhileAnotherThreadClosesItsClients( + String implementation, String version, String scheme) throws Exception { + int port = "https".equals(scheme) ? server.httpsPort() : server.port(); + URL url = new URL(scheme + "://localhost:" + port + "/index.html"); + List failures = new CopyOnWriteArrayList<>(); + CountDownLatch poolWarmedUp = new CountDownLatch(1); + AtomicBoolean samplingDone = new AtomicBoolean(); + + // creates the pooled downloader threads and then keeps closing the clients they may hold on to + Thread closingThread = new Thread(() -> { + prepareThread(); + HTTPSamplerBase sampler = sampleOnce(url, implementation, version, failures); + poolWarmedUp.countDown(); + do { + sampler.threadFinished(); + try { + Thread.sleep(5); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + } while (!samplingDone.get()); + }, "closing-jmeter-thread"); + closingThread.start(); + + List samplingThreads = new ArrayList<>(); + for (int i = 0; i < SAMPLING_THREADS; i++) { + Thread thread = new Thread(() -> { + prepareThread(); + try { + poolWarmedUp.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + HTTPSamplerBase sampler = null; + for (int iteration = 0; iteration < ITERATIONS; iteration++) { + sampler = sampleOnce(url, implementation, version, failures); + } + sampler.threadFinished(); + }, "sampling-jmeter-thread-" + i); + samplingThreads.add(thread); + thread.start(); + } + for (Thread thread : samplingThreads) { + thread.join(); + } + samplingDone.set(true); + closingThread.join(); + + assertEquals(List.of(), failures.stream().distinct().toList(), + "samples must not fail because another JMeter thread closed its clients"); + } + + /** Gives the thread the {@code JMeterContext} a JMeter thread has. */ + private static void prepareThread() { + JMeterContextService.getContext().setVariables(new JMeterVariables()); + } + + private static HTTPSamplerBase sampleOnce(URL url, String implementation, String version, + List failures) { + CacheManager cacheManager = new CacheManager(); + cacheManager.setProperty(TestElement.GUI_CLASS, "CacheManagerGui"); + cacheManager.testStarted(); + CookieManager cookieManager = new CookieManager(); + cookieManager.setProperty(TestElement.GUI_CLASS, "CookiePanel"); + cookieManager.testStarted(); + HeaderManager headerManager = new HeaderManager(); + headerManager.add(new Header("Accept-Language", "en")); + HTTPSamplerBase sampler = HTTPSamplerFactory.newInstance(implementation); + sampler.setHttpVersion(version); + sampler.setImageParser(true); + sampler.setConcurrentDwn(true); + sampler.setConcurrentPool(Integer.toString(PARALLEL_DOWNLOADS)); + sampler.setRunningVersion(true); + sampler.setCacheManager(cacheManager); + sampler.setCookieManager(cookieManager); + sampler.setHeaderManager(headerManager); + try { + collectFailures(sampler.sample(url, HTTPConstants.GET, false, 0), failures); + } catch (RuntimeException e) { // NOSONAR the failure is reported by the test + failures.add("exception " + e); + } + sampler.testIterationStart(new LoopIterationEvent(sampler, 2)); + return sampler; + } + + private static void collectFailures(HTTPSampleResult result, List failures) { + if (!"200".equals(result.getResponseCode())) { + failures.add(result.getResponseCode() + " " + result.getResponseMessage()); + } + for (SampleResult subResult : result.getSubResults()) { + if (!"200".equals(subResult.getResponseCode())) { + failures.add(subResult.getResponseCode() + " " + subResult.getResponseMessage()); + } + } + } +} diff --git a/xdocs/changes.xml b/xdocs/changes.xml index b4561cfe8ed..2d9c2ccb6ba 100644 --- a/xdocs/changes.xml +++ b/xdocs/changes.xml @@ -159,6 +159,11 @@ Summary

    • 6742Count single byte writes in CountingOutputStream as well, so the SMTP Sampler does not under-report the size of the messages it sends.
    +

    HTTP Samplers and Test Script Recorder

    +
      +
    • 6742Keep the HTTP clients of a thread from being used, and closed, by another thread when embedded resources are downloaded in parallel. The threads which perform those downloads are pooled and shared by all threads, so they used to keep the clients of whichever thread happened to create them, which made samples fail with errors such as Non HTTP response code: java.io.IOException, Socket closed or Connection pool shut down as soon as that thread finished or started a new iteration. The clients are now looked up by the context of the thread the download is performed for, in the HttpClient4, HttpClient5 and Java sampler implementations.
    • +
    + Thanks From 8920c06dcd18912151993e192e6b4447539829c9 Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Tue, 18 Aug 2026 22:10:22 +0200 Subject: [PATCH 58/80] Let only the owning thread replace the Java HTTP/2 client JMeter hands out an SSLContext per thread, so a pooled thread downloading embedded resources considered the HTTP/2 client of the JMeter thread it works for outdated, replaced it and shut it down while the sample and its sibling downloads were still using it, failing them with "java.io.IOException: shutdownNow". --- .../protocol/http/sampler/HTTPJavaImpl.java | 37 ++++++++++++++++--- .../http/sampler/TestHTTPJavaFeatures.java | 27 ++++++++++++++ xdocs/changes.xml | 1 + 3 files changed, 60 insertions(+), 5 deletions(-) diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java index c185c106fdb..11a740ecabc 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java @@ -143,7 +143,7 @@ private static String createHttp1DefaultUserAgent() { *

    The keys are weak, so that a context whose clients are never closed explicitly, like the * one of a thread of the HTTP(S) Test Script Recorder, does not keep the entry alive forever. */ - private static final Map> HTTP_2_CLIENTS = + private static final Map HTTP_2_CLIENTS = Collections.synchronizedMap(new WeakHashMap<>()); /** @@ -245,8 +245,29 @@ static Map getHttp2Clients() { if (SHARE_HTTP_2_CONNECTIONS_BETWEEN_THREADS) { return SHARED_HTTP_2_CLIENTS; } + return http2ClientCache().clients; + } + + private static Http2ClientCache http2ClientCache() { return HTTP_2_CLIENTS.computeIfAbsent(JMeterContextService.getContext(), - context -> new ConcurrentHashMap<>()); + context -> new Http2ClientCache()); + } + + /** + * The clients of one JMeter thread, together with the thread that owns them. Only the owner + * replaces a client, because {@link JsseSSLManager} hands out an {@link SSLContext} per thread: + * the pooled threads which download embedded resources therefore always see an SSLContext other + * than the one the client was built with, and would keep replacing the client of the JMeter + * thread they work for, shutting it down while its sample and the sibling downloads are still + * using it, which fails them with {@code java.io.IOException: shutdownNow}. + */ + private static final class Http2ClientCache { + private final Thread owner = Thread.currentThread(); + private final Map clients = new ConcurrentHashMap<>(); + + private boolean isOwnedByCurrentThread() { + return owner == Thread.currentThread(); + } } /** Returns the thread pool of the HTTP/2 clients, it is created when the first client needs it. */ @@ -311,7 +332,8 @@ protected void threadFinished() { * so no client is closed while a request is still using it. */ static void closeHttp2ClientsOfCurrentThread() { - closeHttp2Clients(HTTP_2_CLIENTS.remove(JMeterContextService.getContext())); + Http2ClientCache cache = HTTP_2_CLIENTS.remove(JMeterContextService.getContext()); + closeHttp2Clients(cache == null ? null : cache.clients); } @Override @@ -1515,8 +1537,13 @@ Http2Client getHttpClient(URL url) { proxyUser, proxyPass, autoRedirects, sslContext != null); Map clients = getHttp2Clients(); + // Only the JMeter thread that owns the clients replaces one when its SSLContext was reset. + // A thread which downloads embedded resources has an SSLContext of its own, which never is + // the one of the client, so it has to keep the client of the JMeter thread it works for. + boolean keepCachedClient = SHARE_HTTP_2_CONNECTIONS_BETWEEN_THREADS + || !http2ClientCache().isOwnedByCurrentThread(); Http2Client client = clients.get(key); - if (client != null && (SHARE_HTTP_2_CONNECTIONS_BETWEEN_THREADS || client.usesSslContext(sslContext))) { + if (client != null && (keepCachedClient || client.usesSslContext(sslContext))) { // The SSLContext must not be part of the key, it does not implement equals(), and JMeter hands out // a new instance per thread and after every reset, which would make the cache grow without bound. // When the clients are shared, the SSLContext of the first caller is kept, so all threads keep on @@ -1525,7 +1552,7 @@ Http2Client getHttpClient(URL url) { } synchronized (clients) { client = clients.get(key); - if (client != null && (SHARE_HTTP_2_CONNECTIONS_BETWEEN_THREADS || client.usesSslContext(sslContext))) { + if (client != null && (keepCachedClient || client.usesSslContext(sslContext))) { return client; } Http2Client created = createHttpClient(key, sslContext); diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java index 2c2f8fb41f1..5aeb59c931e 100644 --- a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java +++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java @@ -786,6 +786,33 @@ void doesNotCacheAnAdditionalHttp2ClientWhenTheSslContextIsReset() throws Except } } + @Test + void keepsTheHttp2ClientOfItsThreadWhileEmbeddedResourcesAreDownloaded() throws Exception { + HTTPSamplerBase sampler = newSampler(); + sampler.setHttpVersion("HTTP/2"); + HTTPJavaImpl impl = new HTTPJavaImpl(sampler); + URL url = new URL("https://localhost:1234/http2ParallelDownload"); + try { + Object clientOfSample = impl.getHttpClient(url); + // the embedded resources are downloaded by pooled threads which adopt the context of the + // JMeter thread they are working for, but get an SSLContext of their own from the SSLManager + JMeterContext contextOfSample = JMeterContextService.getContext(); + AtomicReference clientOfDownloader = new AtomicReference<>(); + Thread downloader = new Thread(() -> { + JMeterContextService.replaceContext(contextOfSample); + clientOfDownloader.set(impl.getHttpClient(url)); + }); + downloader.start(); + downloader.join(); + + assertSame(clientOfSample, clientOfDownloader.get(), + "a downloader must not replace the client of its sample, that would shut it down while in use"); + } finally { + impl.threadFinished(); + impl.testEnded(); + } + } + @Test void closesHttp2ClientsWhenTheThreadFinishes() throws Exception { WireMockServer server = createServer(); diff --git a/xdocs/changes.xml b/xdocs/changes.xml index 2d9c2ccb6ba..aa6c16c1949 100644 --- a/xdocs/changes.xml +++ b/xdocs/changes.xml @@ -162,6 +162,7 @@ Summary

    HTTP Samplers and Test Script Recorder

    • 6742Keep the HTTP clients of a thread from being used, and closed, by another thread when embedded resources are downloaded in parallel. The threads which perform those downloads are pooled and shared by all threads, so they used to keep the clients of whichever thread happened to create them, which made samples fail with errors such as Non HTTP response code: java.io.IOException, Socket closed or Connection pool shut down as soon as that thread finished or started a new iteration. The clients are now looked up by the context of the thread the download is performed for, in the HttpClient4, HttpClient5 and Java sampler implementations.
    • +
    • 6742Keep a thread which downloads embedded resources in parallel from replacing the HTTP/2 client of the Java sampler implementation. JMeter hands out an SSLContext per thread, so such a download used to consider the client of the thread it works for outdated, and shut it down while its sample and the sibling downloads were still using it, which failed them with Non HTTP response code: java.util.concurrent.ExecutionException and java.io.IOException: shutdownNow. Only the thread which owns a client replaces it now.
    From 81ff768975e712b0f4984082266079a096dd76eb Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Wed, 19 Aug 2026 09:24:03 +0200 Subject: [PATCH 59/80] Revert the HTTP/2 extension of the Java sampler implementation The Java sampler implementation is left as it was, so HTTPJavaImpl keeps using HttpURLConnection and the HTTP Version combo box offers HTTP/1.1 only for it, as for HttpClient4. HTTP/2 stays with the HttpClient5 implementation. Drop the helpers only that extension needed (ConnectTimeTracker, CountingOutputHttpURLConnection, Http2CapturingHttpURLConnection, the HTTPAbstractImpl testEnded hook), the http.java.h2.* properties and the documentation of both. The shared parts of the earlier bugfixes stay: HTTPMessageSizes, SpillOutputStream, the HTTP client neutral CacheManager API and the jorphan CountingOutputStream fix. --- bin/jmeter.properties | 45 +- .../http/sampler/ConnectTimeTracker.java | 430 -------- .../CountingOutputHttpURLConnection.java | 150 --- .../http/sampler/HTTPAbstractImpl.java | 4 - .../protocol/http/sampler/HTTPHC5Impl.java | 6 +- .../protocol/http/sampler/HTTPJavaImpl.java | 986 +---------------- .../http/sampler/HTTPSamplerFactory.java | 6 +- .../http/sampler/HTTPSamplerProxy.java | 8 - .../Http2CapturingHttpURLConnection.java | 146 --- .../http/gui/TestHttpVersionComboBox.java | 8 +- .../http/sampler/TestHTTPJavaFeatures.java | 992 ------------------ .../http/sampler/TestHTTPSamplerFactory.java | 2 +- .../TestHTTPSamplerParallelDownloads.java | 1 - .../http/sampler/TestHttpVersionProperty.java | 2 +- xdocs/changes.xml | 11 +- xdocs/usermanual/component_reference.xml | 22 +- xdocs/usermanual/properties_reference.xml | 63 +- 17 files changed, 51 insertions(+), 2831 deletions(-) delete mode 100644 src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/ConnectTimeTracker.java delete mode 100644 src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/CountingOutputHttpURLConnection.java delete mode 100644 src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/Http2CapturingHttpURLConnection.java delete mode 100644 src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java diff --git a/bin/jmeter.properties b/bin/jmeter.properties index a9aa8249555..56c0ac2867a 100644 --- a/bin/jmeter.properties +++ b/bin/jmeter.properties @@ -378,9 +378,10 @@ remote_hosts=127.0.0.1 #httpclient.timeout=0 # 0 == no timeout -# Set the default HTTP version for HttpClient5 and Java samplers when HTTP Version is empty -# Valid values are HTTP/1.1, HTTP/2 and, for HttpClient5 only, HTTP/2 Strict (defaults to HTTP/1.1) +# Set the default HTTP version for HttpClient5 samplers when HTTP Version is empty +# Valid values are HTTP/1.1, HTTP/2 and HTTP/2 Strict (defaults to HTTP/1.1) # The legacy values 1.0 and 1.1 are still accepted and both mean HTTP/1.1 for these samplers +# The HttpClient4 and Java samplers always use HTTP/1.1 # The AJP sampler only reads this property to decide between the legacy value 1.0 (or HTTP/1.0), # which makes it announce HTTP/1.0, and any other value, which makes it announce HTTP/1.1 #httpclient.version=HTTP/1.1 @@ -433,46 +434,6 @@ remote_hosts=127.0.0.1 # support h2c #httpclient5.h2.prior_knowledge=false -# HTTP/2 settings used by the Java sampler implementation. Except for the -# connection sharing they are applied as jdk.httpclient.* system properties, so a -# setting given on the command line always takes precedence - -# Multiplex the HTTP/2 message exchanges of all threads over a single connection -# per origin, instead of giving every thread a connection of its own. It saves -# connections and threads, but a test which drives all virtual users over one -# connection no longer measures connection level effects such as the congestion -# window, the TLS handshake cost or per connection limits of the server. The -# requests of a single thread, including the parallel downloads of its embedded -# resources, are multiplexed over one connection either way -#http.java.h2.share_connections_between_threads=false - -# Size in bytes of the HPACK dynamic header table announced to the server. -# 0 disables indexing of headers by the server (JDK default: 16384) -#http.java.h2.header_table_size=16384 - -# Maximum number of server initiated (pushed) streams the client accepts on a -# connection (JDK default: 100 with server push enabled, 0 otherwise) -#http.java.h2.max_concurrent_streams=100 - -# Flow control window in bytes announced per stream (JDK default: 16777216) -#http.java.h2.initial_window_size=16777216 - -# Flow control window in bytes announced for the whole connection, it must not -# be smaller than the stream window (JDK default: 67108864) -#http.java.h2.connection_window_size=67108864 - -# Largest frame payload in bytes the client is willing to receive, between -# 16384 and 16777215 (JDK default: 16384) -#http.java.h2.max_frame_size=16384 - -# Seconds an idle HTTP/2 connection is kept in the pool (JDK default: the value -# of jdk.httpclient.keepalive.timeout, which defaults to 30) -#http.java.h2.keep_alive_timeout=30 - -# Accept server push. JMeter cannot report pushed resources, so they are -# dropped, therefore push is disabled by default -#http.java.h2.push_enabled=false - # If true, default HC5 User-Agent will not be added #httpclient5.default_user_agent_disabled=false diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/ConnectTimeTracker.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/ConnectTimeTracker.java deleted file mode 100644 index 45d92ff6885..00000000000 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/ConnectTimeTracker.java +++ /dev/null @@ -1,430 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to you 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.apache.jmeter.protocol.http.sampler; - -import java.net.URL; -import java.net.http.HttpClient; -import java.nio.ByteBuffer; -import java.security.KeyManagementException; -import java.security.SecureRandom; -import java.util.ArrayList; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicReference; -import java.util.function.BiFunction; - -import javax.net.ssl.KeyManager; -import javax.net.ssl.SSLContext; -import javax.net.ssl.SSLContextSpi; -import javax.net.ssl.SSLEngine; -import javax.net.ssl.SSLEngineResult; -import javax.net.ssl.SSLException; -import javax.net.ssl.SSLParameters; -import javax.net.ssl.SSLServerSocketFactory; -import javax.net.ssl.SSLSession; -import javax.net.ssl.SSLSessionContext; -import javax.net.ssl.SSLSocketFactory; -import javax.net.ssl.TrustManager; - -import org.apache.jmeter.samplers.SampleResult; - -/** - * Measures how long it takes to establish the connections of a JDK {@link HttpClient} and reports the - * result in the {@link SampleResult}s which are waiting for them. - *

    - * The JDK client does not expose a hook for connection establishment, so the wrapped {@code SSLContext} - * is used instead: it creates one {@code SSLEngine} per connection, and that engine reports when the TLS - * handshake of its connection has been finished. Only the samples which are waiting for a connection to - * the origin of that very connection are attached to it, so a sample of another origin is never affected, - * and a sample which is served by an already established connection keeps a connect time of {@code 0}. - * Plain text connections carry no {@code SSLEngine}, so their connect time cannot be measured at all. - *

    - * A client is shared by a JMeter thread and the threads which download its embedded resources, and by all - * JMeter threads when {@code http.java.h2.share_connections_between_threads} is enabled, so the measurement - * happens on the client threads, but the value is only written to the {@link SampleResult} by the thread - * which owns the sample. - */ -final class ConnectTimeTracker { - - /** Samples which are currently in flight, together with the connection they are waiting for. */ - private final Map activeSamples = new ConcurrentHashMap<>(); - - /** - * Registers a sample which is about to be sent. - * - * @param result the sample result, it must have been started - * @param origin the origin the request is sent to, see {@link #origin(URL)} - */ - void sampleStarted(SampleResult result, String origin) { - activeSamples.put(result, new PendingSample(result, origin)); - } - - /** - * Unregisters the sample and stores the connect time of the connection it had to wait for, if any. - * This is called by the thread which owns the sample, so the {@link SampleResult} is never written - * by a client thread. - */ - void sampleFinished(SampleResult result) { - PendingSample pending = activeSamples.remove(result); - if (pending != null) { - pending.storeConnectTime(); - } - } - - /** - * Announces that a new connection to {@code origin} is being opened, and attaches the samples which - * are currently waiting for a connection to that origin to it. - * - * @return the connection, which has to be told when it has been established - */ - Connection connectionOpened(String origin) { - List waiting = new ArrayList<>(); - for (PendingSample pending : activeSamples.values()) { - if (pending.waitsFor(origin) && pending.attach()) { - waiting.add(pending); - } - } - return new Connection(waiting); - } - - /** Returns the origin a sample of the given URL connects to. */ - static String origin(URL url) { - int port = url.getPort() != -1 ? url.getPort() : url.getDefaultPort(); - return origin(url.getHost(), port); - } - - /** Returns the origin a connection to the given host and port belongs to. */ - static String origin(String host, int port) { - return (host == null ? "" : host.toLowerCase(Locale.ROOT)) + ":" + port; // $NON-NLS-1$ $NON-NLS-2$ - } - - /** - * A single connection of an HTTP/2 {@link HttpClient}, together with the samples which are waiting for it - * to be established. - */ - static final class Connection { - /** Set to {@code null} once the connection has been established, which also releases the samples. */ - private final AtomicReference> waitingSamples; - - Connection(List waitingSamples) { - this.waitingSamples = new AtomicReference<>(waitingSamples); - } - - /** Invoked when the connection is ready to carry requests, that is when its handshake is done. */ - void established() { - List samples = waitingSamples.getAndSet(null); - if (samples != null) { - for (PendingSample sample : samples) { - sample.connectEnd(); - } - } - } - } - - /** A sample which is in flight, and the connect time measured for it, if it had to open a connection. */ - private static final class PendingSample { - private static final long NOT_MEASURED = -1; - - private final SampleResult result; - private final String origin; - private final AtomicBoolean attached = new AtomicBoolean(); - private volatile long connectTime = NOT_MEASURED; - - PendingSample(SampleResult result, String origin) { - this.result = result; - this.origin = origin; - } - - boolean waitsFor(String otherOrigin) { - return origin.equals(otherOrigin); - } - - /** Attaches the sample to a connection, a sample can only wait for a single connection. */ - boolean attach() { - return attached.compareAndSet(false, true); - } - - /** Measures the connect time, called on a client thread, so the sample itself is not modified. */ - void connectEnd() { - connectTime = Math.max(0, - result.currentTimeInMillis() - result.getStartTime() - result.getIdleTime()); - } - - /** Stores the measured connect time, called on the thread which owns the sample. */ - void storeConnectTime() { - long measured = connectTime; - if (measured != NOT_MEASURED) { - result.setConnectTime(measured); - } - } - } - - /** - * {@link SSLContext} which creates {@link SSLEngine}s that report the end of the TLS handshake. - */ - static final class MeasuringSSLContext extends SSLContext { - MeasuringSSLContext(SSLContext delegate, ConnectTimeTracker tracker) { - super(new MeasuringSSLContextSpi(delegate, tracker), delegate.getProvider(), - delegate.getProtocol()); - } - } - - private static final class MeasuringSSLContextSpi extends SSLContextSpi { - private final SSLContext delegate; - private final ConnectTimeTracker tracker; - - MeasuringSSLContextSpi(SSLContext delegate, ConnectTimeTracker tracker) { - this.delegate = delegate; - this.tracker = tracker; - } - - @Override - protected void engineInit(KeyManager[] km, TrustManager[] tm, SecureRandom sr) throws KeyManagementException { - delegate.init(km, tm, sr); - } - - @Override - protected SSLSocketFactory engineGetSocketFactory() { - return delegate.getSocketFactory(); - } - - @Override - protected SSLServerSocketFactory engineGetServerSocketFactory() { - return delegate.getServerSocketFactory(); - } - - @Override - protected SSLEngine engineCreateSSLEngine() { - // Without a peer the connection cannot be attributed to the samples of an origin, so it is not measured - return delegate.createSSLEngine(); - } - - @Override - protected SSLEngine engineCreateSSLEngine(String host, int port) { - return new MeasuringSSLEngine(delegate.createSSLEngine(host, port), - tracker.connectionOpened(ConnectTimeTracker.origin(host, port))); - } - - @Override - protected SSLSessionContext engineGetServerSessionContext() { - return delegate.getServerSessionContext(); - } - - @Override - protected SSLSessionContext engineGetClientSessionContext() { - return delegate.getClientSessionContext(); - } - - @Override - protected SSLParameters engineGetDefaultSSLParameters() { - return delegate.getDefaultSSLParameters(); - } - - @Override - protected SSLParameters engineGetSupportedSSLParameters() { - return delegate.getSupportedSSLParameters(); - } - } - - /** - * {@link SSLEngine} which delegates all calls and reports the {@link Connection} it belongs to as - * established as soon as its TLS handshake has been finished. - */ - static final class MeasuringSSLEngine extends SSLEngine { - private final SSLEngine delegate; - private final Connection connection; - - MeasuringSSLEngine(SSLEngine delegate, Connection connection) { - super(delegate.getPeerHost(), delegate.getPeerPort()); - this.delegate = delegate; - this.connection = connection; - } - - private void checkHandshakeFinished(SSLEngineResult result) { - if (result.getHandshakeStatus() == SSLEngineResult.HandshakeStatus.FINISHED) { - connection.established(); - } - } - - @Override - public SSLEngineResult wrap(ByteBuffer[] srcs, int offset, int length, ByteBuffer dst) throws SSLException { - SSLEngineResult result = delegate.wrap(srcs, offset, length, dst); - checkHandshakeFinished(result); - return result; - } - - @Override - public SSLEngineResult unwrap(ByteBuffer src, ByteBuffer[] dsts, int offset, int length) throws SSLException { - SSLEngineResult result = delegate.unwrap(src, dsts, offset, length); - checkHandshakeFinished(result); - return result; - } - - @Override - public Runnable getDelegatedTask() { - return delegate.getDelegatedTask(); - } - - @Override - public void closeInbound() throws SSLException { - delegate.closeInbound(); - } - - @Override - public boolean isInboundDone() { - return delegate.isInboundDone(); - } - - @Override - public void closeOutbound() { - delegate.closeOutbound(); - } - - @Override - public boolean isOutboundDone() { - return delegate.isOutboundDone(); - } - - @Override - public String[] getSupportedCipherSuites() { - return delegate.getSupportedCipherSuites(); - } - - @Override - public String[] getEnabledCipherSuites() { - return delegate.getEnabledCipherSuites(); - } - - @Override - public void setEnabledCipherSuites(String[] suites) { - delegate.setEnabledCipherSuites(suites); - } - - @Override - public String[] getSupportedProtocols() { - return delegate.getSupportedProtocols(); - } - - @Override - public String[] getEnabledProtocols() { - return delegate.getEnabledProtocols(); - } - - @Override - public void setEnabledProtocols(String[] protocols) { - delegate.setEnabledProtocols(protocols); - } - - @Override - public SSLSession getSession() { - return delegate.getSession(); - } - - @Override - public SSLSession getHandshakeSession() { - return delegate.getHandshakeSession(); - } - - @Override - public void beginHandshake() throws SSLException { - delegate.beginHandshake(); - } - - @Override - public SSLEngineResult.HandshakeStatus getHandshakeStatus() { - return delegate.getHandshakeStatus(); - } - - @Override - public void setUseClientMode(boolean mode) { - delegate.setUseClientMode(mode); - } - - @Override - public boolean getUseClientMode() { - return delegate.getUseClientMode(); - } - - @Override - public void setNeedClientAuth(boolean need) { - delegate.setNeedClientAuth(need); - } - - @Override - public boolean getNeedClientAuth() { - return delegate.getNeedClientAuth(); - } - - @Override - public void setWantClientAuth(boolean want) { - delegate.setWantClientAuth(want); - } - - @Override - public boolean getWantClientAuth() { - return delegate.getWantClientAuth(); - } - - @Override - public void setEnableSessionCreation(boolean flag) { - delegate.setEnableSessionCreation(flag); - } - - @Override - public boolean getEnableSessionCreation() { - return delegate.getEnableSessionCreation(); - } - - @Override - public SSLParameters getSSLParameters() { - return delegate.getSSLParameters(); - } - - @Override - public void setSSLParameters(SSLParameters params) { - delegate.setSSLParameters(params); - } - - @Override - public String getApplicationProtocol() { - return delegate.getApplicationProtocol(); - } - - @Override - public String getHandshakeApplicationProtocol() { - return delegate.getHandshakeApplicationProtocol(); - } - - @Override - public void setHandshakeApplicationProtocolSelector(BiFunction, String> selector) { - if (selector == null) { - delegate.setHandshakeApplicationProtocolSelector(null); - } else { - delegate.setHandshakeApplicationProtocolSelector((engine, protocols) -> selector.apply(this, protocols)); - } - } - - @Override - public BiFunction, String> getHandshakeApplicationProtocolSelector() { - return delegate.getHandshakeApplicationProtocolSelector(); - } - } -} diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/CountingOutputHttpURLConnection.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/CountingOutputHttpURLConnection.java deleted file mode 100644 index 360f9b8eae5..00000000000 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/CountingOutputHttpURLConnection.java +++ /dev/null @@ -1,150 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to you 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.apache.jmeter.protocol.http.sampler; - -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.net.HttpURLConnection; -import java.net.URL; -import java.util.List; -import java.util.Map; - -import org.apache.jorphan.io.CountingOutputStream; - -/** - * Delegating {@link HttpURLConnection} that counts the bytes actually written to the request body. - *

    - * It is used to compute the sent bytes of a sample, since the {@code String} returned by - * {@link PostWriter#sendPostData(java.net.URLConnection, HTTPSamplerBase)} is only a human readable - * representation of the request body (e.g. file contents are replaced with a placeholder). - */ -final class CountingOutputHttpURLConnection extends HttpURLConnection { - private final HttpURLConnection delegate; - private CountingOutputStream countingOutput; - - CountingOutputHttpURLConnection(HttpURLConnection delegate) { - super(delegate.getURL()); - this.delegate = delegate; - } - - /** - * @return the number of bytes written to the request body, or {@code -1} if the body was never opened - */ - long getBytesWritten() { - return countingOutput == null ? -1 : countingOutput.getBytesWritten(); - } - - @Override - public OutputStream getOutputStream() throws IOException { - if (countingOutput == null) { - countingOutput = new CountingOutputStream(delegate.getOutputStream()); - } - return countingOutput; - } - - @Override - public InputStream getInputStream() throws IOException { - return delegate.getInputStream(); - } - - @Override - public void setRequestProperty(String key, String value) { - delegate.setRequestProperty(key, value); - } - - @Override - public void addRequestProperty(String key, String value) { - delegate.addRequestProperty(key, value); - } - - @Override - public String getRequestProperty(String key) { - return delegate.getRequestProperty(key); - } - - @Override - public Map> getRequestProperties() { - return delegate.getRequestProperties(); - } - - @Override - public void setDoOutput(boolean doOutput) { - delegate.setDoOutput(doOutput); - } - - @Override - public boolean getDoOutput() { - return delegate.getDoOutput(); - } - - @Override - public void setDoInput(boolean doInput) { - delegate.setDoInput(doInput); - } - - @Override - public boolean getDoInput() { - return delegate.getDoInput(); - } - - @Override - public URL getURL() { - return delegate.getURL(); - } - - @Override - public String getRequestMethod() { - return delegate.getRequestMethod(); - } - - @Override - public String getHeaderField(String name) { - return delegate.getHeaderField(name); - } - - @Override - public String getHeaderField(int n) { - return delegate.getHeaderField(n); - } - - @Override - public String getHeaderFieldKey(int n) { - return delegate.getHeaderFieldKey(n); - } - - @Override - public Map> getHeaderFields() { - return delegate.getHeaderFields(); - } - - @Override - public void connect() throws IOException { - delegate.connect(); - } - - @Override - public void disconnect() { - delegate.disconnect(); - } - - @Override - public boolean usingProxy() { - return delegate.usingProxy(); - } -} diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPAbstractImpl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPAbstractImpl.java index 13d23050ce6..5b005e2149c 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPAbstractImpl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPAbstractImpl.java @@ -133,10 +133,6 @@ protected HTTPAbstractImpl(HTTPSamplerBase testElement){ protected void threadFinished() { } - // Allows HTTPSamplerProxy to call testEnded; subclasses can override if necessary - protected void testEnded() { - } - // Allows HTTPSamplerProxy to call notifyFirstSampleAfterLoopRestart; subclasses can override if necessary protected void notifyFirstSampleAfterLoopRestart() { } diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java index 76e3e4d1320..521a0f00d62 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java @@ -215,9 +215,9 @@ public class HTTPHC5Impl extends HTTPHCAbstractImpl { * for a window update, so the throughput of a single response can never exceed this window divided by * the round trip time. The default of HttpClient is the 64 kB the HTTP/2 specification mandates, which * throttles a download over a link with any noticeable latency to a fraction of the available - * bandwidth. JMeter therefore announces the same 16 MB the JDK client uses by default, see - * {@code http.java.h2.initial_window_size}. The window is only a promise to the server, the body is - * consumed as it arrives and not buffered up to this size. + * bandwidth. JMeter therefore announces the same 16 MB the JDK client uses by default. The window + * is only a promise to the server, the body is consumed as it arrives and not buffered up to this + * size. */ static final int DEFAULT_HTTP_2_INITIAL_WINDOW_SIZE = 16 * 1024 * 1024; diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java index 11a740ecabc..d44f7466d79 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java @@ -20,45 +20,19 @@ import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; -import java.lang.reflect.Method; -import java.net.Authenticator; import java.net.BindException; import java.net.HttpURLConnection; import java.net.InetSocketAddress; -import java.net.PasswordAuthentication; import java.net.Proxy; -import java.net.ProxySelector; -import java.net.URI; import java.net.URL; import java.net.URLConnection; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.nio.charset.StandardCharsets; -import java.nio.file.Path; -import java.security.NoSuchAlgorithmException; -import java.time.Duration; import java.util.ArrayList; import java.util.Collections; -import java.util.HashMap; -import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import java.util.Objects; -import java.util.Properties; -import java.util.WeakHashMap; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.ThreadFactory; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicReference; import java.util.function.Predicate; import java.util.zip.GZIPInputStream; -import javax.net.ssl.SSLContext; - import org.apache.jmeter.protocol.http.control.AuthManager; import org.apache.jmeter.protocol.http.control.Authorization; import org.apache.jmeter.protocol.http.control.CacheManager; @@ -69,10 +43,7 @@ import org.apache.jmeter.samplers.SampleResult; import org.apache.jmeter.testelement.property.CollectionProperty; import org.apache.jmeter.testelement.property.JMeterProperty; -import org.apache.jmeter.threads.JMeterContext; -import org.apache.jmeter.threads.JMeterContextService; import org.apache.jmeter.util.JMeterUtils; -import org.apache.jmeter.util.JsseSSLManager; import org.apache.jmeter.util.SSLManager; import org.apache.jorphan.io.CountingInputStream; import org.apache.jorphan.util.StringUtilities; @@ -85,364 +56,11 @@ * */ public class HTTPJavaImpl extends HTTPAbstractImpl { - protected static final class ConnectionSetup { - private final HttpURLConnection connection; - private final Map securityHeaders; - - ConnectionSetup(HttpURLConnection connection, Map securityHeaders) { - this.connection = connection; - this.securityHeaders = securityHeaders; - } - - public HttpURLConnection getConnection() { - return connection; - } - - public Map getSecurityHeaders() { - return securityHeaders; - } - } - private static final boolean OBEY_CONTENT_LENGTH = JMeterUtils.getPropDefault("httpsampler.obey_contentlength", false); // $NON-NLS-1$ - private static final String DEFAULT_HTTP_VERSION = readDefaultHttpVersion(); - - /** Name of the {@code User-Agent} request header. */ - private static final String HEADER_USER_AGENT = "User-Agent"; // $NON-NLS-1$ - - /** - * {@code User-Agent} of {@link HttpURLConnection}, which is used for HTTP/1.1. JMeter adds it to the - * request itself, so it shows up in the sample result and is accounted for in the sent bytes, instead of - * being added invisibly by the JDK. - */ - private static final String HTTP_1_DEFAULT_USER_AGENT = createHttp1DefaultUserAgent(); - - /** {@code User-Agent} of {@link HttpClient}, which is used for HTTP/2. */ - private static final String HTTP_2_DEFAULT_USER_AGENT = - "Java-http-client/" + System.getProperty("java.version"); // $NON-NLS-1$ - - private static String createHttp1DefaultUserAgent() { - String javaAgent = "Java/" + System.getProperty("java.version"); // $NON-NLS-1$ - String agent = System.getProperty("http.agent"); // $NON-NLS-1$ - return agent == null ? javaAgent : agent + " " + javaAgent; - } - - /** - * HTTP/2 clients of every JMeter thread, looked up by the {@link JMeterContext} of that thread - * instead of by the thread itself. The JDK client sends every exchange of a client over a single - * connection per origin, so one client per JMeter thread multiplexes the requests of that thread - * and of the threads which download its embedded resources over one connection, while every - * JMeter thread still uses a connection of its own, like a real user would. - * - *

    The threads which download embedded resources in parallel are pooled and shared by all - * JMeter threads, and they adopt the context of the JMeter thread they are working for, so - * looking the clients up by context hands them the clients of that thread and keeps a JMeter - * thread from closing clients another one is still using. - * - *

    The keys are weak, so that a context whose clients are never closed explicitly, like the - * one of a thread of the HTTP(S) Test Script Recorder, does not keep the entry alive forever. - */ - private static final Map HTTP_2_CLIENTS = - Collections.synchronizedMap(new WeakHashMap<>()); - - /** - * HTTP/2 clients used when the connections are shared between the JMeter threads. One instance for all - * threads means the whole test plan is driven over a single connection per origin, which saves resources - * but hides connection level effects such as the congestion window or per connection server limits. - */ - private static final Map SHARED_HTTP_2_CLIENTS = new ConcurrentHashMap<>(); - - private static final AtomicReference HTTP_2_EXECUTOR = new AtomicReference<>(); - private static final Logger log = LoggerFactory.getLogger(HTTPJavaImpl.class); - // Available since Java 21; on older JVMs the client is released once it is no longer referenced. - private static final Method HTTP_CLIENT_SHUTDOWN = findHttpClientShutdownMethod(); - - /** - * Multiplex the HTTP/2 message exchanges of all JMeter threads over a single connection per origin, - * instead of giving every thread a connection of its own. It is disabled by default, as sharing one - * connection between the virtual users changes what the test measures. - */ - private static final boolean SHARE_HTTP_2_CONNECTIONS_BETWEEN_THREADS = - JMeterUtils.getPropDefault("http.java.h2.share_connections_between_threads", false); // $NON-NLS-1$ - - /** - * Accept HTTP/2 server push. JMeter has no way of reporting pushed resources, so push is switched off - * to avoid the server wasting bandwidth on responses that are dropped. - */ - private static final String HTTP_2_PUSH_ENABLED_PROPERTY = "http.java.h2.push_enabled"; // $NON-NLS-1$ - - /** System property the JDK client reads to decide whether it announces {@code SETTINGS_ENABLE_PUSH}. */ - private static final String JDK_PUSH_ENABLED_PROPERTY = "jdk.httpclient.enablepush"; // $NON-NLS-1$ - - /** - * Maps the JMeter HTTP/2 properties to the {@code jdk.httpclient.*} system properties, which are the only - * way to configure the HTTP/2 protocol settings of the JDK client. They are read whenever a client is - * built, so they have to be in place before the first HTTP/2 sample is taken. - */ - private static final Map HTTP_2_SYSTEM_PROPERTIES = createHttp2SystemPropertyMapping(); - - private static volatile boolean http2SystemPropertiesApplied; - - private static Map createHttp2SystemPropertyMapping() { - Map mapping = new LinkedHashMap<>(); - // Size of the HPACK dynamic header table announced to the server, 0 disables HPACK indexing - mapping.put("http.java.h2.header_table_size", "jdk.httpclient.hpack.maxheadertablesize"); // $NON-NLS-1$ $NON-NLS-2$ - // Number of server initiated (pushed) streams the client accepts on a connection - mapping.put("http.java.h2.max_concurrent_streams", "jdk.httpclient.maxstreams"); // $NON-NLS-1$ $NON-NLS-2$ - mapping.put("http.java.h2.initial_window_size", "jdk.httpclient.windowsize"); // $NON-NLS-1$ $NON-NLS-2$ - mapping.put("http.java.h2.connection_window_size", "jdk.httpclient.connectionWindowSize"); // $NON-NLS-1$ $NON-NLS-2$ - mapping.put("http.java.h2.max_frame_size", "jdk.httpclient.maxframesize"); // $NON-NLS-1$ $NON-NLS-2$ - mapping.put("http.java.h2.keep_alive_timeout", "jdk.httpclient.keepalive.timeout.h2"); // $NON-NLS-1$ $NON-NLS-2$ - return Collections.unmodifiableMap(mapping); - } - - private static void applyHttp2SystemPropertiesLazily() { - if (!http2SystemPropertiesApplied) { - synchronized (HTTPJavaImpl.class) { - if (!http2SystemPropertiesApplied) { - applyHttp2SystemProperties(JMeterUtils.getJMeterProperties(), System.getProperties()); - http2SystemPropertiesApplied = true; - } - } - } - } - - /** - * Copies the HTTP/2 settings from the JMeter properties to the system properties of the JDK client. - * Values which are already defined, for example on the command line, are never overwritten. - * - * @param jmeterProperties the JMeter properties, may be {@code null} when they have not been loaded - * @param systemProperties the system properties to configure - */ - static void applyHttp2SystemProperties(Properties jmeterProperties, Properties systemProperties) { - Properties source = jmeterProperties != null ? jmeterProperties : new Properties(); - for (Map.Entry entry : HTTP_2_SYSTEM_PROPERTIES.entrySet()) { - String value = source.getProperty(entry.getKey()); - if (StringUtilities.isNotBlank(value)) { - setUnlessDefined(systemProperties, entry.getValue(), value.trim()); - } - } - String pushProperty = source.getProperty(HTTP_2_PUSH_ENABLED_PROPERTY); - if (StringUtilities.isNotBlank(pushProperty)) { - boolean pushEnabled = Boolean.parseBoolean(pushProperty.trim()); - setUnlessDefined(systemProperties, JDK_PUSH_ENABLED_PROPERTY, pushEnabled ? "1" : "0"); // $NON-NLS-1$ $NON-NLS-2$ - } - } - - private static void setUnlessDefined(Properties systemProperties, String name, String value) { - String current = systemProperties.getProperty(name); - if (current == null) { - systemProperties.setProperty(name, value); - } else if (!current.equals(value)) { - log.info("Keeping system property {}={}, it takes precedence over the JMeter property", name, current); // $NON-NLS-1$ - } - } - - static Map getHttp2Clients() { - if (SHARE_HTTP_2_CONNECTIONS_BETWEEN_THREADS) { - return SHARED_HTTP_2_CLIENTS; - } - return http2ClientCache().clients; - } - - private static Http2ClientCache http2ClientCache() { - return HTTP_2_CLIENTS.computeIfAbsent(JMeterContextService.getContext(), - context -> new Http2ClientCache()); - } - - /** - * The clients of one JMeter thread, together with the thread that owns them. Only the owner - * replaces a client, because {@link JsseSSLManager} hands out an {@link SSLContext} per thread: - * the pooled threads which download embedded resources therefore always see an SSLContext other - * than the one the client was built with, and would keep replacing the client of the JMeter - * thread they work for, shutting it down while its sample and the sibling downloads are still - * using it, which fails them with {@code java.io.IOException: shutdownNow}. - */ - private static final class Http2ClientCache { - private final Thread owner = Thread.currentThread(); - private final Map clients = new ConcurrentHashMap<>(); - - private boolean isOwnedByCurrentThread() { - return owner == Thread.currentThread(); - } - } - - /** Returns the thread pool of the HTTP/2 clients, it is created when the first client needs it. */ - private static ExecutorService http2Executor() { - ExecutorService executor = HTTP_2_EXECUTOR.get(); - if (executor != null) { - return executor; - } - ExecutorService created = Executors.newCachedThreadPool(new Http2ThreadFactory()); - if (HTTP_2_EXECUTOR.compareAndSet(null, created)) { - return created; - } - created.shutdownNow(); - return HTTP_2_EXECUTOR.get(); - } - - private static Method findHttpClientShutdownMethod() { - for (String name : new String[] { "shutdownNow", "close" }) { // $NON-NLS-1$ $NON-NLS-2$ - try { - return HttpClient.class.getMethod(name); - } catch (NoSuchMethodException e) { // NOSONAR Java 17 cannot close an HttpClient explicitly - log.debug("HttpClient has no {}() method", name); // $NON-NLS-1$ - } - } - return null; - } - - /** Releases the resources of the client, in particular its selector thread and its connections. */ - private static void closeQuietly(Http2Client client) { - if (client == null || HTTP_CLIENT_SHUTDOWN == null) { - return; - } - try { - HTTP_CLIENT_SHUTDOWN.invoke(client.httpClient); - } catch (Exception e) { // NOSONAR closing must never fail a sample - log.debug("Problem closing HTTP/2 HttpClient", e); // $NON-NLS-1$ - } - } - - private static void closeHttp2Clients(Map clients) { - if (clients == null) { - return; - } - synchronized (clients) { - List closing = new ArrayList<>(clients.values()); - clients.clear(); - for (Http2Client client : closing) { - closeQuietly(client); - } - } - } - - @Override - protected void threadFinished() { - closeHttp2ClientsOfCurrentThread(); - } - - /** - * Closes the HTTP/2 clients of the current JMeter thread, which releases their connections. Only - * the clients of this JMeter thread are dropped. The threads which download embedded resources - * borrow them while they work for this thread, and they are done by the time the sample returns, - * so no client is closed while a request is still using it. - */ - static void closeHttp2ClientsOfCurrentThread() { - Http2ClientCache cache = HTTP_2_CLIENTS.remove(JMeterContextService.getContext()); - closeHttp2Clients(cache == null ? null : cache.clients); - } - - @Override - protected void testEnded() { - releaseSharedHttp2Resources(); - } - - /** Closes the clients shared by all threads and shuts down the thread pool they use. */ - static void releaseSharedHttp2Resources() { - closeHttp2Clients(SHARED_HTTP_2_CLIENTS); - ExecutorService executor = HTTP_2_EXECUTOR.getAndSet(null); - if (executor != null) { - executor.shutdown(); - } - } - - /** - * HTTP/2 does not transmit a reason phrase (see RFC 9113, section 8.3.2), so the response message is - * derived from the status code. The phrases are the ones registered in the IANA HTTP status code registry. - */ - private static final Map HTTP_REASON_PHRASES = createReasonPhrases(); - - private static Map createReasonPhrases() { - Map phrases = new HashMap<>(); - phrases.put(100, "Continue"); // $NON-NLS-1$ - phrases.put(101, "Switching Protocols"); // $NON-NLS-1$ - phrases.put(102, "Processing"); // $NON-NLS-1$ - phrases.put(103, "Early Hints"); // $NON-NLS-1$ - phrases.put(200, "OK"); // $NON-NLS-1$ - phrases.put(201, "Created"); // $NON-NLS-1$ - phrases.put(202, "Accepted"); // $NON-NLS-1$ - phrases.put(203, "Non-Authoritative Information"); // $NON-NLS-1$ - phrases.put(204, "No Content"); // $NON-NLS-1$ - phrases.put(205, "Reset Content"); // $NON-NLS-1$ - phrases.put(206, "Partial Content"); // $NON-NLS-1$ - phrases.put(207, "Multi-Status"); // $NON-NLS-1$ - phrases.put(208, "Already Reported"); // $NON-NLS-1$ - phrases.put(226, "IM Used"); // $NON-NLS-1$ - phrases.put(300, "Multiple Choices"); // $NON-NLS-1$ - phrases.put(301, "Moved Permanently"); // $NON-NLS-1$ - phrases.put(302, "Found"); // $NON-NLS-1$ - phrases.put(303, "See Other"); // $NON-NLS-1$ - phrases.put(304, "Not Modified"); // $NON-NLS-1$ - phrases.put(305, "Use Proxy"); // $NON-NLS-1$ - phrases.put(307, "Temporary Redirect"); // $NON-NLS-1$ - phrases.put(308, "Permanent Redirect"); // $NON-NLS-1$ - phrases.put(400, "Bad Request"); // $NON-NLS-1$ - phrases.put(401, "Unauthorized"); // $NON-NLS-1$ - phrases.put(402, "Payment Required"); // $NON-NLS-1$ - phrases.put(403, "Forbidden"); // $NON-NLS-1$ - phrases.put(404, "Not Found"); // $NON-NLS-1$ - phrases.put(405, "Method Not Allowed"); // $NON-NLS-1$ - phrases.put(406, "Not Acceptable"); // $NON-NLS-1$ - phrases.put(407, "Proxy Authentication Required"); // $NON-NLS-1$ - phrases.put(408, "Request Timeout"); // $NON-NLS-1$ - phrases.put(409, "Conflict"); // $NON-NLS-1$ - phrases.put(410, "Gone"); // $NON-NLS-1$ - phrases.put(411, "Length Required"); // $NON-NLS-1$ - phrases.put(412, "Precondition Failed"); // $NON-NLS-1$ - phrases.put(413, "Content Too Large"); // $NON-NLS-1$ - phrases.put(414, "URI Too Long"); // $NON-NLS-1$ - phrases.put(415, "Unsupported Media Type"); // $NON-NLS-1$ - phrases.put(416, "Range Not Satisfiable"); // $NON-NLS-1$ - phrases.put(417, "Expectation Failed"); // $NON-NLS-1$ - phrases.put(421, "Misdirected Request"); // $NON-NLS-1$ - phrases.put(422, "Unprocessable Content"); // $NON-NLS-1$ - phrases.put(423, "Locked"); // $NON-NLS-1$ - phrases.put(424, "Failed Dependency"); // $NON-NLS-1$ - phrases.put(425, "Too Early"); // $NON-NLS-1$ - phrases.put(426, "Upgrade Required"); // $NON-NLS-1$ - phrases.put(428, "Precondition Required"); // $NON-NLS-1$ - phrases.put(429, "Too Many Requests"); // $NON-NLS-1$ - phrases.put(431, "Request Header Fields Too Large"); // $NON-NLS-1$ - phrases.put(451, "Unavailable For Legal Reasons"); // $NON-NLS-1$ - phrases.put(500, "Internal Server Error"); // $NON-NLS-1$ - phrases.put(501, "Not Implemented"); // $NON-NLS-1$ - phrases.put(502, "Bad Gateway"); // $NON-NLS-1$ - phrases.put(503, "Service Unavailable"); // $NON-NLS-1$ - phrases.put(504, "Gateway Timeout"); // $NON-NLS-1$ - phrases.put(505, "HTTP Version Not Supported"); // $NON-NLS-1$ - phrases.put(506, "Variant Also Negotiates"); // $NON-NLS-1$ - phrases.put(507, "Insufficient Storage"); // $NON-NLS-1$ - phrases.put(508, "Loop Detected"); // $NON-NLS-1$ - phrases.put(510, "Not Extended"); // $NON-NLS-1$ - phrases.put(511, "Network Authentication Required"); // $NON-NLS-1$ - return Collections.unmodifiableMap(phrases); - } - - /** - * Returns the reason phrase belonging to the given HTTP status code. - * - * @param statusCode the HTTP status code - * @return the registered reason phrase, or an empty string if the status code is unknown - */ - static String getReasonPhrase(int statusCode) { - return HTTP_REASON_PHRASES.getOrDefault(statusCode, ""); // $NON-NLS-1$ - } - - static boolean isHttp2(String samplerHttpVersion, String defaultHttpVersion) { - String httpVersion = StringUtilities.isBlank(samplerHttpVersion) ? defaultHttpVersion : samplerHttpVersion; - // java.net.http.HttpClient always negotiates, so a strict HTTP/2 request is negotiated as well - return HTTPConstants.HTTP_VERSION_2.equalsIgnoreCase(httpVersion) - || HTTPConstants.HTTP_VERSION_2_STRICT.equalsIgnoreCase(httpVersion); - } - - private boolean isHttp2() { - return isHttp2(testElement.getHttpVersion(), DEFAULT_HTTP_VERSION); - } - private static final int MAX_CONN_RETRIES = JMeterUtils.getPropDefault("http.java.sampler.retries" // $NON-NLS-1$ ,0); // Maximum connection retries @@ -457,7 +75,6 @@ private boolean isHttp2() { private transient PostWriter postOrPutWriter; private volatile HttpURLConnection savedConn; - private volatile CompletableFuture> currentResponseFuture; protected HTTPJavaImpl(HTTPSamplerBase base) { super(base); @@ -512,11 +129,11 @@ private String sendPutData(URLConnection connection) throws IOException { * GET, POST etc * @param res * sample result to save request infos to - * @return connection and security headers ready for .connect + * @return HttpURLConnection ready for .connect * @throws IOException * if an I/O Exception occurs */ - protected ConnectionSetup setupConnection(URL u, String method, HTTPSampleResult res) throws IOException { + protected HttpURLConnection setupConnection(URL u, String method, HTTPSampleResult res) throws IOException { SSLManager sslmgr = null; if (HTTPConstants.PROTOCOL_HTTPS.equalsIgnoreCase(u.getProtocol())) { try { @@ -574,11 +191,10 @@ protected ConnectionSetup setupConnection(URL u, String method, HTTPSampleResult } conn.setRequestMethod(method); - Map securityHeaders = setConnectionHeaders(conn, u, getHeaderManager(), getCacheManager()); + setConnectionHeaders(conn, u, getHeaderManager(), getCacheManager()); String cookies = setConnectionCookie(conn, u, getCookieManager()); - setConnectionAuthorization(conn, u, getAuthManager(), securityHeaders); - setDefaultUserAgent(conn, HTTP_1_DEFAULT_USER_AGENT); + Map securityHeaders = setConnectionAuthorization(conn, u, getAuthManager()); if (method.equals(HTTPConstants.POST)) { setPostHeaders(conn); @@ -597,7 +213,7 @@ protected ConnectionSetup setupConnection(URL u, String method, HTTPSampleResult } } - return new ConnectionSetup(conn, securityHeaders); + return conn; } /** @@ -732,11 +348,6 @@ private static String setConnectionCookie(HttpURLConnection conn, URL u, CookieM return cookieHeader; } - private static boolean isSecurityHeader(String name) { - return name != null && (HTTPConstants.HEADER_AUTHORIZATION.equalsIgnoreCase(name) - || "Proxy-Authorization".equalsIgnoreCase(name)); - } - /** * Extracts all the required headers for that particular URL request and * sets them in the HttpURLConnection passed in @@ -750,13 +361,11 @@ private static boolean isSecurityHeader(String name) { * the HeaderManager containing all the cookies * for this UrlConfig * @param cacheManager the CacheManager (may be null) - * @return Map of security headers set from HeaderManager */ - private static Map setConnectionHeaders(HttpURLConnection conn, URL u, + private static void setConnectionHeaders(HttpURLConnection conn, URL u, HeaderManager headerManager, CacheManager cacheManager) { // Add all the headers from the HeaderManager Header[] arrayOfHeaders = null; - Map securityHeaders = new LinkedHashMap<>(); if (headerManager != null) { CollectionProperty headers = headerManager.getHeaders(); if (headers != null) { @@ -768,16 +377,12 @@ private static Map setConnectionHeaders(HttpURLConnection conn, String v = header.getValue(); arrayOfHeaders[i++] = header; conn.addRequestProperty(n, v); - if (isSecurityHeader(n)) { - securityHeaders.put(n, v); - } } } } if (cacheManager != null){ cacheManager.setHeaders(conn, arrayOfHeaders, u); } - return securityHeaders; } /** @@ -840,28 +445,13 @@ private static String getFromConnectionHeaders(HttpURLConnection conn, Map entry : securityHeaders.entrySet()) { - if (!hasHeader(requestHeaders, entry.getKey())) { - hdrs.append(entry.getKey()).append(": ") // $NON-NLS-1$ - .append(entry.getValue()).append("\n"); // $NON-NLS-1$ - } + hdrs.append(entry.getKey()).append(": ") // $NON-NLS-1$ + .append(entry.getValue()).append("\n"); // $NON-NLS-1$ } } return hdrs.toString(); } - /** - * Adds the {@code User-Agent} the JDK would add on its own, so it is visible in the sample result and - * counted in the sent bytes. Nothing is added when the test plan defines a {@code User-Agent} itself. - * - * @param conn connection the header is added to - * @param defaultUserAgent {@code User-Agent} used when the request has none - */ - private static void setDefaultUserAgent(HttpURLConnection conn, String defaultUserAgent) { - if (conn.getRequestProperty(HEADER_USER_AGENT) == null) { - conn.setRequestProperty(HEADER_USER_AGENT, defaultUserAgent); - } - } - /** * Extracts all the required authorization for that particular URL request * and sets it in the HttpURLConnection passed in. @@ -874,10 +464,9 @@ private static void setDefaultUserAgent(HttpURLConnection conn, String defaultUs * @param authManager * the AuthManager containing all the cookies for * this UrlConfig - * @param securityHeaders - * Map to collect security headers + * @return String Authorization header value or null if not set */ - private static void setConnectionAuthorization(HttpURLConnection conn, URL u, AuthManager authManager, Map securityHeaders) { + private static Map setConnectionAuthorization(HttpURLConnection conn, URL u, AuthManager authManager) { if (authManager != null) { Authorization auth = authManager.getAuthForURL(u); if (auth != null) { @@ -885,9 +474,10 @@ private static void setConnectionAuthorization(HttpURLConnection conn, URL u, Au conn.setRequestProperty(HTTPConstants.HEADER_AUTHORIZATION, headerValue); // Java hides request properties so we have to // keep trace of it - securityHeaders.put(HTTPConstants.HEADER_AUTHORIZATION, headerValue); + return Collections.singletonMap(HTTPConstants.HEADER_AUTHORIZATION, headerValue); } } + return Collections.emptyMap(); } /** @@ -911,9 +501,6 @@ private static void setConnectionAuthorization(HttpURLConnection conn, URL u, Au */ @Override protected HTTPSampleResult sample(URL url, String method, boolean areFollowingRedirect, int frameDepth) { - if (isHttp2()) { - return sampleHttp2(url, method, areFollowingRedirect, frameDepth); - } HttpURLConnection conn = null; String urlStr = url.toString(); @@ -937,10 +524,6 @@ protected HTTPSampleResult sample(URL url, String method, boolean areFollowingRe } } - Map> requestHeaders = null; - Map securityHeaders = Collections.emptyMap(); - long postBodyLength = -1; - try { // Sampling proper - establish the connection and read the response: // Repeatedly try to connect: @@ -948,14 +531,10 @@ protected HTTPSampleResult sample(URL url, String method, boolean areFollowingRe // Start with -1 so tries at least once, and retries at most MAX_CONN_RETRIES times for (; retry < MAX_CONN_RETRIES; retry++) { try { - ConnectionSetup connectionSetup = setupConnection(url, method, res); - conn = connectionSetup.getConnection(); - requestHeaders = new LinkedHashMap<>(conn.getRequestProperties()); - securityHeaders = connectionSetup.getSecurityHeaders(); + conn = setupConnection(url, method, res); // Attempt the connection: savedConn = conn; conn.connect(); - res.connectEnd(); break; } catch (BindException e) { if (retry >= MAX_CONN_RETRIES) { @@ -978,16 +557,12 @@ protected HTTPSampleResult sample(URL url, String method, boolean areFollowingRe throw new BindException(); } // Nice, we've got a connection. Finish sending the request: - if (method.equals(HTTPConstants.POST) || method.equals(HTTPConstants.PUT)) { - // The returned body is only a display representation (file contents are replaced with - // a placeholder), so the bytes actually written to the connection are counted instead - CountingOutputHttpURLConnection countingConn = new CountingOutputHttpURLConnection(conn); - String body = method.equals(HTTPConstants.POST) - ? sendPostData(countingConn) - : sendPutData(countingConn); - res.setQueryString(body); - postBodyLength = countingConn.getBytesWritten(); - addMissingContentLengthHeader(requestHeaders, postBodyLength); + if (method.equals(HTTPConstants.POST)) { + String postBody = sendPostData(conn); + res.setQueryString(postBody); + } else if (method.equals(HTTPConstants.PUT)) { + String putBody = sendPutData(conn); + res.setQueryString(putBody); } // Request sent. Now get the response: byte[] responseData = readResponse(conn, res); @@ -1069,8 +644,6 @@ protected HTTPSampleResult sample(URL url, String method, boolean areFollowingRe cacheManager.saveDetails(conn, res); } - res.setSentBytes(calculateSentBytes(url, method, testElement.getHttpVersion(), requestHeaders, securityHeaders, postBodyLength)); - res = resultProcessing(areFollowingRedirect, frameDepth, res); log.debug("End : sample"); @@ -1079,7 +652,6 @@ protected HTTPSampleResult sample(URL url, String method, boolean areFollowingRe if (res.getEndTime() == 0) { res.sampleEnd(); } - res.setSentBytes(calculateSentBytes(url, method, testElement.getHttpVersion(), requestHeaders, securityHeaders, postBodyLength)); savedConn = null; // we don't want interrupt to try disconnection again // We don't want to continue using this connection, even if KeepAlive is set if (conn != null) { // May not exist @@ -1148,526 +720,10 @@ private static void saveConnectionCookies(HttpURLConnection conn, URL u, CookieM @Override public boolean interrupt() { HttpURLConnection conn = savedConn; - CompletableFuture> responseFuture = currentResponseFuture; - savedConn = null; - currentResponseFuture = null; if (conn != null) { + savedConn = null; conn.disconnect(); } - if (responseFuture != null) { - responseFuture.cancel(true); - } - return conn != null || responseFuture != null; + return conn != null; } - - private HTTPSampleResult sampleHttp2(URL url, String method, boolean areFollowingRedirect, int frameDepth) { - if (log.isDebugEnabled()) { - log.debug("Start : sampleHttp2 {}, method {}, followingRedirect {}, depth {}", - url, method, areFollowingRedirect, frameDepth); - } - HTTPSampleResult res = new HTTPSampleResult(); - configureSampleLabel(res, url); - res.setURL(url); - res.setHTTPMethod(method); - res.sampleStart(); - final CacheManager cacheManager = getCacheManager(); - if (cacheManager != null && HTTPConstants.GET.equalsIgnoreCase(method)) { - if (cacheManager.inCache(url, getHeaders(getHeaderManager()))) { - return updateSampleResultForResourceInCache(res); - } - } - Http2CapturingHttpURLConnection capturingConn = null; - Path requestBody = null; - byte[] postBodyBytes = null; - long requestBodyLength = -1; - Map securityHeaders = Collections.emptyMap(); - try { - capturingConn = new Http2CapturingHttpURLConnection(url, method); - securityHeaders = setConnectionHeaders(capturingConn, url, getHeaderManager(), getCacheManager()); - String cookies = setConnectionCookie(capturingConn, url, getCookieManager()); - setConnectionAuthorization(capturingConn, url, getAuthManager(), securityHeaders); - setDefaultUserAgent(capturingConn, HTTP_2_DEFAULT_USER_AGENT); - if (method.equals(HTTPConstants.POST)) { - setPostHeaders(capturingConn); - String postBody = sendPostData(capturingConn); - res.setQueryString(postBody); - } else if (method.equals(HTTPConstants.PUT)) { - setPutHeaders(capturingConn); - String putBody = sendPutData(capturingConn); - res.setQueryString(putBody); - } - capturingConn.finishCapture(); - requestBodyLength = capturingConn.getCapturedBodyLength(); - if (capturingConn.isSpilled()) { - requestBody = capturingConn.getCapturedBody(); - } else { - postBodyBytes = capturingConn.getCapturedByteArray(); - } - res.setRequestHeaders(getAllHeadersExceptCookie(capturingConn, securityHeaders)); - if (StringUtilities.isNotEmpty(cookies)) { - res.setCookies(cookies); - } else { - res.setCookies(getOnlyCookieFromHeaders(capturingConn, securityHeaders)); - } - URI uri = url.toURI(); - HttpRequest.Builder reqBuilder = HttpRequest.newBuilder(uri); - if (method.equalsIgnoreCase(HTTPConstants.POST) || method.equalsIgnoreCase(HTTPConstants.PUT) - || method.equalsIgnoreCase(HTTPConstants.PATCH)) { - HttpRequest.BodyPublisher publisher; - if (requestBody != null) { - publisher = HttpRequest.BodyPublishers.ofFile(requestBody); - } else if (postBodyBytes != null) { - publisher = HttpRequest.BodyPublishers.ofByteArray(postBodyBytes); - } else { - publisher = HttpRequest.BodyPublishers.noBody(); - } - reqBuilder.method(method, publisher); - } else if (method.equalsIgnoreCase(HTTPConstants.GET)) { - reqBuilder.GET(); - } else if (method.equalsIgnoreCase(HTTPConstants.DELETE)) { - reqBuilder.DELETE(); - } else { - reqBuilder.method(method, HttpRequest.BodyPublishers.noBody()); - } - int rto = getResponseTimeout(); - if (rto > 0) { - reqBuilder.timeout(Duration.ofMillis(rto)); - } - Map> props = capturingConn.getRequestProperties(); - for (Map.Entry> entry : props.entrySet()) { - String headerName = entry.getKey(); - if (headerName == null || isRestrictedHeader(headerName)) { - continue; - } - for (String value : entry.getValue()) { - reqBuilder.header(headerName, value); - } - } - Http2Client client = getHttpClient(url); - HttpRequest httpRequest = reqBuilder.build(); - HttpResponse response; - ConnectTimeTracker connectTimeTracker = client.connectTimeTracker; - connectTimeTracker.sampleStarted(res, ConnectTimeTracker.origin(url)); - try { - CompletableFuture> responseFuture = - client.httpClient.sendAsync(httpRequest, HttpResponse.BodyHandlers.ofInputStream()); - currentResponseFuture = responseFuture; - try { - response = responseFuture.get(); - } catch (InterruptedException e) { - responseFuture.cancel(true); - Thread.currentThread().interrupt(); - throw e; - } finally { - currentResponseFuture = null; - } - } finally { - connectTimeTracker.sampleFinished(res); - } - res.latencyEnd(); - - byte[] responseData = readResponse(response, res); - - res.sampleEnd(); - - res.setResponseData(responseData); - int statusCode = response.statusCode(); - res.setResponseCode(Integer.toString(statusCode)); - res.setSuccessful(isSuccessCode(statusCode)); - res.setResponseMessage(getReasonPhrase(statusCode)); - String responseHeaders = getResponseHeaders(response); - res.setResponseHeaders(responseHeaders); - String ct = response.headers().firstValue(HTTPConstants.HEADER_CONTENT_TYPE).orElse(null); - if (ct != null) { - res.setContentType(ct); - res.setEncodingAndType(ct); - } - if (res.isRedirect()) { - String location = response.headers().firstValue(HTTPConstants.HEADER_LOCATION).orElse(null); - if (location != null) { - res.setRedirectLocation(location); - } - } - - res.setHeadersSize( - responseHeaders.length() - + StringUtilities.count(responseHeaders, '\n') - + 2); - - if (getAutoRedirects()) { - res.setURL(response.uri().toURL()); - } - - saveConnectionCookies(response, url, getCookieManager()); - - if (cacheManager != null) { - cacheManager.saveDetails( - name -> response.headers().firstValue(name).orElse(null), res); - } - - res.setSentBytes(calculateSentBytes(url, method, HTTPConstants.HTTP_VERSION_2, - capturingConn != null ? capturingConn.getRequestProperties() : null, - securityHeaders, requestBodyLength)); - - res = resultProcessing(areFollowingRedirect, frameDepth, res); - - log.debug("End : sampleHttp2"); - return res; - } catch (Exception e) { - if (res.getEndTime() == 0) { - res.sampleEnd(); - } - res.setSentBytes(calculateSentBytes(url, method, HTTPConstants.HTTP_VERSION_2, - capturingConn != null ? capturingConn.getRequestProperties() : null, - securityHeaders, requestBodyLength)); - return errorResult(e, res); - } finally { - if (capturingConn != null) { - capturingConn.deleteCapturedBody(); - } - } - } - - byte[] readResponse(HttpResponse response, SampleResult res) throws IOException { - try (InputStream in = response.body()) { - if (in == null) { - return NULL_BA; - } - - boolean gzipped = response.headers().firstValue(HTTPConstants.HEADER_CONTENT_ENCODING) - .map(HTTPConstants.ENCODING_GZIP::equalsIgnoreCase) - .orElse(false); - - long contentLength = response.headers().firstValueAsLong(HTTPConstants.HEADER_CONTENT_LENGTH).orElse(-1L); - - if (contentLength == 0 && OBEY_CONTENT_LENGTH) { - log.info("Content-Length: 0, not reading http-body"); - res.setResponseHeaders(getResponseHeaders(response)); - res.latencyEnd(); - return NULL_BA; - } - - CountingInputStream instream = new CountingInputStream(in); - InputStream stream = gzipped ? new GZIPInputStream(instream) : instream; - - try { - byte[] responseData = readResponse(res, stream, contentLength); - res.setBodySize(instream.getBytesRead()); - return responseData; - } finally { - instream.close(); - } - } - } - - private static String getResponseHeaders(HttpResponse response) { - StringBuilder headerBuf = new StringBuilder(); - String versionStr = (response.version() == HttpClient.Version.HTTP_2) - ? HTTPConstants.HTTP_VERSION_2 : HTTPConstants.HTTP_VERSION_1_1; - headerBuf.append(versionStr).append(" ").append(response.statusCode()).append("\n"); // $NON-NLS-1$ $NON-NLS-2$ - - response.headers().map().forEach((key, values) -> { - if (key != null) { - for (String val : values) { - headerBuf.append(key).append(": ").append(val).append("\n"); // $NON-NLS-1$ $NON-NLS-2$ - } - } - }); - return headerBuf.toString(); - } - - private static void saveConnectionCookies(HttpResponse response, URL u, CookieManager cookieManager) { - if (cookieManager != null) { - List setCookies = response.headers().allValues(HTTPConstants.HEADER_SET_COOKIE); - for (String setCookie : setCookies) { - cookieManager.addCookieFromHeader(setCookie, u); - } - } - } - - private static boolean isRestrictedHeader(String name) { - return HTTPConstants.HEADER_CONNECTION.equalsIgnoreCase(name) - || HTTPConstants.HEADER_CONTENT_LENGTH.equalsIgnoreCase(name) - || "Host".equalsIgnoreCase(name) // $NON-NLS-1$ - || "Expect".equalsIgnoreCase(name) // $NON-NLS-1$ - || "Upgrade".equalsIgnoreCase(name); // $NON-NLS-1$ - } - - /** - * Adds the {@code Content-Length} header to the captured request headers when it is missing. - *

    - * {@link HttpURLConnection} silently drops restricted headers such as {@code Content-Length} from - * {@link HttpURLConnection#getRequestProperties()}, even though it does send them on the wire. - * - * @param requestHeaders headers captured from the connection, may be {@code null} - * @param bodyLength number of bytes written to the request body, or {@code -1} if unknown - */ - private static void addMissingContentLengthHeader(Map> requestHeaders, long bodyLength) { - if (requestHeaders == null || bodyLength < 0 - || hasHeader(requestHeaders, HTTPConstants.HEADER_CONTENT_LENGTH)) { - return; - } - requestHeaders.put(HTTPConstants.HEADER_CONTENT_LENGTH, - Collections.singletonList(Long.toString(bodyLength))); - } - - private static long calculateSentBytes( - URL u, - String method, - String version, - Map> requestHeaders, - Map securityHeaders, - long postBodyLength) { - if (StringUtilities.isBlank(method)) { - method = HTTPConstants.GET; - } - - String uri = u != null ? u.getFile() : ""; - if (StringUtilities.isBlank(uri)) { - uri = "/"; // $NON-NLS-1$ - } - - if (StringUtilities.isBlank(version)) { - version = HTTPConstants.HTTP_1_1; - } - - // Request line: METHOD URI VERSION\r\n - long sentBytes = HTTPMessageSizes.requestLineLength(method, uri, version); - - // Request headers - if (requestHeaders != null) { - for (Map.Entry> entry : requestHeaders.entrySet()) { - String key = entry.getKey(); - if (key != null) { - List values = entry.getValue(); - if (values != null) { - for (String val : values) { - sentBytes += HTTPMessageSizes.headerLength(key, val); - } - } - } - } - } - - if (securityHeaders != null && !securityHeaders.isEmpty()) { - for (Map.Entry secEntry : securityHeaders.entrySet()) { - String secKey = secEntry.getKey(); - if (secKey != null && !hasHeader(requestHeaders, secKey)) { - sentBytes += HTTPMessageSizes.headerLength(secKey, secEntry.getValue()); - } - } - } - - // Header/Body separator \r\n - sentBytes += HTTPMessageSizes.EMPTY_LINE; - - // Request body - if (postBodyLength >= 0) { - sentBytes += postBodyLength; - } else if (requestHeaders != null) { - String contentLengthStr = getHeaderValue(requestHeaders, HTTPConstants.HEADER_CONTENT_LENGTH); - if (StringUtilities.isNotEmpty(contentLengthStr)) { - try { - sentBytes += Long.parseLong(contentLengthStr); - } catch (NumberFormatException e) { - log.debug("Could not parse Content-Length header: {}", contentLengthStr, e); - } - } - } - - return sentBytes; - } - - private static boolean hasHeader(Map> headers, String headerName) { - if (headers == null || headerName == null) { - return false; - } - for (String key : headers.keySet()) { - if (headerName.equalsIgnoreCase(key)) { - return true; - } - } - return false; - } - - private static String getHeaderValue(Map> headers, String headerName) { - if (headers == null || headerName == null) { - return null; - } - for (Map.Entry> entry : headers.entrySet()) { - if (headerName.equalsIgnoreCase(entry.getKey())) { - List values = entry.getValue(); - if (values != null && !values.isEmpty()) { - return values.get(0); - } - } - } - return null; - } - - Http2Client getHttpClient(URL url) { - int connectTimeout = getConnectTimeout(); - String proxyHost = getProxyHost(); - int proxyPort = getProxyPortInt(); - String proxyUser = getProxyUser(); - String proxyPass = getProxyPass(); - boolean autoRedirects = getAutoRedirects(); - SSLContext sslContext = null; - - if (HTTPConstants.PROTOCOL_HTTPS.equalsIgnoreCase(url.getProtocol())) { - try { - SSLManager sslmgr = SSLManager.getInstance(); - if (sslmgr instanceof JsseSSLManager jsseSSLManager) { - sslContext = jsseSSLManager.getContext(); - } - } catch (Exception e) { - log.warn("Problem getting SSLContext for HTTP/2 HttpClient: ", e); // $NON-NLS-1$ - } - if (sslContext == null) { - // Use the default context explicitly, so the connect time of the TLS handshake can be measured - try { - sslContext = SSLContext.getDefault(); - } catch (NoSuchAlgorithmException e) { - log.warn("Problem getting default SSLContext for HTTP/2 HttpClient: ", e); // $NON-NLS-1$ - } - } - } - - HttpClientKey key = new HttpClientKey(connectTimeout, proxyHost, proxyPort, - proxyUser, proxyPass, autoRedirects, sslContext != null); - - Map clients = getHttp2Clients(); - // Only the JMeter thread that owns the clients replaces one when its SSLContext was reset. - // A thread which downloads embedded resources has an SSLContext of its own, which never is - // the one of the client, so it has to keep the client of the JMeter thread it works for. - boolean keepCachedClient = SHARE_HTTP_2_CONNECTIONS_BETWEEN_THREADS - || !http2ClientCache().isOwnedByCurrentThread(); - Http2Client client = clients.get(key); - if (client != null && (keepCachedClient || client.usesSslContext(sslContext))) { - // The SSLContext must not be part of the key, it does not implement equals(), and JMeter hands out - // a new instance per thread and after every reset, which would make the cache grow without bound. - // When the clients are shared, the SSLContext of the first caller is kept, so all threads keep on - // multiplexing their exchanges over the same connection. - return client; - } - synchronized (clients) { - client = clients.get(key); - if (client != null && (keepCachedClient || client.usesSslContext(sslContext))) { - return client; - } - Http2Client created = createHttpClient(key, sslContext); - closeQuietly(clients.put(key, created)); - return created; - } - } - - private static Http2Client createHttpClient(HttpClientKey key, SSLContext sslContext) { - applyHttp2SystemPropertiesLazily(); - ConnectTimeTracker connectTimeTracker = new ConnectTimeTracker(); - HttpClient.Builder builder = HttpClient.newBuilder() - .version(HttpClient.Version.HTTP_2) - .executor(http2Executor()) - .followRedirects(key.autoRedirects ? HttpClient.Redirect.NORMAL : HttpClient.Redirect.NEVER); - - if (key.connectTimeout > 0) { - builder.connectTimeout(Duration.ofMillis(key.connectTimeout)); - } - - if (StringUtilities.isNotEmpty(key.proxyHost) && key.proxyPort > 0) { - builder.proxy(ProxySelector.of(new InetSocketAddress(key.proxyHost, key.proxyPort))); - if (StringUtilities.isNotEmpty(key.proxyUser)) { - builder.authenticator(new Authenticator() { - @Override - protected PasswordAuthentication getPasswordAuthentication() { - if (getRequestorType() == RequestorType.PROXY) { - return new PasswordAuthentication(key.proxyUser, - key.proxyPass != null ? key.proxyPass.toCharArray() : new char[0]); - } - return super.getPasswordAuthentication(); - } - }); - } - } - - if (sslContext != null) { - builder.sslContext(new ConnectTimeTracker.MeasuringSSLContext(sslContext, connectTimeTracker)); - } - - return new Http2Client(builder.build(), connectTimeTracker, sslContext); - } - - /** - * Holder for an HTTP/2 {@link HttpClient} and the tracker which measures the connect time of its connections. - */ - static final class Http2Client { - private final HttpClient httpClient; - private final ConnectTimeTracker connectTimeTracker; - private final SSLContext sslContext; - - Http2Client(HttpClient httpClient, ConnectTimeTracker connectTimeTracker, SSLContext sslContext) { - this.httpClient = httpClient; - this.connectTimeTracker = connectTimeTracker; - this.sslContext = sslContext; - } - - boolean usesSslContext(SSLContext other) { - return sslContext == other; - } - } - - private static final class Http2ThreadFactory implements ThreadFactory { - private final AtomicInteger counter = new AtomicInteger(); - - @Override - public Thread newThread(Runnable r) { - Thread thread = new Thread(r, "JMeter-HTTP2-" + counter.incrementAndGet()); // $NON-NLS-1$ - thread.setDaemon(true); - return thread; - } - } - - private static class HttpClientKey { - private final int connectTimeout; - private final String proxyHost; - private final int proxyPort; - private final String proxyUser; - private final String proxyPass; - private final boolean autoRedirects; - private final boolean secure; - - HttpClientKey(int connectTimeout, String proxyHost, int proxyPort, - String proxyUser, String proxyPass, boolean autoRedirects, - boolean secure) { - this.connectTimeout = connectTimeout; - this.proxyHost = proxyHost; - this.proxyPort = proxyPort; - this.proxyUser = proxyUser; - this.proxyPass = proxyPass; - this.autoRedirects = autoRedirects; - this.secure = secure; - } - - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (!(o instanceof HttpClientKey that)) { - return false; - } - return connectTimeout == that.connectTimeout - && proxyPort == that.proxyPort - && autoRedirects == that.autoRedirects - && secure == that.secure - && Objects.equals(proxyHost, that.proxyHost) - && Objects.equals(proxyUser, that.proxyUser) - && Objects.equals(proxyPass, that.proxyPass); - } - - @Override - public int hashCode() { - return Objects.hash(connectTimeout, proxyHost, proxyPort, proxyUser, proxyPass, autoRedirects, secure); - } - } - } diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPSamplerFactory.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPSamplerFactory.java index 30f0d902dcc..780ba2a3e10 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPSamplerFactory.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPSamplerFactory.java @@ -105,11 +105,7 @@ public static String[] getHttpVersions(String implementation) { return new String[]{"", HTTPConstants.HTTP_VERSION_1_1, HTTPConstants.HTTP_VERSION_2, HTTPConstants.HTTP_VERSION_2_STRICT}; } - if (IMPL_JAVA.equals(impl) || HTTP_SAMPLER_JAVA.equals(impl)) { - // java.net.http.HttpClient always negotiates, it has no API to insist on HTTP/2 - return new String[]{"", HTTPConstants.HTTP_VERSION_1_1, HTTPConstants.HTTP_VERSION_2}; - } - // HttpClient4 (and its aliases) never read the HTTP version of the sampler + // HttpClient4 and Java (and their aliases) never read the HTTP version of the sampler return new String[]{"", HTTPConstants.HTTP_VERSION_1_1}; } diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPSamplerProxy.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPSamplerProxy.java index 30d3d72ebf8..f7106e77032 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPSamplerProxy.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPSamplerProxy.java @@ -76,14 +76,6 @@ public void threadFinished(){ } } - @Override - public void testEnded() { - super.testEnded(); - if (impl != null) { - impl.testEnded(); // Forward to sampler, so it can release resources shared by all threads - } - } - @Override public boolean interrupt() { if (impl != null) { diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/Http2CapturingHttpURLConnection.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/Http2CapturingHttpURLConnection.java deleted file mode 100644 index 0de59819f02..00000000000 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/Http2CapturingHttpURLConnection.java +++ /dev/null @@ -1,146 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to you 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.apache.jmeter.protocol.http.sampler; - -import java.io.IOException; -import java.io.OutputStream; -import java.net.HttpURLConnection; -import java.net.URL; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; - -/** - * Captures the URLConnection-based post writer output in a temporary file for the JDK HTTP/2 client. - */ -final class Http2CapturingHttpURLConnection extends HttpURLConnection { - private final Map> requestProperties = new LinkedHashMap<>(); - private SpillOutputStream spillOutput; - - Http2CapturingHttpURLConnection(URL url, String method) { - super(url); - this.method = method; - } - - @Override - public void setRequestProperty(String key, String value) { - if (key == null) { - return; - } - List list = new ArrayList<>(); - list.add(value); - requestProperties.put(key, list); - } - - @Override - public void addRequestProperty(String key, String value) { - if (key != null) { - requestProperties.computeIfAbsent(key, k -> new ArrayList<>()).add(value); - } - } - - @Override - public String getRequestProperty(String key) { - if (key == null) { - return null; - } - List values = requestProperties.get(key); - if (values == null || values.isEmpty()) { - for (Map.Entry> entry : requestProperties.entrySet()) { - if (key.equalsIgnoreCase(entry.getKey())) { - values = entry.getValue(); - break; - } - } - } - return values != null && !values.isEmpty() ? values.get(0) : null; - } - - @Override - public Map> getRequestProperties() { - return Collections.unmodifiableMap(requestProperties); - } - - @Override - public OutputStream getOutputStream() throws IOException { - if (spillOutput == null) { - spillOutput = new SpillOutputStream(256 * 1024, "jmeter-http2-request-", ".tmp"); - } - return spillOutput; - } - - /** Flushes and closes the capture buffer, so the captured body can be read back safely. */ - void finishCapture() throws IOException { - if (spillOutput != null) { - spillOutput.close(); - } - } - - boolean isSpilled() { - return spillOutput != null && spillOutput.isSpilled(); - } - - Path getCapturedBody() { - return spillOutput != null ? spillOutput.getTempFile() : null; - } - - byte[] getCapturedByteArray() { - return spillOutput != null ? spillOutput.toByteArray() : null; - } - - long getCapturedBodyLength() { - return spillOutput == null ? -1 : spillOutput.getLength(); - } - - void deleteCapturedBody() { - if (spillOutput != null) { - spillOutput.releaseResources(); - } - } - - @Override - public void connect() { - } - - @Override - public void disconnect() { - } - - @Override - public boolean usingProxy() { - return false; - } - - @Override - public String getHeaderField(int n) { - return null; - } - - @Override - public String getHeaderFieldKey(int n) { - return null; - } - - @Override - public String getHeaderField(String name) { - return null; - } -} diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/gui/TestHttpVersionComboBox.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/gui/TestHttpVersionComboBox.java index 63478b0e903..e7a262a010a 100644 --- a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/gui/TestHttpVersionComboBox.java +++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/gui/TestHttpVersionComboBox.java @@ -65,7 +65,7 @@ void offersTheVersionsOfTheSelectedImplementation() { assertEquals(Arrays.asList("", "HTTP/1.1", "HTTP/2", "HTTP/2 Strict"), items(httpVersion)); implementation.setSelectedItem("Java"); - assertEquals(Arrays.asList("", "HTTP/1.1", "HTTP/2"), items(httpVersion)); + assertEquals(Arrays.asList("", "HTTP/1.1"), items(httpVersion)); implementation.setSelectedItem("HttpClient4"); assertEquals(Arrays.asList("", "HTTP/1.1"), items(httpVersion)); @@ -78,10 +78,10 @@ void keepsTheSelectionWhenTheNewImplementationSupportsIt() { httpVersion.bindToImplementation(implementation); implementation.setSelectedItem("HttpClient5"); - httpVersion.setSelectedItem("HTTP/2"); + httpVersion.setSelectedItem("HTTP/1.1"); implementation.setSelectedItem("Java"); - assertEquals("HTTP/2", httpVersion.getSelectedItem()); + assertEquals("HTTP/1.1", httpVersion.getSelectedItem()); } @Test @@ -91,7 +91,7 @@ void resetsTheSelectionWhenTheNewImplementationDoesNotSupportIt() { httpVersion.bindToImplementation(implementation); implementation.setSelectedItem("HttpClient5"); - httpVersion.setSelectedItem("HTTP/2 Strict"); + httpVersion.setSelectedItem("HTTP/2"); implementation.setSelectedItem("Java"); assertEquals("", httpVersion.getSelectedItem()); diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java deleted file mode 100644 index 5aeb59c931e..00000000000 --- a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java +++ /dev/null @@ -1,992 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to you 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.apache.jmeter.protocol.http.sampler; - -import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; -import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; -import static com.github.tomakehurst.wiremock.client.WireMock.get; -import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor; -import static com.github.tomakehurst.wiremock.client.WireMock.post; -import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor; -import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotSame; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertSame; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.io.ByteArrayInputStream; -import java.io.IOException; -import java.io.InputStream; -import java.net.Socket; -import java.net.URI; -import java.net.URL; -import java.net.http.HttpClient; -import java.net.http.HttpHeaders; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.security.cert.X509Certificate; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Properties; -import java.util.concurrent.Callable; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.Future; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicReference; - -import javax.net.ssl.SSLContext; -import javax.net.ssl.SSLEngine; -import javax.net.ssl.SSLSession; -import javax.net.ssl.TrustManager; -import javax.net.ssl.X509ExtendedTrustManager; - -import org.apache.jmeter.protocol.http.control.Header; -import org.apache.jmeter.protocol.http.control.HeaderManager; -import org.apache.jmeter.protocol.http.util.HTTPConstants; -import org.apache.jmeter.protocol.http.util.HTTPFileArg; -import org.apache.jmeter.samplers.SampleResult; -import org.apache.jmeter.threads.JMeterContext; -import org.apache.jmeter.threads.JMeterContextService; -import org.apache.jmeter.util.JsseSSLManager; -import org.apache.jmeter.util.SSLManager; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.Test; - -import com.github.tomakehurst.wiremock.WireMockServer; -import com.github.tomakehurst.wiremock.core.WireMockConfiguration; - -class TestHTTPJavaFeatures { - - @AfterEach - void closeHttp2Clients() { - // the HTTP/2 clients keep their connections and their selector thread alive until they are closed - HTTPJavaImpl.closeHttp2ClientsOfCurrentThread(); - HTTPJavaImpl.releaseSharedHttp2Resources(); - } - - @Test - void usesHttpClientVersionWhenSamplerVersionIsEmpty() { - assertTrue(HTTPJavaImpl.isHttp2("", "HTTP/2")); - assertFalse(HTTPJavaImpl.isHttp2("", "HTTP/1.1")); - } - - @Test - void usesSamplerHttpVersionWhenSpecified() { - assertFalse(HTTPJavaImpl.isHttp2("HTTP/1.1", "HTTP/2")); - assertTrue(HTTPJavaImpl.isHttp2("HTTP/2", "HTTP/1.1")); - } - - @Test - void defaultsToHttp11ForUnsupportedHttpVersion() { - assertFalse(HTTPJavaImpl.isHttp2("HTTP/3", "HTTP/2")); - } - - @Test - void usesHttp2WhenSelected() throws Exception { - WireMockServer server = createServer(); - server.start(); - try { - server.stubFor(get(urlEqualTo("/http2")).willReturn(aResponse().withStatus(200))); - HTTPSamplerBase sampler = newSampler(); - sampler.setHttpVersion("HTTP/2"); - - HTTPSampleResult result = sampler.sample( - new URL(server.url("/http2")), HTTPConstants.GET, false, 1); - - assertEquals("200", result.getResponseCode(), result.getResponseMessage()); - assertEquals("HTTP/2", result.getResponseHeaders().substring(0, "HTTP/2".length())); - } finally { - server.stop(); - } - } - - @Test - void uploadsMultipartFileOverHttp2() throws Exception { - WireMockServer server = createServer(); - Path upload = Files.createTempFile("jmeter-http2-upload-", ".bin"); - try { - Files.write(upload, new byte[1_000_000]); - server.start(); - server.stubFor(post(urlEqualTo("/upload")).willReturn(aResponse().withStatus(200))); - HTTPSamplerBase sampler = newSampler(); - sampler.setHttpVersion("HTTP/2"); - sampler.setMethod(HTTPConstants.POST); - sampler.setDoMultipart(true); - sampler.setHTTPFiles(new HTTPFileArg[] { - new HTTPFileArg(upload.toString(), "upload", "application/octet-stream") }); - - HTTPSampleResult result = sampler.sample( - new URL(server.url("/upload")), HTTPConstants.POST, false, 1); - - assertEquals("200", result.getResponseCode(), result.getResponseMessage()); - assertTrue(result.getSentBytes() > 1_000_000, - () -> "the whole file should have been sent, but only " + result.getSentBytes() + " bytes were"); - // The request view must not hold the file contents, otherwise a large upload is in heap twice - assertTrue(result.getQueryString().contains(""), - () -> "file contents should be omitted from the request view: " + result.getQueryString()); - server.verify(postRequestedFor(urlEqualTo("/upload"))); - } finally { - Files.deleteIfExists(upload); - server.stop(); - } - } - - @Test - void countsUploadedFileInSentBytesOverHttp11() throws Exception { - WireMockServer server = createServer(); - Path upload = Files.createTempFile("jmeter-http1-upload-", ".bin"); - try { - Files.write(upload, new byte[1_000_000]); - server.start(); - server.stubFor(post(urlEqualTo("/upload")).willReturn(aResponse().withStatus(200))); - HTTPSamplerBase sampler = newSampler(); - sampler.setHttpVersion(HTTPConstants.HTTP_1_1); - sampler.setMethod(HTTPConstants.POST); - sampler.setDoMultipart(true); - sampler.setHTTPFiles(new HTTPFileArg[] { - new HTTPFileArg(upload.toString(), "upload", "application/octet-stream") }); - - HTTPSampleResult result = sampler.sample( - new URL(server.url("/upload")), HTTPConstants.POST, false, 1); - - assertEquals("200", result.getResponseCode(), result.getResponseMessage()); - assertTrue(result.getSentBytes() > 1_000_000, - () -> "the whole file should have been sent, but only " + result.getSentBytes() + " bytes were"); - // The request view must not hold the file contents, otherwise a large upload is in heap twice - assertTrue(result.getQueryString().contains(""), - () -> "file contents should be omitted from the request view: " + result.getQueryString()); - server.verify(postRequestedFor(urlEqualTo("/upload"))); - } finally { - Files.deleteIfExists(upload); - server.stop(); - } - } - - @Test - void interruptsPendingHttp2Request() throws Exception { - WireMockServer server = createServer(); - server.start(); - ExecutorService executor = Executors.newSingleThreadExecutor(); - try { - server.stubFor(get(urlEqualTo("/interrupted")).willReturn(aResponse().withFixedDelay(10_000))); - HTTPSamplerBase sampler = newSampler(); - sampler.setHttpVersion("HTTP/2"); - HTTPJavaImpl implementation = new HTTPJavaImpl(sampler); - - Future sample = executor.submit(() -> implementation.sample( - new URL(server.url("/interrupted")), HTTPConstants.GET, false, 1)); - - boolean interrupted = false; - for (int attempt = 0; attempt < 100 && !interrupted; attempt++) { - interrupted = implementation.interrupt(); - if (!interrupted) { - Thread.sleep(10); - } - } - - assertTrue(interrupted, "the HTTP/2 response future should be cancellable"); - sample.get(5, TimeUnit.SECONDS); - } finally { - executor.shutdownNow(); - server.stop(); - } - } - - @Test - void setsResponseMessageForHttp2() throws Exception { - WireMockServer server = createServer(); - server.start(); - try { - server.stubFor(get(urlEqualTo("/http2ResponseMessage")).willReturn(aResponse().withStatus(404))); - HTTPSamplerBase sampler = newSampler(); - sampler.setHttpVersion("HTTP/2"); - - HTTPSampleResult result = sampler.sample( - new URL(server.url("/http2ResponseMessage")), HTTPConstants.GET, false, 1); - - assertEquals("404", result.getResponseCode()); - assertEquals("Not Found", result.getResponseMessage()); - } finally { - server.stop(); - } - } - - @Test - void returnsEmptyReasonPhraseForUnknownStatusCode() { - assertEquals("OK", HTTPJavaImpl.getReasonPhrase(200)); - assertEquals("", HTTPJavaImpl.getReasonPhrase(599)); - } - - @Test - void mapsHttp2SettingsToJdkSystemProperties() { - Properties jmeterProperties = new Properties(); - jmeterProperties.setProperty("http.java.h2.header_table_size", "8192"); - jmeterProperties.setProperty("http.java.h2.max_concurrent_streams", "250"); - jmeterProperties.setProperty("http.java.h2.initial_window_size", "65535"); - jmeterProperties.setProperty("http.java.h2.connection_window_size", "1048576"); - jmeterProperties.setProperty("http.java.h2.max_frame_size", "16384"); - jmeterProperties.setProperty("http.java.h2.keep_alive_timeout", "60"); - jmeterProperties.setProperty("http.java.h2.push_enabled", "true"); - Properties systemProperties = new Properties(); - - HTTPJavaImpl.applyHttp2SystemProperties(jmeterProperties, systemProperties); - - assertEquals("8192", systemProperties.getProperty("jdk.httpclient.hpack.maxheadertablesize")); - assertEquals("250", systemProperties.getProperty("jdk.httpclient.maxstreams")); - assertEquals("65535", systemProperties.getProperty("jdk.httpclient.windowsize")); - assertEquals("1048576", systemProperties.getProperty("jdk.httpclient.connectionWindowSize")); - assertEquals("16384", systemProperties.getProperty("jdk.httpclient.maxframesize")); - assertEquals("60", systemProperties.getProperty("jdk.httpclient.keepalive.timeout.h2")); - assertEquals("1", systemProperties.getProperty("jdk.httpclient.enablepush")); - } - - @Test - void keepsJdkDefaultsWhenNothingIsConfigured() { - Properties systemProperties = new Properties(); - - HTTPJavaImpl.applyHttp2SystemProperties(new Properties(), systemProperties); - - assertNull(systemProperties.getProperty("jdk.httpclient.enablepush")); - assertNull(systemProperties.getProperty("jdk.httpclient.hpack.maxheadertablesize")); - assertNull(systemProperties.getProperty("jdk.httpclient.windowsize")); - } - - @Test - void disablesServerPushWhenConfigured() { - Properties jmeterProperties = new Properties(); - jmeterProperties.setProperty("http.java.h2.push_enabled", "false"); - Properties systemProperties = new Properties(); - - HTTPJavaImpl.applyHttp2SystemProperties(jmeterProperties, systemProperties); - - assertEquals("0", systemProperties.getProperty("jdk.httpclient.enablepush")); - } - - @Test - void doesNotOverrideHttp2SettingsGivenOnTheCommandLine() { - Properties jmeterProperties = new Properties(); - jmeterProperties.setProperty("http.java.h2.header_table_size", "8192"); - Properties systemProperties = new Properties(); - systemProperties.setProperty("jdk.httpclient.hpack.maxheadertablesize", "4096"); - systemProperties.setProperty("jdk.httpclient.enablepush", "1"); - - HTTPJavaImpl.applyHttp2SystemProperties(jmeterProperties, systemProperties); - - assertEquals("4096", systemProperties.getProperty("jdk.httpclient.hpack.maxheadertablesize")); - assertEquals("1", systemProperties.getProperty("jdk.httpclient.enablepush")); - } - - @Test - void sharesHttp2ClientsWithTheThreadsDownloadingEmbeddedResources() throws Exception { - AtomicReference> clientsOfFirstThread = new AtomicReference<>(); - AtomicReference> clientsOfSecondThread = new AtomicReference<>(); - AtomicReference> clientsOfResourceDownloader = new AtomicReference<>(); - - Thread firstThread = new Thread(() -> { - clientsOfFirstThread.set(HTTPJavaImpl.getHttp2Clients()); - // the embedded resources are downloaded by pooled threads which adopt the context of the - // JMeter thread they are working for, see HTTPSamplerBase.ASyncSample - JMeterContext contextOfFirstThread = JMeterContextService.getContext(); - Thread downloader = new Thread(() -> { - JMeterContextService.replaceContext(contextOfFirstThread); - clientsOfResourceDownloader.set(HTTPJavaImpl.getHttp2Clients()); - }); - downloader.start(); - try { - downloader.join(); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - }); - Thread secondThread = new Thread(() -> clientsOfSecondThread.set(HTTPJavaImpl.getHttp2Clients())); - firstThread.start(); - secondThread.start(); - firstThread.join(); - secondThread.join(); - - assertSame(clientsOfFirstThread.get(), clientsOfResourceDownloader.get(), - "the downloads of embedded resources should be multiplexed over the connection of their thread"); - assertNotSame(clientsOfFirstThread.get(), clientsOfSecondThread.get(), - "every JMeter thread should use a connection of its own, like a real user would"); - } - - @Test - void multiplexesConcurrentHttp2RequestsOfOneThreadOverOneConnection() throws Exception { - WireMockServer server = createServer(); - server.start(); - try (TcpRelay relay = new TcpRelay(server.port())) { - server.stubFor(get(urlEqualTo("/multiplexed")) - .willReturn(aResponse().withStatus(200).withFixedDelay(200).withBody("multiplexed"))); - URL url = new URL("http://localhost:" + relay.getPort() + "/multiplexed"); - // warm up, so the connection that gets shared by the concurrent samples is already established - HTTPSamplerBase warmUpSampler = newSampler(); - warmUpSampler.setHttpVersion("HTTP/2"); - assertEquals("200", warmUpSampler.sample(url, HTTPConstants.GET, false, 1).getResponseCode()); - assertEquals(1, relay.getAcceptedConnections(), "the warm up should have opened one connection"); - - int requests = 4; - JMeterContext jmeterThreadContext = JMeterContextService.getContext(); - Callable sample = () -> { - // the threads which download embedded resources adopt the context of the JMeter thread - JMeterContextService.replaceContext(jmeterThreadContext); - HTTPSamplerBase sampler = newSampler(); - sampler.setHttpVersion("HTTP/2"); - return sampler.sample(url, HTTPConstants.GET, false, 1); - }; - // the threads are pooled, like the ones which download embedded resources - ExecutorService executor = Executors.newFixedThreadPool(requests); - try { - List> results = new ArrayList<>(); - results.add(executor.submit(sample)); - // let the first sample claim the established connection before the others ask for one, - // otherwise they race for it and the client opens a second connection - Thread.sleep(50); - for (int i = 1; i < requests; i++) { - results.add(executor.submit(sample)); - } - for (Future result : results) { - HTTPSampleResult sampleResult = result.get(60, TimeUnit.SECONDS); - assertEquals("200", sampleResult.getResponseCode()); - assertTrue(sampleResult.getResponseHeaders().startsWith("HTTP/2"), - "Response should have been received over HTTP/2, but was " - + sampleResult.getResponseHeaders()); - } - } finally { - executor.shutdownNow(); - } - assertEquals(1, relay.getAcceptedConnections(), - "the concurrent samples should have been multiplexed over the established connection " - + "instead of opening a connection each"); - } finally { - server.stop(); - } - } - - @Test - void recordsConnectTimeForEverySampleWaitingForTheNewConnection() throws Exception { - ConnectTimeTracker tracker = new ConnectTimeTracker(); - SampleResult first = new SampleResult(); - SampleResult second = new SampleResult(); - first.sampleStart(); - second.sampleStart(); - tracker.sampleStarted(first, "example.com:443"); - tracker.sampleStarted(second, "example.com:443"); - - ConnectTimeTracker.Connection connection = tracker.connectionOpened("example.com:443"); - Thread.sleep(5); - connection.established(); - Thread.sleep(5); - - first.sampleEnd(); - second.sampleEnd(); - tracker.sampleFinished(first); - tracker.sampleFinished(second); - - assertTrue(first.getConnectTime() > 0, "connectTime of the first sample should have been recorded"); - assertTrue(second.getConnectTime() > 0, "connectTime of the second sample should have been recorded"); - assertTrue(first.getConnectTime() <= first.getTime()); - assertTrue(second.getConnectTime() <= second.getTime()); - } - - @Test - void doesNotRecordConnectTimeOfAConnectionToAnotherOrigin() throws Exception { - ConnectTimeTracker tracker = new ConnectTimeTracker(); - SampleResult result = new SampleResult(); - result.sampleStart(); - tracker.sampleStarted(result, "a.example:443"); - - Thread.sleep(5); - // Another JMeter thread opens a connection to a different host over the shared client - tracker.connectionOpened("b.example:443").established(); - - result.sampleEnd(); - tracker.sampleFinished(result); - - assertEquals(0, result.getConnectTime(), - "the connect time of another origin must not be reported for this sample"); - } - - @Test - void doesNotRecordConnectTimeForSampleServedByAnEstablishedConnection() throws Exception { - ConnectTimeTracker tracker = new ConnectTimeTracker(); - tracker.connectionOpened("example.com:443").established(); - - SampleResult result = new SampleResult(); - result.sampleStart(); - tracker.sampleStarted(result, "example.com:443"); - Thread.sleep(5); - result.sampleEnd(); - tracker.sampleFinished(result); - - assertEquals(0, result.getConnectTime(), - "a sample multiplexed over an established connection did not connect"); - } - - @Test - void recordsConnectTimeOfOneConnectionPerSampleOnly() throws Exception { - ConnectTimeTracker tracker = new ConnectTimeTracker(); - SampleResult result = new SampleResult(); - result.sampleStart(); - tracker.sampleStarted(result, "example.com:443"); - - Thread.sleep(5); - tracker.connectionOpened("example.com:443").established(); - long firstConnection = result.getConnectTime(); - Thread.sleep(300); - // A connection opened afterwards belongs to another sample, it must not overwrite the measurement - tracker.connectionOpened("example.com:443").established(); - - result.sampleEnd(); - tracker.sampleFinished(result); - - assertEquals(0, firstConnection, "the sample result should only be written by its own thread"); - assertTrue(result.getConnectTime() > 0, "connectTime should have been recorded"); - assertTrue(result.getConnectTime() < 150, - "connectTime should be the one of the first connection, but was " + result.getConnectTime()); - } - - @Test - void derivesTheOriginOfASampleFromItsUrl() throws Exception { - assertEquals("example.com:443", ConnectTimeTracker.origin(new URL("https://EXAMPLE.com/a"))); - assertEquals("example.com:80", ConnectTimeTracker.origin(new URL("http://example.com/a"))); - assertEquals("example.com:8443", ConnectTimeTracker.origin(new URL("https://example.com:8443/a"))); - } - - @Test - void ignoresSamplesWhichAreNoLongerInFlight() { - ConnectTimeTracker tracker = new ConnectTimeTracker(); - SampleResult result = new SampleResult(); - result.sampleStart(); - tracker.sampleStarted(result, "example.com:443"); - tracker.sampleFinished(result); - - tracker.connectionOpened("example.com:443").established(); - result.sampleEnd(); - - assertEquals(0, result.getConnectTime()); - } - - @Test - void usesHttp2WithProxy() throws Exception { - WireMockServer server = createServer(); - server.start(); - try { - server.stubFor(get(urlEqualTo("/http2proxy")).willReturn(aResponse().withStatus(200))); - HTTPSamplerBase sampler = newSampler(); - sampler.setHttpVersion("HTTP/2"); - sampler.setProxyHost("localhost"); - sampler.setProxyPortInt(Integer.toString(server.port())); - - HTTPSampleResult result = sampler.sample( - new URL(server.url("/http2proxy")), HTTPConstants.GET, false, 1); - - assertEquals("200", result.getResponseCode()); - } finally { - server.stop(); - } - } - - @Test - void setsSentBytesCorrectlyForGetRequest() throws Exception { - WireMockServer server = createServer(); - server.start(); - try { - server.stubFor(get(urlEqualTo("/sentBytesGet")).willReturn(aResponse().withStatus(200))); - HTTPSamplerBase sampler = newSampler(); - sampler.setHttpVersion("HTTP/1.1"); - - HTTPSampleResult result = sampler.sample( - new URL(server.url("/sentBytesGet")), HTTPConstants.GET, false, 1); - - assertEquals("200", result.getResponseCode()); - assertTrue(result.getSentBytes() > 0, "sentBytes should be greater than 0"); - } finally { - server.stop(); - } - } - - @Test - void setsSentBytesCorrectlyForPostRequest() throws Exception { - WireMockServer server = createServer(); - server.start(); - try { - server.stubFor(post(urlEqualTo("/sentBytesPost")).willReturn(aResponse().withStatus(200))); - HTTPSamplerBase sampler = newSampler(); - sampler.setHttpVersion("HTTP/1.1"); - sampler.setPostBodyRaw(true); - sampler.addNonEncodedArgument("", "hello world", ""); - - HTTPSampleResult result = sampler.sample( - new URL(server.url("/sentBytesPost")), HTTPConstants.POST, false, 1); - - assertEquals("200", result.getResponseCode()); - assertTrue(result.getSentBytes() > "hello world".length(), "sentBytes should include request line, headers, and body"); - } finally { - server.stop(); - } - } - - @Test - void setsSentBytesCorrectlyForHttp2GetRequest() throws Exception { - WireMockServer server = createServer(); - server.start(); - try { - server.stubFor(get(urlEqualTo("/http2SentBytesGet")).willReturn(aResponse().withStatus(200))); - HTTPSamplerBase sampler = newSampler(); - sampler.setHttpVersion("HTTP/2"); - - HTTPSampleResult result = sampler.sample( - new URL(server.url("/http2SentBytesGet")), HTTPConstants.GET, false, 1); - - assertEquals("200", result.getResponseCode()); - assertTrue(result.getSentBytes() > 0, "sentBytes should be greater than 0"); - } finally { - server.stop(); - } - } - - @Test - void setsSentBytesCorrectlyForHttp2PostRequest() throws Exception { - WireMockServer server = createServer(); - server.start(); - try { - server.stubFor(post(urlEqualTo("/http2SentBytesPost")).willReturn(aResponse().withStatus(200))); - HTTPSamplerBase sampler = newSampler(); - sampler.setHttpVersion("HTTP/2"); - sampler.setPostBodyRaw(true); - sampler.addNonEncodedArgument("", "hello world http2", ""); - - HTTPSampleResult result = sampler.sample( - new URL(server.url("/http2SentBytesPost")), HTTPConstants.POST, false, 1); - - assertEquals("200", result.getResponseCode()); - assertTrue(result.getSentBytes() > "hello world http2".length(), "sentBytes should include request line, headers, and body"); - } finally { - server.stop(); - } - } - - @Test - void displaysAuthorizationHeaderFromHeaderManagerInHttp11() throws Exception { - WireMockServer server = createServer(); - server.start(); - try { - server.stubFor(get(urlEqualTo("/authHttp11")).willReturn(aResponse().withStatus(200))); - HTTPSamplerBase sampler = newSampler(); - sampler.setHttpVersion("HTTP/1.1"); - HeaderManager headerManager = new HeaderManager(); - headerManager.add(new Header("Authorization", "Bearer my-secret-token")); - sampler.setHeaderManager(headerManager); - - HTTPSampleResult result = sampler.sample( - new URL(server.url("/authHttp11")), HTTPConstants.GET, false, 1); - - assertEquals("200", result.getResponseCode()); - assertTrue(result.getRequestHeaders().contains("Authorization: Bearer my-secret-token"), - "Request headers should contain Authorization header set in HeaderManager"); - } finally { - server.stop(); - } - } - - @Test - void sendsHeaderManagerHeaderOnceForHttp11() throws Exception { - WireMockServer server = createServer(); - server.start(); - try { - server.stubFor(get(urlEqualTo("/headerOnce")).willReturn(aResponse().withStatus(200))); - HTTPSamplerBase sampler = newSampler(); - sampler.setHttpVersion("HTTP/1.1"); - HeaderManager headerManager = new HeaderManager(); - headerManager.add(new Header("Accept", "application/json")); - sampler.setHeaderManager(headerManager); - - HTTPSampleResult result = sampler.sample( - new URL(server.url("/headerOnce")), HTTPConstants.GET, false, 1); - - assertEquals("200", result.getResponseCode()); - server.verify(1, getRequestedFor(urlEqualTo("/headerOnce")) - .withHeader("Accept", equalTo("application/json"))); - } finally { - server.stop(); - } - } - - @Test - void displaysAuthorizationHeaderFromHeaderManagerInHttp2() throws Exception { - WireMockServer server = createServer(); - server.start(); - try { - server.stubFor(get(urlEqualTo("/authHttp2")).willReturn(aResponse().withStatus(200))); - HTTPSamplerBase sampler = newSampler(); - sampler.setHttpVersion("HTTP/2"); - HeaderManager headerManager = new HeaderManager(); - headerManager.add(new Header("Authorization", "Bearer my-secret-token-http2")); - sampler.setHeaderManager(headerManager); - - HTTPSampleResult result = sampler.sample( - new URL(server.url("/authHttp2")), HTTPConstants.GET, false, 1); - - assertEquals("200", result.getResponseCode()); - assertTrue(result.getRequestHeaders().contains("Authorization: Bearer my-secret-token-http2"), - "Request headers should contain Authorization header set in HeaderManager"); - } finally { - server.stop(); - } - } - - @Test - void setsConnectTimeForHttp11() throws Exception { - WireMockServer server = new WireMockServer(WireMockConfiguration.wireMockConfig().dynamicHttpsPort()); - server.start(); - try { - server.stubFor(get(urlEqualTo("/connectTime")).willReturn(aResponse().withStatus(200))); - HTTPSamplerBase sampler = newSampler(); - sampler.setHttpVersion("HTTP/1.1"); - - HTTPSampleResult result = sampler.sample( - new URL("https://localhost:" + server.httpsPort() + "/connectTime"), HTTPConstants.GET, false, 1); - - assertEquals("200", result.getResponseCode()); - assertTrue(result.getConnectTime() > 0, - "connectTime should be greater than 0, but was " + result.getConnectTime()); - assertTrue(result.getConnectTime() <= result.getTime(), - "connectTime should not exceed the elapsed time"); - } finally { - server.stop(); - } - } - - @Test - void doesNotMeasureConnectTimeForPlainTextHttp2() throws Exception { - WireMockServer server = createServer(); - server.start(); - try { - server.stubFor(get(urlEqualTo("/http2ConnectTime")).willReturn(aResponse().withStatus(200))); - HTTPSamplerBase sampler = newSampler(); - sampler.setHttpVersion("HTTP/2"); - - HTTPSampleResult result = sampler.sample( - new URL(server.url("/http2ConnectTime")), HTTPConstants.GET, false, 1); - - assertEquals("200", result.getResponseCode()); - // The JDK client only reveals connection establishment through the SSLEngine it creates per - // connection, so the connect time of a plain text connection cannot be measured. Guessing it - // would report the connect time of unrelated samples of the shared, multiplexed client. - assertEquals(0, result.getConnectTime(), - "connect time cannot be measured for plain text HTTP/2, so it must not be guessed"); - } finally { - server.stop(); - } - } - - @Test - void setsConnectTimeForHttp2OverTls() throws Exception { - WireMockServer server = new WireMockServer( - WireMockConfiguration.wireMockConfig().dynamicHttpsPort().http2TlsDisabled(false)); - server.start(); - try { - server.stubFor(get(urlEqualTo("/http2TlsConnectTime")).willReturn(aResponse().withStatus(200))); - - ConnectTimeTracker tracker = new ConnectTimeTracker(); - SSLContext sslContext = trustAllContext(); - HttpClient client = HttpClient.newBuilder() - .version(HttpClient.Version.HTTP_2) - .sslContext(new ConnectTimeTracker.MeasuringSSLContext(sslContext, tracker)) - .build(); - URL url = new URL("https://localhost:" + server.httpsPort() + "/http2TlsConnectTime"); - HttpRequest request = HttpRequest.newBuilder(url.toURI()).build(); - - SampleResult result = new SampleResult(); - result.sampleStart(); - tracker.sampleStarted(result, ConnectTimeTracker.origin(url)); - HttpResponse response; - try { - response = client.send(request, HttpResponse.BodyHandlers.ofString()); - } finally { - tracker.sampleFinished(result); - } - result.sampleEnd(); - - assertEquals(200, response.statusCode()); - assertEquals(HttpClient.Version.HTTP_2, response.version()); - assertTrue(result.getConnectTime() > 0, - "connectTime should be greater than 0, but was " + result.getConnectTime()); - assertTrue(result.getConnectTime() <= result.getTime(), - "connectTime should not exceed the elapsed time"); - - // The next sample reuses the established connection, so it did not have to connect - SampleResult pooled = new SampleResult(); - pooled.sampleStart(); - tracker.sampleStarted(pooled, ConnectTimeTracker.origin(url)); - try { - assertEquals(200, client.send(request, HttpResponse.BodyHandlers.ofString()).statusCode()); - } finally { - tracker.sampleFinished(pooled); - } - pooled.sampleEnd(); - - assertEquals(0, pooled.getConnectTime(), - "a sample served by an established connection must not report a connect time"); - } finally { - server.stop(); - } - } - - @Test - void doesNotCacheAnAdditionalHttp2ClientWhenTheSslContextIsReset() throws Exception { - HTTPSamplerBase sampler = newSampler(); - sampler.setHttpVersion("HTTP/2"); - HTTPJavaImpl impl = new HTTPJavaImpl(sampler); - Map clients = HTTPJavaImpl.getHttp2Clients(); - URL url = new URL("https://localhost:1234/http2SslReset"); - try { - JsseSSLManager sslManager = (JsseSSLManager) SSLManager.getInstance(); - impl.getHttpClient(url); - Map clientsAfterFirstSample = new HashMap<>(clients); - SSLContext contextOfFirstSample = sslManager.getContext(); - - // Happens on every thread group iteration when "same user on next iteration" is switched off - sslManager.resetContext(); - impl.getHttpClient(url); - - assertNotSame(contextOfFirstSample, sslManager.getContext(), - "resetContext() should hand out a new SSLContext, otherwise the test proves nothing"); - assertFalse(clientsAfterFirstSample.isEmpty(), "an HTTP/2 client should have been cached"); - assertEquals(clientsAfterFirstSample.keySet(), new HashMap(clients).keySet(), - "a new SSLContext must not add another HttpClient to the cache, that would leak clients"); - } finally { - impl.threadFinished(); - impl.testEnded(); - } - } - - @Test - void keepsTheHttp2ClientOfItsThreadWhileEmbeddedResourcesAreDownloaded() throws Exception { - HTTPSamplerBase sampler = newSampler(); - sampler.setHttpVersion("HTTP/2"); - HTTPJavaImpl impl = new HTTPJavaImpl(sampler); - URL url = new URL("https://localhost:1234/http2ParallelDownload"); - try { - Object clientOfSample = impl.getHttpClient(url); - // the embedded resources are downloaded by pooled threads which adopt the context of the - // JMeter thread they are working for, but get an SSLContext of their own from the SSLManager - JMeterContext contextOfSample = JMeterContextService.getContext(); - AtomicReference clientOfDownloader = new AtomicReference<>(); - Thread downloader = new Thread(() -> { - JMeterContextService.replaceContext(contextOfSample); - clientOfDownloader.set(impl.getHttpClient(url)); - }); - downloader.start(); - downloader.join(); - - assertSame(clientOfSample, clientOfDownloader.get(), - "a downloader must not replace the client of its sample, that would shut it down while in use"); - } finally { - impl.threadFinished(); - impl.testEnded(); - } - } - - @Test - void closesHttp2ClientsWhenTheThreadFinishes() throws Exception { - WireMockServer server = createServer(); - server.start(); - HTTPSamplerBase sampler = newSampler(); - try { - server.stubFor(get(urlEqualTo("/http2TestEnded")).willReturn(aResponse().withStatus(200))); - sampler.setHttpVersion("HTTP/2"); - - HTTPSampleResult result = sampler.sample( - new URL(server.url("/http2TestEnded")), HTTPConstants.GET, false, 1); - assertEquals("200", result.getResponseCode()); - assertFalse(HTTPJavaImpl.getHttp2Clients().isEmpty(), "the sample should have cached an HTTP/2 client"); - - sampler.threadFinished(); - - assertTrue(HTTPJavaImpl.getHttp2Clients().isEmpty(), - "HTTP/2 clients should be released when the thread finishes"); - } finally { - server.stop(); - } - } - - @Test - void closesResponseBodyStreamWhenReadingResponse() throws Exception { - HTTPSamplerBase sampler = newSampler(); - HTTPJavaImpl impl = new HTTPJavaImpl(sampler); - AtomicBoolean closed = new AtomicBoolean(false); - InputStream bodyStream = new ByteArrayInputStream("response body".getBytes(StandardCharsets.UTF_8)) { - @Override - public void close() throws IOException { - closed.set(true); - super.close(); - } - }; - - HttpResponse response = createStubResponse(bodyStream, Map.of("Content-Length", List.of("13"))); - - SampleResult result = new SampleResult(); - result.sampleStart(); - byte[] responseData = impl.readResponse(response, result); - - assertEquals("response body", new String(responseData, StandardCharsets.UTF_8)); - assertTrue(closed.get(), "response body stream should be closed"); - } - - @Test - void closesResponseBodyStreamWhenContentLengthIsZero() throws Exception { - HTTPSamplerBase sampler = newSampler(); - HTTPJavaImpl impl = new HTTPJavaImpl(sampler); - AtomicBoolean closed = new AtomicBoolean(false); - InputStream bodyStream = new ByteArrayInputStream(new byte[0]) { - @Override - public void close() throws IOException { - closed.set(true); - super.close(); - } - }; - - HttpResponse response = createStubResponse(bodyStream, Map.of("Content-Length", List.of("0"))); - - SampleResult result = new SampleResult(); - result.sampleStart(); - byte[] responseData = impl.readResponse(response, result); - - assertEquals(0, responseData.length); - assertTrue(closed.get(), "response body stream should be closed by try-with-resources"); - } - - @Test - void returnsEmptyByteArrayWhenResponseBodyIsNull() throws Exception { - HTTPSamplerBase sampler = newSampler(); - HTTPJavaImpl impl = new HTTPJavaImpl(sampler); - - HttpResponse response = createStubResponse(null, Map.of()); - - SampleResult result = new SampleResult(); - result.sampleStart(); - byte[] responseData = impl.readResponse(response, result); - - assertEquals(0, responseData.length); - } - - private static HttpResponse createStubResponse( - InputStream bodyStream, Map> headerMap) { - return new HttpResponse<>() { - @Override - public int statusCode() { - return 200; - } - - @Override - public HttpRequest request() { - return null; - } - - @Override - public Optional> previousResponse() { - return Optional.empty(); - } - - @Override - public HttpHeaders headers() { - return HttpHeaders.of(headerMap, (k, v) -> true); - } - - @Override - public InputStream body() { - return bodyStream; - } - - @Override - public Optional sslSession() { - return Optional.empty(); - } - - @Override - public URI uri() { - return URI.create("http://localhost/"); - } - - @Override - public HttpClient.Version version() { - return HttpClient.Version.HTTP_2; - } - }; - } - - private static SSLContext trustAllContext() throws Exception { TrustManager trustAll = new X509ExtendedTrustManager() { - @Override - public void checkClientTrusted(X509Certificate[] chain, String authType) { - // trust everything in the test - } - - @Override - public void checkServerTrusted(X509Certificate[] chain, String authType) { - // trust everything in the test - } - - @Override - public void checkClientTrusted(X509Certificate[] chain, String authType, Socket socket) { - // trust everything in the test - } - - @Override - public void checkServerTrusted(X509Certificate[] chain, String authType, Socket socket) { - // trust everything in the test - } - - @Override - public void checkClientTrusted(X509Certificate[] chain, String authType, SSLEngine engine) { - // trust everything in the test - } - - @Override - public void checkServerTrusted(X509Certificate[] chain, String authType, SSLEngine engine) { - // trust everything in the test - } - - @Override - public X509Certificate[] getAcceptedIssuers() { - return new X509Certificate[0]; - } - }; - SSLContext context = SSLContext.getInstance("TLS"); - context.init(null, new TrustManager[] { trustAll }, null); - return context; - } - - private static HTTPSamplerBase newSampler() { - return HTTPSamplerFactory.newInstance("Java"); - } - - private static WireMockServer createServer() { - return new WireMockServer(WireMockConfiguration.wireMockConfig().dynamicPort()); - } -} diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPSamplerFactory.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPSamplerFactory.java index fc4387ab795..e59ba5b1f50 100644 --- a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPSamplerFactory.java +++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPSamplerFactory.java @@ -47,7 +47,7 @@ void unknownImplementationIsRejected() { void httpVersionsMatchTheCapabilitiesOfTheImplementation() { assertEquals(Arrays.asList("", "HTTP/1.1"), Arrays.asList(HTTPSamplerFactory.getHttpVersions("HttpClient4"))); - assertEquals(Arrays.asList("", "HTTP/1.1", "HTTP/2"), + assertEquals(Arrays.asList("", "HTTP/1.1"), Arrays.asList(HTTPSamplerFactory.getHttpVersions("Java"))); assertEquals(Arrays.asList("", "HTTP/1.1", "HTTP/2", "HTTP/2 Strict"), Arrays.asList(HTTPSamplerFactory.getHttpVersions("HttpClient5"))); diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPSamplerParallelDownloads.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPSamplerParallelDownloads.java index 57a80da01c8..e3418c9e88f 100644 --- a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPSamplerParallelDownloads.java +++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPSamplerParallelDownloads.java @@ -105,7 +105,6 @@ static void stopServer() { "HttpClient5, HTTP/1.1, http", "HttpClient5, HTTP/2, https", "Java, HTTP/1.1, http", - "Java, HTTP/2, http", }) @Timeout(120) void downloadsEmbeddedResourcesWhileAnotherThreadClosesItsClients( diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHttpVersionProperty.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHttpVersionProperty.java index b51648e1e29..98c80f95534 100644 --- a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHttpVersionProperty.java +++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHttpVersionProperty.java @@ -29,7 +29,7 @@ /** * Tests the handling of the {@code httpclient.version} property, which selects HTTP versions such - * as {@code HTTP/2} for the HttpClient5 and Java samplers, but historically only took the values + * as {@code HTTP/2} for the HttpClient5 sampler, but historically only took the values * {@code 1.0} and {@code 1.1}, which the AJP sampler still understands. */ class TestHttpVersionProperty extends JMeterTestCase implements JMeterSerialTest { diff --git a/xdocs/changes.xml b/xdocs/changes.xml index aa6c16c1949..90caf722aaa 100644 --- a/xdocs/changes.xml +++ b/xdocs/changes.xml @@ -100,16 +100,14 @@ Summary
  • 6742Send a Host header of the HTTP Header Manager without its port when that port is the default one of the URL in the HttpClient5 sampler implementation, as the HttpClient4 implementation does.
  • -

    HTTP/2 support for the HttpClient5 and Java sampler implementations:

    +

    HTTP/2 support for the HttpClient5 sampler implementation:

    • 6742Multiplex concurrent HTTP/2 requests of a thread of the HttpClient5 sampler implementation, for example the parallel downloads of embedded resources, over a single connection, and make the HTTP/2 protocol settings (HPACK header table size and compression, maximum concurrent streams, flow control window, maximum frame size, server push and h2c prior knowledge) configurable with the new httpclient5.h2.* properties. Pooled connections are re-validated after the period of inactivity configured with the new httpclient5.validate_after_inactivity property, so a connection the server closed in between two samples is replaced instead of failing the next sample.
    • -
    • 6742Multiplex concurrent HTTP/2 requests of a thread of the Java sampler implementation, for example the parallel downloads of embedded resources, over a single connection, and make the HTTP/2 protocol settings (HPACK header table size, maximum concurrent streams, flow control windows, maximum frame size, connection keep alive and server push) configurable with the new http.java.h2.* properties. Every thread uses a connection of its own, as a real user would, unless the new http.java.h2.share_connections_between_threads property is enabled.
    • 6742Add the default User-Agent header of the HttpClient5 sampler implementation to the request itself, so it shows up in the sample result and in the sent bytes, and allow suppressing it with the new httpclient5.default_user_agent_disabled property, like httpclient4.default_user_agent_disabled does for HttpClient4.
    • -
    • 6742Add the default User-Agent header of the Java sampler implementation (Java/A.B.C for HTTP/1.1 and Java-http-client/A.B.C for HTTP/2) to the request itself, so the header the JDK sends anyway shows up in the sample result and in the sent bytes.
    • 6742Support the Kerberos mechanism of the HTTP Authorization Manager in the HttpClient5 sampler implementation. HttpClient 5 neither registers the Negotiate and Kerberos auth schemes nor prefers them by default, so they are now set up per request, and the request is executed with the JAAS subject the Authorization Manager logged the user in with. The kerberos.spnego.strip_port, kerberos.spnego.use_canonical_host_name and kerberos.spnego.delegate_cred properties are honoured as with HttpClient4.
    • 6742Answer a Proxy-Authenticate: Negotiate (SPNEGO/Kerberos) challenge of an enterprise proxy in the HttpClient5 sampler implementation, instead of failing the sample with 407 Proxy Authentication Required. The negotiation based auth schemes are now offered to a proxy which has no user name configured, or which is covered by a Kerberos entry of the HTTP Authorization Manager, while a proxy with a configured user name keeps using the password based schemes.
    • -
    • 6742Offer only the HTTP versions the selected implementation can use in the HTTP Version combo box of the HTTP Request sampler and the HTTP Request Defaults: HttpClient4 offers HTTP/1.1 only, as it ignores the HTTP version, Java adds HTTP/2 Negotiate, and HttpClient5 additionally offers the new HTTP/2 Strict value, which only offers h2 during protocol negotiation, so a server without HTTP/2 support fails the sample instead of being used with HTTP/1.1.
    • -
    • 6742Accept the legacy values 1.0 and 1.1 of the httpclient.version property, which now takes the HTTP versions HTTP/1.1, HTTP/2 and HTTP/2 Strict, so an existing configuration keeps selecting HTTP/1.1 for the HttpClient5 and Java sampler implementations. The AJP sampler still announces HTTP/1.0 in its request line for the legacy value 1.0, and now for HTTP/1.0 as well.
    • +
    • 6742Offer only the HTTP versions the selected implementation can use in the HTTP Version combo box of the HTTP Request sampler and the HTTP Request Defaults: HttpClient4 and Java offer HTTP/1.1 only, as they ignore the HTTP version, and HttpClient5 additionally offers HTTP/2 Negotiate and the new HTTP/2 Strict value, which only offers h2 during protocol negotiation, so a server without HTTP/2 support fails the sample instead of being used with HTTP/1.1.
    • +
    • 6742Accept the legacy values 1.0 and 1.1 of the httpclient.version property, which now takes the HTTP versions HTTP/1.1, HTTP/2 and HTTP/2 Strict, so an existing configuration keeps selecting HTTP/1.1 for the HttpClient5 sampler implementation. The AJP sampler still announces HTTP/1.0 in its request line for the legacy value 1.0, and now for HTTP/1.0 as well.

    Timers, Assertions, Config, Pre- & Post-Processors

    @@ -161,8 +159,7 @@ Summary

    HTTP Samplers and Test Script Recorder

      -
    • 6742Keep the HTTP clients of a thread from being used, and closed, by another thread when embedded resources are downloaded in parallel. The threads which perform those downloads are pooled and shared by all threads, so they used to keep the clients of whichever thread happened to create them, which made samples fail with errors such as Non HTTP response code: java.io.IOException, Socket closed or Connection pool shut down as soon as that thread finished or started a new iteration. The clients are now looked up by the context of the thread the download is performed for, in the HttpClient4, HttpClient5 and Java sampler implementations.
    • -
    • 6742Keep a thread which downloads embedded resources in parallel from replacing the HTTP/2 client of the Java sampler implementation. JMeter hands out an SSLContext per thread, so such a download used to consider the client of the thread it works for outdated, and shut it down while its sample and the sibling downloads were still using it, which failed them with Non HTTP response code: java.util.concurrent.ExecutionException and java.io.IOException: shutdownNow. Only the thread which owns a client replaces it now.
    • +
    • 6742Keep the HTTP clients of a thread from being used, and closed, by another thread when embedded resources are downloaded in parallel. The threads which perform those downloads are pooled and shared by all threads, so they used to keep the clients of whichever thread happened to create them, which made samples fail with errors such as Non HTTP response code: java.io.IOException, Socket closed or Connection pool shut down as soon as that thread finished or started a new iteration. The clients are now looked up by the context of the thread the download is performed for, in the HttpClient4 and HttpClient5 sampler implementations.
    diff --git a/xdocs/usermanual/component_reference.xml b/xdocs/usermanual/component_reference.xml index 4e60a172cdf..3f531e7a9fc 100644 --- a/xdocs/usermanual/component_reference.xml +++ b/xdocs/usermanual/component_reference.xml @@ -249,17 +249,12 @@ https.default.protocol=SSLv3 HttpClient5 supports HTTP/1.1, HTTP/2 Negotiate (stored as HTTP/2, falls back to HTTP/1.1 when the server does not offer HTTP/2) and HTTP/2 Strict (stored as HTTP/2 Strict, requires HTTP/2, so the request fails when the server does not support it), - Java supports HTTP/1.1 and HTTP/2 Negotiate, and HttpClient4 - supports HTTP/1.1 only. An empty value uses the httpclient.version property, which - defaults to HTTP/1.1. + while HttpClient4 and Java support HTTP/1.1 only. + An empty value uses the httpclient.version property, which defaults to HTTP/1.1.

    With HTTP/2 a thread multiplexes its concurrent requests, for example the parallel downloads of embedded resources, over a single connection per host, but the connections are not shared between threads, so a test with N threads still opens N connections like - N real users would. Setting the - http.java.h2.share_connections_between_threads property makes the Java - implementation drive all threads over one connection per host, which saves connections and threads, - but the test then no longer measures connection level effects such as the congestion window, the cost - of the TLS handshake or per connection limits of the server.

    + N real users would.

    HTTP, HTTPS or FILE. Default: HTTP GET, POST, HEAD, TRACE, OPTIONS, PUT, DELETE, PATCH (not supported for @@ -3942,17 +3937,12 @@ All port values are treated equally; a sampler that does not specify a port will HttpClient5 supports HTTP/1.1, HTTP/2 Negotiate (stored as HTTP/2, falls back to HTTP/1.1 when the server does not offer HTTP/2) and HTTP/2 Strict (stored as HTTP/2 Strict, requires HTTP/2, so the request fails when the server does not support it), - Java supports HTTP/1.1 and HTTP/2 Negotiate, and HttpClient4 - supports HTTP/1.1 only. An empty value uses the httpclient.version property, which - defaults to HTTP/1.1. + while HttpClient4 and Java support HTTP/1.1 only. + An empty value uses the httpclient.version property, which defaults to HTTP/1.1.

    With HTTP/2 a thread multiplexes its concurrent requests, for example the parallel downloads of embedded resources, over a single connection per host, but the connections are not shared between threads, so a test with N threads still opens N connections like - N real users would. Setting the - http.java.h2.share_connections_between_threads property makes the Java - implementation drive all threads over one connection per host, which saves connections and threads, - but the test then no longer measures connection level effects such as the congestion window, the cost - of the TLS handshake or per connection limits of the server.

    + N real users would.

    HTTP or HTTPS. The encoding to be used for the request. The path to resource (for example, /servlets/myServlet). If the diff --git a/xdocs/usermanual/properties_reference.xml b/xdocs/usermanual/properties_reference.xml index 55416fb6cf7..2bca4f2dd06 100644 --- a/xdocs/usermanual/properties_reference.xml +++ b/xdocs/usermanual/properties_reference.xml @@ -442,10 +442,11 @@ JMETER-SERVER Defaults to: 0 - Set the default HTTP version for HttpClient5 and Java samplers with an empty HTTP Version value.
    - Valid values are HTTP/1.1, HTTP/2 and, for HttpClient5 only, - HTTP/2 Strict. The legacy values 1.0 and 1.1 are still - accepted and both select HTTP/1.1 for these samplers.
    + Set the default HTTP version for HttpClient5 samplers with an empty HTTP Version value.
    + Valid values are HTTP/1.1, HTTP/2 and HTTP/2 Strict. + The legacy values 1.0 and 1.1 are still accepted and both select + HTTP/1.1 for these samplers. The HttpClient4 and Java samplers always use + HTTP/1.1.
    The AJP sampler only reads this property to choose the HTTP version of its request line: the legacy value 1.0 (or HTTP/1.0) announces HTTP/1.0, every other value announces HTTP/1.1.
    @@ -487,8 +488,8 @@ JMETER-SERVER HTTP/2 flow control window in bytes announced per stream. It caps how many bytes the server may send before it has to wait for a window update, so the throughput of a single response cannot exceed this window divided by the round trip time. The HTTP/2 default of 65535 - throttles downloads over links with any noticeable latency, therefore JMeter announces the same - window as the Java sampler implementation.
    + throttles downloads over links with any noticeable latency, therefore JMeter announces a larger + window.
    Defaults to: 16777216
    @@ -511,56 +512,6 @@ JMETER-SERVER A User-Agent header defined in the test plan is still sent.
    Defaults to: false
    - - Multiplex the HTTP/2 message exchanges of all JMeter threads of the Java sampler implementation - over a single connection per origin, instead of giving every thread a connection of its own. It - saves connections and threads, but a test which drives all virtual users over one connection no - longer measures connection level effects such as the congestion window, the cost of the TLS - handshake or per connection limits of the server, therefore it is disabled by default. The - requests of a single thread, including the parallel downloads of its embedded resources, are - multiplexed over one connection either way.
    - Defaults to: false -
    - - Size in bytes of the HPACK dynamic header table the Java sampler implementation announces to the - server. 0 disables indexing of the headers by the server. It is applied as the - jdk.httpclient.hpack.maxheadertablesize system property.
    - Defaults to the JDK default: 16384 -
    - - Maximum number of server initiated (pushed) streams the Java sampler implementation accepts on a - single HTTP/2 connection. It is applied as the jdk.httpclient.maxstreams system - property.
    - Defaults to the JDK default: 100 with server push enabled, 0 otherwise -
    - - HTTP/2 flow control window in bytes the Java sampler implementation announces per stream. It is - applied as the jdk.httpclient.windowsize system property.
    - Defaults to the JDK default: 16777216 -
    - - HTTP/2 flow control window in bytes the Java sampler implementation announces for the whole - connection, it must not be smaller than the stream window. It is applied as the - jdk.httpclient.connectionWindowSize system property.
    - Defaults to the JDK default: 67108864 -
    - - Largest HTTP/2 frame payload in bytes the Java sampler implementation is willing to receive, - between 16384 and 16777215. It is applied as the - jdk.httpclient.maxframesize system property.
    - Defaults to the JDK default: 16384 -
    - - Seconds an idle HTTP/2 connection of the Java sampler implementation is kept in the pool. It is - applied as the jdk.httpclient.keepalive.timeout.h2 system property.
    - Defaults to the JDK default: the value of jdk.httpclient.keepalive.timeout -
    - - Accept HTTP/2 server push in the Java sampler implementation. JMeter cannot report pushed - resources, so they are dropped, therefore push is disabled by default. It is applied as the - jdk.httpclient.enablepush system property.
    - Defaults to: false -
    Set characters per second to a value greater then zero to emulate slow connections.
    Defaults to: 0 From 027e9ff33d1d8cff40486305de3fb853c86f21cb Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Thu, 20 Aug 2026 06:53:59 +0200 Subject: [PATCH 60/80] Fix elapsed time calculation on setup failure in HTTPHC5Impl If setupRequest() throws an exception (such as an invalid charset in Content-Encoding or an unresolvable upload file) before result.sampleStart() is called, the catch block calls result.sampleEnd() with startTime still at 0. This causes the error sample to report an elapsed time calculated from the Unix epoch rather than the actual duration. Ensure result.sampleStart() is invoked in the catch block if startTime has not yet been set. Also update stale Javadoc on HttpClientKey in HTTPHC4Impl to reflect that it keys a map rather than a ThreadLocal, and clarify the class Javadoc of HTTPMessageSizes. --- .../protocol/http/sampler/HTTPHC4Impl.java | 2 +- .../protocol/http/sampler/HTTPHC5Impl.java | 3 +++ .../protocol/http/sampler/HTTPMessageSizes.java | 3 +-- .../http/sampler/TestHTTPHC5Features.java | 16 ++++++++++++++++ 4 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC4Impl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC4Impl.java index 1d71675cc4f..e786dfe809c 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC4Impl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC4Impl.java @@ -940,7 +940,7 @@ private CloseableHttpResponse executeRequest(final CloseableHttpClient httpClien /** * Holder class for all fields that define an HttpClient instance; - * used as the key to the ThreadLocal map of HttpClient instances. + * used as the key to the map of HttpClient instances. */ private static final class HttpClientKey { diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java index 521a0f00d62..c7c5c47839b 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java @@ -548,6 +548,9 @@ protected HTTPSampleResult sample(URL url, String method, boolean areFollowingRe saveConnectionCookies(response, result.getURL(), getCookieManager()); return resultProcessing(areFollowingRedirect, frameDepth, result); } catch (Exception e) { + if (result.getStartTime() == 0) { + result.sampleStart(); + } if (result.getEndTime() == 0) { result.sampleEnd(); } diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPMessageSizes.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPMessageSizes.java index 2dc78d43d1c..7b43c41f5fc 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPMessageSizes.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPMessageSizes.java @@ -20,8 +20,7 @@ import java.nio.charset.StandardCharsets; /** - * Sizes of the parts of an HTTP/1.1 request, shared by the sampler implementations so that all of - * them report the same number of sent bytes for the same request. + * Sizes of the parts of an HTTP/1.1 request. *

    * The lengths are the ones the message has on the wire, where the request line and the headers are * encoded with ISO-8859-1, one byte per character. diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java index 85f66a46a6a..984e70e3913 100644 --- a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java +++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java @@ -1123,6 +1123,22 @@ void keepsANonDefaultPortOfTheHostHeaderOfTheHeaderManager() throws Exception { } } + @Test + void reportsSmallElapsedTimeWhenSetupRequestFails() throws Exception { + HTTPSamplerBase sampler = newSampler(); + sampler.setContentEncoding("invalid-charset-name-12345"); + sampler.addNonEncodedArgument("key", "value", "="); + sampler.setMethod(HTTPConstants.POST); + + HTTPSampleResult result = sampler.sample(new URL("http://localhost:12345/"), HTTPConstants.POST, false, 1); + + assertFalse(result.isSuccessful()); + assertTrue(result.getStartTime() > 0, "Start time should be set"); + assertTrue(result.getEndTime() >= result.getStartTime(), "End time should be >= start time"); + assertTrue(result.getTime() >= 0 && result.getTime() < 10000, + "Elapsed time should be small, but was " + result.getTime()); + } + private static HTTPSamplerBase newSampler() { return HTTPSamplerFactory.newInstance("HttpClient5"); } From cd1b63b9dbe2aef86d8c8747ad32ea42f3be769d Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Mon, 5 Oct 2026 10:06:02 +0200 Subject: [PATCH 61/80] Apply the response timeout to HTTP/2 requests of HTTPHC5Impl HttpClient 5 applies RequestConfig.getResponseTimeout() as socket timeout of the connection, which it skips once HTTP/2 was negotiated, as all streams share the connection. executeHttp2 waited for the response without a timeout, so a server that accepted the stream but never answered, or sent the headers and then stalled, blocked the sampler thread until the test was stopped, while the same element timed out over HTTP/1.1. Enforce the timeout on the sampler thread with the new AsyncResponseTimeout. The timeout starts when a request is handed to the connection and every part of the request body sent and of the response received restarts it, so like the HTTP/1.1 socket timeout it applies to each wait for the server, and a response arriving in chunks may take longer in total. It covers the exchanges of redirects and authentication challenges, and it is suspended while a connection is leased or connected. On expiry the exchange is cancelled and the sample fails with a SocketTimeoutException, and a SocketTimeoutException HttpClient detects itself is no longer wrapped in a plain IOException, so both are reported like with the HTTP/1.1 transport. Add tests with servers that never respond, over negotiated and strict HTTP/2, with one that stalls after the response headers, and with a response that trickles in for longer than the timeout. --- .../http/sampler/AsyncResponseTimeout.java | 313 ++++++++++++++++++ .../protocol/http/sampler/HTTPHC5Impl.java | 13 +- .../http/sampler/StallingHttp2Server.java | 133 ++++++++ .../http/sampler/TestHTTPHC5Features.java | 100 ++++++ 4 files changed, 557 insertions(+), 2 deletions(-) create mode 100644 src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/AsyncResponseTimeout.java create mode 100644 src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/StallingHttp2Server.java diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/AsyncResponseTimeout.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/AsyncResponseTimeout.java new file mode 100644 index 00000000000..013a527e2b1 --- /dev/null +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/AsyncResponseTimeout.java @@ -0,0 +1,313 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you 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.apache.jmeter.protocol.http.sampler; + +import java.io.IOException; +import java.net.SocketTimeoutException; +import java.nio.ByteBuffer; +import java.util.List; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import org.apache.hc.client5.http.async.AsyncExecCallback; +import org.apache.hc.client5.http.async.AsyncExecChainHandler; +import org.apache.hc.client5.http.config.RequestConfig; +import org.apache.hc.client5.http.impl.async.HttpAsyncClientBuilder; +import org.apache.hc.core5.http.EntityDetails; +import org.apache.hc.core5.http.Header; +import org.apache.hc.core5.http.HttpException; +import org.apache.hc.core5.http.HttpRequestInterceptor; +import org.apache.hc.core5.http.HttpResponse; +import org.apache.hc.core5.http.nio.AsyncDataConsumer; +import org.apache.hc.core5.http.nio.AsyncEntityProducer; +import org.apache.hc.core5.http.nio.CapacityChannel; +import org.apache.hc.core5.http.nio.DataStreamChannel; +import org.apache.hc.core5.http.nio.entity.AsyncEntityProducerWrapper; +import org.apache.hc.core5.http.protocol.HttpContext; +import org.apache.hc.core5.util.Timeout; + +/** + * Response timeout of a request that {@link HTTPHC5Impl} executes with the asynchronous HttpClient + * transport, which it uses for HTTP/2. + *

    + * HttpClient applies the response timeout of the request config as socket timeout of the + * connection, which it skips once HTTP/2 was negotiated, as all streams share the connection. + * Without this, a server that accepts the stream but never answers, or stalls in the middle of the + * response, would block the sampler thread until the test is stopped. Like the socket timeout of the + * HTTP/1.1 transport, the timeout applies to each wait for the server, so a response that keeps + * arriving in chunks may take longer than the timeout in total. + *

    + * The timeout starts when a request is handed to the connection, and every part of the request + * body sent and of the response received restarts it. This covers every exchange HttpClient + * executes for a sample, including those of redirects and authentication challenges. In between, + * while an exchange waits for its connection to be leased or connected, the timeout is suspended, + * as it does not cover that with the HTTP/1.1 transport either. HttpClient hands over the response + * an HTTP/2 frame at a time, so a single frame which takes longer than the timeout to arrive counts + * as inactivity, which only matters for an extremely slow link given the default maximum frame size + * of 16 kB. + */ +final class AsyncResponseTimeout { + + private static final String CONTEXT_ATTRIBUTE = "__jmeter.RESPONSE_TIMEOUT__"; //$NON-NLS-1$ + + /** + * Starts the timeout of an exchange. HttpClient runs the request interceptors right before it + * sends the request on the connection, so that leasing and connecting is not counted. + */ + private static final HttpRequestInterceptor START_ON_REQUEST = (request, entity, context) -> { + AsyncResponseTimeout responseTimeout = (AsyncResponseTimeout) context.getAttribute(CONTEXT_ATTRIBUTE); + if (responseTimeout != null) { + responseTimeout.exchangeStarted(); + } + }; + + /** + * Records the activity of an exchange. As the last element of the execution chain it sees the + * request body and the response of every exchange as they pass the connection. + */ + private static final AsyncExecChainHandler TRACK_ACTIVITY = (request, entityProducer, scope, chain, callback) -> { + AsyncResponseTimeout responseTimeout = + (AsyncResponseTimeout) scope.clientContext.getAttribute(CONTEXT_ATTRIBUTE); + if (responseTimeout == null) { + chain.proceed(request, entityProducer, scope, callback); + return; + } + chain.proceed(request, + entityProducer == null ? null : new ActivityTrackingEntityProducer(entityProducer, responseTimeout), + scope, new ActivityTrackingCallback(callback, responseTimeout)); + }; + + private static final AsyncResponseTimeout DISABLED = new AsyncResponseTimeout(null); + + private final Timeout timeout; + private volatile long lastActivityNanos; + private volatile boolean exchangeInProgress; + + private AsyncResponseTimeout(Timeout timeout) { + this.timeout = timeout; + } + + /** + * Installs the interceptors that track the activity of the requests executed by the client. + * + * @param builder builder of the client + */ + static void install(HttpAsyncClientBuilder builder) { + builder.addRequestInterceptorLast(START_ON_REQUEST) + .addExecInterceptorLast("jmeter-response-timeout", TRACK_ACTIVITY); + } + + /** + * Sets up the response timeout of the request config for the request executed with the given + * context. + * + * @param requestConfig config of the request, may be {@code null} + * @param context context the request is executed with + * @return the response timeout to wait for the response with + */ + static AsyncResponseTimeout attach(RequestConfig requestConfig, HttpContext context) { + Timeout timeout = requestConfig == null ? null : requestConfig.getResponseTimeout(); + if (timeout == null || timeout.isDisabled()) { + return DISABLED; + } + AsyncResponseTimeout responseTimeout = new AsyncResponseTimeout(timeout); + context.setAttribute(CONTEXT_ATTRIBUTE, responseTimeout); + return responseTimeout; + } + + /** + * Waits for the response, failing the exchange once no data was sent or received for longer + * than the timeout. + * + * @param responseFuture future of the response + * @param type of the response + * @return the response + * @throws SocketTimeoutException when the timeout expired, the exchange is cancelled then + * @throws InterruptedException when the thread was interrupted while waiting + * @throws ExecutionException when the exchange failed + */ + T await(Future responseFuture) + throws InterruptedException, ExecutionException, SocketTimeoutException { + if (timeout == null) { + return responseFuture.get(); + } + long timeoutNanos = timeout.toNanoseconds(); + while (true) { + long remainingNanos = remainingNanos(timeoutNanos); + if (remainingNanos <= 0) { + // HttpClient discards the connection of a cancelled exchange, which also fails the + // requests multiplexed over it, just like it does when a sample is interrupted + if (responseFuture.cancel(true)) { + throw new SocketTimeoutException("Response timeout of " + timeout.toMilliseconds() + + " ms exceeded while waiting for the server"); + } + // The exchange completed in the meantime + return responseFuture.get(); + } + try { + return responseFuture.get(remainingNanos, TimeUnit.NANOSECONDS); + } catch (TimeoutException e) { + // Check again, as data that arrived while waiting restarts the timeout + } + } + } + + /** + * Nanoseconds until the exchange in progress has been idle for longer than the timeout, the + * full timeout if no exchange is in progress. + */ + private long remainingNanos(long timeoutNanos) { + if (!exchangeInProgress) { + return timeoutNanos; + } + return timeoutNanos - (System.nanoTime() - lastActivityNanos); + } + + private void exchangeStarted() { + lastActivityNanos = System.nanoTime(); + exchangeInProgress = true; + } + + private void touch() { + lastActivityNanos = System.nanoTime(); + } + + private void exchangeFinished() { + exchangeInProgress = false; + } + + /** + * Counts every part of the request body handed to the connection as activity, so that an upload + * which takes longer than the timeout is not cut short, while a server that stops accepting it + * is still timed out. + */ + private static final class ActivityTrackingEntityProducer extends AsyncEntityProducerWrapper { + private final AsyncResponseTimeout responseTimeout; + + private ActivityTrackingEntityProducer(AsyncEntityProducer entityProducer, + AsyncResponseTimeout responseTimeout) { + super(entityProducer); + this.responseTimeout = responseTimeout; + } + + @Override + public void produce(DataStreamChannel channel) throws IOException { + super.produce(new DataStreamChannel() { + @Override + public void requestOutput() { + channel.requestOutput(); + } + + @Override + public int write(ByteBuffer src) throws IOException { + int written = channel.write(src); + if (written > 0) { + responseTimeout.touch(); + } + return written; + } + + @Override + public void endStream(List trailers) throws IOException { + channel.endStream(trailers); + responseTimeout.touch(); + } + + @Override + public void endStream() throws IOException { + channel.endStream(); + responseTimeout.touch(); + } + }); + } + } + + /** Counts the response head and every part of the response body as activity. */ + private static final class ActivityTrackingCallback implements AsyncExecCallback { + private final AsyncExecCallback delegate; + private final AsyncResponseTimeout responseTimeout; + + private ActivityTrackingCallback(AsyncExecCallback delegate, AsyncResponseTimeout responseTimeout) { + this.delegate = delegate; + this.responseTimeout = responseTimeout; + } + + @Override + public AsyncDataConsumer handleResponse(HttpResponse response, EntityDetails entityDetails) + throws HttpException, IOException { + responseTimeout.touch(); + AsyncDataConsumer dataConsumer = delegate.handleResponse(response, entityDetails); + return dataConsumer == null ? null : new ActivityTrackingDataConsumer(dataConsumer, responseTimeout); + } + + @Override + public void handleInformationResponse(HttpResponse response) throws HttpException, IOException { + responseTimeout.touch(); + delegate.handleInformationResponse(response); + } + + // The exchange is marked finished before the delegate is notified, as a redirect or an + // authentication challenge starts the next exchange from in there + + @Override + public void completed() { + responseTimeout.exchangeFinished(); + delegate.completed(); + } + + @Override + public void failed(Exception cause) { + responseTimeout.exchangeFinished(); + delegate.failed(cause); + } + } + + private static final class ActivityTrackingDataConsumer implements AsyncDataConsumer { + private final AsyncDataConsumer delegate; + private final AsyncResponseTimeout responseTimeout; + + private ActivityTrackingDataConsumer(AsyncDataConsumer delegate, AsyncResponseTimeout responseTimeout) { + this.delegate = delegate; + this.responseTimeout = responseTimeout; + } + + @Override + public void updateCapacity(CapacityChannel capacityChannel) throws IOException { + delegate.updateCapacity(capacityChannel); + } + + @Override + public void consume(ByteBuffer src) throws IOException { + responseTimeout.touch(); + delegate.consume(src); + } + + @Override + public void streamEnd(List trailers) throws HttpException, IOException { + responseTimeout.touch(); + delegate.streamEnd(trailers); + } + + @Override + public void releaseResources() { + delegate.releaseResources(); + } + } +} diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java index c7c5c47839b..38dc9900a1b 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java @@ -24,6 +24,7 @@ import java.io.OutputStream; import java.net.InetAddress; import java.net.MalformedURLException; +import java.net.SocketTimeoutException; import java.net.URI; import java.net.URISyntaxException; import java.net.URL; @@ -1239,6 +1240,7 @@ private static CloseableHttpAsyncClient createHttp2Client(HttpClientKey key) { .disableContentCompression() .setH2Config(HTTP_2_CONFIG) .setRoutePlanner(createRoutePlanner(key)); + AsyncResponseTimeout.install(builder); if (HTTP_2_MULTIPLEXING) { // A connection bound to a user token cannot be shared, and HTTP/2 has no connection scoped state anyway builder.disableConnectionState(); @@ -1280,6 +1282,7 @@ private static CloseableHttpAsyncClient createHttp2Client(HttpClientKey key) { private ClassicHttpResponse executeHttp2(CloseableHttpAsyncClient client, org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request, HttpClientContext context) throws IOException { + AsyncResponseTimeout responseTimeout = AsyncResponseTimeout.attach(request.getConfig(), context); RequestBody requestBody = createRequestBody(request); try { Future responseFuture = client.execute(requestBody.producer, @@ -1292,13 +1295,19 @@ private ClassicHttpResponse executeHttp2(CloseableHttpAsyncClient client, responseFuture.cancel(true); } try { - return responseFuture.get(); + return responseTimeout.await(responseFuture); } catch (InterruptedException e) { responseFuture.cancel(true); Thread.currentThread().interrupt(); throw new IOException("Interrupted while executing HTTP/2 request", e); } catch (ExecutionException e) { - throw new IOException("Could not execute HTTP/2 request", e.getCause()); + Throwable cause = e.getCause(); + if (cause instanceof SocketTimeoutException timeout) { + // A timeout HttpClient detected itself, like the response timeout it applies when + // HTTP/1.1 was negotiated, so the sample reports it like the HTTP/1.1 transport does + throw timeout; + } + throw new IOException("Could not execute HTTP/2 request", cause); } finally { currentResponseFuture = null; } diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/StallingHttp2Server.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/StallingHttp2Server.java new file mode 100644 index 00000000000..a6c5e03d6d5 --- /dev/null +++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/StallingHttp2Server.java @@ -0,0 +1,133 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you 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.apache.jmeter.protocol.http.sampler; + +import java.io.Closeable; +import java.io.IOException; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.nio.ByteBuffer; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.EntityDetails; +import org.apache.hc.core5.http.Header; +import org.apache.hc.core5.http.HttpException; +import org.apache.hc.core5.http.HttpRequest; +import org.apache.hc.core5.http.HttpStatus; +import org.apache.hc.core5.http.URIScheme; +import org.apache.hc.core5.http.impl.BasicEntityDetails; +import org.apache.hc.core5.http.impl.bootstrap.HttpAsyncServer; +import org.apache.hc.core5.http.message.BasicHttpResponse; +import org.apache.hc.core5.http.nio.AsyncServerExchangeHandler; +import org.apache.hc.core5.http.nio.CapacityChannel; +import org.apache.hc.core5.http.nio.DataStreamChannel; +import org.apache.hc.core5.http.nio.ResponseChannel; +import org.apache.hc.core5.http.protocol.HttpContext; +import org.apache.hc.core5.http2.HttpVersionPolicy; +import org.apache.hc.core5.http2.impl.nio.bootstrap.H2ServerBootstrap; +import org.apache.hc.core5.io.CloseMode; +import org.apache.hc.core5.reactor.ListenerEndpoint; + +/** + * Cleartext HTTP/2 server (h2c with prior knowledge) that accepts every stream but never completes + * the response. Depending on how it is set up it either does not answer at all, or it sends the + * response headers and then stalls without sending a single byte of the body. That is what a hung + * backend looks like on a connection, which is still alive and keeps answering HTTP/2 pings. + */ +final class StallingHttp2Server implements Closeable { + + private final HttpAsyncServer server; + private final int port; + + StallingHttp2Server(boolean sendResponseHeaders) throws Exception { + server = H2ServerBootstrap.bootstrap() + .setCanonicalHostName("localhost") + .setVersionPolicy(HttpVersionPolicy.FORCE_HTTP_2) + .register("*", () -> new StallingExchangeHandler(sendResponseHeaders)) + .create(); + server.start(); + ListenerEndpoint endpoint = server.listen( + new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), URIScheme.HTTP).get(10, TimeUnit.SECONDS); + port = ((InetSocketAddress) endpoint.getAddress()).getPort(); + } + + int getPort() { + return port; + } + + @Override + public void close() { + server.close(CloseMode.IMMEDIATE); + } + + private static final class StallingExchangeHandler implements AsyncServerExchangeHandler { + + private final boolean sendResponseHeaders; + + private StallingExchangeHandler(boolean sendResponseHeaders) { + this.sendResponseHeaders = sendResponseHeaders; + } + + @Override + public void handleRequest(HttpRequest request, EntityDetails entityDetails, ResponseChannel responseChannel, + HttpContext context) throws HttpException, IOException { + if (sendResponseHeaders) { + // a body of unknown length is announced, so the client keeps waiting for it + responseChannel.sendResponse(new BasicHttpResponse(HttpStatus.SC_OK), + new BasicEntityDetails(-1, ContentType.TEXT_PLAIN), context); + } + } + + @Override + public void updateCapacity(CapacityChannel capacityChannel) throws IOException { + capacityChannel.update(Integer.MAX_VALUE); + } + + @Override + public void consume(ByteBuffer src) { + src.position(src.limit()); + } + + @Override + public void streamEnd(List trailers) { + // the request is complete, the response is deliberately never finished + } + + @Override + public int available() { + return 0; + } + + @Override + public void produce(DataStreamChannel channel) { + // never sends any of the announced body + } + + @Override + public void failed(Exception cause) { + // the client giving up on the stream is expected + } + + @Override + public void releaseResources() { + // nothing to release + } + } +} diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java index 984e70e3913..51763b25d3a 100644 --- a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java +++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java @@ -503,6 +503,106 @@ void recordsHttp2LatencyBeforeDribbledResponseBodyCompletes() throws Exception { } } + /** + * HttpClient applies the response timeout only to HTTP/1.1 connections, as it can set it as socket + * timeout of the connection only, which all streams of an HTTP/2 connection share. A server that + * accepts the stream and never answers must still fail the sample once the timeout expires, like it + * does over HTTP/1.1, instead of blocking the thread until the test is stopped. + */ + @Test + @org.junit.jupiter.api.Timeout(60) + void failsNegotiatedHttp2SampleWhenServerDoesNotRespondWithinResponseTimeout() throws Exception { + WireMockServer server = new WireMockServer(WireMockConfiguration.wireMockConfig() + .dynamicHttpsPort() + .http2TlsDisabled(false)); + server.start(); + try { + server.stubFor(get(urlEqualTo("/silent")).willReturn(aResponse().withStatus(200).withFixedDelay(20_000))); + HTTPSamplerBase sampler = newSampler(); + sampler.setHttpVersion("HTTP/2"); + sampler.setResponseTimeout("1000"); + + HTTPSampleResult result = sampler.sample( + new URL("https://localhost:" + server.httpsPort() + "/silent"), HTTPConstants.GET, false, 1); + + assertResponseTimedOut(result); + } finally { + server.stop(); + } + } + + @Test + @org.junit.jupiter.api.Timeout(60) + void failsStrictHttp2SampleWhenServerDoesNotRespondWithinResponseTimeout() throws Exception { + try (StallingHttp2Server server = new StallingHttp2Server(false)) { + HTTPSamplerBase sampler = newSampler(); + sampler.setHttpVersion("HTTP/2 Strict"); + sampler.setResponseTimeout("1000"); + + HTTPSampleResult result = sampler.sample( + new URL("http://localhost:" + server.getPort() + "/silent"), HTTPConstants.GET, false, 1); + + assertResponseTimedOut(result); + } + } + + @Test + @org.junit.jupiter.api.Timeout(60) + void failsHttp2SampleWhenResponseBodyStallsLongerThanResponseTimeout() throws Exception { + try (StallingHttp2Server server = new StallingHttp2Server(true)) { + HTTPSamplerBase sampler = newSampler(); + sampler.setHttpVersion("HTTP/2 Strict"); + sampler.setResponseTimeout("1000"); + + HTTPSampleResult result = sampler.sample( + new URL("http://localhost:" + server.getPort() + "/stalled"), HTTPConstants.GET, false, 1); + + assertResponseTimedOut(result); + } + } + + /** + * The response timeout limits each wait for the server, not the whole exchange, so a response that + * keeps arriving in chunks must not fail even though it takes longer than the timeout in total. + */ + @Test + @org.junit.jupiter.api.Timeout(60) + void doesNotFailHttp2SampleWhileResponseDataKeepsArrivingWithinResponseTimeout() throws Exception { + WireMockServer server = new WireMockServer(WireMockConfiguration.wireMockConfig() + .dynamicHttpsPort() + .http2TlsDisabled(false)); + server.start(); + try { + // a chunk every 250 ms, so the exchange takes about 2.5 s, well beyond the timeout + server.stubFor(get(urlEqualTo("/trickle")) + .willReturn(aResponse().withStatus(200).withBody(new byte[10_000]).withChunkedDribbleDelay(10, 2_500))); + HTTPSamplerBase sampler = newSampler(); + sampler.setHttpVersion("HTTP/2"); + sampler.setResponseTimeout("1500"); + + HTTPSampleResult result = sampler.sample( + new URL("https://localhost:" + server.httpsPort() + "/trickle"), HTTPConstants.GET, false, 1); + + assertEquals("200", result.getResponseCode(), result::getResponseMessage); + assertEquals("HTTP/2", result.getResponseHeaders().substring(0, "HTTP/2".length())); + assertEquals(10_000, result.getResponseData().length); + assertTrue(result.getTime() > 1500, + () -> "the exchange should have taken longer than the response timeout, but took " + + result.getTime() + " ms"); + } finally { + server.stop(); + } + } + + private static void assertResponseTimedOut(HTTPSampleResult result) { + assertFalse(result.isSuccessful(), "the sample should have failed with a response timeout"); + assertEquals("Non HTTP response code: java.net.SocketTimeoutException", result.getResponseCode(), + result::getResponseMessage); + assertTrue(result.getTime() >= 900 && result.getTime() < 10_000, + () -> "the sample should have failed after the response timeout of 1000 ms, but took " + + result.getTime() + " ms"); + } + @Test void fallsBackToHttp11WhenServerDoesNotSupportHttp2() throws Exception { WireMockServer server = new WireMockServer(WireMockConfiguration.wireMockConfig() From 925748470483c5f64a80e2925b0d6ebb2013a153 Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Mon, 5 Oct 2026 10:38:32 +0200 Subject: [PATCH 62/80] Drop the stale Rhino 1.8.0 entry from changes.xml Rebasing onto master re-added the "Update Rhino JavaScript engine to 1.8.0" entry to the non-functional changes, next to the "1.9.1" entry master has. The HttpClient 5 update entry was anchored on that line, which master had changed, and git kept the old line without reporting a conflict. --- xdocs/changes.xml | 1 - 1 file changed, 1 deletion(-) diff --git a/xdocs/changes.xml b/xdocs/changes.xml index 90caf722aaa..93c47c10fe5 100644 --- a/xdocs/changes.xml +++ b/xdocs/changes.xml @@ -134,7 +134,6 @@ Summary

  • Update Apache Commons libraries: commons-codec 1.22.1, commons-collections4 4.6.0, commons-io 2.22.0, commons-jexl3 3.7.0, commons-lang3 3.21.0, commons-logging 1.4.0, commons-net 3.13.0, commons-pool2 2.13.1, commons-text 1.15.0.
  • -
  • Update Rhino JavaScript engine to 1.8.0 for JSR-223 JavaScript execution.
  • 6742Update Apache HttpClient 5 to 5.6.4 and Apache HttpCore 5 to 5.4.3, which corrects the application of SSL parameters in the async TLS upgrade method used by the HttpClient5 sampler implementation for HTTP/2.
  • 6742Give the Cache Manager a single HTTP client neutral API: saveDetails(CacheManager.ResponseHeaderSource, HTTPSampleResult), inCache(URL, CacheManager.RequestHeaderSource) and setHeaders(URL, CacheManager.RequestHeaderSink) replace the per implementation overloads, so the Vary and cache control handling exists once and the config element does not depend on a specific HTTP client library. The sampler implementations adapt their requests and responses to those interfaces. The methods taking Apache HttpClient 4 types are deprecated and delegate to the new ones.
  • From 2e83dd320e1c883492d1bdddcc45b8d1fac8c5ee Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Mon, 5 Oct 2026 12:35:20 +0200 Subject: [PATCH 63/80] Present client certificates on the HTTP/2 transport of HTTPHC5Impl The asynchronous client, which the HttpClient5 implementation uses for HTTP/2, performs the TLS handshake with an SSLEngine on its I/O threads. JSSE then asks the key manager for chooseEngineClientAlias, which JsseSSLManager.WrappedX509KeyManager did not override, so the inherited implementation returned null and no client certificate was presented. A server requiring mutual TLS failed the handshake over HTTP/2, and over HTTP/1.1 negotiated by the same client, while the classic HTTP/1.1 transport, which uses an SSLSocket, worked. Choose the alias for an SSLEngine like for a socket. The alias variable of the Keystore Configuration is resolved with the variables of the current JMeterContext, which on an I/O thread is not the one of the sampler, so let the threads of an HTTP/2 client share the context of the JMeter thread the client belongs to, like the threads which download embedded resources in parallel do. The alias is thus still chosen during the handshake with the variables of the sampler, as over HTTP/1.1. Add a test with a server that requires a client certificate and trusts only the one the alias variable selects, over HTTP/1.1, HTTP/2 and HTTP/2 Strict. --- .../apache/jmeter/util/JsseSSLManager.java | 17 ++ .../protocol/http/sampler/HTTPHC5Impl.java | 33 +++- .../TestHTTPHC5ClientCertificates.java | 158 ++++++++++++++++++ xdocs/changes.xml | 1 + 4 files changed, 202 insertions(+), 7 deletions(-) create mode 100644 src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5ClientCertificates.java diff --git a/src/core/src/main/java/org/apache/jmeter/util/JsseSSLManager.java b/src/core/src/main/java/org/apache/jmeter/util/JsseSSLManager.java index b31af47d3a9..39598b90553 100644 --- a/src/core/src/main/java/org/apache/jmeter/util/JsseSSLManager.java +++ b/src/core/src/main/java/org/apache/jmeter/util/JsseSSLManager.java @@ -30,6 +30,7 @@ import javax.net.ssl.KeyManager; import javax.net.ssl.KeyManagerFactory; import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLEngine; import javax.net.ssl.TrustManager; import javax.net.ssl.TrustManagerFactory; import javax.net.ssl.X509ExtendedKeyManager; @@ -383,6 +384,22 @@ public String chooseClientAlias(String[] keyType, Principal[] issuers, Socket so return alias; } + /** + * Select the alias to authenticate as for a connection that performs the + * TLS handshake with an {@link SSLEngine}, like an asynchronous HTTP + * client does. The alias is chosen like for a socket, see + * {@link #chooseClientAlias(String[], Principal[], Socket)}. Such a client + * usually runs the handshake on its own I/O threads, so these have to + * share the {@link org.apache.jmeter.threads.JMeterContext} of the JMeter + * thread they work for, for the alias variable to be resolved. + * + * @see javax.net.ssl.X509ExtendedKeyManager#chooseEngineClientAlias(String[], Principal[], SSLEngine) + */ + @Override + public String chooseEngineClientAlias(String[] keyType, Principal[] issuers, SSLEngine engine) { + return chooseClientAlias(keyType, issuers, null); + } + /** * Choose the server alias for the SSLServerSockets. This are not used * in JMeter. diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java index 38dc9900a1b..ad6bd5334a6 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java @@ -50,6 +50,7 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; +import java.util.concurrent.ThreadFactory; import javax.net.ssl.SSLContext; import javax.security.auth.Subject; @@ -104,6 +105,7 @@ import org.apache.hc.client5.http.ssl.NoopHostnameVerifier; import org.apache.hc.client5.http.ssl.TlsSocketStrategy; import org.apache.hc.client5.http.utils.URIUtils; +import org.apache.hc.core5.concurrent.DefaultThreadFactory; import org.apache.hc.core5.concurrent.FutureCallback; import org.apache.hc.core5.http.ClassicHttpResponse; import org.apache.hc.core5.http.ContentType; @@ -1196,9 +1198,10 @@ private static CloseableHttpClient getClient(HttpClientKey key) { } private static CloseableHttpAsyncClient getHttp2Client(HttpClientKey key) { + JMeterContext jmeterContext = JMeterContextService.getContext(); Map clients = - HTTP_2_CLIENTS.computeIfAbsent(JMeterContextService.getContext(), context -> new ConcurrentHashMap<>()); - return clients.computeIfAbsent(key, HTTPHC5Impl::createHttp2Client); + HTTP_2_CLIENTS.computeIfAbsent(jmeterContext, context -> new ConcurrentHashMap<>()); + return clients.computeIfAbsent(key, k -> createHttp2Client(k, jmeterContext)); } private static boolean isDefaultUserAgentDisabled() { @@ -1231,7 +1234,7 @@ private static CloseableHttpClient createClient(HttpClientKey key) { .build(); } - private static CloseableHttpAsyncClient createHttp2Client(HttpClientKey key) { + private static CloseableHttpAsyncClient createHttp2Client(HttpClientKey key, JMeterContext jmeterContext) { HttpAsyncClientBuilder builder = HttpAsyncClients.custom() .disableAutomaticRetries() // HttpClient 5.6 added transparent content compression to the async transport. JMeter decodes @@ -1239,7 +1242,8 @@ private static CloseableHttpAsyncClient createHttp2Client(HttpClientKey key) { // does not depend on the optional codec libraries HttpClient detects on the classpath. .disableContentCompression() .setH2Config(HTTP_2_CONFIG) - .setRoutePlanner(createRoutePlanner(key)); + .setRoutePlanner(createRoutePlanner(key)) + .setThreadFactory(threadsSharingContext(jmeterContext)); AsyncResponseTimeout.install(builder); if (HTTP_2_MULTIPLEXING) { // A connection bound to a user token cannot be shared, and HTTP/2 has no connection scoped state anyway @@ -1279,6 +1283,21 @@ private static CloseableHttpAsyncClient createHttp2Client(HttpClientKey key) { return asyncClient; } + /** + * Creates the threads of an HTTP/2 client with the {@link JMeterContext} of the JMeter thread the + * client belongs to, like the threads which download embedded resources in parallel. They run + * the TLS handshakes, in which the key manager of the JMeter key store picks the client + * certificate, by the alias variable of the Keystore Configuration among others, so they have to + * see the variables of that thread rather than those of a context of their own. + */ + private static ThreadFactory threadsSharingContext(JMeterContext jmeterContext) { + ThreadFactory threadFactory = new DefaultThreadFactory("httpclient-dispatch", true); + return runnable -> threadFactory.newThread(() -> { + JMeterContextService.replaceContext(jmeterContext); + runnable.run(); + }); + } + private ClassicHttpResponse executeHttp2(CloseableHttpAsyncClient client, org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request, HttpClientContext context) throws IOException { @@ -1319,9 +1338,9 @@ private ClassicHttpResponse executeHttp2(CloseableHttpAsyncClient client, } /** - * Number of response body bytes worth keeping, {@code 0} meaning all of them. This has to be - * determined on the sampler thread, as the recording flag lives in a thread local the HTTP/2 - * I/O reactor threads do not see. Truncation is also given up when an MD5 digest is requested, + * Number of response body bytes worth keeping, {@code 0} meaning all of them, which is + * determined on the sampler thread when the request is executed and handed to the response + * consumer. Truncation is given up while recording, and when an MD5 digest is requested, * because that is computed over the whole body. */ private long getMaxBytesToStore() { diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5ClientCertificates.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5ClientCertificates.java new file mode 100644 index 00000000000..20a40df37a6 --- /dev/null +++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5ClientCertificates.java @@ -0,0 +1,158 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you 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.apache.jmeter.protocol.http.sampler; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import java.io.InputStream; +import java.io.OutputStream; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyStore; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.apache.jmeter.protocol.http.util.HTTPConstants; +import org.apache.jmeter.threads.JMeterContextService; +import org.apache.jmeter.threads.JMeterVariables; +import org.apache.jmeter.util.SSLManager; +import org.apache.jorphan.exec.KeyToolUtils; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.api.parallel.Isolated; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.core.WireMockConfiguration; + +/** + * The client certificate of the JMeter key store has to be presented by the HttpClient5 sampler + * implementation over every transport. The asynchronous transport, which HTTP/2 uses, performs the TLS + * handshake with an {@code SSLEngine} on its I/O threads, so the certificate has to be chosen there, with + * the variables of the JMeter thread the connection is opened for. + */ +@Isolated("changes the JMeter key store and the javax.net.ssl system properties") +class TestHTTPHC5ClientCertificates { + + private static final String PASSWORD = "jmeter"; + + private static final String ALIAS_VARIABLE = "certAlias"; + + private static final List KEY_STORE_PROPERTIES = List.of(SSLManager.JAVAX_NET_SSL_KEY_STORE, + "javax.net.ssl.keyStorePassword", "javax.net.ssl.keyStoreType"); + + private final Map savedProperties = new HashMap<>(); + + @BeforeAll + static void requireKeytool() { + assumeTrue(KeyToolUtils.haveKeytool(), "keytool is needed to create the client certificates"); + } + + @AfterEach + void restoreKeyStore() { + HTTPHC5Impl.closeClientsOfCurrentThread(); + savedProperties.forEach((name, value) -> { + if (value == null) { + System.clearProperty(name); + } else { + System.setProperty(name, value); + } + }); + SSLManager.reset(); + JMeterContextService.getContext().setVariables(null); + } + + /** + * The server only trusts the second of two client certificates, so the sample only succeeds when the + * certificate is presented at all, and when it is the one the alias variable of the JMeter thread + * selects. + */ + @ParameterizedTest + @ValueSource(strings = {"HTTP/1.1", "HTTP/2", "HTTP/2 Strict"}) + void presentsTheClientCertificateSelectedByTheAliasVariable(String httpVersion, @TempDir Path dir) + throws Exception { + Path keyStore = dir.resolve("client.p12"); + KeyToolUtils.genkeypair(keyStore.toFile(), "first", PASSWORD, 30, "cn=first", null); + KeyToolUtils.genkeypair(keyStore.toFile(), "second", PASSWORD, 30, "cn=second", null); + Path trustStore = trustOnly(keyStore, "second", dir.resolve("server-trust.p12")); + useJMeterKeyStore(keyStore); + JMeterVariables variables = new JMeterVariables(); + variables.put(ALIAS_VARIABLE, "second"); + JMeterContextService.getContext().setVariables(variables); + + WireMockServer server = new WireMockServer(WireMockConfiguration.wireMockConfig() + .httpDisabled(true) + .dynamicHttpsPort() + .http2TlsDisabled(false) + .needClientAuth(true) + .trustStorePath(trustStore.toString()) + .trustStorePassword(PASSWORD) + .trustStoreType("PKCS12")); + server.start(); + try { + server.stubFor(get(urlEqualTo("/mtls")).willReturn(aResponse().withStatus(200))); + HTTPSamplerBase sampler = HTTPSamplerFactory.newInstance("HttpClient5"); + sampler.setHttpVersion(httpVersion); + + HTTPSampleResult result = sampler.sample( + new URL("https://localhost:" + server.httpsPort() + "/mtls"), HTTPConstants.GET, false, 1); + + assertEquals("200", result.getResponseCode(), + () -> result.getResponseMessage() + "\n" + result.getResponseDataAsString()); + String expectedProtocol = httpVersion.startsWith("HTTP/2") ? "HTTP/2" : "HTTP/1.1"; + assertEquals(expectedProtocol, result.getResponseHeaders().substring(0, expectedProtocol.length())); + } finally { + server.stop(); + } + } + + private void useJMeterKeyStore(Path keyStore) { + for (String name : KEY_STORE_PROPERTIES) { + savedProperties.put(name, System.getProperty(name)); + } + System.setProperty(SSLManager.JAVAX_NET_SSL_KEY_STORE, keyStore.toString()); + System.setProperty("javax.net.ssl.keyStorePassword", PASSWORD); + System.setProperty("javax.net.ssl.keyStoreType", "PKCS12"); + // The clients of this thread were set up with the previous key store + HTTPHC5Impl.closeClientsOfCurrentThread(); + SSLManager.reset(); + SSLManager.getInstance().configureKeystore(true, 0, -1, ALIAS_VARIABLE); + } + + private static Path trustOnly(Path keyStore, String alias, Path trustStore) throws Exception { + KeyStore keys = KeyStore.getInstance("PKCS12"); + try (InputStream in = Files.newInputStream(keyStore)) { + keys.load(in, PASSWORD.toCharArray()); + } + KeyStore trusted = KeyStore.getInstance("PKCS12"); + trusted.load(null, null); + trusted.setCertificateEntry(alias, keys.getCertificate(alias)); + try (OutputStream out = Files.newOutputStream(trustStore)) { + trusted.store(out, PASSWORD.toCharArray()); + } + return trustStore; + } +} diff --git a/xdocs/changes.xml b/xdocs/changes.xml index 93c47c10fe5..15b7e8e7dd3 100644 --- a/xdocs/changes.xml +++ b/xdocs/changes.xml @@ -154,6 +154,7 @@ Summary
  • 6620Fix report generation paths so dashboard output files are created in the correct location after internal refactoring.
  • 6456Handle malformed percent-encoded URLs gracefully when recording HTTP traffic, logging a warning instead of failing the recording.
  • 6742Count single byte writes in CountingOutputStream as well, so the SMTP Sampler does not under-report the size of the messages it sends.
  • +
  • 6742Present the client certificate of the JMeter key store also on TLS connections that perform the handshake with an SSLEngine, like those of asynchronous HTTP clients, which the HttpClient5 sampler implementation uses for HTTP/2. So far it was only presented on socket based connections.
  • HTTP Samplers and Test Script Recorder

    From be3a804674052ae5ccc24e05a9a797e5604d7ac6 Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Tue, 6 Oct 2026 09:48:11 +0200 Subject: [PATCH 64/80] Follow automatic redirects across origins in HTTPHC5Impl HttpClient 5 copies the headers of the initial request to every redirect, and its DefaultRedirectStrategy refuses to follow a redirect to another origin when they include a Cookie or an Authorization header. JMeter sets both itself, from the Cookie Manager and for pre-emptive Basic auth, so with "Redirect Automatically" a redirect to an identity provider ended the sample with the 3xx response, while HTTPHC4Impl follows it. HttpClient also applied its own default of 50 redirects instead of httpsampler.max_redirects. Install AutoRedirectStrategy on both clients. It follows the redirect and sends each hop with what the managers hold for its own URL, like a sample of "Follow Redirects": the cookies a redirect sets are stored in the Cookie Manager, the Cookie header holds the cookies for the URL of the hop, and the Authorization header the pre-emptive Basic credentials of the Authorization Manager for it, whose credentials also answer a challenge of the new host. A Cookie or Authorization header of the Header Manager is only sent to the origin of the sampled URL, so it does not leak to another host. Limit the redirects by httpsampler.max_redirects, as HTTPHC4Impl does. Disable the cookie management of HttpClient. It stored every Set-Cookie in a cookie store of its own and sent those cookies whenever a request had no Cookie header, so cookies were sent without a Cookie Manager, unlike with the other implementations, and a redirect could get cookies the Cookie Manager does not hold. Add tests for a login flow through an identity provider on another origin, for httpsampler.max_redirects and for sending no cookies without a Cookie Manager, over HTTP/1.1 and HTTP/2. --- .../http/sampler/AutoRedirectStrategy.java | 185 +++++++++++++++ .../protocol/http/sampler/HTTPHC5Impl.java | 13 +- .../http/sampler/TestHTTPHC5Redirects.java | 221 ++++++++++++++++++ xdocs/changes.xml | 1 + xdocs/usermanual/component_reference.xml | 7 + 5 files changed, 426 insertions(+), 1 deletion(-) create mode 100644 src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/AutoRedirectStrategy.java create mode 100644 src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Redirects.java diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/AutoRedirectStrategy.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/AutoRedirectStrategy.java new file mode 100644 index 00000000000..90b812e7f25 --- /dev/null +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/AutoRedirectStrategy.java @@ -0,0 +1,185 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you 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.apache.jmeter.protocol.http.sampler; + +import java.net.MalformedURLException; +import java.net.URISyntaxException; +import java.net.URL; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +import org.apache.hc.client5.http.auth.AuthScope; +import org.apache.hc.client5.http.auth.UsernamePasswordCredentials; +import org.apache.hc.client5.http.impl.DefaultRedirectStrategy; +import org.apache.hc.client5.http.impl.auth.BasicCredentialsProvider; +import org.apache.hc.client5.http.protocol.HttpClientContext; +import org.apache.hc.core5.http.Header; +import org.apache.hc.core5.http.HttpHeaders; +import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.HttpRequest; +import org.apache.hc.core5.http.HttpResponse; +import org.apache.hc.core5.http.protocol.HttpContext; +import org.apache.jmeter.protocol.http.control.AuthManager; +import org.apache.jmeter.protocol.http.control.Authorization; +import org.apache.jmeter.protocol.http.control.CookieManager; +import org.apache.jmeter.protocol.http.control.HeaderManager; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Redirect strategy of {@link HTTPHC5Impl} for the redirects HttpClient follows on its own, when + * "Redirect Automatically" is selected. + *

    + * HttpClient sends every hop with the headers of the initial request. Its default strategy therefore + * refuses to follow a redirect to another origin when they include a {@code Cookie} or an + * {@code Authorization} header, so as not to leak them, and returns the redirect as the response. + * JMeter sets both headers itself, from the Cookie and the Authorization Manager, so a redirect to an + * identity provider, for example, would end the sample. Instead, each hop is sent with what the + * managers hold for its own URL, the way a sample of "Follow Redirects" would be: + *

      + *
    • The cookies a redirect sets are stored in the Cookie Manager, against the URL of the redirect.
    • + *
    • The {@code Cookie} header holds the cookies of the Cookie Manager for the URL of the hop.
    • + *
    • The {@code Authorization} header holds the pre-emptive Basic credentials of the Authorization + * Manager for that URL, whose credentials also answer a challenge of the host of the hop.
    • + *
    • A {@code Cookie} or {@code Authorization} header of the Header Manager is only sent to the origin + * of the sampled URL, and only where the managers do not replace it, as for the initial request.
    • + *
    + */ +final class AutoRedirectStrategy extends DefaultRedirectStrategy { + + private static final Logger log = LoggerFactory.getLogger(AutoRedirectStrategy.class); + + static final AutoRedirectStrategy INSTANCE = new AutoRedirectStrategy(); + + private static final String CONTEXT_ATTRIBUTE = "__jmeter.AUTO_REDIRECTS__"; //$NON-NLS-1$ + + private AutoRedirectStrategy() { + } + + /** + * Sets up the hops of the request executed with the given context. + * + * @param context context the request is executed with + * @param url URL of the sampled request + * @param cookieManager holds the cookies of the thread, may be {@code null} + * @param authManager holds the credentials of the thread, may be {@code null} + * @param headerManager holds the headers of the sampler, may be {@code null} + * @param credentials credentials of the request, which HttpClient answers challenges with + */ + static void attach(HttpClientContext context, URL url, CookieManager cookieManager, AuthManager authManager, + HeaderManager headerManager, BasicCredentialsProvider credentials) { + context.setAttribute(CONTEXT_ATTRIBUTE, + new Hops(url, cookieManager, authManager, headerManager, credentials)); + } + + @Override + public boolean isRedirectAllowed(HttpHost currentTarget, HttpHost newTarget, HttpRequest redirect, + HttpContext context) { + if (!(context.getAttribute(CONTEXT_ATTRIBUTE) instanceof Hops hops)) { + return super.isRedirectAllowed(currentTarget, newTarget, redirect, context); + } + URL url; + try { + url = redirect.getUri().toURL(); + } catch (URISyntaxException | MalformedURLException | IllegalArgumentException e) { + log.debug("Not applying the managers to the redirect to {}", newTarget, e); + return super.isRedirectAllowed(currentTarget, newTarget, redirect, context); + } + hops.next(HttpClientContext.cast(context).getResponse(), url, redirect); + return true; + } + + /** State of the redirects HttpClient follows for a single sample. */ + private static final class Hops { + private final URL sampledUrl; + private final CookieManager cookieManager; + private final AuthManager authManager; + private final List headerManagerCookies = new ArrayList<>(); + private final List headerManagerAuthorizations = new ArrayList<>(); + private final BasicCredentialsProvider credentials; + /** URL the response being redirected was received from. */ + private URL currentUrl; + + private Hops(URL url, CookieManager cookieManager, AuthManager authManager, HeaderManager headerManager, + BasicCredentialsProvider credentials) { + this.sampledUrl = url; + this.currentUrl = url; + this.cookieManager = cookieManager; + this.authManager = authManager; + this.credentials = credentials; + HTTPHCAbstractImpl.setConnectionHeaders(headerManager, url, + name -> HttpHeaders.COOKIE.equalsIgnoreCase(name) + || HttpHeaders.AUTHORIZATION.equalsIgnoreCase(name), + (name, value) -> (HttpHeaders.COOKIE.equalsIgnoreCase(name) + ? headerManagerCookies : headerManagerAuthorizations).add(value)); + } + + /** + * Prepares the request of the next hop. + * + * @param redirect the response being redirected + * @param url URL of the next hop + * @param request request of the next hop + */ + void next(HttpResponse redirect, URL url, HttpRequest request) { + if (redirect != null) { + HTTPHCAbstractImpl.saveConnectionCookies(action -> { + for (Header header : redirect.getHeaders()) { + action.accept(header.getName(), header.getValue()); + } + }, currentUrl, cookieManager); + } + boolean sampledOrigin = isSameOrigin(url, sampledUrl); + + request.removeHeaders(HttpHeaders.COOKIE); + String cookies = cookieManager == null ? null : cookieManager.getCookieHeaderForURL(url); + if (cookies != null) { + request.setHeader(HttpHeaders.COOKIE, cookies); + } else if (sampledOrigin) { + headerManagerCookies.forEach(value -> request.addHeader(HttpHeaders.COOKIE, value)); + } + + request.removeHeaders(HttpHeaders.AUTHORIZATION); + Authorization authorization = authManager == null ? null : authManager.getAuthForURL(url); + boolean kerberos = authorization != null + && AuthManager.Mechanism.KERBEROS.equals(authorization.getMechanism()); + if (authorization != null && !kerberos) { + credentials.setCredentials(new AuthScope(url.getHost(), getPort(url)), + new UsernamePasswordCredentials(authorization.getUser(), + authorization.getPass().toCharArray())); + } + if (authorization != null && AuthManager.Mechanism.BASIC.equals(authorization.getMechanism())) { + request.setHeader(HttpHeaders.AUTHORIZATION, authorization.toBasicHeader()); + } else if (sampledOrigin) { + headerManagerAuthorizations.forEach(value -> request.addHeader(HttpHeaders.AUTHORIZATION, value)); + } + currentUrl = url; + } + + private static boolean isSameOrigin(URL url, URL other) { + return url.getProtocol().equalsIgnoreCase(other.getProtocol()) + && url.getHost().toLowerCase(Locale.ROOT).equals(other.getHost().toLowerCase(Locale.ROOT)) + && getPort(url) == getPort(other); + } + + private static int getPort(URL url) { + return url.getPort() == -1 ? url.getDefaultPort() : url.getPort(); + } + } +} diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java index ad6bd5334a6..1adadb637dd 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java @@ -620,7 +620,8 @@ private void setupRequest(URL url, org.apache.hc.client5.http.classic.methods.Ht HttpVersionPolicy httpVersionPolicy = getHttpVersionPolicy(testElement.getHttpVersion(), DEFAULT_HTTP_VERSION, url.getProtocol()); RequestConfig.Builder config = RequestConfig.custom() - .setRedirectsEnabled(getAutoRedirects() && !areFollowingRedirect); + .setRedirectsEnabled(getAutoRedirects() && !areFollowingRedirect) + .setMaxRedirects(HTTPSamplerBase.MAX_REDIRECTS); int responseTimeout = getResponseTimeout(); if (responseTimeout > 0) { config.setResponseTimeout(Timeout.ofMilliseconds(responseTimeout)); @@ -841,6 +842,10 @@ HttpClientContext createHttpClientContext(URL url, HttpClientKey key, } else { context.setCredentialsProvider(credentialsProvider); } + if (request.getConfig() == null || request.getConfig().isRedirectsEnabled()) { + AutoRedirectStrategy.attach(context, url, getCookieManager(), getAuthManager(), getHeaderManager(), + credentialsProvider); + } return context; } @@ -1230,6 +1235,9 @@ private static CloseableHttpClient createClient(HttpClientKey key) { builder.setConnectionManager(new ConnectTimeMeasuringConnectionManager(connectionManagerBuilder.build())); builder.setRoutePlanner(createRoutePlanner(key)); return builder.disableContentCompression() + // Cookies are left to the Cookie Manager, see AutoRedirectStrategy for those of redirects + .disableCookieManagement() + .setRedirectStrategy(AutoRedirectStrategy.INSTANCE) .addExecInterceptorFirst("response-content-encoding", RESPONSE_CONTENT_ENCODING) .build(); } @@ -1243,6 +1251,9 @@ private static CloseableHttpAsyncClient createHttp2Client(HttpClientKey key, JMe .disableContentCompression() .setH2Config(HTTP_2_CONFIG) .setRoutePlanner(createRoutePlanner(key)) + // Cookies are left to the Cookie Manager, see AutoRedirectStrategy for those of redirects + .disableCookieManagement() + .setRedirectStrategy(AutoRedirectStrategy.INSTANCE) .setThreadFactory(threadsSharingContext(jmeterContext)); AsyncResponseTimeout.install(builder); if (HTTP_2_MULTIPLEXING) { diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Redirects.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Redirects.java new file mode 100644 index 00000000000..a0e8a834f7e --- /dev/null +++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Redirects.java @@ -0,0 +1,221 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you 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.apache.jmeter.protocol.http.sampler; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.anyRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.List; + +import org.apache.jmeter.protocol.http.control.AuthManager; +import org.apache.jmeter.protocol.http.control.CookieManager; +import org.apache.jmeter.protocol.http.control.Header; +import org.apache.jmeter.protocol.http.control.HeaderManager; +import org.apache.jmeter.protocol.http.util.HTTPConstants; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.core.WireMockConfiguration; +import com.github.tomakehurst.wiremock.verification.LoggedRequest; + +/** + * Redirects the HttpClient5 sampler implementation follows on its own, when "Redirect Automatically" is + * selected. Each hop is sent with the cookies and credentials the Cookie and the Authorization Manager hold + * for its URL, like the samples of "Follow Redirects" are, so a hop to another origin is followed instead of + * being refused for the headers of the previous origin, and those headers do not leak to it. + */ +class TestHTTPHC5Redirects { + + @AfterEach + void closeClientsOfThisThread() { + HTTPHC5Impl.closeClientsOfCurrentThread(); + } + + /** + * A login flow through an identity provider: the application redirects to the identity provider, which + * redirects back to the application. The two origins are told apart by their host name, so they also + * keep separate cookies. + */ + @ParameterizedTest + @CsvSource({"HTTP/1.1, http", "HTTP/2, https"}) + void followsRedirectsAcrossOriginsWithTheCookiesAndCredentialsOfEachOrigin(String httpVersion, String scheme) + throws Exception { + WireMockServer server = createServer(); + server.start(); + try { + String app = origin(server, scheme, "localhost"); + String idp = origin(server, scheme, "127.0.0.1"); + server.stubFor(get(urlEqualTo("/app/start")).willReturn(aResponse().withStatus(302) + .withHeader(HTTPConstants.HEADER_LOCATION, idp + "/idp/login") + .withHeader(HTTPConstants.HEADER_SET_COOKIE, "state=xyz; Path=/"))); + server.stubFor(get(urlEqualTo("/idp/login")).willReturn(aResponse().withStatus(302) + .withHeader(HTTPConstants.HEADER_LOCATION, app + "/app/callback") + .withHeader(HTTPConstants.HEADER_SET_COOKIE, "idp-session=1; Path=/"))); + server.stubFor(get(urlEqualTo("/app/callback")).willReturn(aResponse().withStatus(200))); + CookieManager cookieManager = new CookieManager(); + cookieManager.testStarted(); + cookieManager.addCookieFromHeader("app-session=1; Path=/", new URL(app + "/")); + cookieManager.addCookieFromHeader("idp-remembered=1; Path=/", new URL(idp + "/")); + AuthManager authManager = new AuthManager(); + authManager.set(-1, app + "/", "appuser", "apppass", "", "", AuthManager.Mechanism.BASIC); + authManager.set(-1, idp + "/", "idpuser", "idppass", "", "", AuthManager.Mechanism.BASIC); + HeaderManager headerManager = new HeaderManager(); + headerManager.add(new Header("X-Test", "kept")); + HTTPSamplerBase sampler = newSampler(httpVersion); + sampler.setCookieManager(cookieManager); + sampler.setAuthManager(authManager); + sampler.setHeaderManager(headerManager); + + HTTPSampleResult result = sampler.sample(new URL(app + "/app/start"), HTTPConstants.GET, false, 1); + + assertEquals("200", result.getResponseCode(), result::getResponseMessage); + assertEquals(app + "/app/callback", result.getUrlAsString()); + assertTrue(result.getResponseHeaders().startsWith(httpVersion), + () -> "expected " + httpVersion + ", got " + result.getResponseHeaders()); + + LoggedRequest login = singleRequest(server, "/idp/login"); + assertEquals("idp-remembered=1", cookies(login), + "the identity provider must get its own cookies only, not those of the application"); + assertEquals(basic("idpuser", "idppass"), login.getHeader(HTTPConstants.HEADER_AUTHORIZATION), + "the identity provider must get its own credentials, not those of the application"); + assertEquals("kept", login.getHeader("X-Test")); + + LoggedRequest callback = singleRequest(server, "/app/callback"); + assertTrue(cookies(callback).contains("app-session=1"), () -> cookies(callback)); + assertTrue(cookies(callback).contains("state=xyz"), + () -> "the cookie the application set on its redirect must be sent back, got " + + cookies(callback)); + assertFalse(cookies(callback).contains("idp-"), () -> cookies(callback)); + assertEquals(basic("appuser", "apppass"), callback.getHeader(HTTPConstants.HEADER_AUTHORIZATION)); + + assertTrue(cookieManager.getCookieHeaderForURL(new URL(idp + "/")).contains("idp-session=1"), + "the cookie the identity provider set on its redirect must be kept in the Cookie Manager"); + } finally { + server.stop(); + } + } + + @ParameterizedTest + @CsvSource({"HTTP/1.1, http", "HTTP/2, https"}) + void followsAsManyRedirectsAsHttpsamplerMaxRedirectsAllows(String httpVersion, String scheme) throws Exception { + int maxRedirects = HTTPSamplerBase.MAX_REDIRECTS; + WireMockServer server = createServer(); + server.start(); + try { + for (int hop = 0; hop <= maxRedirects; hop++) { + server.stubFor(get(urlEqualTo("/hop/" + hop)).willReturn(aResponse().withStatus(302) + .withHeader(HTTPConstants.HEADER_LOCATION, "/hop/" + (hop + 1)))); + } + server.stubFor(get(urlEqualTo("/hop/" + (maxRedirects + 1))).willReturn(aResponse().withStatus(200))); + String base = origin(server, scheme, "localhost"); + + HTTPSampleResult withinLimit = newSampler(httpVersion) + .sample(new URL(base + "/hop/1"), HTTPConstants.GET, false, 1); + HTTPSampleResult beyondLimit = newSampler(httpVersion) + .sample(new URL(base + "/hop/0"), HTTPConstants.GET, false, 1); + + assertEquals("200", withinLimit.getResponseCode(), withinLimit::getResponseMessage); + assertFalse(beyondLimit.isSuccessful(), "one redirect more than httpsampler.max_redirects must fail"); + assertTrue(beyondLimit.getResponseDataAsString() + .contains("Maximum redirects (" + maxRedirects + ") exceeded"), + beyondLimit::getResponseDataAsString); + } finally { + server.stop(); + } + } + + /** + * Cookies are handled by the Cookie Manager, so without one no cookie is sent, neither on a redirect + * nor by a later sample, like with the other sampler implementations. + */ + @ParameterizedTest + @CsvSource({"HTTP/1.1, http", "HTTP/2, https"}) + void sendsNoCookiesWithoutCookieManager(String httpVersion, String scheme) throws Exception { + WireMockServer server = createServer(); + server.start(); + try { + server.stubFor(get(urlEqualTo("/set")).willReturn(aResponse().withStatus(302) + .withHeader(HTTPConstants.HEADER_LOCATION, "/next") + .withHeader(HTTPConstants.HEADER_SET_COOKIE, "unmanaged=1; Path=/"))); + server.stubFor(get(urlEqualTo("/next")).willReturn(aResponse().withStatus(200))); + String base = origin(server, scheme, "localhost"); + HTTPSamplerBase sampler = newSampler(httpVersion); + + assertEquals("200", sampler.sample(new URL(base + "/set"), HTTPConstants.GET, false, 1) + .getResponseCode()); + assertEquals("200", sampler.sample(new URL(base + "/next"), HTTPConstants.GET, false, 1) + .getResponseCode()); + + List requests = server.findAll(anyRequestedFor(urlEqualTo("/next"))); + assertEquals(2, requests.size()); + for (LoggedRequest request : requests) { + assertEquals("", cookies(request), "no cookie must be sent without a Cookie Manager"); + } + } finally { + server.stop(); + } + } + + private static HTTPSamplerBase newSampler(String httpVersion) { + HTTPSamplerBase sampler = HTTPSamplerFactory.newInstance("HttpClient5"); + sampler.setHttpVersion(httpVersion); + sampler.setAutoRedirects(true); + return sampler; + } + + private static WireMockServer createServer() { + return new WireMockServer(WireMockConfiguration.wireMockConfig() + .dynamicPort() + .dynamicHttpsPort() + .http2TlsDisabled(false)); + } + + private static String origin(WireMockServer server, String scheme, String host) { + int port = HTTPConstants.PROTOCOL_HTTPS.equals(scheme) ? server.httpsPort() : server.port(); + return scheme + "://" + host + ":" + port; + } + + private static LoggedRequest singleRequest(WireMockServer server, String path) { + List requests = server.findAll(anyRequestedFor(urlEqualTo(path))); + assertEquals(1, requests.size(), () -> "expected exactly one request for " + path + ", got " + requests); + return requests.get(0); + } + + /** All cookies of the request, which HTTP/2 allows to be split over several {@code Cookie} headers. */ + private static String cookies(LoggedRequest request) { + if (!request.containsHeader(HTTPConstants.HEADER_COOKIE)) { + return ""; + } + return String.join("; ", request.header(HTTPConstants.HEADER_COOKIE).values()); + } + + private static String basic(String user, String password) { + return "Basic " + Base64.getEncoder() + .encodeToString((user + ":" + password).getBytes(StandardCharsets.ISO_8859_1)); + } +} diff --git a/xdocs/changes.xml b/xdocs/changes.xml index 15b7e8e7dd3..f9b761e813a 100644 --- a/xdocs/changes.xml +++ b/xdocs/changes.xml @@ -98,6 +98,7 @@ Summary
  • 6742Add the new HttpClient5 sampler implementation, which is based on Apache HttpComponents HttpClient 5.x and can be selected in the Implementation combo box of the HTTP Request sampler and the HTTP Request Defaults, or with the jmeter.httpsampler property. It supports the features of the existing HttpClient4 implementation (cookie, cache, header and authorization management, proxies, client certificates, WebDav methods, DNS Cache Manager, connection reuse and retries) and adds HTTP/2 support.
  • 6742Add the HTTP Version combo box (property HTTPSampler.httpVersion) to the HTTP Request sampler and the HTTP Request Defaults, so the HTTP version can be selected per sampler instead of only globally with the httpclient.version property. An empty value keeps using that property, which now defaults to HTTP/1.1.
  • 6742Send a Host header of the HTTP Header Manager without its port when that port is the default one of the URL in the HttpClient5 sampler implementation, as the HttpClient4 implementation does.
  • +
  • 6742Follow automatic redirects to another host in the HttpClient5 sampler implementation, like the HttpClient4 implementation does, instead of returning the redirect when the request carries cookies or credentials. Each redirect is sent with the cookies of the HTTP Cookie Manager and the credentials of the HTTP Authorization Manager for its own URL, the cookies the redirects set are kept in the Cookie Manager, and a Cookie or Authorization header of the HTTP Header Manager is only sent to the host of the initial URL. The number of redirects is limited by httpsampler.max_redirects, and cookies are only handled by the Cookie Manager, so none are sent without one.
  • HTTP/2 support for the HttpClient5 sampler implementation:

    diff --git a/xdocs/usermanual/component_reference.xml b/xdocs/usermanual/component_reference.xml index 3f531e7a9fc..9dc4ca2d710 100644 --- a/xdocs/usermanual/component_reference.xml +++ b/xdocs/usermanual/component_reference.xml @@ -414,6 +414,13 @@ rather than www.example.co.uk, it will not get the correct cookies. Likewise, Headers are sent for the initial request, and won't be sent for the redirect. This is generally only a problem for manually created test plans, as a test plan created using a recorder would continue from the redirected URL. +
    +The HttpClient5 implementation sends each redirect with the cookies the + holds for the URL of the redirect, and keeps the cookies the redirects set, +like the samples of "Follow Redirects" are handled. The credentials of the + are likewise the ones for the URL of the redirect, and a +Cookie or Authorization header of the is only sent +to the host of the initial URL.

    Parameter Handling:

    From f60c76e5d8a687d7fb9c02ca543326d346232c5c Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Tue, 6 Oct 2026 10:29:13 +0200 Subject: [PATCH 65/80] Report the received body size of uncompressed HTTP/1.1 responses in HTTPHC5Impl The HTTP/1.1 transport only counted the response body when a Content-Encoding made it decode the body, and otherwise reported the size of what readResponse stored. That is the body truncated to httpsampler.max_bytes_to_store_per_request, or the 32 characters of its digest when "Save response as MD5 hash" is selected, so a large download reported a fraction of its size and Received KB/sec was wrong. Count every response body as it is read off the connection, before it is decoded, like HTTPHC4Impl reports the bytes it received. The HTTP/2 transport already counts the body while it streams it. Add a test that stores only the MD5 digest of plain and gzip encoded bodies, over HTTP/1.1 and HTTP/2, with HttpClient4 as the reference. --- .../protocol/http/sampler/HTTPHC5Impl.java | 36 ++++++----- .../http/sampler/TestHTTPHC5Features.java | 61 +++++++++++++++++++ 2 files changed, 83 insertions(+), 14 deletions(-) diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java index 1adadb637dd..4518de85920 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java @@ -359,12 +359,28 @@ public char[] getPassword() { requestConfig = RequestConfig.DEFAULT; } ClassicHttpResponse response = chain.proceed(request, scope); + countResponseBody(response, context); if (!requestConfig.isContentCompressionEnabled()) { return response; } - return decompressResponse(response, context); + return decompressResponse(response); }; + /** + * Counts the response body as it is read off the connection, before it is decoded, so that the + * sample reports the size that was received, as HTTPHC4Impl does. The size of the body + * {@link HTTPSamplerBase#readResponse} keeps would be the size of the decoded body, of the + * truncated one, or of the MD5 digest of the body. + */ + private static void countResponseBody(ClassicHttpResponse response, HttpContext context) { + HttpEntity entity = response.getEntity(); + if (entity != null) { + CountingEntity countingEntity = new CountingEntity(entity); + response.setEntity(countingEntity); + context.setAttribute(CONTEXT_ATTRIBUTE_RESPONSE_BODY_COUNTER, countingEntity); + } + } + /** * Decodes the response body while keeping the {@code Content-Encoding}, {@code Content-Length} and * {@code Content-MD5} headers, so the sample result still reports what the server actually sent. @@ -372,7 +388,7 @@ public char[] getPassword() { * and decodes the response itself for both the HTTP/1.1 and the HTTP/2 transport. */ @SuppressWarnings("deprecation") // DecompressingEntity is superseded by the @Internal ContentCodecRegistry - private static ClassicHttpResponse decompressResponse(ClassicHttpResponse response, HttpContext context) { + private static ClassicHttpResponse decompressResponse(ClassicHttpResponse response) { HttpEntity entity = response.getEntity(); if (entity == null || entity.getContentLength() == 0) { return response; @@ -395,16 +411,9 @@ private static ClassicHttpResponse decompressResponse(ClassicHttpResponse respon } HeaderElement[] codecs = BasicHeaderValueParser.INSTANCE.parseElements(contentEncoding, new ParserCursor(0, contentEncoding.length())); - CountingEntity countingEntity = new CountingEntity(entity); - boolean decompressing = false; for (HeaderElement codec : codecs) { InputStreamFactory decoderFactory = CONTENT_DECODERS.lookup(codec.getName().toLowerCase(Locale.ROOT)); if (decoderFactory != null) { - if (!decompressing) { - response.setEntity(countingEntity); - context.setAttribute(CONTEXT_ATTRIBUTE_RESPONSE_BODY_COUNTER, countingEntity); - decompressing = true; - } response.setEntity(new DecompressingEntity(response.getEntity(), decoderFactory)); response.removeHeaders(HttpHeaders.CONTENT_LENGTH); response.removeHeaders(HttpHeaders.CONTENT_ENCODING); @@ -1052,10 +1061,9 @@ private void readResponse(ClassicHttpResponse response, HTTPSampleResult result, if (entity != null) { byte[] body = readResponse(result, entity.getContent(), entity.getContentLength()); result.setResponseData(body); - // The HTTP/2 transport counts the body while it streams it, so a truncated response - // still reports the number of bytes the server actually sent. For HTTP/1.1 a compressed - // body is counted before it is decoded, so the reported size is the one that crossed the - // wire, like HTTPHC4Impl and HTTPJavaImpl report it + // Both transports count the body as they receive it, so the size that crossed the wire is + // reported, before the body is decoded, truncated or replaced by its MD5 digest, like + // HTTPHC4Impl reports it Long bodySize = (Long) context.getAttribute(CONTEXT_ATTRIBUTE_RESPONSE_BODY_SIZE); CountingEntity bodyCounter = (CountingEntity) context.getAttribute(CONTEXT_ATTRIBUTE_RESPONSE_BODY_COUNTER); @@ -1530,7 +1538,7 @@ protected ClassicHttpResponse buildResult() { } // The body may have been truncated, so report what the server actually sent context.setAttribute(CONTEXT_ATTRIBUTE_RESPONSE_BODY_SIZE, totalBodyBytes); - return decompressResponse(response, context); + return decompressResponse(response); } @Override diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java index 51763b25d3a..418d9158958 100644 --- a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java +++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java @@ -31,6 +31,7 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.io.ByteArrayOutputStream; import java.io.IOException; import java.net.URL; import java.nio.charset.StandardCharsets; @@ -46,6 +47,7 @@ import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; +import java.util.zip.GZIPOutputStream; import org.apache.hc.client5.http.HttpRoute; import org.apache.hc.client5.http.auth.AuthSchemeFactory; @@ -77,8 +79,11 @@ import org.apache.jmeter.threads.JMeterContextService; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.client.ResponseDefinitionBuilder; import com.github.tomakehurst.wiremock.client.WireMock; import com.github.tomakehurst.wiremock.core.WireMockConfiguration; import com.github.tomakehurst.wiremock.http.HttpHeader; @@ -448,6 +453,62 @@ void readsHttp2ResponseThatIsLargerThanTheInMemoryBuffer() throws Exception { } } + /** + * Only the MD5 digest of the body is stored, which must not make the sample report the size of the + * digest instead of the bytes that were received, which is what Received KB/sec is computed from. A + * compressed body is reported with its compressed size. HttpClient4 is the reference. + */ + @ParameterizedTest + @CsvSource({ + "HttpClient4, HTTP/1.1, identity", + "HttpClient4, HTTP/1.1, gzip", + "HttpClient5, HTTP/1.1, identity", + "HttpClient5, HTTP/1.1, gzip", + "HttpClient5, HTTP/2, identity", + "HttpClient5, HTTP/2, gzip"}) + void reportsTheReceivedBodySizeWhenOnlyTheMd5IsStored(String implementation, String httpVersion, + String contentEncoding) throws Exception { + WireMockServer server = new WireMockServer(WireMockConfiguration.wireMockConfig() + .dynamicHttpsPort() + .http2TlsDisabled(false) + .gzipDisabled(true)); + try { + server.start(); + byte[] body = new byte[200_000]; + for (int i = 0; i < body.length; i++) { + body[i] = (byte) (i % 251); + } + byte[] sent = "gzip".equals(contentEncoding) ? gzip(body) : body; + ResponseDefinitionBuilder response = aResponse().withStatus(200) + .withHeader(HttpHeaders.CONTENT_LENGTH, Integer.toString(sent.length)) + .withBody(sent); + if ("gzip".equals(contentEncoding)) { + response.withHeader(HttpHeaders.CONTENT_ENCODING, contentEncoding); + } + server.stubFor(get(urlEqualTo("/md5")).willReturn(response)); + HTTPSamplerBase sampler = HTTPSamplerFactory.newInstance(implementation); + sampler.setHttpVersion(httpVersion); + sampler.setMD5(true); + + HTTPSampleResult result = sampler.sample( + new URL("https://localhost:" + server.httpsPort() + "/md5"), HTTPConstants.GET, false, 1); + + assertEquals("200", result.getResponseCode(), result::getResponseMessage); + assertEquals(32, result.getResponseData().length, "only the MD5 digest should be stored"); + assertEquals(sent.length, result.getBodySizeAsLong(), "the received body size should be reported"); + } finally { + server.stop(); + } + } + + private static byte[] gzip(byte[] data) throws IOException { + ByteArrayOutputStream compressed = new ByteArrayOutputStream(); + try (GZIPOutputStream out = new GZIPOutputStream(compressed)) { + out.write(data); + } + return compressed.toByteArray(); + } + @Test void interruptsPendingHttp2Request() throws Exception { WireMockServer server = new WireMockServer(WireMockConfiguration.wireMockConfig() From b1aa5ebe54bca192d4e6b6f1cf90961381225000 Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Tue, 6 Oct 2026 10:51:06 +0200 Subject: [PATCH 66/80] Serve the HTTP/2 requests of a JMeter thread with a single I/O thread HTTPHC5Impl built its HTTP/2 clients without an IOReactorConfig, so each client started an I/O thread per processor, plus the thread HttpClient starts the I/O reactor from. The clients were kept per JMeter thread and per target host, so 1,000 threads sampling 3 hosts on a 16-core injector ran some 51,000 threads, rebuilt every iteration when the thread does not keep the same user. Start a single I/O thread per client, as a JMeter thread only has a few requests in flight, its embedded resources included. Leave the scheme and authority out of the key of the HTTP/2 clients, as their pool keeps the connections per route and nothing else about them depends on the host, so a JMeter thread usually has a single HTTP/2 client whatever the hosts it samples. Name the threads of a client after the JMeter thread it belongs to, so a thread dump tells which thread they work for. Add a test that samples two hosts over HTTP/2 from one JMeter thread and checks the threads of its client, and that they end when the client is closed. --- .../protocol/http/sampler/HTTPHC5Impl.java | 24 +++++++-- .../http/sampler/TestHTTPHC5Features.java | 53 +++++++++++++++++++ 2 files changed, 74 insertions(+), 3 deletions(-) diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java index 4518de85920..9fb8aaa3c96 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java @@ -141,6 +141,7 @@ import org.apache.hc.core5.net.URIAuthority; import org.apache.hc.core5.pool.PoolConcurrencyPolicy; import org.apache.hc.core5.reactor.ConnectionInitiator; +import org.apache.hc.core5.reactor.IOReactorConfig; import org.apache.hc.core5.util.TimeValue; import org.apache.hc.core5.util.Timeout; import org.apache.hc.core5.util.VersionInfo; @@ -158,6 +159,7 @@ import org.apache.jmeter.testelement.property.JMeterProperty; import org.apache.jmeter.threads.JMeterContext; import org.apache.jmeter.threads.JMeterContextService; +import org.apache.jmeter.threads.JMeterThread; import org.apache.jmeter.threads.JMeterVariables; import org.apache.jmeter.util.JMeterUtils; import org.apache.jmeter.util.JsseSSLManager; @@ -1214,7 +1216,7 @@ private static CloseableHttpAsyncClient getHttp2Client(HttpClientKey key) { JMeterContext jmeterContext = JMeterContextService.getContext(); Map clients = HTTP_2_CLIENTS.computeIfAbsent(jmeterContext, context -> new ConcurrentHashMap<>()); - return clients.computeIfAbsent(key, k -> createHttp2Client(k, jmeterContext)); + return clients.computeIfAbsent(key.forAnyOrigin(), k -> createHttp2Client(k, jmeterContext)); } private static boolean isDefaultUserAgentDisabled() { @@ -1262,6 +1264,9 @@ private static CloseableHttpAsyncClient createHttp2Client(HttpClientKey key, JMe // Cookies are left to the Cookie Manager, see AutoRedirectStrategy for those of redirects .disableCookieManagement() .setRedirectStrategy(AutoRedirectStrategy.INSTANCE) + // A JMeter thread only has a few requests in flight, which a single I/O thread serves + // easily, whereas HttpClient would start one per processor for every client + .setIOReactorConfig(IOReactorConfig.custom().setIoThreadCount(1).build()) .setThreadFactory(threadsSharingContext(jmeterContext)); AsyncResponseTimeout.install(builder); if (HTTP_2_MULTIPLEXING) { @@ -1307,10 +1312,13 @@ private static CloseableHttpAsyncClient createHttp2Client(HttpClientKey key, JMe * client belongs to, like the threads which download embedded resources in parallel. They run * the TLS handshakes, in which the key manager of the JMeter key store picks the client * certificate, by the alias variable of the Keystore Configuration among others, so they have to - * see the variables of that thread rather than those of a context of their own. + * see the variables of that thread rather than those of a context of their own. They are named + * after that thread, so a thread dump tells which JMeter thread they work for. */ private static ThreadFactory threadsSharingContext(JMeterContext jmeterContext) { - ThreadFactory threadFactory = new DefaultThreadFactory("httpclient-dispatch", true); + JMeterThread jmeterThread = jmeterContext.getThread(); + String owner = jmeterThread != null ? jmeterThread.getThreadName() : Thread.currentThread().getName(); + ThreadFactory threadFactory = new DefaultThreadFactory(owner + " httpclient5", true); return runnable -> threadFactory.newThread(() -> { JMeterContextService.replaceContext(jmeterContext); runnable.run(); @@ -1889,6 +1897,16 @@ private HttpClientKey(String protocol, String authority, boolean hasProxy, Strin this.httpVersionPolicy = httpVersionPolicy; } + /** + * Key of the HTTP/2 client, which serves every origin, as its pool keeps the connections per + * route and nothing else about it depends on the origin. So a JMeter thread usually has a + * single HTTP/2 client, and with it a single I/O reactor, whatever the hosts it samples. + */ + HttpClientKey forAnyOrigin() { + return new HttpClientKey(null, null, hasProxy, proxyScheme, proxyHost, proxyPort, proxyUser, proxyPass, + connectTimeout, dnsCacheManager, localAddress, httpVersionPolicy); + } + @Override public boolean equals(Object object) { if (this == object) { diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java index 418d9158958..319a57a8c62 100644 --- a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java +++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java @@ -40,6 +40,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Properties; +import java.util.UUID; import java.util.concurrent.Callable; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.CountDownLatch; @@ -194,6 +195,58 @@ void multiplexesConcurrentRequestsOverASingleHttp2Connection() throws Exception } } + /** + * A JMeter thread only has a few requests in flight at a time, its embedded resources included, which + * a single I/O thread serves easily. HttpClient would otherwise start an I/O thread per processor for + * every client, and the clients of a JMeter thread are kept per host, which adds up to tens of + * thousands of threads in a large test. The threads are named after the JMeter thread they work for. + */ + @Test + @org.junit.jupiter.api.Timeout(60) + void servesAllHostsOfAJMeterThreadWithASingleIoThread() throws Exception { + WireMockServer server = new WireMockServer(WireMockConfiguration.wireMockConfig() + .dynamicHttpsPort() + .http2TlsDisabled(false)); + server.start(); + String owner = "io-thread-test-" + UUID.randomUUID(); + ExecutorService jmeterThread = Executors.newSingleThreadExecutor(runnable -> new Thread(runnable, owner)); + try { + server.stubFor(get(urlEqualTo("/io")).willReturn(aResponse().withStatus(200))); + List threadsWhileOpen = jmeterThread.submit(() -> { + for (String host : List.of("localhost", "127.0.0.1")) { + HTTPSamplerBase sampler = newSampler(); + sampler.setHttpVersion("HTTP/2"); + HTTPSampleResult result = sampler.sample( + new URL("https://" + host + ":" + server.httpsPort() + "/io"), HTTPConstants.GET, false, 1); + assertEquals("200", result.getResponseCode(), result::getResponseMessage); + } + List threads = httpClientThreadsOf(owner); + HTTPHC5Impl.closeClientsOfCurrentThread(); + return threads; + }).get(30, TimeUnit.SECONDS); + + // the I/O thread, and the one HttpClient starts the I/O reactor from, which idles until it is closed + assertEquals(2, threadsWhileOpen.size(), + () -> "expected one client with a single I/O thread for both hosts, got " + threadsWhileOpen); + for (int attempt = 0; attempt < 100 && !httpClientThreadsOf(owner).isEmpty(); attempt++) { + Thread.sleep(100); + } + assertEquals(List.of(), httpClientThreadsOf(owner), "the threads should end when the client is closed"); + } finally { + jmeterThread.shutdownNow(); + server.stop(); + } + } + + private static List httpClientThreadsOf(String owner) { + return Thread.getAllStackTraces().keySet().stream() + .filter(Thread::isAlive) + .map(Thread::getName) + .filter(name -> name.startsWith(owner + " httpclient5")) + .sorted() + .toList(); + } + /** * The threads which download embedded resources in parallel are pooled and shared by all JMeter * threads, so they must use the clients of the JMeter thread they are working for. Otherwise From 4335c8f98e9c1eb2710c5a0f127c8fe5f30541ca Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Tue, 6 Oct 2026 12:54:41 +0200 Subject: [PATCH 67/80] Support NTLM and the domain and realm of the Authorization Manager in HTTPHC5Impl HTTPHC5Impl offered neither NTLM nor credentials for it: target and proxy credentials were always UsernamePasswordCredentials scoped to host and port, so the Domain and Realm of the Authorization Manager and the http.proxyDomain property were ignored. An IIS site or a corporate proxy that only offers NTLM answered 401 or 407, while HTTPHC4Impl authenticates. Pre-emptive Basic credentials were also set as an Authorization header of the request, which keeps HttpClient from answering any challenge, so an entry with the BASIC mechanism, the default of new entries, could not fall back to NTLM or Digest either. Set up authentication like HTTPHC4Impl does, in the new HC5Authentication: register NTLM, which HttpClient 5 deprecates but still ships, and prefer it ahead of Digest and Basic. Add NTCredentials with the domain of the entry, or of http.proxyDomain for the proxy, and scope the credentials of an entry with a realm to that realm. Have HttpClient send the pre-emptive Basic credentials itself, so a challenge for another scheme can still be answered, on redirects as well. They are still reported with the request and counted in the sent bytes. Document how NTLM is answered, and that it needs HTTP/1.1. Add tests for an NTLM server and an NTLM proxy, over HTTP/1.1 and HTTP/2, for a Digest challenge to an entry with the BASIC mechanism, for the realm of an entry and for the reporting of the pre-emptive credentials. --- .../http/sampler/AutoRedirectStrategy.java | 23 +- .../http/sampler/HC5Authentication.java | 169 ++++++++++ .../protocol/http/sampler/HTTPHC5Impl.java | 69 +++-- .../sampler/TestHTTPHC5Authentication.java | 288 ++++++++++++++++++ .../http/sampler/TestHTTPHC5Features.java | 2 +- xdocs/usermanual/component_reference.xml | 9 + 6 files changed, 521 insertions(+), 39 deletions(-) create mode 100644 src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HC5Authentication.java create mode 100644 src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Authentication.java diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/AutoRedirectStrategy.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/AutoRedirectStrategy.java index 90b812e7f25..ab248a96002 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/AutoRedirectStrategy.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/AutoRedirectStrategy.java @@ -24,8 +24,6 @@ import java.util.List; import java.util.Locale; -import org.apache.hc.client5.http.auth.AuthScope; -import org.apache.hc.client5.http.auth.UsernamePasswordCredentials; import org.apache.hc.client5.http.impl.DefaultRedirectStrategy; import org.apache.hc.client5.http.impl.auth.BasicCredentialsProvider; import org.apache.hc.client5.http.protocol.HttpClientContext; @@ -55,8 +53,8 @@ *

      *
    • The cookies a redirect sets are stored in the Cookie Manager, against the URL of the redirect.
    • *
    • The {@code Cookie} header holds the cookies of the Cookie Manager for the URL of the hop.
    • - *
    • The {@code Authorization} header holds the pre-emptive Basic credentials of the Authorization - * Manager for that URL, whose credentials also answer a challenge of the host of the hop.
    • + *
    • The credentials of the Authorization Manager for that URL answer a challenge of the host of the hop, + * and those of an entry with the BASIC mechanism are sent pre-emptively, see {@link HC5Authentication}.
    • *
    • A {@code Cookie} or {@code Authorization} header of the Header Manager is only sent to the origin * of the sampled URL, and only where the managers do not replace it, as for the initial request.
    • *
    @@ -101,7 +99,7 @@ public boolean isRedirectAllowed(HttpHost currentTarget, HttpHost newTarget, Htt log.debug("Not applying the managers to the redirect to {}", newTarget, e); return super.isRedirectAllowed(currentTarget, newTarget, redirect, context); } - hops.next(HttpClientContext.cast(context).getResponse(), url, redirect); + hops.next(HttpClientContext.cast(context), url, redirect); return true; } @@ -133,11 +131,12 @@ private Hops(URL url, CookieManager cookieManager, AuthManager authManager, Head /** * Prepares the request of the next hop. * - * @param redirect the response being redirected - * @param url URL of the next hop - * @param request request of the next hop + * @param context context of the sample, which holds the response being redirected + * @param url URL of the next hop + * @param request request of the next hop */ - void next(HttpResponse redirect, URL url, HttpRequest request) { + void next(HttpClientContext context, URL url, HttpRequest request) { + HttpResponse redirect = context.getResponse(); if (redirect != null) { HTTPHCAbstractImpl.saveConnectionCookies(action -> { for (Header header : redirect.getHeaders()) { @@ -160,12 +159,10 @@ void next(HttpResponse redirect, URL url, HttpRequest request) { boolean kerberos = authorization != null && AuthManager.Mechanism.KERBEROS.equals(authorization.getMechanism()); if (authorization != null && !kerberos) { - credentials.setCredentials(new AuthScope(url.getHost(), getPort(url)), - new UsernamePasswordCredentials(authorization.getUser(), - authorization.getPass().toCharArray())); + HC5Authentication.setTargetCredentials(credentials, url, authorization); } if (authorization != null && AuthManager.Mechanism.BASIC.equals(authorization.getMechanism())) { - request.setHeader(HttpHeaders.AUTHORIZATION, authorization.toBasicHeader()); + HC5Authentication.preemptBasic(context, url, request, authorization); } else if (sampledOrigin) { headerManagerAuthorizations.forEach(value -> request.addHeader(HttpHeaders.AUTHORIZATION, value)); } diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HC5Authentication.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HC5Authentication.java new file mode 100644 index 00000000000..4efbe8b051f --- /dev/null +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HC5Authentication.java @@ -0,0 +1,169 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you 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.apache.jmeter.protocol.http.sampler; + +import java.net.URL; +import java.util.List; + +import org.apache.hc.client5.http.AuthenticationStrategy; +import org.apache.hc.client5.http.auth.AuthExchange; +import org.apache.hc.client5.http.auth.AuthSchemeFactory; +import org.apache.hc.client5.http.auth.AuthScope; +import org.apache.hc.client5.http.auth.AuthenticationException; +import org.apache.hc.client5.http.auth.NTCredentials; +import org.apache.hc.client5.http.auth.StandardAuthScheme; +import org.apache.hc.client5.http.auth.UsernamePasswordCredentials; +import org.apache.hc.client5.http.impl.DefaultAuthenticationStrategy; +import org.apache.hc.client5.http.impl.auth.BasicCredentialsProvider; +import org.apache.hc.client5.http.impl.auth.BasicScheme; +import org.apache.hc.client5.http.impl.auth.BasicSchemeFactory; +import org.apache.hc.client5.http.impl.auth.BearerSchemeFactory; +import org.apache.hc.client5.http.impl.auth.DigestSchemeFactory; +import org.apache.hc.client5.http.impl.auth.NTLMSchemeFactory; +import org.apache.hc.client5.http.impl.auth.ScramSchemeFactory; +import org.apache.hc.client5.http.protocol.HttpClientContext; +import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.HttpRequest; +import org.apache.hc.core5.http.config.RegistryBuilder; +import org.apache.jmeter.protocol.http.control.Authorization; +import org.apache.jorphan.util.StringUtilities; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Authentication of {@link HTTPHC5Impl} with the credentials of the HTTP Authorization Manager and of the + * proxy, set up the way {@link HTTPHC4Impl} does it: + *
      + *
    • NTLM is offered, with the domain of the Authorization Manager entry, or of the + * {@code http.proxyDomain} property for the proxy. HttpClient 5 deprecates it, but still ships it.
    • + *
    • The credentials of an entry with a realm only answer a challenge of that realm.
    • + *
    • The credentials of an entry with the BASIC mechanism are sent with the first request, by HttpClient + * rather than as a header of the request, so that a challenge for another scheme, like NTLM or Digest, can + * still be answered.
    • + *
    + */ +@SuppressWarnings("deprecation") // NTLM is deprecated in HttpClient 5, but HttpClient 4 supports it +final class HC5Authentication { + + private static final Logger log = LoggerFactory.getLogger(HC5Authentication.class); + + /** + * Order in which the schemes a server or a proxy offers are tried: the one of HttpClient 5, with NTLM + * ahead of Digest and Basic, like HttpClient 4 prefers it. + */ + static final List SCHEME_PRIORITY = List.of(StandardAuthScheme.BEARER, StandardAuthScheme.NTLM, + StandardAuthScheme.DIGEST, StandardAuthScheme.BASIC); + + /** Selects the scheme to answer a challenge with, in the order of {@link #SCHEME_PRIORITY}. */ + static final AuthenticationStrategy STRATEGY = new DefaultAuthenticationStrategy() { + @Override + protected List getSchemePriority() { + return SCHEME_PRIORITY; + } + }; + + private HC5Authentication() { + } + + /** + * Auth schemes HttpClient 5 registers by default, and NTLM. + * + * @return builder of the registry, so further schemes can be added + */ + static RegistryBuilder authSchemes() { + return RegistryBuilder.create() + .register(StandardAuthScheme.BASIC, BasicSchemeFactory.INSTANCE) + .register(StandardAuthScheme.DIGEST, DigestSchemeFactory.INSTANCE) + .register(StandardAuthScheme.BEARER, BearerSchemeFactory.INSTANCE) + .register(StandardAuthScheme.SCRAM_SHA_256, ScramSchemeFactory.INSTANCE) + .register(StandardAuthScheme.NTLM, NTLMSchemeFactory.INSTANCE); + } + + /** + * Sets up the credentials of an Authorization Manager entry, which is not a Kerberos one, to answer the + * challenges of the host of the URL. + * + * @param credentials credentials of the request + * @param url URL the entry applies to + * @param authorization the entry + */ + static void setTargetCredentials(BasicCredentialsProvider credentials, URL url, Authorization authorization) { + String realm = StringUtilities.isEmpty(authorization.getRealm()) ? null : authorization.getRealm(); + setCredentials(credentials, url.getHost(), getPort(url), realm, + authorization.getUser(), authorization.getPass(), authorization.getDomain()); + } + + /** + * Sets up the credentials of the proxy, with the domain of the {@code http.proxyDomain} property for NTLM. + * + * @param credentials credentials of the request + * @param host host of the proxy + * @param port port of the proxy + * @param user user to authenticate as + * @param password password of the user + */ + static void setProxyCredentials(BasicCredentialsProvider credentials, String host, int port, String user, + String password) { + setCredentials(credentials, host, port, null, user, password, HTTPHCAbstractImpl.PROXY_DOMAIN); + } + + /** + * NTLM only accepts NT credentials, which the other schemes do not accept, so both are set up, and the + * NT credentials are scoped to NTLM, which makes them the better match for an NTLM challenge. + */ + private static void setCredentials(BasicCredentialsProvider credentials, String host, int port, String realm, + String user, String password, String domain) { + credentials.setCredentials(new AuthScope(null, host, port, realm, null), + new UsernamePasswordCredentials(user, password.toCharArray())); + credentials.setCredentials(new AuthScope(null, host, port, realm, StandardAuthScheme.NTLM), + new NTCredentials(user, password.toCharArray(), HTTPHCAbstractImpl.LOCALHOST, domain)); + } + + /** + * Has HttpClient send the credentials of an entry with the BASIC mechanism with the first request to the + * host of the URL, unless it already authenticated with that host. + * + * @param context context of the request + * @param url URL of the request + * @param request the request + * @param authorization the entry + * @return value of the {@code Authorization} header HttpClient sends, {@code null} if it sends none + */ + static String preemptBasic(HttpClientContext context, URL url, HttpRequest request, Authorization authorization) { + HttpHost target = new HttpHost(url.getProtocol(), url.getHost(), getPort(url)); + if (context.getAuthExchange(target).getState() != AuthExchange.State.UNCHALLENGED) { + return null; + } + BasicScheme basic = new BasicScheme(); + basic.initPreemptive(new UsernamePasswordCredentials(authorization.getUser(), + authorization.getPass().toCharArray())); + String header; + try { + header = basic.generateAuthResponse(target, request, context); + } catch (AuthenticationException e) { + log.warn("Cannot send the credentials of {} as Basic: {}", authorization.getUser(), e.getMessage()); + return null; + } + context.resetAuthExchange(target, basic); + return header; + } + + private static int getPort(URL url) { + return url.getPort() == -1 ? url.getDefaultPort() : url.getPort(); + } +} diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java index 9fb8aaa3c96..bc65a205c67 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java @@ -51,6 +51,7 @@ import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; import java.util.concurrent.ThreadFactory; +import java.util.stream.Stream; import javax.net.ssl.SSLContext; import javax.security.auth.Subject; @@ -60,14 +61,12 @@ import org.apache.hc.client5.http.RouteInfo; import org.apache.hc.client5.http.SystemDefaultDnsResolver; import org.apache.hc.client5.http.async.methods.AbstractBinResponseConsumer; -import org.apache.hc.client5.http.auth.AuthSchemeFactory; import org.apache.hc.client5.http.auth.AuthScope; import org.apache.hc.client5.http.auth.Credentials; import org.apache.hc.client5.http.auth.CredentialsProvider; import org.apache.hc.client5.http.auth.KerberosConfig; import org.apache.hc.client5.http.auth.KerberosCredentials; import org.apache.hc.client5.http.auth.StandardAuthScheme; -import org.apache.hc.client5.http.auth.UsernamePasswordCredentials; import org.apache.hc.client5.http.classic.ExecChainHandler; import org.apache.hc.client5.http.config.ConnectionConfig; import org.apache.hc.client5.http.config.RequestConfig; @@ -83,9 +82,6 @@ import org.apache.hc.client5.http.impl.async.HttpAsyncClientBuilder; import org.apache.hc.client5.http.impl.async.HttpAsyncClients; import org.apache.hc.client5.http.impl.auth.BasicCredentialsProvider; -import org.apache.hc.client5.http.impl.auth.BasicSchemeFactory; -import org.apache.hc.client5.http.impl.auth.BearerSchemeFactory; -import org.apache.hc.client5.http.impl.auth.DigestSchemeFactory; import org.apache.hc.client5.http.impl.auth.KerberosSchemeFactory; import org.apache.hc.client5.http.impl.auth.SPNegoSchemeFactory; import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; @@ -188,6 +184,7 @@ public class HTTPHC5Impl extends HTTPHCAbstractImpl { private static final String CONTEXT_ATTRIBUTE_RESPONSE_LATENCY = "__jmeter.S_R_LATENCY__"; //$NON-NLS-1$ private static final String CONTEXT_ATTRIBUTE_RESPONSE_BODY_SIZE = "__jmeter.S_R_BODY_SIZE__"; //$NON-NLS-1$ private static final String CONTEXT_ATTRIBUTE_RESPONSE_BODY_COUNTER = "__jmeter.S_R_BODY_COUNTER__"; //$NON-NLS-1$ + private static final String CONTEXT_ATTRIBUTE_PREEMPTIVE_AUTHORIZATION = "__jmeter.PREEMPTIVE_AUTH__"; //$NON-NLS-1$ /** * Clients of every JMeter thread, looked up by the {@link JMeterContext} of that thread instead @@ -299,12 +296,13 @@ public class HTTPHC5Impl extends HTTPHCAbstractImpl { /** * Auth schemes offered to a proxy that can be authenticated with Kerberos. Unlike the target * schemes these keep the password based schemes as a fallback, so that a proxy which offers - * {@code Negotiate} next to {@code Basic} or {@code Digest} can still be used. + * {@code Negotiate} next to {@code NTLM}, {@code Basic} or {@code Digest} can still be used. */ @SuppressWarnings("deprecation") // The GSS based auth schemes of HttpClient 5 have no replacement yet - private static final List KERBEROS_PROXY_PREFERRED_AUTH_SCHEMES = - List.of(StandardAuthScheme.SPNEGO, StandardAuthScheme.KERBEROS, StandardAuthScheme.BEARER, - StandardAuthScheme.DIGEST, StandardAuthScheme.BASIC); + private static final List KERBEROS_PROXY_PREFERRED_AUTH_SCHEMES = Stream.concat( + Stream.of(StandardAuthScheme.SPNEGO, StandardAuthScheme.KERBEROS), + HC5Authentication.SCHEME_PRIORITY.stream()) + .toList(); private static final Oid SPNEGO_OID = createOid("1.3.6.1.5.5.2"); @@ -526,6 +524,7 @@ protected HTTPHC5Impl(HTTPSamplerBase testElement) { protected HTTPSampleResult sample(URL url, String method, boolean areFollowingRedirect, int frameDepth) { HTTPSampleResult result = createSampleResult(url, method); org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request = null; + HttpClientContext context = null; ClassicHttpResponse response = null; try { resetStateIfNeeded(); @@ -543,7 +542,7 @@ protected HTTPSampleResult sample(URL url, String method, boolean areFollowingRe interruptRequested = false; currentRequest = request; HttpClientKey clientKey = createHttpClientKey(url); - HttpClientContext context = createHttpClientContext(url, clientKey, request); + context = createHttpClientContext(url, clientKey, request); context.setAttribute(CONTEXT_ATTRIBUTE_SAMPLER_RESULT, result); response = executeRequest(url, clientKey, request, context); readResponse(response, result, context); @@ -555,6 +554,7 @@ protected HTTPSampleResult sample(URL url, String method, boolean areFollowingRe currentRequest = null; updateResult(response, request, result); + reportPreemptiveAuthorization(context, result); updateUrlAfterAutoRedirects(url, context, result); if (cacheManager != null) { cacheManager.saveDetails(cacheResponseHeaders(response), result); @@ -571,6 +571,7 @@ protected HTTPSampleResult sample(URL url, String method, boolean areFollowingRe if (request != null) { result.setRequestHeaders(getRequestHeaders(request)); result.setSentBytes(calculateSentBytes(request)); + reportPreemptiveAuthorization(context, result); } return errorResult(e, result); } finally { @@ -844,7 +845,7 @@ HttpClientContext createHttpClientContext(URL url, HttpClientKey key, HttpClientContext context = HttpClientContext.create(); BasicCredentialsProvider credentialsProvider = new BasicCredentialsProvider(); Authorization authorization = getAuthorizationForUrl(url); - configureTargetCredentials(url, request, credentialsProvider, authorization); + configureTargetCredentials(url, request, context, credentialsProvider, authorization); configureProxyCredentials(key, credentialsProvider); boolean kerberosTarget = isKerberos(authorization); boolean kerberosProxy = isKerberosProxy(key); @@ -897,17 +898,36 @@ private static boolean isKerberos(Authorization authorization) { } private static void configureTargetCredentials(URL url, - org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request, + org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request, HttpClientContext context, BasicCredentialsProvider credentialsProvider, Authorization authorization) { if (authorization == null || isKerberos(authorization)) { // The credentials of a Kerberos authorization are used to log in to the KDC, they must // not be sent to the server return; } - credentialsProvider.setCredentials(new AuthScope(url.getHost(), getPort(url)), - new UsernamePasswordCredentials(authorization.getUser(), authorization.getPass().toCharArray())); + HC5Authentication.setTargetCredentials(credentialsProvider, url, authorization); if (AuthManager.Mechanism.BASIC.equals(authorization.getMechanism())) { - request.setHeader(HttpHeaders.AUTHORIZATION, authorization.toBasicHeader()); + // The Authorization Manager takes precedence over an Authorization header of the Header + // Manager, which would also keep HttpClient from sending the credentials + request.removeHeaders(HttpHeaders.AUTHORIZATION); + String preemptiveAuthorization = HC5Authentication.preemptBasic(context, url, request, authorization); + if (preemptiveAuthorization != null) { + context.setAttribute(CONTEXT_ATTRIBUTE_PREEMPTIVE_AUTHORIZATION, preemptiveAuthorization); + } + } + } + + /** + * Reports the {@code Authorization} header HttpClient sent pre-emptively with the request, as it is + * not part of the request JMeter built. It is not added to that request, as HttpClient reads it + * again whenever it answers a challenge. + */ + private static void reportPreemptiveAuthorization(HttpContext context, HTTPSampleResult result) { + String header = context == null ? null + : (String) context.getAttribute(CONTEXT_ATTRIBUTE_PREEMPTIVE_AUTHORIZATION); + if (header != null) { + result.setRequestHeaders(result.getRequestHeaders() + HttpHeaders.AUTHORIZATION + ": " + header + "\n"); + result.setSentBytes(result.getSentBytes() + HTTPMessageSizes.headerLength(HttpHeaders.AUTHORIZATION, header)); } } @@ -930,10 +950,7 @@ private void configureKerberos(URL url, HttpClientKey key, ? KerberosConfig.Option.ENABLE : KerberosConfig.Option.DEFAULT) .build(); DnsResolver dnsResolver = SystemDefaultDnsResolver.INSTANCE; - context.setAuthSchemeRegistry(RegistryBuilder.create() - .register(StandardAuthScheme.BASIC, BasicSchemeFactory.INSTANCE) - .register(StandardAuthScheme.DIGEST, DigestSchemeFactory.INSTANCE) - .register(StandardAuthScheme.BEARER, BearerSchemeFactory.INSTANCE) + context.setAuthSchemeRegistry(HC5Authentication.authSchemes() .register(StandardAuthScheme.SPNEGO, new SPNegoSchemeFactory(kerberosConfig, dnsResolver)) .register(StandardAuthScheme.KERBEROS, new KerberosSchemeFactory(kerberosConfig, dnsResolver)) .build()); @@ -1043,15 +1060,11 @@ private static Credentials createKerberosCredentials(Subject subject, Oid oid) { private static void configureProxyCredentials(HttpClientKey key, BasicCredentialsProvider credentialsProvider) { if (key.hasProxy && StringUtilities.isNotEmpty(key.proxyUser)) { - credentialsProvider.setCredentials(new AuthScope(key.proxyHost, key.proxyPort), - new UsernamePasswordCredentials(key.proxyUser, key.proxyPass.toCharArray())); + HC5Authentication.setProxyCredentials(credentialsProvider, key.proxyHost, key.proxyPort, key.proxyUser, + key.proxyPass); } } - private static int getPort(URL url) { - return url.getPort() == -1 ? url.getDefaultPort() : url.getPort(); - } - private void readResponse(ClassicHttpResponse response, HTTPSampleResult result, HttpContext context) throws IOException { Header contentType = response.getFirstHeader(HTTPConstants.HEADER_CONTENT_TYPE); @@ -1248,6 +1261,9 @@ private static CloseableHttpClient createClient(HttpClientKey key) { // Cookies are left to the Cookie Manager, see AutoRedirectStrategy for those of redirects .disableCookieManagement() .setRedirectStrategy(AutoRedirectStrategy.INSTANCE) + .setDefaultAuthSchemeRegistry(HC5Authentication.authSchemes().build()) + .setTargetAuthenticationStrategy(HC5Authentication.STRATEGY) + .setProxyAuthenticationStrategy(HC5Authentication.STRATEGY) .addExecInterceptorFirst("response-content-encoding", RESPONSE_CONTENT_ENCODING) .build(); } @@ -1264,6 +1280,9 @@ private static CloseableHttpAsyncClient createHttp2Client(HttpClientKey key, JMe // Cookies are left to the Cookie Manager, see AutoRedirectStrategy for those of redirects .disableCookieManagement() .setRedirectStrategy(AutoRedirectStrategy.INSTANCE) + .setDefaultAuthSchemeRegistry(HC5Authentication.authSchemes().build()) + .setTargetAuthenticationStrategy(HC5Authentication.STRATEGY) + .setProxyAuthenticationStrategy(HC5Authentication.STRATEGY) // A JMeter thread only has a few requests in flight, which a single I/O thread serves // easily, whereas HttpClient would start one per processor for every client .setIOReactorConfig(IOReactorConfig.custom().setIoThreadCount(1).build()) diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Authentication.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Authentication.java new file mode 100644 index 00000000000..ae75e040b29 --- /dev/null +++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Authentication.java @@ -0,0 +1,288 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you 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.apache.jmeter.protocol.http.sampler; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.anyRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.matching; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.URL; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.List; + +import org.apache.jmeter.protocol.http.control.AuthManager; +import org.apache.jmeter.protocol.http.util.HTTPConstants; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; + +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.core.WireMockConfiguration; + +/** + * Authentication of the HttpClient5 sampler implementation with the credentials of the HTTP Authorization + * Manager and of the proxy, which has to work the way it does with HttpClient4: a server or a proxy that asks + * for NTLM is answered with the user, the password and the domain, and an entry with the BASIC mechanism + * sends its credentials pre-emptively, but can still answer a challenge for another scheme. + */ +class TestHTTPHC5Authentication { + + /** Start of the Base64 encoding of an NTLM type 1 (negotiate) message. */ + private static final String NTLM_TYPE_1 = "NTLM TlRMTVNTUAAB.*"; + + /** Start of the Base64 encoding of an NTLM type 3 (authenticate) message. */ + private static final String NTLM_TYPE_3 = "NTLM TlRMTVNTUAAD.*"; + + @AfterEach + void closeClientsOfThisThread() { + HTTPHC5Impl.closeClientsOfCurrentThread(); + } + + /** + * Like an IIS site with Windows authentication. The entry keeps the BASIC mechanism new entries default to, + * so its credentials are sent pre-emptively as Basic first, which the server rejects. + */ + @ParameterizedTest + @ValueSource(strings = {"HTTP/1.1", "HTTP/2"}) + void answersAnNtlmChallengeWithTheDomainOfTheAuthorizationManager(String httpVersion) throws Exception { + WireMockServer server = createServer(); + server.start(); + try { + stubNtlm(server, "/ntlm", HTTPConstants.HEADER_AUTHORIZATION, "WWW-Authenticate", 401); + AuthManager authManager = new AuthManager(); + authManager.set(-1, server.url("/"), "user", "pass", "EXAMPLE", "", AuthManager.Mechanism.BASIC); + HTTPSamplerBase sampler = newSampler(httpVersion); + sampler.setAuthManager(authManager); + + HTTPSampleResult result = sampler.sample(new URL(server.url("/ntlm")), HTTPConstants.GET, false, 1); + + assertEquals("200", result.getResponseCode(), result::getResponseMessage); + List authorizations = headerValues(server, "/ntlm", HTTPConstants.HEADER_AUTHORIZATION); + assertTrue(authorizations.contains(basic("user", "pass")), + () -> "the credentials should have been sent pre-emptively as Basic first: " + authorizations); + byte[] authenticate = ntlmMessage(authorizations, 3); + assertEquals("user", securityBufferString(authenticate, 36), "user of the NTLM authenticate message"); + assertEquals("EXAMPLE", securityBufferString(authenticate, 28), "domain of the NTLM authenticate message"); + } finally { + server.stop(); + } + } + + /** Like a corporate proxy that only offers NTLM, answered with the proxy user of the sampler. */ + @ParameterizedTest + @ValueSource(strings = {"HTTP/1.1", "HTTP/2"}) + void answersAnNtlmChallengeOfTheProxy(String httpVersion) throws Exception { + WireMockServer proxy = createServer(); + proxy.start(); + try { + stubNtlm(proxy, "/through-proxy", "Proxy-Authorization", "Proxy-Authenticate", 407); + HTTPSamplerBase sampler = newSampler(httpVersion); + sampler.setProxyHost("localhost"); + sampler.setProxyPortInt(Integer.toString(proxy.port())); + sampler.setProxyUser("proxyuser"); + sampler.setProxyPass("proxypass"); + + // The target has to be another host than the proxy, as HttpClient keeps the state of an + // authentication per host, which the NTLM handshakes with the two would otherwise share + HTTPSampleResult result = sampler.sample( + new URL("http://127.0.0.1:" + proxy.port() + "/through-proxy"), HTTPConstants.GET, false, 1); + + assertEquals("200", result.getResponseCode(), result::getResponseMessage); + byte[] authenticate = ntlmMessage(headerValues(proxy, "/through-proxy", "Proxy-Authorization"), 3); + assertEquals("proxyuser", securityBufferString(authenticate, 36), "user of the NTLM authenticate message"); + } finally { + proxy.stop(); + } + } + + /** + * The pre-emptive Basic credentials of an entry with the BASIC mechanism must not keep it from answering a + * challenge for another scheme, like Digest here. + */ + @ParameterizedTest + @ValueSource(strings = {"HTTP/1.1", "HTTP/2"}) + void answersADigestChallengeForAnEntryWithTheBasicMechanism(String httpVersion) throws Exception { + WireMockServer server = createServer(); + server.start(); + try { + server.stubFor(get(urlEqualTo("/digest")).atPriority(1) + .withHeader(HTTPConstants.HEADER_AUTHORIZATION, matching("Digest .*username=\"user\".*")) + .willReturn(aResponse().withStatus(200))); + server.stubFor(get(urlEqualTo("/digest")).atPriority(10) + .willReturn(aResponse().withStatus(401).withHeader("WWW-Authenticate", + "Digest realm=\"test\", nonce=\"dcd98b7102dd2f0e8b11d0f600bfb0c093\", qop=\"auth\""))); + AuthManager authManager = new AuthManager(); + authManager.set(-1, server.url("/"), "user", "pass", "", "", AuthManager.Mechanism.BASIC); + HTTPSamplerBase sampler = newSampler(httpVersion); + sampler.setAuthManager(authManager); + + HTTPSampleResult result = sampler.sample(new URL(server.url("/digest")), HTTPConstants.GET, false, 1); + + assertEquals("200", result.getResponseCode(), result::getResponseMessage); + } finally { + server.stop(); + } + } + + /** The credentials of an entry with a realm only answer a challenge of that realm, as with HttpClient4. */ + @ParameterizedTest + @CsvSource({"jmeter, 200", "other, 401"}) + void answersOnlyChallengesOfTheRealmOfTheEntry(String challengedRealm, String expectedCode) throws Exception { + WireMockServer server = createServer(); + server.start(); + try { + server.stubFor(get(urlEqualTo("/realm")).atPriority(1) + .withHeader(HTTPConstants.HEADER_AUTHORIZATION, matching("Basic .*")) + .willReturn(aResponse().withStatus(200))); + server.stubFor(get(urlEqualTo("/realm")).atPriority(10) + .willReturn(aResponse().withStatus(401) + .withHeader("WWW-Authenticate", "Basic realm=\"" + challengedRealm + "\""))); + AuthManager authManager = new AuthManager(); + // DIGEST, as an entry with the BASIC mechanism sends its credentials before any challenge + authManager.set(-1, server.url("/"), "user", "pass", "", "jmeter", AuthManager.Mechanism.DIGEST); + HTTPSamplerBase sampler = newSampler("HTTP/1.1"); + sampler.setAuthManager(authManager); + + HTTPSampleResult result = sampler.sample(new URL(server.url("/realm")), HTTPConstants.GET, false, 1); + + assertEquals(expectedCode, result.getResponseCode(), result::getResponseMessage); + } finally { + server.stop(); + } + } + + /** The pre-emptive Basic credentials are reported with the request, and accounted for in the sent bytes. */ + @Test + void reportsThePreemptiveBasicCredentials() throws Exception { + WireMockServer server = createServer(); + server.start(); + try { + server.stubFor(get(urlEqualTo("/basic")).willReturn(aResponse().withStatus(200))); + URL url = new URL(server.url("/basic")); + HTTPSampleResult anonymous = newSampler("HTTP/1.1").sample(url, HTTPConstants.GET, false, 1); + AuthManager authManager = new AuthManager(); + authManager.set(-1, server.url("/"), "user", "pass", "", "", AuthManager.Mechanism.BASIC); + HTTPSamplerBase sampler = newSampler("HTTP/1.1"); + sampler.setAuthManager(authManager); + + HTTPSampleResult authenticated = sampler.sample(url, HTTPConstants.GET, false, 1); + + assertEquals("200", authenticated.getResponseCode()); + assertEquals(List.of(basic("user", "pass")), + server.findAll(anyRequestedFor(urlEqualTo("/basic"))).stream() + .filter(request -> request.containsHeader(HTTPConstants.HEADER_AUTHORIZATION)) + .map(request -> request.getHeader(HTTPConstants.HEADER_AUTHORIZATION)) + .toList()); + assertTrue(authenticated.getRequestHeaders().contains("Authorization: " + basic("user", "pass") + "\n"), + authenticated::getRequestHeaders); + assertEquals(anonymous.getSentBytes() + + HTTPMessageSizes.headerLength(HTTPConstants.HEADER_AUTHORIZATION, basic("user", "pass")), + authenticated.getSentBytes()); + } finally { + server.stop(); + } + } + + /** + * Scripts the NTLM handshake: anything but an NTLM message is challenged, a type 1 message is answered with + * a type 2 message, and a type 3 message is accepted. The messages are not verified. + */ + private static void stubNtlm(WireMockServer server, String path, String authorization, String authenticate, + int challengeStatus) { + server.stubFor(get(urlEqualTo(path)).atPriority(1) + .withHeader(authorization, matching(NTLM_TYPE_3)) + .willReturn(aResponse().withStatus(200))); + server.stubFor(get(urlEqualTo(path)).atPriority(2) + .withHeader(authorization, matching(NTLM_TYPE_1)) + .willReturn(aResponse().withStatus(challengeStatus) + .withHeader(authenticate, "NTLM " + Base64.getEncoder().encodeToString(ntlmChallenge())))); + server.stubFor(get(urlEqualTo(path)).atPriority(10) + .willReturn(aResponse().withStatus(challengeStatus).withHeader(authenticate, "Negotiate", "NTLM"))); + } + + /** An NTLM type 2 (challenge) message, negotiating Unicode and NTLM, without target information. */ + private static byte[] ntlmChallenge() { + ByteBuffer message = ByteBuffer.allocate(48).order(ByteOrder.LITTLE_ENDIAN); + message.put("NTLMSSP\0".getBytes(StandardCharsets.US_ASCII)); + message.putInt(2); + // empty target name, the security buffer points at the end of the message + message.putShort((short) 0).putShort((short) 0).putInt(48); + // NTLMSSP_NEGOTIATE_UNICODE | NTLMSSP_NEGOTIATE_NTLM + message.putInt(0x00000201); + message.put(new byte[] {1, 2, 3, 4, 5, 6, 7, 8}); + message.put(new byte[8]); + // empty target information + message.putShort((short) 0).putShort((short) 0).putInt(48); + return message.array(); + } + + private static byte[] ntlmMessage(List headerValues, int type) { + for (String value : headerValues) { + if (value.startsWith("NTLM ")) { + byte[] message = Base64.getDecoder().decode(value.substring("NTLM ".length())); + if (ByteBuffer.wrap(message).order(ByteOrder.LITTLE_ENDIAN).getInt(8) == type) { + return message; + } + } + } + throw new AssertionError("no NTLM type " + type + " message was sent: " + headerValues); + } + + /** Reads the Unicode string a security buffer at the given offset of an NTLM message points to. */ + private static String securityBufferString(byte[] message, int offset) { + ByteBuffer buffer = ByteBuffer.wrap(message).order(ByteOrder.LITTLE_ENDIAN); + int length = Short.toUnsignedInt(buffer.getShort(offset)); + int start = buffer.getInt(offset + 4); + return new String(message, start, length, StandardCharsets.UTF_16LE); + } + + private static List headerValues(WireMockServer server, String path, String header) { + return server.findAll(anyRequestedFor(urlEqualTo(path))).stream() + .filter(request -> request.containsHeader(header)) + .map(request -> request.getHeader(header)) + .toList(); + } + + private static String basic(String user, String password) { + return "Basic " + Base64.getEncoder() + .encodeToString((user + ":" + password).getBytes(StandardCharsets.UTF_8)); + } + + private static HTTPSamplerBase newSampler(String httpVersion) { + HTTPSamplerBase sampler = HTTPSamplerFactory.newInstance("HttpClient5"); + sampler.setHttpVersion(httpVersion); + return sampler; + } + + /** + * The request journal is bounded, so that an authentication that does not end fails the test on its + * timeout, rather than filling the heap with the requests it sends. + */ + private static WireMockServer createServer() { + return new WireMockServer(WireMockConfiguration.wireMockConfig().dynamicPort().maxRequestJournalEntries(100)); + } +} diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java index 319a57a8c62..d6f9adadc75 100644 --- a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java +++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java @@ -1054,7 +1054,7 @@ void negotiatesWithAProxyThatHasNoCredentialsConfigured() throws Exception { assertNotNull(authSchemes, "the Kerberos auth schemes have to be registered for the request"); assertNotNull(authSchemes.lookup(StandardAuthScheme.SPNEGO), "Negotiate has to be supported"); assertEquals(List.of(StandardAuthScheme.SPNEGO, StandardAuthScheme.KERBEROS, StandardAuthScheme.BEARER, - StandardAuthScheme.DIGEST, StandardAuthScheme.BASIC), + StandardAuthScheme.NTLM, StandardAuthScheme.DIGEST, StandardAuthScheme.BASIC), new ArrayList<>(request.getConfig().getProxyPreferredAuthSchemes()), "a proxy challenging with Negotiate has to be answered with a Kerberos token"); assertNull(request.getConfig().getTargetPreferredAuthSchemes(), diff --git a/xdocs/usermanual/component_reference.xml b/xdocs/usermanual/component_reference.xml index 9dc4ca2d710..a953e1f0859 100644 --- a/xdocs/usermanual/component_reference.xml +++ b/xdocs/usermanual/component_reference.xml @@ -3744,6 +3744,15 @@ information for the user named, "jmeter".
    The Realm only applies to the HttpClient sampler. + +With the HttpClient 4 and HttpClient 5 implementations, a server that asks for NTLM is answered with the +Username, Password and Domain of the entry, whatever its Mechanism, +unless it is Kerberos. A proxy that asks for NTLM is answered with the proxy user of the sampler +and the domain of the http.proxyDomain property. The BASIC mechanism sends the credentials +pre-emptively, and a challenge for another scheme, like NTLM or DIGEST, is still answered. +NTLM authenticates the connection rather than the request, which HTTP/2 does not allow, so select +HTTP/1.1 as HTTP version of the HttpClient 5 implementation for a server that asks for it. +

    Kerberos Configuration:

    To configure Kerberos you need to setup at least two JVM system properties:

    From 5246bf22f60d6a7e499b3436c20b6cf3b1d20f53 Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Tue, 6 Oct 2026 14:36:01 +0200 Subject: [PATCH 68/80] Set up the HTTP client before the sample starts in HTTPHC5Impl HTTPHC5Impl looked up its HttpClient only when it executed the request, after result.sampleStart(). The first sample of a thread to a host, and of each iteration when the thread does not keep the same user, therefore included building the client, with its connection pool, the SSL context of the thread and, for HTTP/2, the start of its I/O reactor. HTTPHC4Impl sets its client up before the sample starts. Look up, or build, the client for the request before sampleStart(), and execute the request with that client afterwards. The connect and the TLS handshake stay part of the sample. --- .../protocol/http/sampler/HTTPHC5Impl.java | 38 +++++++++++++------ 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java index bc65a205c67..2ae5ce11d8e 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java @@ -530,6 +530,11 @@ protected HTTPSampleResult sample(URL url, String method, boolean areFollowingRe resetStateIfNeeded(); request = createRequest(url.toURI(), method); setupRequest(url, request, result, areFollowingRedirect); + // The client is set up before the sample starts, like HTTPHC4Impl does, so that the first + // sample of a thread, or of each iteration that starts as a new user, does not include + // building the client, its SSL context and, for HTTP/2, starting its I/O reactor + HttpClientKey clientKey = createHttpClientKey(url); + RequestExecutor executor = getRequestExecutor(clientKey); result.sampleStart(); CacheManager cacheManager = getCacheManager(); @@ -541,10 +546,9 @@ protected HTTPSampleResult sample(URL url, String method, boolean areFollowingRe interruptRequested = false; currentRequest = request; - HttpClientKey clientKey = createHttpClientKey(url); context = createHttpClientContext(url, clientKey, request); context.setAttribute(CONTEXT_ATTRIBUTE_SAMPLER_RESULT, result); - response = executeRequest(url, clientKey, request, context); + response = executeRequest(url, clientKey, executor, request, context); readResponse(response, result, context); Long responseLatency = (Long) context.getAttribute(CONTEXT_ATTRIBUTE_RESPONSE_LATENCY); if (responseLatency != null) { @@ -597,7 +601,7 @@ private static org.apache.hc.client5.http.classic.methods.HttpUriRequestBase cre * the proxy is covered by a Kerberos authorization, so that the auth scheme can use its * credentials. */ - private ClassicHttpResponse executeRequest(URL url, HttpClientKey clientKey, + private ClassicHttpResponse executeRequest(URL url, HttpClientKey clientKey, RequestExecutor executor, org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request, HttpClientContext context) throws IOException { Subject subject = getSubjectForUrl(url); @@ -605,11 +609,11 @@ private ClassicHttpResponse executeRequest(URL url, HttpClientKey clientKey, subject = getSubjectForProxy(clientKey); } if (subject == null) { - return doExecuteRequest(clientKey, request, context); + return executor.execute(request, context); } try { return Subject.doAs(subject, (PrivilegedExceptionAction) () -> - doExecuteRequest(clientKey, request, context)); + executor.execute(request, context)); } catch (PrivilegedActionException e) { Exception cause = e.getException(); if (cause instanceof IOException ioException) { @@ -619,12 +623,24 @@ private ClassicHttpResponse executeRequest(URL url, HttpClientKey clientKey, } } - private ClassicHttpResponse doExecuteRequest(HttpClientKey clientKey, - org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request, HttpClientContext context) - throws IOException { - return clientKey.httpVersionPolicy != HttpVersionPolicy.FORCE_HTTP_1 - ? executeHttp2(getHttp2Client(clientKey), request, context) - : getClient(clientKey).executeOpen(null, request, context); + /** Executes a request with a client that is already set up. */ + @FunctionalInterface + private interface RequestExecutor { + ClassicHttpResponse execute(org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request, + HttpClientContext context) throws IOException; + } + + /** + * Looks up the client for requests with the given key, the HTTP/2 one unless HTTP/1.1 is + * forced, and builds it if the thread has none yet. + */ + private RequestExecutor getRequestExecutor(HttpClientKey clientKey) { + if (clientKey.httpVersionPolicy == HttpVersionPolicy.FORCE_HTTP_1) { + CloseableHttpClient client = getClient(clientKey); + return (request, context) -> client.executeOpen(null, request, context); + } + CloseableHttpAsyncClient client = getHttp2Client(clientKey); + return (request, context) -> executeHttp2(client, request, context); } private void setupRequest(URL url, org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request, From fc1f3824c456b8795d4d7a372f048fc309a35123 Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Tue, 6 Oct 2026 17:54:13 +0200 Subject: [PATCH 69/80] Allow a connection per parallel download in the HTTP/1.1 pool of HTTPHC5Impl The connection pool of the HTTP/1.1 client kept the defaults of HttpClient, five connections per host and 25 in total. The parallel downloads of embedded resources share the client of their JMeter thread, and download six resources at a time by default, so the downloads beyond five waited for a connection, which added to their elapsed time. HTTPHC4Impl raises the limit to the number of parallel downloads when it builds its client. Keep the pool with the client, in the new HC5ClassicClient, and raise its limits to the number of parallel downloads of the sampler before each sample starts. The limits are only ever raised, so a host first sampled without parallel downloads does not keep the later ones waiting. Add a test that downloads more embedded resources in parallel than the default of HttpClient allows, and checks that each of them gets a connection. --- .../http/sampler/HC5ClassicClient.java | 56 +++++++++++++++++++ .../protocol/http/sampler/HTTPHC5Impl.java | 38 +++++++++---- .../http/sampler/TestHTTPHC5Features.java | 44 +++++++++++++++ 3 files changed, 128 insertions(+), 10 deletions(-) create mode 100644 src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HC5ClassicClient.java diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HC5ClassicClient.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HC5ClassicClient.java new file mode 100644 index 00000000000..e0317e563c6 --- /dev/null +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HC5ClassicClient.java @@ -0,0 +1,56 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you 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.apache.jmeter.protocol.http.sampler; + +import java.io.Closeable; +import java.io.IOException; + +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager; + +/** + * Client of {@link HTTPHC5Impl} for HTTP/1.1, with the pool of its connections, which a JMeter + * thread shares with the threads that download its embedded resources in parallel. + * + * @param client the client + * @param pool the pool of the connections of the client + */ +record HC5ClassicClient(CloseableHttpClient client, PoolingHttpClientConnectionManager pool) implements Closeable { + + /** + * Lets the pool open as many connections to a host as requests are made to it at the same + * time, rather than have those beyond the default of HttpClient, five per host, wait for one, + * which would add to their elapsed time. The limits are only ever raised, so a sampler that + * downloads less in parallel than another one using the same client does not lower them. + * + * @param connections number of requests made at the same time + */ + synchronized void allowConnectionsPerRoute(int connections) { + if (pool.getDefaultMaxPerRoute() < connections) { + pool.setDefaultMaxPerRoute(connections); + } + if (pool.getMaxTotal() < connections) { + pool.setMaxTotal(connections); + } + } + + @Override + public void close() throws IOException { + client.close(); + } +} diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java index 2ae5ce11d8e..cd454112a7f 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java @@ -84,8 +84,8 @@ import org.apache.hc.client5.http.impl.auth.BasicCredentialsProvider; import org.apache.hc.client5.http.impl.auth.KerberosSchemeFactory; import org.apache.hc.client5.http.impl.auth.SPNegoSchemeFactory; -import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; import org.apache.hc.client5.http.impl.classic.HttpClients; +import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager; import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder; import org.apache.hc.client5.http.impl.nio.PoolingAsyncClientConnectionManagerBuilder; import org.apache.hc.client5.http.impl.routing.DefaultRoutePlanner; @@ -197,7 +197,7 @@ public class HTTPHC5Impl extends HTTPHCAbstractImpl { *

    The keys are weak, so that a context whose clients are never closed explicitly, like the * one of a thread of the HTTP(S) Test Script Recorder, does not keep the entry alive forever. */ - private static final Map> HTTP_CLIENTS = + private static final Map> HTTP_CLIENTS = Collections.synchronizedMap(new WeakHashMap<>()); /** @@ -636,13 +636,30 @@ ClassicHttpResponse execute(org.apache.hc.client5.http.classic.methods.HttpUriRe */ private RequestExecutor getRequestExecutor(HttpClientKey clientKey) { if (clientKey.httpVersionPolicy == HttpVersionPolicy.FORCE_HTTP_1) { - CloseableHttpClient client = getClient(clientKey); - return (request, context) -> client.executeOpen(null, request, context); + HC5ClassicClient client = getClient(clientKey); + client.allowConnectionsPerRoute(getParallelRequests()); + return (request, context) -> client.client().executeOpen(null, request, context); } CloseableHttpAsyncClient client = getHttp2Client(clientKey); return (request, context) -> executeHttp2(client, request, context); } + /** + * Number of requests the sampler makes to a host at the same time, which is the number of + * parallel downloads of its embedded resources, if it downloads them in parallel. + */ + private int getParallelRequests() { + if (!testElement.isConcurrentDwn()) { + return 1; + } + try { + return Integer.parseInt(testElement.getConcurrentPool()); + } catch (NumberFormatException e) { + // the sampler falls back to the default as well + return HTTPSamplerBase.CONCURRENT_POOL_SIZE; + } + } + private void setupRequest(URL url, org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request, HTTPSampleResult result, boolean areFollowingRedirect) throws IOException { HttpVersionPolicy httpVersionPolicy = getHttpVersionPolicy(testElement.getHttpVersion(), DEFAULT_HTTP_VERSION, @@ -1235,8 +1252,8 @@ private static void saveConnectionCookies(ClassicHttpResponse response, URL url, saveConnectionCookies(headersOf(response), url, cookieManager); } - private static CloseableHttpClient getClient(HttpClientKey key) { - Map clients = + private static HC5ClassicClient getClient(HttpClientKey key) { + Map clients = HTTP_CLIENTS.computeIfAbsent(JMeterContextService.getContext(), context -> new ConcurrentHashMap<>()); return clients.computeIfAbsent(key, HTTPHC5Impl::createClient); } @@ -1252,7 +1269,7 @@ private static boolean isDefaultUserAgentDisabled() { return JMeterUtils.getPropDefault(DISABLE_DEFAULT_UA_PROPERTY, false); } - private static CloseableHttpClient createClient(HttpClientKey key) { + private static HC5ClassicClient createClient(HttpClientKey key) { org.apache.hc.client5.http.impl.classic.HttpClientBuilder builder = HttpClients.custom().disableAutomaticRetries(); if (isDefaultUserAgentDisabled()) { builder.disableDefaultUserAgent(); @@ -1271,9 +1288,10 @@ private static CloseableHttpClient createClient(HttpClientKey key) { connectionConfig.setConnectTimeout(Timeout.ofMilliseconds(key.connectTimeout)); } connectionManagerBuilder.setDefaultConnectionConfig(connectionConfig.build()); - builder.setConnectionManager(new ConnectTimeMeasuringConnectionManager(connectionManagerBuilder.build())); + PoolingHttpClientConnectionManager pool = connectionManagerBuilder.build(); + builder.setConnectionManager(new ConnectTimeMeasuringConnectionManager(pool)); builder.setRoutePlanner(createRoutePlanner(key)); - return builder.disableContentCompression() + return new HC5ClassicClient(builder.disableContentCompression() // Cookies are left to the Cookie Manager, see AutoRedirectStrategy for those of redirects .disableCookieManagement() .setRedirectStrategy(AutoRedirectStrategy.INSTANCE) @@ -1281,7 +1299,7 @@ private static CloseableHttpClient createClient(HttpClientKey key) { .setTargetAuthenticationStrategy(HC5Authentication.STRATEGY) .setProxyAuthenticationStrategy(HC5Authentication.STRATEGY) .addExecInterceptorFirst("response-content-encoding", RESPONSE_CONTENT_ENCODING) - .build(); + .build(), pool); } private static CloseableHttpAsyncClient createHttp2Client(HttpClientKey key, JMeterContext jmeterContext) { diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java index d6f9adadc75..430aef936b7 100644 --- a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java +++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java @@ -247,6 +247,50 @@ private static List httpClientThreadsOf(String owner) { .toList(); } + /** + * The parallel downloads of embedded resources share the client of the thread, so its pool has to let each + * of them have a connection. With the default of HttpClient, five per host, the downloads beyond that would + * wait for a connection, which adds to their elapsed time. + */ + @Test + void opensAConnectionForEachParallelDownloadOverHttp11() throws Exception { + HTTPSamplerBase.registerParser("text/html", LagartoBasedHtmlParser.class.getName()); + WireMockServer server = createServer(); + server.start(); + try (TcpRelay relay = new TcpRelay(server.port())) { + int downloads = 8; + StringBuilder html = new StringBuilder(""); + for (int i = 0; i < downloads; i++) { + html.append(""); + // slow enough for all of the downloads to be in progress at the same time + server.stubFor(get(urlEqualTo("/image" + i + ".png")) + .willReturn(aResponse().withStatus(200).withFixedDelay(1000).withBody("image" + i))); + } + server.stubFor(get(urlEqualTo("/index.html")).willReturn(aResponse().withStatus(200) + .withHeader("Content-Type", "text/html").withBody(html.append("").toString()))); + HTTPSamplerBase sampler = newSampler(); + sampler.setHttpVersion("HTTP/1.1"); + // as in the GUI, so connections are reused and only as many are opened as the pool allows at a time + sampler.setUseKeepAlive(true); + sampler.setImageParser(true); + sampler.setConcurrentDwn(true); + sampler.setConcurrentPool(Integer.toString(downloads)); + sampler.setRunningVersion(true); + + HTTPSampleResult result = sampler.sample( + new URL("http://localhost:" + relay.getPort() + "/index.html"), HTTPConstants.GET, false, 0); + + assertTrue(result.isSuccessful(), result::getResponseMessage); + assertEquals(downloads + 1, result.getSubResults().length, "the page and its embedded resources"); + int connections = relay.getAcceptedConnections(); + assertTrue(connections >= downloads, + () -> "each of the " + downloads + " parallel downloads should have had a connection, but only " + + connections + " were opened"); + } finally { + server.stop(); + } + } + /** * The threads which download embedded resources in parallel are pooled and shared by all JMeter * threads, so they must use the clients of the JMeter thread they are working for. Otherwise From 7e9ff4a606fee01307990c730684a60c711f7a6c Mon Sep 17 00:00:00 2001 From: Andreas Lind Date: Tue, 13 Oct 2026 21:18:28 +0200 Subject: [PATCH 70/80] Preserve Header Manager headers on method-changing redirects in HTTPHC5Impl Restore headers omitted when a redirect changes the request method, and keep Host overrides scoped to the sampled origin. --- .../http/sampler/AutoRedirectStrategy.java | 40 ++++++++-- .../http/sampler/TestHTTPHC5Redirects.java | 73 +++++++++++++++++++ 2 files changed, 106 insertions(+), 7 deletions(-) diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/AutoRedirectStrategy.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/AutoRedirectStrategy.java index ab248a96002..cb052ee5b87 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/AutoRedirectStrategy.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/AutoRedirectStrategy.java @@ -44,9 +44,11 @@ * Redirect strategy of {@link HTTPHC5Impl} for the redirects HttpClient follows on its own, when * "Redirect Automatically" is selected. *

    - * HttpClient sends every hop with the headers of the initial request. Its default strategy therefore - * refuses to follow a redirect to another origin when they include a {@code Cookie} or an - * {@code Authorization} header, so as not to leak them, and returns the redirect as the response. + * HttpClient sends every hop with the headers of the initial request, except when the hop changes the + * method (301/302 after POST, 303 after any non-GET), where it builds a GET with no headers at all. + * Its default strategy refuses to follow a redirect to another origin when the hop includes a + * {@code Cookie} or an {@code Authorization} header, so as not to leak them, and returns the redirect + * as the response. * JMeter sets both headers itself, from the Cookie and the Authorization Manager, so a redirect to an * identity provider, for example, would end the sample. Instead, each hop is sent with what the * managers hold for its own URL, the way a sample of "Follow Redirects" would be: @@ -57,6 +59,8 @@ * and those of an entry with the BASIC mechanism are sent pre-emptively, see {@link HC5Authentication}. *

  • A {@code Cookie} or {@code Authorization} header of the Header Manager is only sent to the origin * of the sampled URL, and only where the managers do not replace it, as for the initial request.
  • + *
  • Other Header Manager headers are re-applied when HttpClient does not copy them, except that a + * {@code Host} override is only kept for the sampled origin.
  • * */ final class AutoRedirectStrategy extends DefaultRedirectStrategy { @@ -110,6 +114,8 @@ private static final class Hops { private final AuthManager authManager; private final List headerManagerCookies = new ArrayList<>(); private final List headerManagerAuthorizations = new ArrayList<>(); + /** Header Manager headers other than Cookie and Authorization, as name-value pairs. */ + private final List headerManagerGeneral = new ArrayList<>(); private final BasicCredentialsProvider credentials; /** URL the response being redirected was received from. */ private URL currentUrl; @@ -122,10 +128,16 @@ private Hops(URL url, CookieManager cookieManager, AuthManager authManager, Head this.authManager = authManager; this.credentials = credentials; HTTPHCAbstractImpl.setConnectionHeaders(headerManager, url, - name -> HttpHeaders.COOKIE.equalsIgnoreCase(name) - || HttpHeaders.AUTHORIZATION.equalsIgnoreCase(name), - (name, value) -> (HttpHeaders.COOKIE.equalsIgnoreCase(name) - ? headerManagerCookies : headerManagerAuthorizations).add(value)); + name -> true, + (name, value) -> { + if (HttpHeaders.COOKIE.equalsIgnoreCase(name)) { + headerManagerCookies.add(value); + } else if (HttpHeaders.AUTHORIZATION.equalsIgnoreCase(name)) { + headerManagerAuthorizations.add(value); + } else { + headerManagerGeneral.add(new String[]{name, value}); + } + }); } /** @@ -166,6 +178,20 @@ void next(HttpClientContext context, URL url, HttpRequest request) { } else if (sampledOrigin) { headerManagerAuthorizations.forEach(value -> request.addHeader(HttpHeaders.AUTHORIZATION, value)); } + + // For method-changing redirects (301/302 after POST, 303 after non-GET), HttpClient + // builds a GET with no headers, losing the Header Manager headers the initial request + // carried. Re-apply them so that Accept, User-Agent, and custom headers survive. + for (String[] header : headerManagerGeneral) { + if (request.containsHeader(header[0])) { + continue; + } + // Host is specific to the sampled origin. Let HttpClient generate it for another origin. + if (HttpHeaders.HOST.equalsIgnoreCase(header[0]) && !sampledOrigin) { + continue; + } + request.addHeader(header[0], header[1]); + } currentUrl = url; } diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Redirects.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Redirects.java index a0e8a834f7e..1d30629d8ff 100644 --- a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Redirects.java +++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Redirects.java @@ -20,6 +20,7 @@ import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; import static com.github.tomakehurst.wiremock.client.WireMock.anyRequestedFor; import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.post; import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -181,6 +182,78 @@ void sendsNoCookiesWithoutCookieManager(String httpVersion, String scheme) throw } } + /** + * POST → 302 → GET: HttpClient changes the method and builds a GET with no headers. The Header Manager + * headers must survive the redirect, except Cookie and Authorization which are handled by their managers. + */ + @ParameterizedTest + @CsvSource({"HTTP/1.1, http", "HTTP/2, https"}) + void postRedirectGetPreservesHeaderManagerHeaders(String httpVersion, String scheme) throws Exception { + WireMockServer server = createServer(); + server.start(); + try { + String base = origin(server, scheme, "localhost"); + server.stubFor(post(urlEqualTo("/login")).willReturn(aResponse().withStatus(302) + .withHeader(HTTPConstants.HEADER_LOCATION, base + "/home"))); + server.stubFor(get(urlEqualTo("/home")).willReturn(aResponse().withStatus(200))); + HeaderManager headerManager = new HeaderManager(); + headerManager.add(new Header("X-Custom", "value1")); + headerManager.add(new Header("Accept", "application/json")); + HTTPSamplerBase sampler = newSampler(httpVersion); + sampler.setHeaderManager(headerManager); + + HTTPSampleResult result = sampler.sample(new URL(base + "/login"), HTTPConstants.POST, false, 1); + + assertEquals("200", result.getResponseCode(), result::getResponseMessage); + LoggedRequest home = singleRequest(server, "/home"); + assertEquals("value1", home.getHeader("X-Custom"), + "Header Manager X-Custom header must survive a POST → 302 → GET redirect"); + assertEquals("application/json", home.getHeader("Accept"), + "Header Manager Accept header must survive a POST → 302 → GET redirect"); + } finally { + server.stop(); + } + } + + /** + * A Header Manager {@code Authorization} header must not be sent to a different origin on redirect, + * to prevent credential leaking. + */ + @ParameterizedTest + @CsvSource({"HTTP/1.1, http", "HTTP/2, https"}) + void headerManagerAuthorizationIsNotSentToAnotherOrigin(String httpVersion, String scheme) throws Exception { + WireMockServer server = createServer(); + server.start(); + try { + String app = origin(server, scheme, "localhost"); + String other = origin(server, scheme, "127.0.0.1"); + server.stubFor(get(urlEqualTo("/start")).willReturn(aResponse().withStatus(302) + .withHeader(HTTPConstants.HEADER_LOCATION, other + "/external"))); + server.stubFor(get(urlEqualTo("/external")).willReturn(aResponse().withStatus(200))); + HeaderManager headerManager = new HeaderManager(); + headerManager.add(new Header(HTTPConstants.HEADER_AUTHORIZATION, "Bearer my-token")); + headerManager.add(new Header("X-Test", "kept")); + HTTPSamplerBase sampler = newSampler(httpVersion); + sampler.setHeaderManager(headerManager); + + HTTPSampleResult result = sampler.sample(new URL(app + "/start"), HTTPConstants.GET, false, 1); + + assertEquals("200", result.getResponseCode(), result::getResponseMessage); + LoggedRequest start = singleRequest(server, "/start"); + assertEquals("Bearer my-token", start.getHeader(HTTPConstants.HEADER_AUTHORIZATION), + "the Authorization header must be sent to the sampled origin"); + assertEquals("kept", start.getHeader("X-Test")); + + LoggedRequest external = singleRequest(server, "/external"); + assertFalse(external.containsHeader(HTTPConstants.HEADER_AUTHORIZATION), + "the Header Manager Authorization must not be sent to a different origin"); + assertEquals("kept", external.getHeader("X-Test"), + "non-sensitive Header Manager headers must still be sent to a different origin"); + } finally { + server.stop(); + } + } + private static HTTPSamplerBase newSampler(String httpVersion) { HTTPSamplerBase sampler = HTTPSamplerFactory.newInstance("HttpClient5"); sampler.setHttpVersion(httpVersion); From 9dc9b184de546b02c49b00a8bca312333724d194 Mon Sep 17 00:00:00 2001 From: Andreas Lind Date: Thu, 15 Oct 2026 12:13:04 +0200 Subject: [PATCH 71/80] Document HTTP/2 body spill threshold in HTTPHC5Impl --- bin/jmeter.properties | 4 ++++ xdocs/usermanual/properties_reference.xml | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/bin/jmeter.properties b/bin/jmeter.properties index 56c0ac2867a..a94cce8b8fe 100644 --- a/bin/jmeter.properties +++ b/bin/jmeter.properties @@ -404,6 +404,10 @@ remote_hosts=127.0.0.1 # enabled a single connection usually serves all concurrent requests #httpclient5.h2.max_connections_per_route=6 +# Number of bytes of an HTTP/2 request body buffered in memory before it is +# spilled to a temporary file +#httpclient5.h2.body_spill_threshold=262144 + # Size in bytes of the HPACK dynamic header table announced to the server. # 0 disables indexing of headers by the server #httpclient5.h2.header_table_size=8192 diff --git a/xdocs/usermanual/properties_reference.xml b/xdocs/usermanual/properties_reference.xml index 2bca4f2dd06..196552c1a49 100644 --- a/xdocs/usermanual/properties_reference.xml +++ b/xdocs/usermanual/properties_reference.xml @@ -471,6 +471,11 @@ JMETER-SERVER connection usually serves all concurrent requests.
    Defaults to: 6 + + Number of bytes of an HTTP/2 request body buffered in memory before it is spilled to a temporary + file.
    + Defaults to: 262144 +
    Size in bytes of the HPACK dynamic header table announced to the server. 0 disables indexing of the headers by the server.
    From 76d5d37333b98433617089f2b64989ae5713474a Mon Sep 17 00:00:00 2001 From: Andreas Lind Date: Thu, 15 Oct 2026 12:24:41 +0200 Subject: [PATCH 72/80] Document HttpClient5 sampler configuration and limitations Clarify that the sampler reuses connections but disables automatic retries, and that client certificates are supported for HTTP/1.1 only. Reference the issues fixed by the HttpClient5 implementation. --- xdocs/changes.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/xdocs/changes.xml b/xdocs/changes.xml index f9b761e813a..e2350f71766 100644 --- a/xdocs/changes.xml +++ b/xdocs/changes.xml @@ -95,7 +95,7 @@ Summary
  • 6250Avoid adding "; charset=" automatically to multipart/form-data requests to align behavior with modern HTTP clients.
  • 6080Preserve the original HTTP method when following 307 and 308 redirects according to the HTTP specification. Contributed by LeeJiWon (github.com/dlwldnjs1009)
  • 62676268Add a space between key and value after : in View Results Tree > Sampler result tab for better readability.
  • -
  • 6742Add the new HttpClient5 sampler implementation, which is based on Apache HttpComponents HttpClient 5.x and can be selected in the Implementation combo box of the HTTP Request sampler and the HTTP Request Defaults, or with the jmeter.httpsampler property. It supports the features of the existing HttpClient4 implementation (cookie, cache, header and authorization management, proxies, client certificates, WebDav methods, DNS Cache Manager, connection reuse and retries) and adds HTTP/2 support.
  • +
  • 508140236742Add the new HttpClient5 sampler implementation, which is based on Apache HttpComponents HttpClient 5.x and can be selected in the Implementation combo box of the HTTP Request sampler and the HTTP Request Defaults, or with the jmeter.httpsampler property. It supports the features of the existing HttpClient4 implementation (cookie, cache, header and authorization management, proxies, client certificates over HTTP/1.1, WebDav methods, DNS Cache Manager and connection reuse) and adds HTTP/2 support. Automatic retries are disabled.
  • 6742Add the HTTP Version combo box (property HTTPSampler.httpVersion) to the HTTP Request sampler and the HTTP Request Defaults, so the HTTP version can be selected per sampler instead of only globally with the httpclient.version property. An empty value keeps using that property, which now defaults to HTTP/1.1.
  • 6742Send a Host header of the HTTP Header Manager without its port when that port is the default one of the URL in the HttpClient5 sampler implementation, as the HttpClient4 implementation does.
  • 6742Follow automatic redirects to another host in the HttpClient5 sampler implementation, like the HttpClient4 implementation does, instead of returning the redirect when the request carries cookies or credentials. Each redirect is sent with the cookies of the HTTP Cookie Manager and the credentials of the HTTP Authorization Manager for its own URL, the cookies the redirects set are kept in the Cookie Manager, and a Cookie or Authorization header of the HTTP Header Manager is only sent to the host of the initial URL. The number of redirects is limited by httpsampler.max_redirects, and cookies are only handled by the Cookie Manager, so none are sent without one.
  • From 73e3f9c5dc448f128bb383c57836f57dc838e959 Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Fri, 16 Oct 2026 10:55:58 +0200 Subject: [PATCH 73/80] Keep HTTP clients in the JMeterContext instead of a global map HttpClient4 and HttpClient5 looked up the clients of a JMeter thread in a static Collections.synchronizedMap(WeakHashMap) keyed by JMeterContext. Every sample of every thread went through that single JVM-wide lock, and HttpClient4 is the default implementation. The InheritableThreadLocal used before needed no lock. Add JMeterContext#getThreadState(), a thread-safe map that lasts as long as the context. Unlike the sampler context, it is not cleared after each sample or by clear(), which JMeterThread calls before threadFinished(). Clients can therefore still be closed when the thread ends. Store the clients there. The threads that download embedded resources still share the clients of the JMeter thread they work for, because they adopt its context. Clients whose context is never closed explicitly, such as those of HTTP(S) Test Script Recorder threads, still go away with that context. --- .../apache/jmeter/threads/JMeterContext.java | 19 +++++++ .../jmeter/threads/TestJMeterContext.java | 42 ++++++++++++++++ .../protocol/http/sampler/HTTPHC4Impl.java | 22 ++++---- .../protocol/http/sampler/HTTPHC5Impl.java | 50 +++++++++++-------- 4 files changed, 100 insertions(+), 33 deletions(-) create mode 100644 src/core/src/test/java/org/apache/jmeter/threads/TestJMeterContext.java diff --git a/src/core/src/main/java/org/apache/jmeter/threads/JMeterContext.java b/src/core/src/main/java/org/apache/jmeter/threads/JMeterContext.java index b8bf6815134..619dc729ab8 100644 --- a/src/core/src/main/java/org/apache/jmeter/threads/JMeterContext.java +++ b/src/core/src/main/java/org/apache/jmeter/threads/JMeterContext.java @@ -25,6 +25,7 @@ import org.apache.jmeter.samplers.SampleResult; import org.apache.jmeter.samplers.Sampler; import org.apache.jmeter.util.JMeterUtils; +import org.apiguardian.api.API; /** * Holds context for a thread. @@ -52,6 +53,7 @@ public enum TestLogicalAction { private int threadNum; private TestLogicalAction testLogicalAction = TestLogicalAction.CONTINUE; private final ConcurrentHashMap samplerContext = new ConcurrentHashMap<>(5); + private final ConcurrentHashMap threadState = new ConcurrentHashMap<>(5); private boolean recording; JMeterContext() { @@ -281,6 +283,23 @@ public Map getSamplerContext() { return samplerContext; } + /** + * State kept for the whole lifetime of the thread, like the clients a sampler reuses from one + * sample to the next. Unlike {@link #getSamplerContext()}, it is neither cleaned after each + * sample nor by {@link #clear()}, so that + * {@link org.apache.jmeter.testelement.ThreadListener#threadFinished()}, which runs after the + * context has been cleared, can still release what it holds. It goes away with the context. + *

    + * The threads which download embedded resources in parallel adopt the context of the JMeter + * thread they work for, so the map is thread-safe. + * + * @return state of the thread, keyed by names unique to the component which uses them + */ + @API(status = API.Status.EXPERIMENTAL, since = "6.0.0") + public Map getThreadState() { + return threadState; + } + /** * Internally called by JMeter, never call it directly * @param recording flag whether recording should be done diff --git a/src/core/src/test/java/org/apache/jmeter/threads/TestJMeterContext.java b/src/core/src/test/java/org/apache/jmeter/threads/TestJMeterContext.java new file mode 100644 index 00000000000..a199f046506 --- /dev/null +++ b/src/core/src/test/java/org/apache/jmeter/threads/TestJMeterContext.java @@ -0,0 +1,42 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you 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.apache.jmeter.threads; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import org.junit.jupiter.api.Test; + +class TestJMeterContext { + + @Test + void threadStateOutlivesSamplesAndClear() { + JMeterContext context = new JMeterContext(); + Object state = new Object(); + context.getThreadState().put("state", state); + context.getSamplerContext().put("sample", new Object()); + + context.cleanAfterSample(); + assertNull(context.getSamplerContext().get("sample"), "sampler context should be cleaned after a sample"); + assertEquals(state, context.getThreadState().get("state"), "thread state should be kept after a sample"); + + // JMeterThread clears the context before the thread listeners release what the thread state holds + context.clear(); + assertEquals(state, context.getThreadState().get("state"), "thread state should be kept by clear()"); + } +} diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC4Impl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC4Impl.java index e786dfe809c..52045614517 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC4Impl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC4Impl.java @@ -35,11 +35,9 @@ import java.security.PrivilegedExceptionAction; import java.util.ArrayList; import java.util.Arrays; -import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Objects; -import java.util.WeakHashMap; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeUnit; @@ -512,22 +510,23 @@ public void process(HttpResponse response, HttpContext context) }; /** - * Clients of every JMeter thread, one per {@link HttpClientKey}, looked up by the + * Name of the {@link JMeterContext#getThreadState() thread state} entry which holds the clients + * of a JMeter thread, one per {@link HttpClientKey}. They are looked up by the * {@link JMeterContext} of that thread instead of by the thread itself. The threads which * download embedded resources in parallel are pooled and shared by all JMeter threads, and they * adopt the context of the JMeter thread they are working for, so looking the clients up by * context hands them the clients of that thread and keeps a JMeter thread from closing or * resetting clients another one is still using. * - *

    The keys are weak, so that a context whose clients are never closed explicitly, like the - * one of a thread of the HTTP(S) Test Script Recorder, does not keep the entry alive forever. + *

    The clients go away with the context, so the ones of a context which never closes them + * explicitly, like the one of a thread of the HTTP(S) Test Script Recorder, are not kept forever. */ - private static final Map> - HTTPCLIENTS_CACHE_PER_THREAD_AND_HTTPCLIENTKEY = Collections.synchronizedMap(new WeakHashMap<>()); + private static final String THREAD_STATE_CLIENTS = "__jmeter.HC4_CLIENTS__"; //$NON-NLS-1$ + @SuppressWarnings("unchecked") private static Map getClientsOfCurrentThread() { - return HTTPCLIENTS_CACHE_PER_THREAD_AND_HTTPCLIENTKEY.computeIfAbsent( - JMeterContextService.getContext(), context -> new ConcurrentHashMap<>(5)); + return (Map) JMeterContextService.getContext().getThreadState() + .computeIfAbsent(THREAD_STATE_CLIENTS, name -> new ConcurrentHashMap(5)); } /** @@ -1796,9 +1795,10 @@ protected void threadFinished() { * thread, and they are done by the time the sample returns, so no client is closed while a * request is still using it. */ + @SuppressWarnings("unchecked") private static void closeClientsOfCurrentThread() { - Map mapHttpClientPerHttpClientKey = - HTTPCLIENTS_CACHE_PER_THREAD_AND_HTTPCLIENTKEY.remove(JMeterContextService.getContext()); + Map mapHttpClientPerHttpClientKey = (Map) + JMeterContextService.getContext().getThreadState().remove(THREAD_STATE_CLIENTS); if (mapHttpClientPerHttpClientKey != null ) { for (HttpClientState clientState : mapHttpClientPerHttpClientKey.values() ) { JOrphanUtils.closeQuietly(clientState.getClient()); diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java index cd454112a7f..c7d5bdb4cdd 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java @@ -39,14 +39,12 @@ import java.security.PrivilegedActionException; import java.security.PrivilegedExceptionAction; import java.util.ArrayList; -import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Objects; import java.util.Properties; -import java.util.WeakHashMap; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; @@ -187,25 +185,25 @@ public class HTTPHC5Impl extends HTTPHCAbstractImpl { private static final String CONTEXT_ATTRIBUTE_PREEMPTIVE_AUTHORIZATION = "__jmeter.PREEMPTIVE_AUTH__"; //$NON-NLS-1$ /** - * Clients of every JMeter thread, looked up by the {@link JMeterContext} of that thread instead - * of by the thread itself. The threads that download embedded resources in parallel are pooled - * and shared by all JMeter threads, and they adopt the context of the JMeter thread they are - * working for, so looking the clients up by context hands them the clients of that thread. That - * shares a connection between a sample and its embedded resources, which lets HTTP/2 multiplex - * them, and it keeps a JMeter thread from closing clients another one is still using. + * Name of the {@link JMeterContext#getThreadState() thread state} entry which holds the clients + * of a JMeter thread. They are looked up by the {@link JMeterContext} of that thread instead of + * by the thread itself. The threads that download embedded resources in parallel are pooled and + * shared by all JMeter threads, and they adopt the context of the JMeter thread they are working + * for, so looking the clients up by context hands them the clients of that thread. That shares a + * connection between a sample and its embedded resources, which lets HTTP/2 multiplex them, and + * it keeps a JMeter thread from closing clients another one is still using. * - *

    The keys are weak, so that a context whose clients are never closed explicitly, like the - * one of a thread of the HTTP(S) Test Script Recorder, does not keep the entry alive forever. + *

    The clients go away with the context, so the ones of a context which never closes them + * explicitly, like the one of a thread of the HTTP(S) Test Script Recorder, are not kept forever. */ - private static final Map> HTTP_CLIENTS = - Collections.synchronizedMap(new WeakHashMap<>()); + private static final String THREAD_STATE_CLIENTS = "__jmeter.HC5_CLIENTS__"; //$NON-NLS-1$ /** - * HTTP/2 clients of every JMeter thread, see {@link #HTTP_CLIENTS} for how they are shared with - * the threads that download embedded resources in parallel. + * Name of the {@link JMeterContext#getThreadState() thread state} entry which holds the HTTP/2 + * clients of a JMeter thread, see {@link #THREAD_STATE_CLIENTS} for how they are shared with the + * threads that download embedded resources in parallel. */ - private static final Map> HTTP_2_CLIENTS = - Collections.synchronizedMap(new WeakHashMap<>()); + private static final String THREAD_STATE_HTTP_2_CLIENTS = "__jmeter.HC5_HTTP_2_CLIENTS__"; //$NON-NLS-1$ /** Multiplex concurrent message exchanges over a single HTTP/2 connection. */ private static final boolean HTTP_2_MULTIPLEXING = @@ -1254,17 +1252,23 @@ private static void saveConnectionCookies(ClassicHttpResponse response, URL url, private static HC5ClassicClient getClient(HttpClientKey key) { Map clients = - HTTP_CLIENTS.computeIfAbsent(JMeterContextService.getContext(), context -> new ConcurrentHashMap<>()); + clientsOfCurrentThread(JMeterContextService.getContext(), THREAD_STATE_CLIENTS); return clients.computeIfAbsent(key, HTTPHC5Impl::createClient); } private static CloseableHttpAsyncClient getHttp2Client(HttpClientKey key) { JMeterContext jmeterContext = JMeterContextService.getContext(); Map clients = - HTTP_2_CLIENTS.computeIfAbsent(jmeterContext, context -> new ConcurrentHashMap<>()); + clientsOfCurrentThread(jmeterContext, THREAD_STATE_HTTP_2_CLIENTS); return clients.computeIfAbsent(key.forAnyOrigin(), k -> createHttp2Client(k, jmeterContext)); } + @SuppressWarnings("unchecked") + private static Map clientsOfCurrentThread(JMeterContext jmeterContext, String name) { + return (Map) jmeterContext.getThreadState() + .computeIfAbsent(name, n -> new ConcurrentHashMap()); + } + private static boolean isDefaultUserAgentDisabled() { return JMeterUtils.getPropDefault(DISABLE_DEFAULT_UA_PROPERTY, false); } @@ -1756,12 +1760,14 @@ static void closeClientsOfCurrentThread() { // Only the clients of this JMeter thread are dropped. The threads which download embedded // resources borrow them while they work for this thread, and they are done by the time the // sample returns, so no client is closed while a request is still using it. - JMeterContext context = JMeterContextService.getContext(); - closeQuietly(HTTP_CLIENTS.remove(context)); - closeQuietly(HTTP_2_CLIENTS.remove(context)); + Map threadState = JMeterContextService.getContext().getThreadState(); + closeQuietly(threadState.remove(THREAD_STATE_CLIENTS)); + closeQuietly(threadState.remove(THREAD_STATE_HTTP_2_CLIENTS)); } - private static void closeQuietly(Map clients) { + @SuppressWarnings("unchecked") + private static void closeQuietly(Object threadStateClients) { + Map clients = (Map) threadStateClients; if (clients == null) { return; } From d0c257affd352a316cf339963c5d2c79896f14a8 Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Fri, 16 Oct 2026 11:18:39 +0200 Subject: [PATCH 74/80] Restore HTTPHCAbstractImpl.HTTP_VERSION and add @since to new HTTP API Renaming HTTP_VERSION to DEFAULT_HTTP_VERSION broke third-party subclasses that reference it. Restore it as a deprecated field that still holds the raw httpclient.version value. Drop HTTPConstantsInterface.HTTP_VERSION_1_1, which duplicated the existing HTTP_1_1, and mark the public and protected API added for 6.0 with @since 6.0. --- .../apache/jmeter/threads/JMeterContext.java | 1 + .../protocol/http/control/AuthManager.java | 1 + .../protocol/http/sampler/AjpSampler.java | 2 +- .../http/sampler/HTTPAbstractImpl.java | 11 ++++++--- .../protocol/http/sampler/HTTPHC5Impl.java | 4 +++- .../http/sampler/HTTPHCAbstractImpl.java | 23 ++++++++++++++++++- .../http/sampler/HTTPSamplerBase.java | 18 +++++++++++++++ .../http/sampler/HTTPSamplerFactory.java | 10 ++++++-- .../http/util/HTTPConstantsInterface.java | 10 ++++++-- .../http/sampler/HTTPSamplerBaseSchema.kt | 5 ++++ .../http/sampler/TestHTTPHC5Features.java | 2 +- 11 files changed, 76 insertions(+), 11 deletions(-) diff --git a/src/core/src/main/java/org/apache/jmeter/threads/JMeterContext.java b/src/core/src/main/java/org/apache/jmeter/threads/JMeterContext.java index 619dc729ab8..547587ca7f4 100644 --- a/src/core/src/main/java/org/apache/jmeter/threads/JMeterContext.java +++ b/src/core/src/main/java/org/apache/jmeter/threads/JMeterContext.java @@ -294,6 +294,7 @@ public Map getSamplerContext() { * thread they work for, so the map is thread-safe. * * @return state of the thread, keyed by names unique to the component which uses them + * @since 6.0 */ @API(status = API.Status.EXPERIMENTAL, since = "6.0.0") public Map getThreadState() { diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/AuthManager.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/AuthManager.java index 9fbe3f18f68..ce2df46c1d5 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/AuthManager.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/AuthManager.java @@ -502,6 +502,7 @@ public void setupCredentials(Authorization auth, URL url, * * @param url to be checked * @return true when port should omitted in SPN + * @since 6.0 */ public static boolean isStripPort(URL url) { if (STRIP_PORT) { diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/AjpSampler.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/AjpSampler.java index 00f6cfad2e0..f7002d911fb 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/AjpSampler.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/AjpSampler.java @@ -180,7 +180,7 @@ private void setupConnection(URL url, } else { setByte((byte)2); } - if (isHttp10(JMeterUtils.getPropDefault("httpclient.version", HTTPConstants.HTTP_VERSION_1_1))) {//$NON-NLS-1$ + if (isHttp10(JMeterUtils.getPropDefault("httpclient.version", HTTPConstants.HTTP_1_1))) {//$NON-NLS-1$ setString(HTTP_1_0); } else { setString(HTTPConstants.HTTP_1_1); diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPAbstractImpl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPAbstractImpl.java index 5b005e2149c..dbdd00374fb 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPAbstractImpl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPAbstractImpl.java @@ -81,7 +81,11 @@ private enum CachedResourceMode { private static final String RETURN_CUSTOM_STATUS_CODE = JMeterUtils.getProperty("RETURN_CUSTOM_STATUS.code");//$NON-NLS-1$ - /** Name of the property which sets the default HTTP version of the samplers. */ + /** + * Name of the property which sets the default HTTP version of the samplers. + * + * @since 6.0 + */ protected static final String HTTP_VERSION_PROPERTY = "httpclient.version"; // $NON-NLS-1$ protected static final Predicate ALL_EXCEPT_COOKIE = s -> !HTTPConstants.HEADER_COOKIE.equalsIgnoreCase(s); @@ -101,10 +105,11 @@ private enum CachedResourceMode { * * @return the value of the property, normalized by {@link #normalizeHttpVersion(String)}, * defaulting to {@code HTTP/1.1} + * @since 6.0 */ protected static String readDefaultHttpVersion() { return normalizeHttpVersion( - JMeterUtils.getPropDefault(HTTP_VERSION_PROPERTY, HTTPConstants.HTTP_VERSION_1_1)); + JMeterUtils.getPropDefault(HTTP_VERSION_PROPERTY, HTTPConstants.HTTP_1_1)); } /** @@ -118,7 +123,7 @@ protected static String readDefaultHttpVersion() { static String normalizeHttpVersion(String httpVersion) { String version = httpVersion == null ? "" : httpVersion.trim(); if ("1.0".equals(version) || "1.1".equals(version)) { // $NON-NLS-1$ $NON-NLS-2$ - return HTTPConstants.HTTP_VERSION_1_1; + return HTTPConstants.HTTP_1_1; } return version; } diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java index c7d5bdb4cdd..0d4ce191bbb 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java @@ -172,6 +172,8 @@ /** * HTTP Sampler using Apache HttpClient 5.x. + * + * @since 6.0 */ public class HTTPHC5Impl extends HTTPHCAbstractImpl { @@ -1186,7 +1188,7 @@ private static long calculateSentBytes(org.apache.hc.client5.http.classic.method } String uri = request.getRequestUri() != null ? request.getRequestUri() : ""; org.apache.hc.core5.http.ProtocolVersion version = request.getVersion(); - String versionStr = version != null ? version.toString() : HTTPConstants.HTTP_VERSION_1_1; + String versionStr = version != null ? version.toString() : HTTPConstants.HTTP_1_1; long sentBytes = HTTPMessageSizes.requestLineLength(request.getMethod(), uri, versionStr); for (Header header : request.getHeaders()) { diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHCAbstractImpl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHCAbstractImpl.java index 5b2cd7e1775..b8d42ff558f 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHCAbstractImpl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHCAbstractImpl.java @@ -90,7 +90,20 @@ public abstract class HTTPHCAbstractImpl extends HTTPAbstractImpl { protected static final boolean USE_LOOPBACK = JMeterUtils.getPropDefault("httpclient.loopback", false); - /** Default value of the sampler {@code HTTPSampler.httpVersion} property, see {@code httpclient.version}. */ + /** + * Raw value of the {@code httpclient.version} property, defaulting to {@code 1.1}. + * + * @deprecated use {@link #DEFAULT_HTTP_VERSION}, which maps the legacy values {@code 1.0} and + * {@code 1.1} to {@code HTTP/1.1} + */ + @Deprecated + protected static final String HTTP_VERSION = JMeterUtils.getPropDefault(HTTP_VERSION_PROPERTY, "1.1"); + + /** + * Default value of the sampler {@code HTTPSampler.httpVersion} property, see {@code httpclient.version}. + * + * @since 6.0 + */ protected static final String DEFAULT_HTTP_VERSION = readDefaultHttpVersion(); // -1 means not defined @@ -206,6 +219,8 @@ protected static boolean isNullOrEmptyTrimmed(String value) { /** * Iterates the headers of a request or a response, so that the helpers below can be shared by * the HttpClient implementations although they represent a header with different types. + * + * @since 6.0 */ @FunctionalInterface protected interface HeaderIterable { @@ -223,6 +238,7 @@ protected interface HeaderIterable { * @param headers headers of the request or the response * @param predicate selects the headers to render by name * @return the headers as a string, one per line + * @since 6.0 */ protected static String formatHeaders(HeaderIterable headers, Predicate predicate) { StringBuilder result = new StringBuilder(150); @@ -242,6 +258,7 @@ protected static String formatHeaders(HeaderIterable headers, Predicate setHeader) { @@ -262,6 +279,7 @@ protected static String setConnectionCookie(URL url, CookieManager cookieManager * @param headers headers of the request * @return the value of the {@code Cookie} header, the values of several such headers joined the * way a single header would hold them, or an empty string if the request has none + * @since 6.0 */ protected static String getOnlyCookieFromHeaders(HeaderIterable headers) { StringBuilder cookies = new StringBuilder(); @@ -282,6 +300,7 @@ protected static String getOnlyCookieFromHeaders(HeaderIterable headers) { * @param headers headers of the response * @param url URL the response was received from * @param cookieManager stores the cookies, may be {@code null} + * @since 6.0 */ protected static void saveConnectionCookies(HeaderIterable headers, URL url, CookieManager cookieManager) { if (cookieManager == null) { @@ -303,6 +322,7 @@ protected static void saveConnectionCookies(HeaderIterable headers, URL url, Coo * @param url URL of the request, used to normalize the {@code Host} header * @param isAllowed additional filter of the implementation, by header name * @param addHeader adds a header to the request, keeping the ones already set + * @since 6.0 */ protected static void setConnectionHeaders(HeaderManager headerManager, URL url, Predicate isAllowed, BiConsumer addHeader) { @@ -374,6 +394,7 @@ private static int getPortFromHostHeader(String hostHeaderValue, int defaultValu * @param contentEncoding encoding of the request body * @param consumer called with the name and the value of every parameter * @throws UnsupportedEncodingException when the content encoding is not supported + * @since 6.0 */ protected void forEachFormParameter(String contentEncoding, BiConsumer consumer) throws UnsupportedEncodingException { diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPSamplerBase.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPSamplerBase.java index 553248bd21e..43b446ed83b 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPSamplerBase.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPSamplerBase.java @@ -160,6 +160,12 @@ public abstract class HTTPSamplerBase extends AbstractSampler public static final String IMPLEMENTATION = "HTTPSampler.implementation"; // $NON-NLS-1$ + /** + * Name of the property which selects the HTTP version of the sampler, see + * {@link #setHttpVersion(String)}. + * + * @since 6.0 + */ public static final String HTTP_VERSION = HTTPSamplerBaseSchema.INSTANCE.getHttpVersion().getName(); public static final String PATH = "HTTPSampler.path"; // $NON-NLS-1$ @@ -654,10 +660,22 @@ public String getImplementation() { return get(getSchema().getImplementation()); } + /** + * Sets the HTTP version to use, one of {@link HTTPConstants#HTTP_1_1}, + * {@link HTTPConstants#HTTP_VERSION_2} or {@link HTTPConstants#HTTP_VERSION_2_STRICT}. An empty + * value uses the {@code httpclient.version} property instead. + * + * @param value the HTTP version + * @since 6.0 + */ public void setHttpVersion(String value) { set(getSchema().getHttpVersion(), value); } + /** + * @return the HTTP version to use, see {@link #setHttpVersion(String)} + * @since 6.0 + */ public String getHttpVersion() { return get(getSchema().getHttpVersion()); } diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPSamplerFactory.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPSamplerFactory.java index 780ba2a3e10..56a83272466 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPSamplerFactory.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPSamplerFactory.java @@ -39,6 +39,11 @@ public final class HTTPSamplerFactory { //+ JMX implementation attribute values (also displayed in GUI) - do not change public static final String IMPL_HTTP_CLIENT4 = "HttpClient4"; // $NON-NLS-1$ + /** + * Use Apache HttpClient 5 HTTP implementation + * + * @since 6.0 + */ public static final String IMPL_HTTP_CLIENT5 = "HttpClient5"; // $NON-NLS-1$ public static final String IMPL_HTTP_CLIENT3_1 = "HttpClient3.1"; // $NON-NLS-1$ @@ -97,16 +102,17 @@ public static String[] getImplementations(){ * * @param implementation implementation name, an empty value refers to the default implementation * @return the selectable values of the {@code HTTPSampler.httpVersion} property + * @since 6.0 */ public static String[] getHttpVersions(String implementation) { String impl = StringUtilities.isBlank(implementation) ? DEFAULT_CLASSNAME : implementation; if (IMPL_HTTP_CLIENT5.equals(impl)) { // HttpClient 5 is the only implementation which can require HTTP/2 for the connection - return new String[]{"", HTTPConstants.HTTP_VERSION_1_1, HTTPConstants.HTTP_VERSION_2, + return new String[]{"", HTTPConstants.HTTP_1_1, HTTPConstants.HTTP_VERSION_2, HTTPConstants.HTTP_VERSION_2_STRICT}; } // HttpClient4 and Java (and their aliases) never read the HTTP version of the sampler - return new String[]{"", HTTPConstants.HTTP_VERSION_1_1}; + return new String[]{"", HTTPConstants.HTTP_1_1}; } public static HTTPAbstractImpl getImplementation(String impl, HTTPSamplerBase base){ diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/util/HTTPConstantsInterface.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/util/HTTPConstantsInterface.java index 3157fddc049..5defe6aee12 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/util/HTTPConstantsInterface.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/util/HTTPConstantsInterface.java @@ -33,17 +33,19 @@ public interface HTTPConstantsInterface { // CHECKSTYLE IGNORE InterfaceIsType String DEFAULT_HTTP_PORT_STRING = "80"; // $NON-NLS-1$ String PROTOCOL_HTTP = "http"; // $NON-NLS-1$ String PROTOCOL_HTTPS = "https"; // $NON-NLS-1$ - /** Value of the sampler {@code HTTPSampler.httpVersion} property which forces HTTP/1.1. */ - String HTTP_VERSION_1_1 = "HTTP/1.1"; // $NON-NLS-1$ /** * Value of the sampler {@code HTTPSampler.httpVersion} property which uses HTTP/2 if the server * agrees to it, falling back to HTTP/1.1 otherwise. It is also the protocol version as it appears * on the wire and in the request/response headers of a sample. + * + * @since 6.0 */ String HTTP_VERSION_2 = "HTTP/2"; // $NON-NLS-1$ /** * Value of the sampler {@code HTTPSampler.httpVersion} property which requires HTTP/2, so a * server that does not support it fails the sample instead of being used with HTTP/1.1. + * + * @since 6.0 */ String HTTP_VERSION_2_STRICT = "HTTP/2 Strict"; // $NON-NLS-1$ String HEAD = "HEAD"; // $NON-NLS-1$ @@ -74,6 +76,10 @@ public interface HTTPConstantsInterface { // CHECKSTYLE IGNORE InterfaceIsType // e.g. "Transfer-Encoding: chunked", which is processed automatically by the underlying protocol String TRANSFER_ENCODING = "transfer-encoding"; // $NON-NLS-1$ String HEADER_CONTENT_ENCODING = "content-encoding"; // $NON-NLS-1$ + /** + * HTTP/1.1 protocol version. It is also the value of the sampler {@code HTTPSampler.httpVersion} + * property which forces HTTP/1.1. + */ String HTTP_1_1 = "HTTP/1.1"; // $NON-NLS-1$ String HEADER_SET_COOKIE = "set-cookie"; // $NON-NLS-1$ // Brotli compression not supported yet by HC4 4.5.2 , but to be added diff --git a/src/protocol/http/src/main/kotlin/org/apache/jmeter/protocol/http/sampler/HTTPSamplerBaseSchema.kt b/src/protocol/http/src/main/kotlin/org/apache/jmeter/protocol/http/sampler/HTTPSamplerBaseSchema.kt index 84698fff881..6e4b8668023 100644 --- a/src/protocol/http/src/main/kotlin/org/apache/jmeter/protocol/http/sampler/HTTPSamplerBaseSchema.kt +++ b/src/protocol/http/src/main/kotlin/org/apache/jmeter/protocol/http/sampler/HTTPSamplerBaseSchema.kt @@ -87,6 +87,11 @@ public abstract class HTTPSamplerBaseSchema : TestElementSchema() { public val implementation: StringPropertyDescriptor by string("HTTPSampler.implementation") + /** + * HTTP version of the sampler, see [HTTPSamplerBase.setHttpVersion]. + * + * @since 6.0 + */ public val httpVersion: StringPropertyDescriptor by string("HTTPSampler.httpVersion") diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java index 430aef936b7..765856b7b3b 100644 --- a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java +++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java @@ -917,7 +917,7 @@ private static long wireSize(LoggedRequest request) { StringBuilder head = new StringBuilder() .append(request.getMethod().getName()).append(' ') .append(request.getUrl()).append(' ') - .append(HTTPConstants.HTTP_VERSION_1_1).append("\r\n"); + .append(HTTPConstants.HTTP_1_1).append("\r\n"); for (HttpHeader header : request.getHeaders().all()) { for (String value : header.values()) { head.append(header.key()).append(": ").append(value).append("\r\n"); From 30470c5c99a6e1b7b5b54ff17fa106e8bff650d1 Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Fri, 16 Oct 2026 11:30:20 +0200 Subject: [PATCH 75/80] Document that an HTTP/2 response timeout fails the whole connection HttpClient discards the connection of a failed or cancelled exchange and offers no way to reset only the stream. With multiplexing, a request that exceeds its response timeout or is interrupted therefore also fails the other requests the thread has in flight over that connection, like the remaining parallel downloads of embedded resources. Over HTTP/1.1 only the request concerned fails. Describe this in the component and properties reference, along with httpclient5.h2.multiplexing=false as a way to give each concurrent request a connection of its own. --- bin/jmeter.properties | 5 ++++- .../http/sampler/AsyncResponseTimeout.java | 4 +++- xdocs/usermanual/component_reference.xml | 14 ++++++++++++-- xdocs/usermanual/properties_reference.xml | 4 +++- 4 files changed, 22 insertions(+), 5 deletions(-) diff --git a/bin/jmeter.properties b/bin/jmeter.properties index a94cce8b8fe..d7ac2f4f4bb 100644 --- a/bin/jmeter.properties +++ b/bin/jmeter.properties @@ -397,7 +397,10 @@ remote_hosts=127.0.0.1 # Multiplex concurrent message exchanges (e.g. parallel downloads of embedded # resources) over a single HTTP/2 connection. The connections are not shared -# between threads, every JMeter thread uses connections of its own +# between threads, every JMeter thread uses connections of its own. +# A request which exceeds its response timeout or is interrupted closes the +# shared connection, which fails the other requests in flight over it as well. +# Set to false to give each concurrent request a connection of its own #httpclient5.h2.multiplexing=true # Maximum number of HTTP/2 connections kept per target host. With multiplexing diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/AsyncResponseTimeout.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/AsyncResponseTimeout.java index 013a527e2b1..4aba2be6c6e 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/AsyncResponseTimeout.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/AsyncResponseTimeout.java @@ -153,7 +153,9 @@ T await(Future responseFuture) long remainingNanos = remainingNanos(timeoutNanos); if (remainingNanos <= 0) { // HttpClient discards the connection of a cancelled exchange, which also fails the - // requests multiplexed over it, just like it does when a sample is interrupted + // requests multiplexed over it, just like it does when a sample is interrupted. It + // discards the connection of any failed exchange and offers no way to reset only + // the stream, see the HTTP Version of the HTTP Request in component_reference.xml if (responseFuture.cancel(true)) { throw new SocketTimeoutException("Response timeout of " + timeout.toMilliseconds() + " ms exceeded while waiting for the server"); diff --git a/xdocs/usermanual/component_reference.xml b/xdocs/usermanual/component_reference.xml index a953e1f0859..b82cf711291 100644 --- a/xdocs/usermanual/component_reference.xml +++ b/xdocs/usermanual/component_reference.xml @@ -254,7 +254,12 @@ https.default.protocol=SSLv3

    With HTTP/2 a thread multiplexes its concurrent requests, for example the parallel downloads of embedded resources, over a single connection per host, but the connections are not shared between threads, so a test with N threads still opens N connections like - N real users would.

    + N real users would.

    +

    When a request over such a shared connection exceeds the Response Timeout or is interrupted, + HttpClient closes the whole connection, so the other requests the thread has in flight over it, like + the remaining parallel downloads, fail as well. Over HTTP/1.1 only the request concerned fails. Set the + property httpclient5.h2.multiplexing to false to give each concurrent request + an HTTP/2 connection of its own, up to httpclient5.h2.max_connections_per_route per host.

    HTTP, HTTPS or FILE. Default: HTTP GET, POST, HEAD, TRACE, OPTIONS, PUT, DELETE, PATCH (not supported for @@ -3958,7 +3963,12 @@ All port values are treated equally; a sampler that does not specify a port will

    With HTTP/2 a thread multiplexes its concurrent requests, for example the parallel downloads of embedded resources, over a single connection per host, but the connections are not shared between threads, so a test with N threads still opens N connections like - N real users would.

    + N real users would.

    +

    When a request over such a shared connection exceeds the Response Timeout or is interrupted, + HttpClient closes the whole connection, so the other requests the thread has in flight over it, like + the remaining parallel downloads, fail as well. Over HTTP/1.1 only the request concerned fails. Set the + property httpclient5.h2.multiplexing to false to give each concurrent request + an HTTP/2 connection of its own, up to httpclient5.h2.max_connections_per_route per host.

    HTTP or HTTPS. The encoding to be used for the request. The path to resource (for example, /servlets/myServlet). If the diff --git a/xdocs/usermanual/properties_reference.xml b/xdocs/usermanual/properties_reference.xml index 196552c1a49..e0de82d4a1e 100644 --- a/xdocs/usermanual/properties_reference.xml +++ b/xdocs/usermanual/properties_reference.xml @@ -463,7 +463,9 @@ JMETER-SERVER Multiplex concurrent message exchanges (for example parallel downloads of embedded resources) over a single HTTP/2 connection. The connections are not shared between threads, every JMeter - thread uses connections of its own.
    + thread uses connections of its own. A request which exceeds its response timeout or is interrupted + closes the shared connection, which fails the other requests in flight over it as well. Set to + false to give each concurrent request a connection of its own.
    Defaults to: true
    From ee5fa402d60124c30983347a4801ff6e0160af5b Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Fri, 16 Oct 2026 11:57:58 +0200 Subject: [PATCH 76/80] Document the HTTP/2 connection limit of HTTPHC5Impl for parallel downloads The HTTP/2 client of HTTPHC5Impl keeps at most httpclient5.h2.max_connections_per_route connections per host, six by default. When the server negotiates HTTP/1.1 through ALPN, or multiplexing is disabled, parallel downloads beyond that limit wait for a connection, and the wait counts in their elapsed time. Unlike the pool of the HTTP/1.1 client, the limit stays a hard maximum: HttpClient only shares an HTTP/2 connection once it is open, so raising it would let a burst of downloads open that many HTTP/2 connections. Describe this at the pool size of the HTTP Request and in the properties reference. --- bin/jmeter.properties | 8 ++++++-- .../protocol/http/sampler/HTTPHC5Impl.java | 6 ++++++ xdocs/usermanual/component_reference.xml | 18 ++++++++++++++++-- xdocs/usermanual/properties_reference.xml | 9 +++++++-- 4 files changed, 35 insertions(+), 6 deletions(-) diff --git a/bin/jmeter.properties b/bin/jmeter.properties index d7ac2f4f4bb..d25be9de279 100644 --- a/bin/jmeter.properties +++ b/bin/jmeter.properties @@ -403,8 +403,12 @@ remote_hosts=127.0.0.1 # Set to false to give each concurrent request a connection of its own #httpclient5.h2.multiplexing=true -# Maximum number of HTTP/2 connections kept per target host. With multiplexing -# enabled a single connection usually serves all concurrent requests +# Maximum number of connections kept per target host when HTTP/2 is selected. +# With multiplexing enabled a single connection usually serves all concurrent +# requests. When the server negotiates HTTP/1.1 instead, or multiplexing is +# disabled, each concurrent request needs a connection of its own and the ones +# beyond this limit wait for one, so set it to at least the number of parallel +# downloads of embedded resources then #httpclient5.h2.max_connections_per_route=6 # Number of bytes of an HTTP/2 request body buffered in memory before it is diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java index 0d4ce191bbb..a669ee3e18c 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java @@ -230,6 +230,12 @@ public class HTTPHC5Impl extends HTTPHCAbstractImpl { private static final boolean HTTP_2_PRIOR_KNOWLEDGE = JMeterUtils.getPropDefault("httpclient5.h2.prior_knowledge", false); + /** + * Connections per host of the HTTP/2 client. Unlike {@link HC5ClassicClient#allowConnectionsPerRoute(int)} + * this maximum is not raised to the parallel downloads, as HttpClient only shares an HTTP/2 connection + * once it is open, so a burst of downloads to a host would open that many HTTP/2 connections. A server + * that negotiates HTTP/1.1 through ALPN therefore needs the property raised. + */ private static final int HTTP_2_MAX_CONNECTIONS_PER_ROUTE = JMeterUtils.getPropDefault("httpclient5.h2.max_connections_per_route", 6); diff --git a/xdocs/usermanual/component_reference.xml b/xdocs/usermanual/component_reference.xml index b82cf711291..06632fca95b 100644 --- a/xdocs/usermanual/component_reference.xml +++ b/xdocs/usermanual/component_reference.xml @@ -375,7 +375,14 @@ and send HTTP/HTTPS requests for all images, Java applets, JavaScript files, CSS .*\.(?i:svg|png) Use a pool of concurrent connections to get embedded resources. - Pool size for concurrent connections used to get embedded resources. + Pool size for concurrent connections used to get embedded resources. +

    With the HttpClient5 implementation and HTTP/2 selected as HTTP Version, at most + httpclient5.h2.max_connections_per_route (default 6) connections are opened per host. + Downloads beyond that limit wait for a connection, and the wait counts in their elapsed time, unless they are + multiplexed over an HTTP/2 connection to that host which is already open. So when the server negotiates + HTTP/1.1 instead of HTTP/2, or when httpclient5.h2.multiplexing is false, set the + property to at least the pool size. With HTTP/1.1 selected, HttpClient5 allows as many + connections per host as the pool size by itself.

    [Only for HTTP Request with HTTPClient implementation]

    To distinguish the source address value, select the type of these: @@ -3991,7 +3998,14 @@ All port values are treated equally; a sampler that does not specify a port will and send HTTP/HTTPS requests for all images, Java applets, JavaScript files, CSSs, etc. referenced in the file.
    Use a pool of concurrent connections to get embedded resources. - Pool size for concurrent connections used to get embedded resources. + Pool size for concurrent connections used to get embedded resources. +

    With the HttpClient5 implementation and HTTP/2 selected as HTTP Version, at most + httpclient5.h2.max_connections_per_route (default 6) connections are opened per host. + Downloads beyond that limit wait for a connection, and the wait counts in their elapsed time, unless they are + multiplexed over an HTTP/2 connection to that host which is already open. So when the server negotiates + HTTP/1.1 instead of HTTP/2, or when httpclient5.h2.multiplexing is false, set the + property to at least the pool size. With HTTP/1.1 selected, HttpClient5 allows as many + connections per host as the pool size by itself.

    If present, this must be a regular expression that is used to match against any embedded URLs found. So if you only want to download embedded resources from http://example.invalid/, use the expression: diff --git a/xdocs/usermanual/properties_reference.xml b/xdocs/usermanual/properties_reference.xml index e0de82d4a1e..e186f0787a0 100644 --- a/xdocs/usermanual/properties_reference.xml +++ b/xdocs/usermanual/properties_reference.xml @@ -469,8 +469,13 @@ JMETER-SERVER Defaults to: true - Maximum number of HTTP/2 connections kept per target host. With multiplexing enabled a single - connection usually serves all concurrent requests.
    + Maximum number of connections kept per target host by the client that the HttpClient5 + implementation uses when HTTP/2 is selected. With multiplexing enabled a single connection + usually serves all concurrent requests. When the server negotiates HTTP/1.1 instead, or + multiplexing is disabled, every concurrent request needs a connection of its own, and the requests + beyond this limit wait for one, which counts in their elapsed time. Set it to at least the number of + parallel downloads of embedded resources then, as unlike with HTTP/1.1 selected, the limit is not + raised to that number automatically.
    Defaults to: 6
    From d169226553301b2f5017c63f5cbfc815e5f0fb43 Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Fri, 16 Oct 2026 15:07:57 +0200 Subject: [PATCH 77/80] Report the request HttpClient sent last in HTTPHC5Impl The request headers, cookies and sent bytes of a sample were those of the request JMeter built. When HttpClient follows redirects on its own, AutoRedirectStrategy sets the Cookie and Authorization headers per hop, so after a redirect to another origin the View Results Tree showed the cookies and credentials of the first origin rather than those sent. Report the request both transports keep in the context, like HTTPHC4Impl does. It is the request of the last hop, with the headers HttpClient adds itself, such as Host or the Authorization of an authentication. If HttpClient fails before it sends the request, the one JMeter built is reported as before. The reporting moves to the new HC5SentRequest. --- .../protocol/http/sampler/HC5SentRequest.java | 174 ++++++++++++++++++ .../protocol/http/sampler/HTTPHC5Impl.java | 99 +--------- .../http/sampler/TestHTTPHC5Redirects.java | 61 ++++++ 3 files changed, 239 insertions(+), 95 deletions(-) create mode 100644 src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HC5SentRequest.java diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HC5SentRequest.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HC5SentRequest.java new file mode 100644 index 00000000000..11920bcd772 --- /dev/null +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HC5SentRequest.java @@ -0,0 +1,174 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you 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.apache.jmeter.protocol.http.sampler; + +import java.io.IOException; +import java.io.OutputStream; + +import org.apache.hc.client5.http.classic.methods.HttpUriRequestBase; +import org.apache.hc.client5.http.protocol.HttpClientContext; +import org.apache.hc.core5.http.ClassicHttpRequest; +import org.apache.hc.core5.http.Header; +import org.apache.hc.core5.http.HttpEntity; +import org.apache.hc.core5.http.HttpHeaders; +import org.apache.hc.core5.http.HttpRequest; +import org.apache.hc.core5.http.ProtocolVersion; +import org.apache.hc.core5.net.URIAuthority; +import org.apache.jmeter.protocol.http.util.HTTPConstants; +import org.apache.jorphan.io.CountingOutputStream; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Reports the request of a sample of {@link HTTPHC5Impl} the way HttpClient sent it, like + * {@link HTTPHC4Impl} does. + *

    + * HttpClient sends a copy of the request JMeter built, to which its protocol interceptors add headers + * like {@code Host}, or the {@code Authorization} of an authentication. When it follows redirects on its + * own, it sends a request of its own for every hop, whose {@code Cookie} and {@code Authorization} + * headers {@link AutoRedirectStrategy} sets for the URL of that hop. Both transports keep the request + * they sent last in the context, so that is the one reported, which is what the server of the final + * hop received. + */ +final class HC5SentRequest { + + private static final Logger log = LoggerFactory.getLogger(HC5SentRequest.class); + + private static final String CONTEXT_ATTRIBUTE_PREEMPTIVE_AUTHORIZATION = "__jmeter.PREEMPTIVE_AUTH__"; //$NON-NLS-1$ + + private HC5SentRequest() { + // utility class + } + + /** + * Remembers the {@code Authorization} header HttpClient is going to send pre-emptively, so that it + * can be reported even if HttpClient fails before it sends the request. + * + * @param context context the request is executed with + * @param header value of the header + */ + static void rememberPreemptiveAuthorization(HttpClientContext context, String header) { + context.setAttribute(CONTEXT_ATTRIBUTE_PREEMPTIVE_AUTHORIZATION, header); + } + + /** + * Sets the request headers, the cookies and the sent bytes of the result to those of the request + * HttpClient sent last. If it failed before it sent any, the request JMeter built is reported, with + * the {@code Authorization} header HttpClient was going to add pre-emptively. + * + * @param request request JMeter built + * @param context context the request was executed with, may be {@code null} + * @param result result of the sample + */ + static void report(HttpUriRequestBase request, HttpClientContext context, HTTPSampleResult result) { + HttpRequest sent = context == null ? null : context.getRequest(); + if (sent != null) { + result.setRequestHeaders(headers(sent)); + result.setSentBytes(size(sent, entityOf(sent, request))); + result.setCookies(HTTPHCAbstractImpl.getOnlyCookieFromHeaders(HTTPHC5Impl.headersOf(sent))); + return; + } + String headers = headers(request); + long size = size(request, request.getEntity()); + String authorization = context == null ? null + : (String) context.getAttribute(CONTEXT_ATTRIBUTE_PREEMPTIVE_AUTHORIZATION); + if (authorization != null) { + headers += HttpHeaders.AUTHORIZATION + ": " + authorization + "\n"; + size += HTTPMessageSizes.headerLength(HttpHeaders.AUTHORIZATION, authorization); + } + result.setRequestHeaders(headers); + result.setSentBytes(size); + } + + /** + * Body of the request HttpClient sent. The classic transport keeps it with the request, while the + * HTTP/2 one streams the body of the request JMeter built, which a redirect only drops when it + * changes the method to {@code GET}. + */ + private static HttpEntity entityOf(HttpRequest sent, HttpUriRequestBase request) { + if (sent instanceof ClassicHttpRequest classicRequest) { + return classicRequest.getEntity(); + } + return request.getMethod().equalsIgnoreCase(sent.getMethod()) ? request.getEntity() : null; + } + + private static String headers(HttpRequest request) { + return HTTPHCAbstractImpl.formatHeaders(HTTPHC5Impl.headersOf(request), HTTPAbstractImpl.ALL_EXCEPT_COOKIE); + } + + private static long size(HttpRequest request, HttpEntity entity) { + String uri = request.getRequestUri() != null ? request.getRequestUri() : ""; + ProtocolVersion version = request.getVersion(); + String versionStr = version != null ? version.toString() : HTTPConstants.HTTP_1_1; + long sentBytes = HTTPMessageSizes.requestLineLength(request.getMethod(), uri, versionStr); + + for (Header header : request.getHeaders()) { + sentBytes += HTTPMessageSizes.headerLength(header.getName(), header.getValue()); + } + sentBytes += implicitHeaderLength(request, entity); + sentBytes += HTTPMessageSizes.EMPTY_LINE; + + if (entity != null) { + long contentLength = entity.getContentLength(); + if (contentLength >= 0) { + sentBytes += contentLength; + } else if (entity.isRepeatable()) { + CountingOutputStream counter = new CountingOutputStream(OutputStream.nullOutputStream()); + try { + entity.writeTo(counter); + sentBytes += counter.getBytesWritten(); + } catch (IOException e) { + log.debug("Exception measuring entity length", e); + } + } + } + return sentBytes; + } + + /** + * Length of the headers the protocol layer adds when it writes the request, so that they are + * accounted for although they are not part of the request object, like those the HTTP/2 transport + * turns into pseudo-headers or leaves out. + */ + private static long implicitHeaderLength(HttpRequest request, HttpEntity entity) { + long length = 0; + URIAuthority authority = request.getAuthority(); + if (authority != null && !request.containsHeader(HttpHeaders.HOST)) { + length += HTTPMessageSizes.headerLength(HttpHeaders.HOST, authority.toString()); + } + if (entity == null) { + return length; + } + if (entity.getContentType() != null && !request.containsHeader(HttpHeaders.CONTENT_TYPE)) { + length += HTTPMessageSizes.headerLength(HttpHeaders.CONTENT_TYPE, entity.getContentType()); + } + if (entity.getContentEncoding() != null && !request.containsHeader(HttpHeaders.CONTENT_ENCODING)) { + length += HTTPMessageSizes.headerLength(HttpHeaders.CONTENT_ENCODING, entity.getContentEncoding()); + } + if (request.containsHeader(HttpHeaders.CONTENT_LENGTH) || request.containsHeader(HttpHeaders.TRANSFER_ENCODING)) { + return length; + } + long contentLength = entity.getContentLength(); + if (contentLength >= 0) { + length += HTTPMessageSizes.headerLength(HttpHeaders.CONTENT_LENGTH, Long.toString(contentLength)); + } else if (entity.isChunked()) { + length += HTTPMessageSizes.headerLength(HttpHeaders.TRANSFER_ENCODING, "chunked"); + } + return length; + } +} diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java index a669ee3e18c..b0c29be590c 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java @@ -132,7 +132,6 @@ import org.apache.hc.core5.http2.HttpVersionPolicy; import org.apache.hc.core5.http2.config.H2Config; import org.apache.hc.core5.io.CloseMode; -import org.apache.hc.core5.net.URIAuthority; import org.apache.hc.core5.pool.PoolConcurrencyPolicy; import org.apache.hc.core5.reactor.ConnectionInitiator; import org.apache.hc.core5.reactor.IOReactorConfig; @@ -159,7 +158,6 @@ import org.apache.jmeter.util.JsseSSLManager; import org.apache.jmeter.util.SSLManager; import org.apache.jorphan.io.CountingInputStream; -import org.apache.jorphan.io.CountingOutputStream; import org.apache.jorphan.util.JOrphanUtils; import org.apache.jorphan.util.StringUtilities; import org.brotli.dec.BrotliInputStream; @@ -184,7 +182,6 @@ public class HTTPHC5Impl extends HTTPHCAbstractImpl { private static final String CONTEXT_ATTRIBUTE_RESPONSE_LATENCY = "__jmeter.S_R_LATENCY__"; //$NON-NLS-1$ private static final String CONTEXT_ATTRIBUTE_RESPONSE_BODY_SIZE = "__jmeter.S_R_BODY_SIZE__"; //$NON-NLS-1$ private static final String CONTEXT_ATTRIBUTE_RESPONSE_BODY_COUNTER = "__jmeter.S_R_BODY_COUNTER__"; //$NON-NLS-1$ - private static final String CONTEXT_ATTRIBUTE_PREEMPTIVE_AUTHORIZATION = "__jmeter.PREEMPTIVE_AUTH__"; //$NON-NLS-1$ /** * Name of the {@link JMeterContext#getThreadState() thread state} entry which holds the clients @@ -564,7 +561,7 @@ protected HTTPSampleResult sample(URL url, String method, boolean areFollowingRe currentRequest = null; updateResult(response, request, result); - reportPreemptiveAuthorization(context, result); + HC5SentRequest.report(request, context, result); updateUrlAfterAutoRedirects(url, context, result); if (cacheManager != null) { cacheManager.saveDetails(cacheResponseHeaders(response), result); @@ -579,9 +576,7 @@ protected HTTPSampleResult sample(URL url, String method, boolean areFollowingRe result.sampleEnd(); } if (request != null) { - result.setRequestHeaders(getRequestHeaders(request)); - result.setSentBytes(calculateSentBytes(request)); - reportPreemptiveAuthorization(context, result); + HC5SentRequest.report(request, context, result); } return errorResult(e, result); } finally { @@ -864,7 +859,7 @@ private static void setConnectionHeaders(org.apache.hc.client5.http.classic.meth * @param message request or response * @return the headers of the message */ - private static HeaderIterable headersOf(org.apache.hc.core5.http.MessageHeaders message) { + static HeaderIterable headersOf(org.apache.hc.core5.http.MessageHeaders message) { return action -> { for (Header header : message.getHeaders()) { action.accept(header.getName(), header.getValue()); @@ -951,25 +946,11 @@ private static void configureTargetCredentials(URL url, request.removeHeaders(HttpHeaders.AUTHORIZATION); String preemptiveAuthorization = HC5Authentication.preemptBasic(context, url, request, authorization); if (preemptiveAuthorization != null) { - context.setAttribute(CONTEXT_ATTRIBUTE_PREEMPTIVE_AUTHORIZATION, preemptiveAuthorization); + HC5SentRequest.rememberPreemptiveAuthorization(context, preemptiveAuthorization); } } } - /** - * Reports the {@code Authorization} header HttpClient sent pre-emptively with the request, as it is - * not part of the request JMeter built. It is not added to that request, as HttpClient reads it - * again whenever it answers a challenge. - */ - private static void reportPreemptiveAuthorization(HttpContext context, HTTPSampleResult result) { - String header = context == null ? null - : (String) context.getAttribute(CONTEXT_ATTRIBUTE_PREEMPTIVE_AUTHORIZATION); - if (header != null) { - result.setRequestHeaders(result.getRequestHeaders() + HttpHeaders.AUTHORIZATION + ": " + header + "\n"); - result.setSentBytes(result.getSentBytes() + HTTPMessageSizes.headerLength(HttpHeaders.AUTHORIZATION, header)); - } - } - /** * Sets up the {@code Negotiate} and {@code Kerberos} auth schemes for a single request, as * HttpClient 5 neither registers them nor prefers them by default. The credentials are taken @@ -1128,8 +1109,6 @@ private void readResponse(ClassicHttpResponse response, HTTPSampleResult result, private void updateResult(ClassicHttpResponse response, org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request, HTTPSampleResult result) { - result.setRequestHeaders(getRequestHeaders(request)); - result.setSentBytes(calculateSentBytes(request)); int statusCode = response.getCode(); result.setResponseCode(Integer.toString(statusCode)); result.setResponseMessage(response.getReasonPhrase()); @@ -1184,76 +1163,6 @@ private static String getResponseHeaders(ClassicHttpResponse response) { return headers.toString(); } - private static String getRequestHeaders(org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request) { - return formatHeaders(headersOf(request), ALL_EXCEPT_COOKIE); - } - - private static long calculateSentBytes(org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request) { - if (request == null) { - return 0; - } - String uri = request.getRequestUri() != null ? request.getRequestUri() : ""; - org.apache.hc.core5.http.ProtocolVersion version = request.getVersion(); - String versionStr = version != null ? version.toString() : HTTPConstants.HTTP_1_1; - long sentBytes = HTTPMessageSizes.requestLineLength(request.getMethod(), uri, versionStr); - - for (Header header : request.getHeaders()) { - sentBytes += HTTPMessageSizes.headerLength(header.getName(), header.getValue()); - } - sentBytes += implicitHeaderLength(request); - sentBytes += HTTPMessageSizes.EMPTY_LINE; - - HttpEntity entity = request.getEntity(); - if (entity != null) { - long contentLength = entity.getContentLength(); - if (contentLength >= 0) { - sentBytes += contentLength; - } else if (entity.isRepeatable()) { - CountingOutputStream counter = new CountingOutputStream(OutputStream.nullOutputStream()); - try { - entity.writeTo(counter); - sentBytes += counter.getBytesWritten(); - } catch (IOException e) { - log.debug("Exception measuring entity length", e); - } - } - } - - return sentBytes; - } - - /** - * Length of the headers the protocol layer adds when it writes the request, so that they are - * accounted for although they are not part of the request object JMeter built. - */ - private static long implicitHeaderLength(org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request) { - long length = 0; - URIAuthority authority = request.getAuthority(); - if (authority != null && !request.containsHeader(HttpHeaders.HOST)) { - length += HTTPMessageSizes.headerLength(HttpHeaders.HOST, authority.toString()); - } - HttpEntity entity = request.getEntity(); - if (entity == null) { - return length; - } - if (entity.getContentType() != null && !request.containsHeader(HttpHeaders.CONTENT_TYPE)) { - length += HTTPMessageSizes.headerLength(HttpHeaders.CONTENT_TYPE, entity.getContentType()); - } - if (entity.getContentEncoding() != null && !request.containsHeader(HttpHeaders.CONTENT_ENCODING)) { - length += HTTPMessageSizes.headerLength(HttpHeaders.CONTENT_ENCODING, entity.getContentEncoding()); - } - if (request.containsHeader(HttpHeaders.CONTENT_LENGTH) || request.containsHeader(HttpHeaders.TRANSFER_ENCODING)) { - return length; - } - long contentLength = entity.getContentLength(); - if (contentLength >= 0) { - length += HTTPMessageSizes.headerLength(HttpHeaders.CONTENT_LENGTH, Long.toString(contentLength)); - } else if (entity.isChunked()) { - length += HTTPMessageSizes.headerLength(HttpHeaders.TRANSFER_ENCODING, "chunked"); - } - return length; - } - private static void saveConnectionCookies(ClassicHttpResponse response, URL url, CookieManager cookieManager) { saveConnectionCookies(headersOf(response), url, cookieManager); } diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Redirects.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Redirects.java index 1d30629d8ff..fa5a91ea4d2 100644 --- a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Redirects.java +++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Redirects.java @@ -42,6 +42,7 @@ import com.github.tomakehurst.wiremock.WireMockServer; import com.github.tomakehurst.wiremock.core.WireMockConfiguration; +import com.github.tomakehurst.wiremock.http.HttpHeader; import com.github.tomakehurst.wiremock.verification.LoggedRequest; /** @@ -254,6 +255,51 @@ void headerManagerAuthorizationIsNotSentToAnotherOrigin(String httpVersion, Stri } } + /** + * The result reports the request of the last hop the way the server received it, not the one the + * sampler started with, whose cookies and credentials are those of another origin. + */ + @ParameterizedTest + @CsvSource({"HTTP/1.1, http", "HTTP/2, https"}) + void reportsTheRequestOfTheLastHop(String httpVersion, String scheme) throws Exception { + WireMockServer server = createServer(); + server.start(); + try { + String app = origin(server, scheme, "localhost"); + String idp = origin(server, scheme, "127.0.0.1"); + server.stubFor(get(urlEqualTo("/app/start")).willReturn(aResponse().withStatus(302) + .withHeader(HTTPConstants.HEADER_LOCATION, idp + "/idp/login"))); + server.stubFor(get(urlEqualTo("/idp/login")).willReturn(aResponse().withStatus(200))); + CookieManager cookieManager = new CookieManager(); + cookieManager.testStarted(); + cookieManager.addCookieFromHeader("app-session=1; Path=/", new URL(app + "/")); + cookieManager.addCookieFromHeader("idp-remembered=1; Path=/", new URL(idp + "/")); + AuthManager authManager = new AuthManager(); + authManager.set(-1, app + "/", "appuser", "apppass", "", "", AuthManager.Mechanism.BASIC); + authManager.set(-1, idp + "/", "idpuser", "idppass", "", "", AuthManager.Mechanism.BASIC); + HTTPSamplerBase sampler = newSampler(httpVersion); + sampler.setCookieManager(cookieManager); + sampler.setAuthManager(authManager); + + HTTPSampleResult result = sampler.sample(new URL(app + "/app/start"), HTTPConstants.GET, false, 1); + + assertEquals("200", result.getResponseCode(), result::getResponseMessage); + LoggedRequest login = singleRequest(server, "/idp/login"); + assertEquals(cookies(login), result.getCookies(), + "the cookies of the last hop should be reported, not those of the sampled URL"); + String requestHeaders = result.getRequestHeaders(); + assertTrue(requestHeaders.contains( + HTTPConstants.HEADER_AUTHORIZATION + ": " + basic("idpuser", "idppass") + "\n"), + () -> "the credentials of the last hop should be reported, got " + requestHeaders); + assertFalse(requestHeaders.contains(basic("appuser", "apppass")), requestHeaders); + if (HTTPConstants.HTTP_1_1.equals(httpVersion)) { + assertEquals(wireSize(login), result.getSentBytes(), "sentBytes should be those of the last hop"); + } + } finally { + server.stop(); + } + } + private static HTTPSamplerBase newSampler(String httpVersion) { HTTPSamplerBase sampler = HTTPSamplerFactory.newInstance("HttpClient5"); sampler.setHttpVersion(httpVersion); @@ -291,4 +337,19 @@ private static String basic(String user, String password) { return "Basic " + Base64.getEncoder() .encodeToString((user + ":" + password).getBytes(StandardCharsets.ISO_8859_1)); } + + /** Number of bytes the given request occupies on an HTTP/1.1 connection. */ + private static long wireSize(LoggedRequest request) { + StringBuilder head = new StringBuilder() + .append(request.getMethod().getName()).append(' ') + .append(request.getUrl()).append(' ') + .append(HTTPConstants.HTTP_1_1).append("\r\n"); + for (HttpHeader header : request.getHeaders().all()) { + for (String value : header.values()) { + head.append(header.key()).append(": ").append(value).append("\r\n"); + } + } + head.append("\r\n"); + return head.toString().getBytes(StandardCharsets.ISO_8859_1).length + request.getBody().length; + } } From 4e16298cb53d97610b0b6c51685281a90ca073db Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Fri, 16 Oct 2026 15:25:59 +0200 Subject: [PATCH 78/80] Document that the HTTP/2 I/O thread of HTTPHC5Impl serves all requests of a thread The HTTP/2 client of a JMeter thread runs a single I/O thread. HttpClient also uses it for TLS handshakes, for computing the answer to an authentication challenge such as SPNEGO or NTLM, and for the redirect strategy, which applies the Cookie and Authorization Manager. While it does, the other requests the thread has in flight, like the remaining parallel downloads, wait. Mention this in the component reference and next to the I/O reactor configuration. --- .../jmeter/protocol/http/sampler/HTTPHC5Impl.java | 3 ++- xdocs/usermanual/component_reference.xml | 12 ++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java index b0c29be590c..df5d8f3f942 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java @@ -1239,7 +1239,8 @@ private static CloseableHttpAsyncClient createHttp2Client(HttpClientKey key, JMe .setTargetAuthenticationStrategy(HC5Authentication.STRATEGY) .setProxyAuthenticationStrategy(HC5Authentication.STRATEGY) // A JMeter thread only has a few requests in flight, which a single I/O thread serves - // easily, whereas HttpClient would start one per processor for every client + // easily, whereas HttpClient would start one per processor for every client. Work done on + // it, like TLS handshakes, auth challenges or AutoRedirectStrategy, holds up the others. .setIOReactorConfig(IOReactorConfig.custom().setIoThreadCount(1).build()) .setThreadFactory(threadsSharingContext(jmeterContext)); AsyncResponseTimeout.install(builder); diff --git a/xdocs/usermanual/component_reference.xml b/xdocs/usermanual/component_reference.xml index 06632fca95b..2e64ec36578 100644 --- a/xdocs/usermanual/component_reference.xml +++ b/xdocs/usermanual/component_reference.xml @@ -255,6 +255,12 @@ https.default.protocol=SSLv3 embedded resources, over a single connection per host, but the connections are not shared between threads, so a test with N threads still opens N connections like N real users would.

    +

    The HTTP/2 client of a thread serves all of its requests with a single I/O thread. While HttpClient + works on that thread, for example on the TLS handshake of a new connection, on the answer to an + authentication challenge such as SPNEGO or NTLM, or on applying the Cookie and Authorization Manager to + a redirect it follows by itself, the other requests the thread has in flight, like the remaining + parallel downloads, wait. This is negligible in normal use, but a slow Kerberos KDC, for example, + delays them all.

    When a request over such a shared connection exceeds the Response Timeout or is interrupted, HttpClient closes the whole connection, so the other requests the thread has in flight over it, like the remaining parallel downloads, fail as well. Over HTTP/1.1 only the request concerned fails. Set the @@ -3971,6 +3977,12 @@ All port values are treated equally; a sampler that does not specify a port will embedded resources, over a single connection per host, but the connections are not shared between threads, so a test with N threads still opens N connections like N real users would.

    +

    The HTTP/2 client of a thread serves all of its requests with a single I/O thread. While HttpClient + works on that thread, for example on the TLS handshake of a new connection, on the answer to an + authentication challenge such as SPNEGO or NTLM, or on applying the Cookie and Authorization Manager to + a redirect it follows by itself, the other requests the thread has in flight, like the remaining + parallel downloads, wait. This is negligible in normal use, but a slow Kerberos KDC, for example, + delays them all.

    When a request over such a shared connection exceeds the Response Timeout or is interrupted, HttpClient closes the whole connection, so the other requests the thread has in flight over it, like the remaining parallel downloads, fail as well. Over HTTP/1.1 only the request concerned fails. Set the From e27b56647a8d6b775c666e681698e2cf48872386 Mon Sep 17 00:00:00 2001 From: Andreas Lind Date: Sun, 25 Oct 2026 23:05:09 +0100 Subject: [PATCH 79/80] Clarify client cleanup behavior in HTTP/2 context management in HTTPHC5Impl --- .../org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java index df5d8f3f942..64845384d4c 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java @@ -192,8 +192,9 @@ public class HTTPHC5Impl extends HTTPHCAbstractImpl { * connection between a sample and its embedded resources, which lets HTTP/2 multiplex them, and * it keeps a JMeter thread from closing clients another one is still using. * - *

    The clients go away with the context, so the ones of a context which never closes them - * explicitly, like the one of a thread of the HTTP(S) Test Script Recorder, are not kept forever. + *

    The clients are closed when their owning JMeter thread finishes. This explicit cleanup is + * important for HTTP/2: its I/O threads share the context, so they can keep the context and its + * clients reachable until {@link #closeClientsOfCurrentThread()} closes them. */ private static final String THREAD_STATE_CLIENTS = "__jmeter.HC5_CLIENTS__"; //$NON-NLS-1$ From 99566231bad282ccf339df999ab6a200fe94a349 Mon Sep 17 00:00:00 2001 From: Andreas Lind Date: Sun, 25 Oct 2026 23:11:27 +0100 Subject: [PATCH 80/80] Preserve all Header Manager values on method-changing redirects in AutoRedirectStrategy --- .../protocol/http/sampler/AutoRedirectStrategy.java | 8 +++++++- .../protocol/http/sampler/TestHTTPHC5Redirects.java | 4 ++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/AutoRedirectStrategy.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/AutoRedirectStrategy.java index cb052ee5b87..199e657afb4 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/AutoRedirectStrategy.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/AutoRedirectStrategy.java @@ -21,8 +21,10 @@ import java.net.URISyntaxException; import java.net.URL; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; import java.util.Locale; +import java.util.Set; import org.apache.hc.client5.http.impl.DefaultRedirectStrategy; import org.apache.hc.client5.http.impl.auth.BasicCredentialsProvider; @@ -182,8 +184,12 @@ void next(HttpClientContext context, URL url, HttpRequest request) { // For method-changing redirects (301/302 after POST, 303 after non-GET), HttpClient // builds a GET with no headers, losing the Header Manager headers the initial request // carried. Re-apply them so that Accept, User-Agent, and custom headers survive. + Set present = new HashSet<>(); + for (Header header : request.getHeaders()) { + present.add(header.getName().toLowerCase(Locale.ROOT)); + } for (String[] header : headerManagerGeneral) { - if (request.containsHeader(header[0])) { + if (present.contains(header[0].toLowerCase(Locale.ROOT))) { continue; } // Host is specific to the sampled origin. Let HttpClient generate it for another origin. diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Redirects.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Redirects.java index fa5a91ea4d2..7b0039bfa68 100644 --- a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Redirects.java +++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Redirects.java @@ -200,6 +200,8 @@ void postRedirectGetPreservesHeaderManagerHeaders(String httpVersion, String sch HeaderManager headerManager = new HeaderManager(); headerManager.add(new Header("X-Custom", "value1")); headerManager.add(new Header("Accept", "application/json")); + headerManager.add(new Header("X-Foo", "first")); + headerManager.add(new Header("x-foo", "second")); HTTPSamplerBase sampler = newSampler(httpVersion); sampler.setHeaderManager(headerManager); @@ -211,6 +213,8 @@ void postRedirectGetPreservesHeaderManagerHeaders(String httpVersion, String sch "Header Manager X-Custom header must survive a POST → 302 → GET redirect"); assertEquals("application/json", home.getHeader("Accept"), "Header Manager Accept header must survive a POST → 302 → GET redirect"); + assertEquals(List.of("first", "second"), home.getHeaders().getHeader("X-Foo").values(), + "all Header Manager values of the same name must survive a POST → 302 → GET redirect"); } finally { server.stop(); }