Skip to content

Add HttpClient5 sampler implementation with HTTP/2 support - #6742

Open
andreaslind01 wants to merge 68 commits into
apache:masterfrom
andreaslind01:httpclient5_http2
Open

andreaslind01 wants to merge 68 commits into
apache:masterfrom
andreaslind01:httpclient5_http2

Conversation

@andreaslind01

Copy link
Copy Markdown
Contributor

Description

This PR adds a new HTTP sampler implementation, HttpClient5, based on Apache HttpComponents HttpClient 5.x, and introduces a configurable HTTP Version setting (HTTP/1.1 / HTTP/2) for both the HttpClient5 and the Java implementation.

Main changes:

  • New HTTPHC5Impl (HTTPSamplerFactory.IMPL_HTTP_CLIENT5, selectable as HttpClient5 in the GUI and in JMX files):
    • Classic (blocking) client for HTTP/1.1 and an async H2 client for HTTP/2, selected via HttpVersionPolicy (FORCE_HTTP_1 / NEGOTIATE), with automatic fallback to HTTP/1.1 when the server does not offer h2 via ALPN.
    • Per-thread client caching keyed by target/proxy/timeouts/local address/version policy.
    • Support for connect & response timeouts, proxies (incl. proxy authentication), AuthManager (BASIC/DIGEST, pre-emptive BASIC), CacheManager (conditional requests via If-Modified-Since / If-None-Match), CookieManager, DNSCacheManager, response decompression (gzip/deflate/brotli), and retry handling.
    • Correct population of SampleResult metrics: sentBytes, connectTime (measured for both HTTP/1.1 and HTTP/2, including TLS), latency, headers and response code/message.
  • HTTPJavaImpl: HTTP/2 support via the JDK java.net.http.HttpClient when HTTP/2 is selected, including caching, proxies, user authentication, sentBytes accounting, connect-time measurement, reason-phrase derivation (HTTP/2 has no reason phrase) and preservation of Authorization / Proxy-Authorization headers.
  • New sampler property HTTPSampler.httpVersion (HTTPSamplerBaseSchema.httpVersion, getter/setter on HTTPSamplerBase) with a new combo box in HTTP Request and HTTP Request Defaults (http_version resource key added to all messages_*.properties).
  • CacheManager: new overloads for HC5 (ClassicHttpRequest / ClassicHttpResponse / org.apache.hc.core5.http.Header[]) and for the JDK java.net.http.HttpResponse.
  • Property httpclient.version re-purposed as the default HTTP version (HTTP/1.1 | HTTP/2) used when the sampler's HTTP Version field is empty.
  • Dependencies: httpclient5 and httpcore5 added to src/protocol/http and to the third-party BOM (httpcore5:5.3.4).
  • Documentation updated: component_reference.xml, properties_reference.xml, get-started.xml, bin/jmeter.properties.

Motivation and Context

JMeter's HTTP samplers currently only support HTTP/1.1: the HttpClient4 implementation is built on the HttpComponents 4.x line, which will not receive HTTP/2 support, and the Java implementation used the legacy HttpURLConnection. Modern web applications and APIs are increasingly served over HTTP/2, so load tests against them either could not be executed at all or did not represent realistic client behaviour (multiplexing, HPACK header compression, single connection per origin).

This change gives users a supported migration path to HttpComponents 5.x and makes it possible to run load tests over HTTP/2 — either with the fully featured HttpClient5 implementation or, for lightweight scenarios, with the JDK client in the Java implementation. Existing test plans are unaffected: HttpClient4 remains the default and an empty HTTP Version falls back to the previous HTTP/1.1 behaviour.

Fixes:

How Has This Been Tested?

  • New unit/integration tests (34 tests, all green):
    • TestHTTPHC5Features (16 tests): version selection and precedence (sampler value vs. httpclient.version vs. unsupported value), HTTP/2 usage, fallback to HTTP/1.1 when the server does not support h2, HTTP/2 via proxy, sentBytes for GET/POST, conditional requests through CacheManager, BASIC credentials from AuthManager, proxy authentication, and connectTime for HTTP/1.1 and HTTP/2.
    • TestHTTPJavaFeatures (~16 tests): version selection, HTTP/2 requests (incl. via proxy), response message / reason-phrase handling for HTTP/2, sentBytes for GET/POST in both versions, Authorization header from the HeaderManager, and connectTime for HTTP/1.1, HTTP/2 plaintext and HTTP/2 over TLS.
    • TestHTTPSamplerFactory: creation and lookup of the new HttpClient5 implementation, plus the unchanged behaviour for the existing aliases.
    • The tests run against locally started embedded HTTP/1.1, HTTP/2 (h2c and h2 over TLS) and proxy servers, so no external services are required.
  • ./gradlew classes style — compiles cleanly and reports no style/checkstyle/autostyle violations.
  • The existing src:protocol:http test suite (including JMeterTest, extended by httpVersion in the ignored-properties list) still passes.
  • Manual verification in the JMeter GUI: the new HTTP Version combo box in HTTP Request and HTTP Request Defaults is saved/restored correctly in JMX files, and requests against an HTTP/2 endpoint are reported as HTTP/2 in the View Results Tree.
  • Test environment: Windows, JDK 21 toolchain, Gradle build JMeter 6.0.0-SNAPSHOT.

Screenshots (if appropriate):

image

Types of changes

  • New feature (non-breaking change which adds functionality)

Checklist:

  • My code follows the code style of this project.
  • I have updated the documentation accordingly.

@milamberspace milamberspace left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a full review of this PR — just a scoped, timely note on the dependency versions.

Apache HttpComponents Client just released 5.6.4: "Corrects application of SSL parameters in the async TLS upgrade method" (RELEASE_NOTES-5.6.x.txt). This PR pins httpclient5:5.5.1, and HTTPHC5Impl is exactly the kind of code that exercises that path — it uses the async H2 client with HttpVersionPolicy.NEGOTIATE for HTTP/2-over-TLS via ALPN, i.e. an async TLS upgrade. Worth pulling in the fix before this lands, rather than shipping the new HTTP/2 sampler with a known bug in SSL-parameter application during that exact upgrade.

See inline comment for the concrete version bump (and the matching httpcore5 pairing, since httpclient5:5.6.4 is built/tested against httpcore5:5.4.3, not 5.3.4).

This review was drafted by an AI-assisted tool and confirmed by an Apache JMeter maintainer.

Comment thread src/bom-thirdparty/build.gradle.kts Outdated

@milamberspace milamberspace left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found a real bug while manually testing the HTTP/2 sampler against a long-running server: intermittent

java.io.IOException: Could not execute HTTP/2 request
Caused by: org.apache.hc.core5.http2.impl.nio.ConnectionClosedException: Connection is closed
	at org.apache.hc.core5.http2.impl.nio.H2Streams.shutdownAndReleaseAll(H2Streams.java:149)
	at org.apache.hc.core5.http2.impl.nio.AbstractH2StreamMultiplexer.onOutput(...)
	...

Root cause

createHttp2Client() never configures ConnectionConfig.validateAfterInactivity on the PoolingAsyncClientConnectionManagerBuilder, and ConnectionConfig.DEFAULT documents it as null (undefined). In PoolingAsyncClientConnectionManager#lease(), the re-validation step (an HTTP/2 PING before handing out a pooled connection, StaleCheckCommand for HTTP/1.1) is gated by:

final TimeValue timeValue = connectionConfig.getValidateAfterInactivity();
if (connection.isOpen() && TimeValue.isNonNegative(timeValue)) { ... }

TimeValue.isNonNegative(null) is false, so with the default config this block never runs — pooled async connections are leased straight out of the pool with zero liveness check.

Concretely: HTTP_2_CLIENTS caches the async client per JMeter thread and reuses it across iterations. If the server (or an idle load balancer/NAT) closes an idle pooled HTTP/2 connection between two samples, the next sample picks it from the pool as-is; the I/O reactor only discovers it's dead when it tries to write to it, surfacing as ConnectionClosedException deep in H2Streams.

This is made worse by disableAutomaticRetries() (called on both the classic and async builders) — correctly disabled so JMeter doesn't silently mask real server behavior/timing from the sample result, but it also removes HttpClient5's own safety net for exactly this failure mode. Without proactive pool validation, there's nothing left to catch it, and it surfaces as a hard sampler failure instead of a transparent retry.

Suggested fix

See inline comment — add .setValidateAfterInactivity(...) to the ConnectionConfig built in createHttp2Client() (worth doing for createClient()'s classic-transport config too, same gap applies there).

This review was drafted by an AI-assisted tool and confirmed by an Apache JMeter maintainer.

@milamberspace

Copy link
Copy Markdown
Contributor

Following up on the stale-connection bug above with a concrete repro I ran manually against a real server (own domain, browsing-style scenario: 5 threads, 3 loops, ~800–2800ms think time between transactions on the same reused HTTP/2 connection).

Worth a regression test to prove validateAfterInactivity actually catches this rather than just proving it's configured. One nuance that matters for how the test is shaped: it's not the request count that triggers this, it's the idle gap between two requests on the same pooled connection. A tight rapid-fire loop with no pauses almost certainly won't reproduce it — my manual repro needed nothing more exotic than ~1–3s of think time between transactions, which was enough for the server side to close the idle connection before the next request picked it back up from the pool.

Suggested shape for the test:

  1. Spin up an embedded HTTP/2-over-TLS server the test controls directly (not WireMock's black-box backend — this needs to forcibly close an already-accepted connection without stopping the whole server, which WireMock doesn't expose).
  2. Fire request Expand / Collapse all buttons #1 through HTTPHC5Impl with HTTP/2 selected, get a 200.
  3. Forcibly close the underlying TCP connection from the server side right after responding (server keeps listening for new connections on the same port — just this one connection dies), simulating an idle server-side timeout.
  4. Sleep a little longer than whatever validateAfterInactivity ends up configured to (e.g. 1.1s if it's set to 1s).
  5. Fire request BUG-49753 Enhancement implemented #2 from the same HTTPSamplerBase instance / same thread, so it goes through the cached HTTP_2_CLIENTS entry and reuses the pool.
  6. Assert request BUG-49753 Enhancement implemented #2 succeeds (200, no ConnectionClosedException) — and ideally assert it would fail without the fix, e.g. by running the same test body against a build with validateAfterInactivity stripped out, or documenting in the test comment that it reproduces apache/jmeter#<this-PR>'s manual repro.

Happy to be wrong about the exact mechanics of forcing step 3 cleanly with whatever test HTTP/2 server this project already has infrastructure for (TestHTTPHC5Features already stands up embedded h2/h2c servers per the PR description) — but the idle-gap-not-volume framing is the part I'd want the test to actually exercise, since that's what makes it a faithful regression test rather than one that happens to pass for unrelated reasons.

@andreaslind01

Copy link
Copy Markdown
Contributor Author

Thanks @milamberspace - fixed and covered by a regression test.

Fix: setValidateAfterInactivity is now applied unconditionally (not under the connectTimeout > 0 guard) to the ConnectionConfig of both createHttp2Client() and createClient(), exactly as suggested. The interval is configurable via a new httpclient5.validate_after_inactivity property (ms, default 2000, -1 disables), documented in jmeter.properties, properties_reference.xml and changes.xml.

One correction on the mechanics, which changed how the test had to be shaped. Tracing httpclient5 5.6.4 / httpcore5 5.4.3: a cleanly closed connection isn't what fails. LaxConnPool.getAvailableEntry does hand out the dead entry, but AsyncConnectExec then checks isEndpointConnected() and transparently reconnects - and AbstractH2StreamMultiplexer.isOpen() is connState == ACTIVE, so once the reactor has processed the FIN (or a GOAWAY) the endpoint reports "not connected" and recovers on its own. Step 3 as suggested would therefore have passed with and without the fix. The failure needs the client to still believe the connection is usable when the request is submitted - that's the H2Streams.shutdownAndReleaseAll path in the trace.

What the test does instead, keeping the idle-gap framing:

A raw TCP relay sits in front of WireMock's h2/TLS port, so the test owns the client-facing socket. It can mark a connection doomed: it stays open, but is dropped as soon as the client writes to it again - what an idle server/LB timeout looks like to a client that hasn't noticed yet.

  1. Request 1 → 200, connection pooled.
  2. Sleep validateAfterInactivity + 500ms - the idle gap, not the request count, arms the check.
  3. Doom the pooled connection.
  4. Request 2 from the same sampler/thread, reusing the cached HTTP_2_CLIENTS entry and pool.
  5. Assert 200 and that the relay accepted exactly 2 connections - proving the stale one was replaced, not that it passed for unrelated reasons.

On asserting it fails without the fix: verified directly by forcing the default to -1, which reproduces the reported error verbatim (Could not execute HTTP/2 request / Non HTTP response code: java.io.IOException). Since that can't be asserted in one build, a companion test doesNotRevalidatePooledHttp2ConnectionWithoutAnIdleGap runs the same body with no idle gap, where the check is legitimately skipped and the sample fails - pinning the mechanism from the other side.

./gradlew classes style clean, full :src:protocol:http:test (983 tests) green, HC5 tests run 3× without flakiness.

@milamberspace

Copy link
Copy Markdown
Contributor

Thanks @andreaslind01 — manual testing against the built distribution (browsing-style scenario, HTTP/2, same idle-gap pattern as the original repro) is conclusive: the stale-connection failure is gone. Great fix, and a genuinely clever regression test.

Two smaller, non-blocking points from going through the GUI while testing:

1. The HTTP Version combo doesn't reflect what each implementation actually supports

In HTTP Request → Advanced (and HTTP Request Defaults), the Implementation combo offers HttpClient4 / HttpClient5 / Java / (default), and HTTP Version offers HTTP/1.1 / HTTP/2 / (empty) — independently of each other. HttpClient4 and HTTP/2 can both be selected together, but HTTPHC4Impl never reads the httpVersion property at all, so the request silently runs as HTTP/1.1 regardless — nothing in the UI signals that the combination is a no-op.

Would be worth either:

  • disabling/graying out HTTP/2 in the HTTP Version combo when HttpClient4 is the selected implementation, and/or
  • relabeling the item to make the fallback explicit, e.g. HTTP/2.0 (back to HTTP/1.1) when the active implementation doesn't support it.

Relevant: httpImplementation/httpVersion combos in HttpTestSampleGui.java and HttpDefaultsGui.java — currently just two independent JComboBoxes with no listener tying one to the other's state/labels.

2. Advanced-tab screenshot is stale

xdocs/images/screenshots/http-request-advanced-tab.png (referenced from component_reference.xml) predates this PR by several years and doesn't show the new HTTP Version field or the HttpClient5 implementation choice. Worth refreshing it as part of this PR (ideally with the Metal look-and-feel, to match the rest of the JMeter docs' screenshots) so the manual covers the feature it now documents.

@milamberspace

Copy link
Copy Markdown
Contributor

A few notes on xdocs/changes.xml:

Missing <pr>6742</pr> reference

None of the entries this PR adds carry a <pr> tag, unlike their neighbors in the same lists (e.g. <pr>6268</pr>, <pr>6620</pr>). Worth adding <pr>6742</pr> to:

  • the four new bullets under Changes → HTTP Samplers and Test Script Recorder (HTTP/2 multiplexing for HttpClient5 and Java, default User-Agent for both),
  • the HttpClient5/HttpCore5 version-bump bullet under Changes → Non-functional changes.

Maybe group the HttpClient5/HTTP2 entries under one heading

The four new HTTP Samplers bullets (HTTP/2 multiplexing ×2, default User-Agent ×2) currently sit flat in the same list as older, unrelated entries (IE conditional comments, argument enable/disable, multipart charset, redirect method preservation…). They're really one coherent piece of work — might read better with a short lead-in grouping them, e.g. a one-line "HTTP/2 support for the HttpClient5 and Java sampler implementations:" before the four bullets, so a reader scanning the changelog sees it as one feature rather than four scattered items. No strong opinion on the exact markup — this file doesn't have a <h4> sub-heading precedent elsewhere, so whatever's lightest.

The pooled-connection re-validation entry shouldn't be in Bug fixes

Re-validate pooled connections of the HttpClient5 sampler implementation after they have been idle...

This fixes a bug introduced and fixed entirely within this same unreleased PR — it never shipped in any JMeter release, so it isn't a "bug fix" from the changelog reader's perspective (there's nothing between two releases for them to have hit). Suggest dropping this bullet from Bug fixes → HTTP Samplers and Test Script Recorder entirely, since the fixed behavior is just folded into the feature as it ships. If it's worth keeping any trace of it at all, httpclient5.validate_after_inactivity could just be mentioned in passing in one of the Changes bullets above instead — but a dedicated "bug fix" entry for a bug the released code never had reads as noise.

Thanks section

Missing Andreas Lind (github.com/andreaslind01) in the Thanks list at the bottom.

@andreaslind01

Copy link
Copy Markdown
Contributor Author

Thanks @andreaslind01 — manual testing against the built distribution (browsing-style scenario, HTTP/2, same idle-gap pattern as the original repro) is conclusive: the stale-connection failure is gone. Great fix, and a genuinely clever regression test.

Two smaller, non-blocking points from going through the GUI while testing:

1. The HTTP Version combo doesn't reflect what each implementation actually supports

In HTTP Request → Advanced (and HTTP Request Defaults), the Implementation combo offers HttpClient4 / HttpClient5 / Java / (default), and HTTP Version offers HTTP/1.1 / HTTP/2 / (empty) — independently of each other. HttpClient4 and HTTP/2 can both be selected together, but HTTPHC4Impl never reads the httpVersion property at all, so the request silently runs as HTTP/1.1 regardless — nothing in the UI signals that the combination is a no-op.

Would be worth either:

  • disabling/graying out HTTP/2 in the HTTP Version combo when HttpClient4 is the selected implementation, and/or
  • relabeling the item to make the fallback explicit, e.g. HTTP/2.0 (back to HTTP/1.1) when the active implementation doesn't support it.

Relevant: httpImplementation/httpVersion combos in HttpTestSampleGui.java and HttpDefaultsGui.java — currently just two independent JComboBoxes with no listener tying one to the other's state/labels.

2. Advanced-tab screenshot is stale

xdocs/images/screenshots/http-request-advanced-tab.png (referenced from component_reference.xml) predates this PR by several years and doesn't show the new HTTP Version field or the HttpClient5 implementation choice. Worth refreshing it as part of this PR (ideally with the Metal look-and-feel, to match the rest of the JMeter docs' screenshots) so the manual covers the feature it now documents.

Thanks for the testing and the detailed review @milamberspace.

Regarding the HTTP Version selector: I actually tend towards removing it rather than introducing implementation-specific enable/disable logic. The selected implementation already determines the effective protocol behavior (HttpClient4 → HTTP/1.1, HttpClient5/Java → HTTP/2 with HTTP/1.1 fallback).

A tooltip explaining this behavior would likely be clearer than allowing users to choose combinations that are effectively ignored. For the few cases where HTTP/1.1 must be enforced, I would prefer dedicated properties in jmeter.properties rather than additional GUI complexity.

@milamberspace

Copy link
Copy Markdown
Contributor

Following up on removing the HTTP Version field — I'd argue the opposite: keep it, but make its options implementation-aware instead of static. Removing it loses something a load-testing tool specifically benefits from.

Why keep it: comparative testing on the same target

The whole point of a field like this in JMeter is to let a single test plan run the same request against the same target under different explicit protocol conditions — e.g. HttpClient5 forced to HTTP/1.1 vs HttpClient5 negotiating HTTP/2 vs HttpClient5 strictly requiring HTTP/2, all as separate samplers/thread groups in one scenario, to compare latency, throughput or behavior side by side. Or Java HTTP/1.1 vs Java HTTP/2 Negotiate. That's a legitimate, common load-testing use case (validating an HTTP/2 migration, quantifying its actual performance benefit, or just making sure a "should be HTTP/2" test really is one — which is exactly the failure mode my own manual test just ran into: NEGOTIATE silently testing HTTP/1.1 with nothing in the GUI making that obvious). A global jmeter.properties default can't do that — it's one value for the whole run, not a per-sampler choice.

Losing per-sampler control to gain a tooltip trades away a real capability for a cosmetic simplification.

Proposal: implementation-aware options, not a removed field

Rather than a static 3-item combo shared by all implementations, populate it based on the currently selected Implementation, dynamically (an ItemListener on httpImplementation refreshing httpVersion's model — this also directly answers the concern about combinations that are silently ignored, since the no-op ones simply wouldn't be offered anymore):

  • HttpClient4: (default) / HTTP/1.1 — no HTTP/2 entry at all, since HTTPHC4Impl never reads httpVersion; today's silent no-op combination becomes structurally impossible instead of documented-away.
  • Java: (default) / HTTP/1.1 / HTTP/2 Negotiate — matches what java.net.http.HttpClient can actually do (no strict mode exists in the JDK's public API, so don't offer one).
  • HttpClient5: (default) / HTTP/1.1 / HTTP/2 Negotiate / HTTP/2 Strict — HttpClient5 is the one implementation where the underlying library can genuinely do all three, so expose all three rather than the two the current PR limits it to.

HTTP/2 Strict for HttpClient5 needs one more code change

Right now getHttpVersionPolicy(String, String, String scheme) only ever returns FORCE_HTTP_2 for plaintext http:// with prior knowledge — for https://, HTTP/2 always resolves to NEGOTIATE, with silent ALPN fallback to HTTP/1.1 if the server doesn't offer h2:

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;
}

ClientTlsStrategyBuilder/TlsConfig.setVersionPolicy(FORCE_HTTP_2) offers only h2 in the ALPN extension, so the TLS handshake itself fails when the server doesn't support it — exactly the "strict" behavior. Worth a distinct sampler-level value (not reusing the existing "HTTP/2" string, to keep old JMX files negotiating as they do today) that maps to FORCE_HTTP_2 regardless of scheme, surfaced as HTTP/2 Strict in the combo.

Happy to be told this is more scope than this PR should carry and belongs in a follow-up — just wanted to lay out the full shape of it while the HTTP Version field is under discussion, since the three-tier HttpClient5 combo and the field's removal are mutually exclusive decisions.

@andreaslind01

Copy link
Copy Markdown
Contributor Author

Thanks for the detailed feedback and for laying out the use case so clearly. I agree that removing the field entirely would sacrifice a legitimate and valuable load-testing capability, particularly for side-by-side protocol comparisons within a single test plan.

I've implemented the proposed direction and the changes are now available:

  • The HTTP Version field has been retained.
  • The available options are now implementation-aware and dynamically updated based on the selected HTTP implementation, preventing unsupported combinations from being selected.
  • HttpClient4, Java, and HttpClient5 each expose only the protocol modes that are actually supported by their underlying implementation.
  • Support for HTTP/2 Strict has been added for HttpClient5, mapping to FORCE_HTTP_2 independently of the scheme, while preserving the existing behavior of legacy HTTP/2 configurations for backward compatibility.

This keeps per-sampler protocol control available while eliminating the previous silent no-op combinations that motivated the original discussion.

Thanks again for the suggestion. I think this results in a clearer UI without reducing functionality.

@milamberspace

Copy link
Copy Markdown
Contributor
http-request-advanced-tab

@milamberspace

Copy link
Copy Markdown
Contributor

Manual testing against the latest build is OK on these points:

  • The HTTP Version combo now correctly restricts itself to what each implementation supports
  • HTTP/2 Strict for HttpClient5 behaves exactly as intended: pointed at a server that only speaks HTTP/1.1, the sample fails as expected

Also did a clean :src:dist:assemble build from this commit to get a good distributable archive (using for my tests)

The last change is to update ./xdocs/images/screenshots/http-request-advanced-tab.png with the capture posted on this discussion (and change the component_reference.xml with the good dimension of screenshot: width="1734" height="669")

Thanks again for you PR / work.

@milamberspace
milamberspace requested a review from vlsi August 14, 2026 04:45
@andreaslind01

Copy link
Copy Markdown
Contributor Author

Thanks a lot for the thorough testing.

Both screenshots are updated: http-request-advanced-tab.png and — for consistency, since it shows the same new HTTP Version combo — http-config/http-request-defaults-advanced-tab.png.

I exported them at 950×391 (and set component_reference.xml accordingly), so they match the other figures in that file (e.g. graphql-http-request.png at 950×618) and don't get scaled by the browser.

@milamberspace milamberspace left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@andreaslind01 Hi, the constants review. Thanks

* 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

change for @SInCE 6.0

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";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Move to constants variable


private static String getEntityPreview(HttpEntity entity, String contentEncoding) throws IOException {
if (!entity.isRepeatable()) {
return "<Entity was not repeatable, cannot view what was sent>";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Constant or translate string ?

if (HTTPConstants.HTTP_VERSION_2_STRICT.equalsIgnoreCase(httpVersion)) {
return HttpVersionPolicy.FORCE_HTTP_2;
}
return HTTPConstants.HTTP_VERSION_2.equals(httpVersion) || "2".equals(httpVersion)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where the httpVersion can have the value "2"?

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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same question about the value "2"

cacheManager.saveDetails(response, res);
}

res.setSentBytes(calculateSentBytes(url, method, "HTTP/2",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Constant for "HTTP/2" here

if (res.getEndTime() == 0) {
res.sampleEnd();
}
res.setSentBytes(calculateSentBytes(url, method, "HTTP/2",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Constant


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$

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Constants

uri = "";
}
org.apache.hc.core5.http.ProtocolVersion version = request.getVersion();
String versionStr = version != null ? version.toString() : "HTTP/1.1";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Constant pls

@vlsi vlsi left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review comments — PR #6742 "Add HttpClient5 sampler implementation with HTTP/2 support"

Reviewed at head 919b9125, against merge base ad6ecbd1.

Two independent reviewers (Codex and a Claude subagent) went through the diff; the items below are the merged, verified
result. Line numbers refer to the PR head.


Blockers

B1. HTTPHC5Impl ends the sample before the body is read, so elapsed excludes the download and latency exceeds elapsed

HTTPHC5Impl.java:444-448 calls result.sampleEnd() right after executeRequest() returns. For the classic transport
that point is "status line and headers received" — the body is read afterwards, in updateResult() →
readResponse() (HTTPHC5Impl.java:904). HTTPSamplerBase.readResponse() calls sampleResult.latencyEnd() on the
first body byte (HTTPSamplerBase.java:1972), which now happens after endTime was stamped.

GET a 100 MB file over HTTP/1.1 with HttpClient5: headers arrive after 50 ms, the body takes 10 s. The sample reports
elapsed ≈ 50 ms and latency ≈ 10050 ms. Elapsed, throughput, the Latency column, the aggregate report and the HTML
dashboard are all wrong, and latency > elapsed is a state JMeter's own model treats as impossible.

HTTPHC4Impl does the opposite order deliberately — HTTPHC4Impl.java:667-672, res.sampleEnd(); // Done with the sampling proper. The new HTTPJavaImpl HTTP/2 path also gets it right, so HC5 is the odd one out.

Suggested fix: read the entity inside the try block, then sampleEnd(), then fill in status code, headers, sizes and
redirect location. Add a regression test asserting result.getLatency() <= result.getTime() against a WireMock stub
with withChunkedDribbleDelay.

B2. The HttpClient5 HTTP/1.1 transport ignores SSLManager: no trust-all, no client keystore, no https.socket.protocols

HTTPHC5Impl.createClient() (HTTPHC5Impl.java:1043-1066) builds the classic connection manager without installing any
TLS socket strategy, so HttpClient falls back to SSLContexts.createSystemDefault(). JMeter's per-thread SSLContext
from JsseSSLManager — which carries the https.keyStore client certificates, the trust-all trust manager,
https.socket.protocols and https.cipherSuites — is never used, and HttpClient 5.4+ defaults
HostnameVerificationPolicy to BOTH.

Since an empty HTTP Version maps to FORCE_HTTP_1, this is the default path. Three failures follow:

  • an HTTPS target with a self-signed or internal-CA certificate fails with SSLHandshakeException, while the same plan
    works with HttpClient4 and Java;
  • client-certificate authentication configured through the JMeter keystore silently sends no certificate;
  • https.socket.protocols and https.cipherSuites have no effect.

Note the asymmetry inside the same class: the async client does install a TLS strategy
(HTTP_2_TLS_STRATEGY, HTTPHC5Impl.java:391-405), so the same URL behaves differently depending on the selected HTTP
version. HTTPHC4Impl installs LazyLayeredConnectionSocketFactory (HTTPHC4Impl.java:1106), which wraps
HttpSSLProtocolSocketFactory → JsseSSLManager.

No test covers this: every HTTPS test in TestHTTPHC5Features sets setHttpVersion("HTTP/2") and routes to the async
client.

Suggested fix: build one TLS strategy from ((JsseSSLManager) SSLManager.getInstance()).getContext() plus the two
cipher/protocol properties and NoopHostnameVerifier, and install it on both connection managers
(setTlsSocketStrategy / setTlsStrategy). Resolve the SSLContext lazily per client so the per-thread context and
resetContext() keep working.

B3. createHttp2TlsStrategy() builds a trust-all context and drops the configured client certificate

HTTPHC5Impl.java:391-405 always creates a fresh SSLContexts.custom().loadTrustMaterial(null, TrustAllStrategy…)
context. Nothing from JsseSSLManager reaches it, so HTTP/2 requests to a mutual-TLS endpoint present no client
certificate and fail TLS authentication, unlike every existing implementation.

This is the same root cause as B2 seen from the other side: there is one JMeter SSLContext and neither transport uses
it. Fixing B2 and B3 together with a single shared strategy is the right shape.

B4. HTTPJavaImpl now sends every Header Manager header twice on the HTTP/1.1 path

setupConnection() already calls setConnectionHeaders(conn, u, getHeaderManager(), getCacheManager())
(HTTPJavaImpl.java:440). The diff adds a second call in sample() (HTTPJavaImpl.java:816), and
setConnectionHeaders uses conn.addRequestProperty(n, v), which appends rather than replaces.

A plan on the legacy Java implementation with a Header Manager entry Accept: application/json now puts
Accept: application/json,application/json on the wire. Cookie, Range and Authorization are duplicated the same
way. It is silent, because res.setRequestHeaders(...) is computed inside setupConnection before the second call, so
the sample result still shows one copy.

This regresses the existing HTTP/1.1 Java sampler for everyone, and the only reason for the second call is to obtain
securityHeaders for the new calculateSentBytes.

Suggested fix: return the security headers from setupConnection (or hold them in a field) instead of re-running
setConnectionHeaders. Add a WireMock test asserting the header arrives exactly once.

B5. SHARED_HTTP_2_CLIENTS grows without bound: keyed by a per-thread SSLContext, never evicted, never closed

getHttpClient(URL) puts the SSLContext returned by ((JsseSSLManager) SSLManager.getInstance()).getContext() into
the cache key, and SSLContext does not override equals/hashCode, so the key compares by identity.
JsseSSLManager.getContext() returns a per-thread context by default (https.sessioncontext.shared defaults to false).

So with the default http.java.h2.multiplexing=true, the static SHARED_HTTP_2_CLIENTS map holds one
java.net.http.HttpClient per JMeter thread rather than one shared client — the stated multiplexing goal is not met —
and each client owns a selector thread and a connection pool.

It gets worse across iterations: HTTPHC5Impl/HTTPHC4Impl call resetContext() on every thread-group iteration when
"same user on next iteration" is off (HTTPHC5Impl.java:1281). The next HTTPS HTTP/2 sample then gets a new
SSLContext, a new map key and a new HttpClient, while the old one stays in the static map forever.

200 threads × 500 iterations against an HTTPS target accumulates up to 100 000 HttpClient instances and their selector
threads. The JVM dies with OutOfMemoryError: unable to create native thread.

Nothing ever removes entries — there is no threadFinished()/testEnded() hook for these maps — and HTTP_2_EXECUTOR
(an unbounded cached pool) is never shut down either. HTTPHC5Impl does clean up in threadFinished(); HTTPJavaImpl
does not.

Suggested fix: keep the SSLContext instance out of the key, add a threadFinished()/testEnded() hook that closes
the clients and shuts the executor down, and add a test that samples HTTPS twice with a resetContext() in between and
asserts the map does not grow.

B6. The release-jar manifest is not updated, so :src:dist:verifyReleaseDependencies fails

src/dist/src/dist/expected_release_jars.csv has no entries for httpclient5, httpcore5 or httpcore5-h2, and the
PR does not touch the file. CI already reports it:

External dependencies differ (you could update src/dist/src/dist/expected_release_jars.csv if you run
:src:dist:verifyReleaseDependencies -PupdateExpectedJars)

Run that task and commit the result. Please also confirm the new jars carry the license and NOTICE coverage the ASF
release requires.


Major

M1. Neither HTTP/2 path can be interrupted, so "Stop Test Now" leaves threads blocked

HTTPHC5Impl.interrupt() (HTTPHC5Impl.java:1304-1312) cancels currentRequest, but the request actually executed
over HTTP/2 is a copy — SimpleHttpRequest asyncRequest = SimpleHttpRequest.copy(request)
(HTTPHC5Impl.java:1142). The classic request never becomes the cancellable dependency of the async exchange, so
cancel() is a no-op while the sampler thread sits in responseFuture.get(...).

HTTPJavaImpl has the mirror problem: interrupt() only touches savedConn, which sampleHttp2() never assigns, so
it returns false and the thread stays blocked in client.httpClient.send(...).

Against a server that accepts the connection and never answers, an HC5 HTTP/2 sampler with no Response Timeout blocks
for the hard-coded 60 s of getHttp2ExecutionTimeoutMillis() and a Java HTTP/2 sampler blocks forever. Neither
"Stop Test Now" nor the JMeter thread interrupt shortens it.

Suggested fix: hold the Future in a volatile field and cancel it from interrupt(); for HTTPJavaImpl, use
sendAsync and keep the CompletableFuture. While you are there, drop the arbitrary 60 s ceiling — HttpClient enforces
the configured response timeout itself, and a legitimate 90-second sample currently fails at 60 s on HTTP/2 but succeeds
on HTTP/1.1.

M2. Both HTTP/2 paths buffer the whole request and response body in heap

HTTPHC5Impl.executeHttp2() converts the request entity with EntityUtils.toByteArray(requestEntity)
(HTTPHC5Impl.java:1144-1149) and executes a SimpleHttpRequest, whose SimpleHttpResponse holds the entire body as a
byte array; createClassicResponse() then wraps that array in a ByteArrayEntity — a third copy.

HTTPJavaImpl.sampleHttp2() has the same shape on the request side: CapturingHttpURLConnection.getOutputStream()
returns a ByteArrayOutputStream, so sendPostData/sendPutData write the whole upload into heap before
BodyPublishers.ofByteArray(...) is built.

Two concrete failures: a POST that uploads a 2 GB file with Files Upload dies with OutOfMemoryError on both HTTP/2
paths while it succeeds on HTTP/1.1; and downloading a 1 GB response over HTTP/2 keeps the full gigabyte in heap even
when httpsampler.max_bytes_to_store_per_request is set, because that limit is applied later, inside
HTTPSamplerBase.readResponse, when the bytes are already materialized. Under load this multiplies by the thread count.

Suggested fix: use the streaming async API — AsyncRequestBuilder with a FileEntityProducer/BasicRequestProducer,
and an AbstractBinResponseConsumer that feeds HTTPSamplerBase.readResponse's truncation logic. For HTTPJavaImpl,
publish with BodyPublishers.ofFile/ofInputStream.

M3. HTTPHC5Impl never updates the sample URL after an automatic redirect

The request config enables HttpClient's own redirect handling (HTTPHC5Impl.java:520), but sample() never writes the
final URI back into the result — there is no result.setURL(...) anywhere in the class. HTTPHC4Impl does exactly that
(HTTPHC4Impl.java:708-718).

Consequences:

  • saveConnectionCookies(response, result.getURL(), getCookieManager()) runs with the original URL, so a Set-Cookie
    issued by the redirect target is stored against the original host. A login flow
    http://www.example.com/login → https://auth.example.com/session stores the session cookie for www.example.com,
    and every later request is unauthenticated.
  • cacheManager.saveDetails(response, result) uses res.getUrlAsString(), so the ETag of the redirect target is cached
    under the original URL.
  • Listeners show the pre-redirect URL.

The same method also drops a redirect without a Location header silently, where HC4 raises IllegalArgumentException
(HTTPHC4Impl.java:684-686).

M4. ConnectTimeTracker stamps connectEnd() onto unrelated in-flight samples

ConnectTimeTracker.recordConnectEnd() walks all entries of activeSamples and calls result.connectEnd() on each.
The tracker belongs to an Http2Client, and with http.java.h2.multiplexing=true that client lives in the static
SHARED_HTTP_2_CLIENTS map — one instance for every JMeter thread and every origin, since HttpClientKey carries no
host or port.

Thread A samples https://a.example over an already-pooled connection while thread B opens a new connection to
https://b.example; B's TLS handshake stamps a connect time into A's result.

Separately, ConnectTimeMeasuringExecutor.execute() calls tracker.connectionEstablished() on every task the JDK
client submits — body delivery, stream callbacks, retries — not only on connection establishment, so the first such task
after a sample starts is reported as its connect time even on a fully pooled connection.

recordsConnectTimeForEveryMultiplexedSample (TestHTTPJavaFeatures.java:219-242) asserts this behavior rather than the
invariant, so it locks the defect in. There is also a cross-thread write to a SampleResult owned by another thread.

Suggested fix: scope the tracker to a connection rather than to a client, drop the executor heuristic, and change the
test to assert that a sample served by an established connection reports connectTime == 0.

M5. HTTPHC5Impl reports the decompressed length as bodySize

updateResult() sets bodySize = body.length where body is the decoded payload
(HTTPHC5Impl.java:901-914). HTTPHC4Impl derives it from connection metrics —
res.setBodySize(totalBytes - headerBytes) — that is, wire bytes; and the new HTTPJavaImpl HTTP/2 path uses a
CountingInputStream around the compressed stream.

A gzip response of 20 KB on the wire that expands to 400 KB is reported as 400 KB by HttpClient5 and as 20 KB by
HttpClient4 and Java. Switching a plan's implementation changes the Bytes column and any bandwidth SLA by a factor of
20, with no warning. headersSize is approximated differently too.

Suggested fix: wrap the raw entity stream in org.apache.jorphan.io.CountingInputStream before the decompressing
wrapper — the class HTTPJavaImpl already uses — and compute headersSize the way HC4 does. TestDecompression is
already parameterized across all three implementations; an assertion on getBytesAsLong() there would cover it.

M6. HTTPJavaImpl sent-bytes counts the human-readable body preview for file uploads

The HTTP/1.1 path computes postBodyBytes = getBytes(postBody) where postBody is the String returned by
sendPostData/sendPutData — the display form JMeter puts in the Request Body tab. For a file upload that is the
placeholder <actual file content, not shown here> plus the multipart boundary preview, not the payload.
calculateSentBytes then takes the postBodyBytes.length > 0 branch and never falls back to Content-Length.

POST a 10 MB file with Files Upload and the sample reports a few hundred sent bytes — the sent-bytes graph and the
dashboard understate upload traffic by three orders of magnitude.

The requestHeaders snapshot for the same computation is taken from conn.getRequestProperties() before
setPostHeaders/setPutHeaders has added Content-Length/Content-Type, so the header portion is under-counted too.

Suggested fix: count what is actually written by wrapping conn.getOutputStream() in a counting stream, falling back to
Content-Length.

M7. HTTPJavaImpl mutates global system properties from a static initializer

HTTPJavaImpl has static { applyHttp2SystemProperties(JMeterUtils.getJMeterProperties(), System.getProperties()); },
which writes jdk.httpclient.hpack.maxheadertablesize, jdk.httpclient.maxstreams, jdk.httpclient.windowsize,
jdk.httpclient.connectionWindowSize, jdk.httpclient.maxframesize, jdk.httpclient.keepalive.timeout.h2 and —
unconditionally — jdk.httpclient.enablepush into the JVM-wide table, as a side effect of loading the class.

Selecting the legacy Java implementation for a plain HTTP/1.1 sampler, or running TestDecompression (which
instantiates every implementation), flips jdk.httpclient.enablepush to 0 for every other component in the JVM:
JSR223 scripts, plugins, the backend listener, anything that builds its own java.net.http.HttpClient. In a
distributed-test server process the change survives across runs and cannot be undone. The setUnlessDefined guard only
protects values that are already present.

JMeter already has system.properties for exactly this. Suggested fix: apply the properties lazily and idempotently
when the first HTTP/2 sample is about to be taken, skip jdk.httpclient.enablepush when the JMeter property is absent,
or document that these belong on the command line and drop the mutation.


Design and reuse

D1. HTTPHC5Impl re-implements HTTPHC4Impl rather than extracting the shared logic

Roughly 700 of HTTPHC5Impl's 1499 lines are HTTPHC4Impl with the types substituted: getRequestHeaders,
getAllHeadersExceptCookie, setConnectionCookie, saveConnectionCookies, getOnlyCookieFromHeaders,
setConnectionHeaders, setupRequestEntity/createNameValuePairs, HttpClientKey (12 fields plus equals/
hashCode), the Kerberos SPN and strip-port logic, the route planner, and the connect-time-measuring connection
manager. CountingOutputStream (HTTPHC5Impl.java:997-1013) is derived a third time inside HTTPJavaImpl's
calculateSentBytes.

This is not a theoretical cost: B1, M3 and M5 are all cases where the HC5 copy drifted from the HC4 original within a
single PR. Lifting the cookie, entity and sent-bytes helpers into HTTPHCAbstractImpl (or a package-private utility
shared by HC4 and HC5) would have made those three impossible.

D2. CacheManager grows a third and fourth parallel API

CacheManager now carries three near-identical saveDetails overloads (HC4 HttpResponse, HC5 ClassicHttpResponse,
java.net.http.HttpResponse), two inCache, two setHeaders and two asHeaders adapters. A fix to the Vary or
cache-control handling now has to land in three places, and a core config element acquires a compile-time dependency on
both HttpClient 4 and HttpClient 5 — which also pins those versions into JMeter's public API for plugin authors.

Suggested shape: one saveDetails(ResponseHeaderSource, HTTPSampleResult), one inCache(URL, Header[]) and one
setHeaders(URL, RequestHeaderSink), with the adapters at the three call sites. The existing
CacheManager.Header/HeaderAdapter pair is already most of that abstraction.

D3. Is HTTPJavaImpl the right home for a second full HTTP/2 client?

HTTPJavaImpl grows from ~350 to ~1800 lines. Around 250 of those are a hand-written delegating SSLContext /
SSLContextSpi / SSLEngine triple whose only purpose is to observe when the TLS handshake finished, and ~60 more are
a hand-maintained IANA reason-phrase table (HTTP_REASON_PHRASES) — EnglishReasonPhraseCatalog is already on this
module's classpath.

The legacy Java implementation exists as the minimal, dependency-free fallback. Putting a second production HTTP/2
stack inside it means two independent HTTP/2 implementations to maintain, with different bugs (compare M4 against the
HC5 connect-time path) and different semantics (equals vs equalsIgnoreCase on the same stored value, see N5).

Worth deciding explicitly before merge: either HTTPJavaImpl gets HTTP/2 as a thin java.net.http passthrough without
the connect-time instrumentation and the multiplexing machinery, or the HTTP/2 support lives only in HTTPHC5Impl and
this PR shrinks by about a third. Splitting the PR along that line would also make it reviewable — 4855 lines across two
new HTTP stacks, a new GUI component, new core API and a dependency bump is a lot to land at once.

D4. HTTP/2 multiplexing across virtual users changes what the test measures

http.java.h2.multiplexing and httpclient5.h2.multiplexing both default to true, and for the Java implementation
that means one HttpClient shared by every JMeter thread. A load test then drives N virtual users over a single TCP
connection, which is not what N real browser users do — connection-level effects (congestion window, server-side
per-connection limits, TLS handshake cost) disappear from the measurement.

Multiplexing within one thread's embedded-resource downloads is clearly right. Sharing across threads is a modeling
decision that deserves a default of false, or at least an explicit note in component_reference.xml about what it
does to the results.


Minor and nits

N1. Response stream leaked when obey_contentlength short-circuits

In readResponse(HttpResponse<InputStream>, SampleResult) the if (contentLength == 0 && OBEY_CONTENT_LENGTH) branch
returns NULL_BA without closing in (response.body()). The JDK client releases an HTTP/2 stream only once the body
stream is consumed or closed, so with httpsampler.obey_contentlength=true against a target that answers
Content-Length: 0 every sample leaks a stream. Restructure as try-with-resources around response.body().

N2. Test quality

Several of the 1756 new test lines cannot fail when the behavior they name breaks:

  • enablesMessageMultiplexingWithoutRequiringHttpClient55, doesNotRequireProtocolUpgradeConfiguration and
    doesNotRequireHttpAsyncClassicAdapter read HTTPHC5Impl.class bytes and run a 90-line hand-written constant-pool
    parser (TestHTTPHC5Features.java:784-846). They assert on the shape of the compiled artifact, throw
    IOException("Unknown class-file constant-pool tag") on any tag a future JVM adds, and would pass if multiplexing
    were looked up reflectively but never invoked.
  • appliesHttp2ProtocolSettings asserts the literals 8192/250/65535/65536, which are httpcore5's own H2Config
    defaults — it re-asserts the library, breaks on a dependency bump, and never covers the JMeter property override it
    exists for.
  • setsSentBytesCorrectlyForGetRequest asserts only getSentBytes() > 0, and the POST variant only
    > "hello world".length(), so the sent-bytes arithmetic this PR adds is effectively untested. Assert the exact count
    against WireMock's request journal.
  • multiplexesConcurrentRequestsOverASingleHttp2Connection (both copies) asserts four HTTP/2 200s and would pass with
    four separate connections. Count accepted connections instead.
  • TestHTTPHC5Features sleeps validateAfterInactivityMillis() + 500 (2.5 s) and Thread.sleep(50); neither suite
    closes the static clients it creates, so I/O reactor and selector threads accumulate for the life of the test JVM.

Related: the reflective findMessageMultiplexingSetter() lookup and its "JMeter still runs with older HttpClient
versions" comment are dead weight — build.gradle.kts pins httpclient5 at 5.6.4, so the method is always present.
Dropping the reflection removes the three class-file tests with it.

N3. HttpVersionComboBox hard-codes an English label next to a localized one

HttpVersionComboBox.java:47 defines "HTTP/2 Negotiate" as a Java constant with $NON-NLS-1$, while the label beside
it goes through JMeterUtils.getResString("http_version") and was translated into all 12 bundles. A user running JMeter
in German or Japanese sees a translated field label followed by an untranslated value, and translators have no key.
component_reference.xml documents the string as user-visible, so it is UI text.

Also, http_version was inserted out of alphabetical order in all 12 bundles (messages.properties:476 sits between
html_assertion_title and html_report; messages_de.properties:19 sits right after about).

N4. HttpDefaultsGui writes httpVersion unconditionally

HttpDefaultsGui.java:157 does config.set(httpSchema.getHttpVersion(), String.valueOf(httpVersion.getSelectedItem()))
while HttpTestSampleGui.java:183-185 normalizes a blank selection to null. Every HTTP Request Defaults element
opened and saved in the GUI therefore grows an empty HTTPSampler.httpVersion property — spurious diffs in
version-controlled JMX files — and String.valueOf turns a null selection into the literal "null". Neither GUI resets
the combo in clearGui().

N5. Duplicated constants and inconsistent version matching in the new API

  • HTTPConstantsInterface gains both HTTP_VERSION_2 = "HTTP/2" and HTTP_2 = "HTTP/2" — two public constants with
    identical values and overlapping Javadoc. Callers already mix them.
  • HTTPSamplerBase.HTTP_VERSION = "HTTPSampler.httpVersion" shadows HTTPHCAbstractImpl.HTTP_VERSION = JMeterUtils.getPropDefault("httpclient.version", "1.1") by simple name inside the same hierarchy: unqualified
    HTTP_VERSION means the property value in one class and the property name in the other. The rest of
    HTTPSamplerBase now derives such constants from the schema — HTTPSamplerBaseSchema.INSTANCE.getHttpVersion() .getName() would keep that convention.
  • HTTPHC5Impl.getHttpVersionPolicy compares HTTP_VERSION_2_STRICT with equalsIgnoreCase but HTTP_VERSION_2 with
    equals, so http/2 selects HTTP/1.1 while http/2 strict selects HTTP/2. HTTPJavaImpl.isHttp2 uses
    equalsIgnoreCase for both, so the two implementations disagree about the same stored value.

N6. calculateSentBytes uses the platform default charset and models HTTP/2 as HTTP/1.1 text

Every getBytes(Charset.defaultCharset()) in HTTPHC5Impl.calculateSentBytes (HTTPHC5Impl.java:957-976) measures
header names and values in the platform encoding rather than the ISO-8859-1 the wire format uses, so the same plan
reports different sent bytes on a non-UTF-8 JVM.

More fundamentally, the method reconstructs an HTTP/1.1 request line and CRLF-separated headers even when the request
went out over HTTP/2, where headers are HPACK-compressed pseudo-headers — the reported value is systematically too high
once HPACK indexing kicks in on the second request to a host. HTTPHC4Impl takes the real count from connection
metrics (HTTPHC4Impl.java:701); HC5 exposes the same through HttpClientContext.getEndpointDetails() .getSentBytesCount().

Also: for a repeatable entity the method calls entity.writeTo(counter), which re-reads the whole upload from disk on
every sample purely to count it.

N7. httpclient.version is re-purposed but two existing readers still expect the old values

bin/jmeter.properties and properties_reference.xml now document httpclient.version as taking HTTP/1.1, HTTP/2
or HTTP/2 Strict. Two existing readers still expect 1.0/1.1:

  • AjpSampler.java:180 — JMeterUtils.getPropDefault("httpclient.version","1.1").equals("1.0");
  • HTTPHCAbstractImpl.java:80 — protected static final String HTTP_VERSION = JMeterUtils.getPropDefault( "httpclient.version", "1.1").

So the documentation is now wrong for AJP, and a user who set httpclient.version=1.1 for the old meaning gets an
unrecognized value for HC5/Java. Either introduce a separate property for the new domain, or accept both spellings and
say so in the docs.

N8. Documentation gaps

  • changes.xml never announces the headline feature. All seven entries describe HTTP/2 details and assume
    HttpClient5 already exists; there is no entry for the new sampler implementation itself or for the new
    HTTPSampler.httpVersion property.
  • The HTTP Request Defaults <properties> block in component_reference.xml gains no HTTP Version entry, although
    HttpDefaultsGui adds the field.
  • <dt><code>HTTPClient5</code></dt> (component_reference.xml:145) uses casing that does not match the alias
    HttpClient5 — it mirrors the pre-existing HTTPClient4 typo, so fixing both is optional but welcome.

@andreaslind01

Copy link
Copy Markdown
Contributor Author

Thanks for the review @vlsi.

All findings have been addressed.

Concerning D3, the changes in HTTPJavaImpl have been reverted. The additional HTTP/2 implementation based on java.net.http is therefore no longer part of this PR.

@milamberspace

Copy link
Copy Markdown
Contributor

Code review

Found 3 issues, all minor relative to the rest of the PR:

  1. setupRequest runs before result.sampleStart() in HTTPHC5Impl.sample(). If it throws (e.g. an invalid Content-Encoding charset, or an unresolvable upload file), the catch block calls result.sampleEnd() with startTime still at its zero default, so the error sample reports an elapsed time computed from the epoch instead of the real (small) one. HTTPHC4Impl avoids this by calling sampleStart() before building the request.

resetStateIfNeeded();
request = createRequest(url.toURI(), method);
setupRequest(url, request, result, areFollowingRedirect);
result.sampleStart();

  1. Stale Javadoc: HttpClientKey still says "used as the key to the ThreadLocal map of HttpClient instances", but the field itself is now a synchronized WeakHashMap<JMeterContext, ...>, not a ThreadLocal.

/**
* Holder class for all fields that define an HttpClient instance;
* used as the key to the ThreadLocal map of HttpClient instances.
*/
private static final class HttpClientKey {

  1. HTTPMessageSizes's class Javadoc says it's "shared by the sampler implementations so that all of them report the same number of sent bytes" — in practice only HTTPHC5Impl uses it; HTTPHC4Impl still derives sent bytes from its own connection metrics, so the claimed cross-implementation consistency doesn't actually hold.

/**
* 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.
* <p>
* The lengths are the ones the message has on the wire, where the request line and the headers are

@milamberspace milamberspace left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review at 009fa5ff48. First, thanks: the previous round is in very good shape. Every blocker from @vlsi's review (B1–B6) is either fixed or moot after the HTTPJavaImpl revert, my 2026-08-14 constant / @since threads are addressed, and the 2026-08-19 points (elapsed time on setup failure, stale Javadoc) are fixed with tests. Going through the HttpClient 5 internals more deeply, though, I found two issues that would hit users of the new HTTP/2 path directly, plus a few behaviour gaps compared with HttpClient4, so I'm requesting changes.

The branch currently conflicts with master and needs a rebase before this can merge: master already has httpclient5:5.6.4 in src/bom-thirdparty/build.gradle.kts, so the resolution is simply to keep that line and add the two httpcore5 / httpcore5-h2 5.4.3 lines. The last CI run also has one red job (:src:dist-check:batchServerBatchTestLocal on Windows / JDK 17), which may well be the usual flaky batch test, but it needs a green re-run after the rebase.

Blocking — the response timeout is ignored once HTTP/2 is negotiated (HTTPHC5Impl.java:1295)

setupRequest puts the Response timeout into RequestConfig (line 622), and executeHttp2 then waits with responseFuture.get() with no timeout. In httpclient5 5.6.4, InternalHttpAsyncExecRuntime only applies RequestConfig.getResponseTimeout() to the endpoint (setSocketTimeout) when the negotiated protocol is below HTTP/2 (checked in the bytecode: protocol.greaterEquals(HttpVersion.HTTP_2) skips the call). No ConnectionConfig.setSocketTimeout or IOReactorConfig socket timeout is set either, so the effective timeout is infinite.

With HTTP/2 Negotiate or HTTP/2 Strict and a response timeout of e.g. 5000 ms, a server that never answers (or sends HEADERS and then stalls) blocks the sampler thread until Stop Test Now. The same element over HTTP/1.1 times out correctly. Suggested fix: responseFuture.get(responseTimeout, MILLISECONDS) when a timeout is set, cancel(true) on TimeoutException, and map it to a SocketTimeoutException so the sample fails like on HTTP/1.1. A test with an h2 server that accepts the stream but never responds would lock this in.

Blocking — client certificates are not presented on the async (HTTP/2) transport (HTTPHC5Impl.java:452)

The async client handshakes through an SSLEngine, so JSSE asks the key manager for chooseEngineClientAlias(...). JMeter's JsseSSLManager.WrappedX509KeyManager (src/core/.../util/JsseSSLManager.java, not touched by this PR) only overrides chooseClientAlias(..., Socket); the inherited X509ExtendedKeyManager.chooseEngineClientAlias returns null. So with a Keystore Configuration and a server requiring mutual TLS, HTTP/2 (and HTTP/2 Negotiate falling back to 1.1 on the same client) presents no certificate and the handshake fails. The classic HTTP/1.1 path uses SSLSocket and is fine.

Simply overriding chooseEngineClientAlias is not quite enough: JmeterKeyStore.getAlias() resolves the alias from JMeterContextService.getContext().getVariables(), and on the I/O reactor thread that is not the sampler's context. The alias should be picked on the sampler thread (e.g. carried in the HttpClientContext / per-request TLS attachment) and read back by the engine variant. The changes.xml entry currently lists "client certificates" as supported, so this needs either a fix plus a client-auth test, or an explicit documented limitation for HTTP/2.

Automatic redirects stop at any cross-origin hop when a Cookie or Authorization header is present (HTTPHC5Impl.java:620)

No RedirectStrategy is configured, so HttpClient 5's DefaultRedirectStrategy is used. Its isRedirectAllowed refuses to follow a redirect to a different authority when the request carries a Cookie or Authorization header (or any sensitive header), and RedirectExec / AsyncRedirectExec then return the 3xx as the final response. JMeter sets cookies (Cookie Manager) and pre-emptive Basic auth as explicit request headers, so a typical SSO flow (app.example.com → 302 → idp.example.com) with Redirect Automatically records a successful 302 and never reaches the IdP, whereas HTTPHC4Impl (LaxRedirectStrategy) follows it. Suggested fix: a redirect strategy that allows the hop (and ideally re-computes Cookie / drops Authorization per hop, as the Cookie/Auth managers would for the new host), plus setMaxRedirects(HTTPSamplerBase.MAX_REDIRECTS) so httpsampler.max_redirects is honoured as in HC4 (HC5 currently uses its own default of 50).

Received bytes are wrong for uncompressed HTTP/1.1 bodies when truncation or MD5 is active (HTTPHC5Impl.java:1054)

On the classic path the body counter only exists when a Content-Encoding triggered decompression, so an uncompressed response falls back to body.length, i.e. what readResponse stored: truncated to httpsampler.max_bytes_to_store_per_request, or the 32-character hex digest when Save response as MD5 hash is on. A 50 MB download with MD5 enabled then reports ~32 bytes of body, and Received KB/sec in the reports is wrong. Wrapping every HTTP/1.1 entity in CountingEntity (not only encoded ones) and always using its count would align it with HC4, which reports wire bytes.

One I/O reactor with availableProcessors() threads per (JMeter thread × client key) (HTTPHC5Impl.java:1233)

createHttp2Client sets no IOReactorConfig, so each async client starts Runtime.availableProcessors() I/O dispatcher threads plus its main reactor thread. Clients are cached per JMeterContext and per HttpClientKey (which includes the target authority), and when the Thread Group's Same user on each iteration is unchecked (with httpclient.reset_state_on_thread_group_iteration=true, the default) they are closed and rebuilt every iteration. 1,000 threads on a 16-core injector hitting 3 hosts means ~51,000 platform threads — recreated each iteration in that mode. IOReactorConfig.custom().setIoThreadCount(1) per client would already make this proportional to the thread count; sharing one reactor per JMeter thread would be better still.

NTLM and the Authorization Manager domain/realm are not supported (HTTPHC5Impl.java:889)

Target and proxy credentials are always UsernamePasswordCredentials scoped to host:port: the Domain / Realm columns of the Authorization Manager and http.proxyDomain are ignored, and no NTLMSchemeFactory is registered. HC4 registers NTLM and uses NTCredentials for both target and proxy, so an IIS site or corporate proxy that only offers NTLM answers 401/407 with HttpClient5. Since the changes.xml entry says HC5 "supports the features of the existing HttpClient4 implementation", either register NTLM (deprecated in HC5 but still shipped) and build NTCredentials when a domain is set, or document the gap.

Smaller observations

Inline comments on HTTPHC5Impl.java:241, :531, :1207, HTTPHC4Impl.java:526, HTTPHCAbstractImpl.java:94 and xdocs/changes.xml:84; the remaining ones:

  • HTTPHC5Impl.java:1061 — after automatic redirects the reported request headers and sent bytes describe the first hop, while the URL is the final one (HC4 reads the executed request from the context). Headers added by HttpClient itself (Digest / Negotiate Authorization) are also not shown.
  • HTTPHC5Impl.java:427 — if a parallel-download pool thread is the first to need a client for a key, the TLS strategy captures that pool thread's thread-local SSLContext, not the JMeter thread's.
  • HTTPHC4Impl.java:1167 — the per-key map is now explicitly shared with the parallel-download threads (hence the ConcurrentHashMap), but lookup (line 1066) and insert are still separate get / put, so two concurrent downloads to a new authority can both build a client and the overwritten one is never closed. computeIfAbsent or putIfAbsent + close-the-loser would fix it.
  • @since 6.0 is still missing on most new public/protected API: HTTPHC5Impl, HTTPSamplerFactory.IMPL_HTTP_CLIENT5 / getHttpVersions, HTTPConstantsInterface.HTTP_VERSION_1_1 / HTTP_VERSION_2 / HTTP_VERSION_2_STRICT, HTTPSamplerBase.HTTP_VERSION / setHttpVersion / getHttpVersion, HTTPAbstractImpl.HTTP_VERSION_PROPERTY / readDefaultHttpVersion, HTTPHCAbstractImpl.DEFAULT_HTTP_VERSION and the new protected helpers. Also, HTTP_VERSION_1_1 duplicates the existing HTTPConstantsInterface.HTTP_1_1 value.
  • messages.properties:481 — http_version_2_negotiate exists only in English while http_version was added to 11 locales; at least a French entry would be nice.
  • Remaining literal "<actual file content, not shown here>" is repeated in HTTPHC5Impl (lines 720, 778) and HTTPHC4Impl — a shared constant would close the last of my earlier threads. The HTTPJavaImpl threads from 2026-08-14 are moot after the revert and can be resolved.

This review was drafted by an AI-assisted tool and
confirmed by an Apache JMeter maintainer. After you've
addressed the points above and pushed an update, an Apache JMeter
maintainer — a real person — will take the next look
at the PR. The findings cite the project's review criteria;
if you think one of them is mis-applied, please reply on the
PR and a maintainer will weigh in.

More on how Apache JMeter handles maintainer review:
Contributing guide.

responseFuture.cancel(true);
}
try {
return responseFuture.get();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking — this get() has no timeout, and on an HTTP/2 connection httpclient5 5.6.4 never applies RequestConfig.getResponseTimeout() (InternalHttpAsyncExecRuntime only calls setSocketTimeout when the protocol is below HTTP/2). A stalled server blocks the sampler thread until Stop Test Now.

Reproduced against a real HTTP/2 server: with a 1 ms response timeout, HttpClient4 and HttpClient5/HTTP/1.1 fail with Read timed out, while HttpClient5/HTTP/2 returns the full response after ~2 s.

Suggestion: responseFuture.get(responseTimeout, TimeUnit.MILLISECONDS) when a timeout is set, cancel(true) on TimeoutException, and rethrow as SocketTimeoutException.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for catching this.

Rebase is done, and I’ve addressed the timeout handling in commit cd1b63b. This should now be resolved.

}

private static TlsStrategy createTlsStrategy() {
return createTlsStrategyBuilder().buildAsync();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking — the async transport handshakes through an SSLEngine, so JSSE calls chooseEngineClientAlias, which JsseSSLManager.WrappedX509KeyManager does not override (the inherited default returns null). With a Keystore Configuration and a server requiring client auth, HTTP/2 presents no certificate. Note the alias must be chosen on the sampler thread: JmeterKeyStore.getAlias() reads the JMeter variables of the current context, which is wrong on the I/O reactor thread. See the review body for details.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This has been addressed and should be resolved by commit 2e83dd3. Thanks for pointing this out.

HttpVersionPolicy httpVersionPolicy = getHttpVersionPolicy(testElement.getHttpVersion(), DEFAULT_HTTP_VERSION,
url.getProtocol());
RequestConfig.Builder config = RequestConfig.custom()
.setRedirectsEnabled(getAutoRedirects() && !areFollowingRedirect);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No RedirectStrategy is set, so DefaultRedirectStrategy.isRedirectAllowed refuses a hop to a different scheme/host/port whenever the request carries Cookie or Authorization, and the 3xx becomes the final result. Reproduced: http://host/ → 302 → https://host/ reaches 200 with HttpClient4 and with HttpClient5 without cookies, but stays on 302 with HttpClient5 + a Cookie Manager cookie or an Authorization header.

Also missing here: setMaxRedirects(HTTPSamplerBase.MAX_REDIRECTS) so httpsampler.max_redirects is honoured as in HC4.

@andreaslind01 andreaslind01 Oct 6, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, this has been addressed and should be resolved by commit be3a804.

Long bodySize = (Long) context.getAttribute(CONTEXT_ATTRIBUTE_RESPONSE_BODY_SIZE);
CountingEntity bodyCounter =
(CountingEntity) context.getAttribute(CONTEXT_ATTRIBUTE_RESPONSE_BODY_COUNTER);
result.setBodySize(bodySize != null ? bodySize

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Without Content-Encoding the classic path has no bodyCounter, so this falls back to body.length — what readResponse stored, i.e. the truncated body or the 32-char MD5 hex. Reproduced with Save response as MD5 hash: bodySize=32 with HttpClient5/HTTP/1.1 vs 8377 with HttpClient4 and 8124 with HttpClient5/HTTP/2 on the same page. Wrapping every HTTP/1.1 entity in CountingEntity would fix it.

@andreaslind01 andreaslind01 Oct 6, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, this has been addressed and should be resolved by commit f60c76e.

.build();
}

private static CloseableHttpAsyncClient createHttp2Client(HttpClientKey key) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No IOReactorConfig here, so each async client starts availableProcessors() dispatcher threads + a main reactor thread, per JMeter thread and per HttpClientKey. Measured: 20 VUs on 8 CPUs → 160 httpclient-dispatch + 20 httpclient-main threads, and 320 created after 2 iterations when Same user on each iteration is unchecked. IOReactorConfig.custom().setIoThreadCount(1) would already make it proportional to the thread count.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, this has been addressed and should be resolved by commit b1aa5eb.

return JMeterUtils.getPropDefault(DISABLE_DEFAULT_UA_PROPERTY, false);
}

private static CloseableHttpClient createClient(HttpClientKey key) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: the classic PoolingHttpClientConnectionManager keeps HttpClient's defaults (5 per route / 25 total). Parallel embedded-resource downloads now share the parent thread's client and JMeter's default concurrent pool is 6, so extra downloads wait for a lease and inflate elapsed times. Consider setMaxConnPerRoute ≥ the concurrent pool size.

* materialized as a byte array.
*/
private static final int HTTP_2_BODY_SPILL_THRESHOLD =
JMeterUtils.getPropDefault("httpclient5.h2.body_spill_threshold", 256 * 1024);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: httpclient5.h2.body_spill_threshold is not documented in bin/jmeter.properties nor in properties_reference.xml.

}
};
private static final Map<JMeterContext, Map<HttpClientKey, HttpClientState>>
HTTPCLIENTS_CACHE_PER_THREAD_AND_HTTPCLIENTKEY = Collections.synchronizedMap(new WeakHashMap<>());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: HttpClient4 is the default implementation and every HC4 sample now goes through this single JVM-wide synchronizedMap (computeIfAbsent, plus WeakHashMap.expungeStaleEntries under the lock), where the previous InheritableThreadLocal had no lock. The fix itself is right; keeping the map on the JMeterContext (e.g. in getSamplerContext()) would give the same per-context semantics without global contention.


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 = readDefaultHttpVersion();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: the protected static final String HTTP_VERSION this replaces is gone, which breaks third-party subclasses referencing it (compile error / NoSuchFieldError). A @Deprecated HTTP_VERSION kept alongside would be safer. Also @since 6.0 on the new protected API.

Comment thread xdocs/changes.xml
<li><issue>6250</issue>Avoid adding "; charset=" automatically to <code>multipart/form-data</code> requests to align behavior with modern HTTP clients.</li>
<li><issue>6080</issue>Preserve the original HTTP method when following 307 and 308 redirects according to the HTTP specification. Contributed by LeeJiWon (github.com/dlwldnjs1009)</li>
<li><issue>6267</issue><pr>6268</pr>Add a space between key and value after <code>:</code> in View Results Tree &gt; Sampler result tab for better readability.</li>
<li><pr>6742</pr>Add the new <code>HttpClient5</code> sampler implementation, which is based on Apache HttpComponents HttpClient 5.x and can be selected in the <code>Implementation</code> combo box of the HTTP Request sampler and the HTTP Request Defaults, or with the <code>jmeter.httpsampler</code> property. It supports the features of the existing <code>HttpClient4</code> implementation (cookie, cache, header and authorization management, proxies, client certificates, WebDav methods, DNS Cache Manager, connection reuse and retries) and adds HTTP/2 support.</li>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"connection reuse and retries": automatic retries are disabled (disableAutomaticRetries() in both builders), and client certificates don't currently work over HTTP/2. Please also add <issue>5081</issue> / <issue>4023</issue> for the issues this PR fixes.

…ownloads

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.
…meTracker to measure connection establishment duration and update sample results accordingly
… size and response bytes accounting across HTTP implementations. Update tests to validate compressed body size handling.
…oduce CountingOutputHttpURLConnection to track request body size accurately. Update sent bytes calculation and add Content-Length header when missing. Add tests for large file uploads.
- 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.
…th is enabled

Wrap the HTTP/2 response body stream in a try-with-resources block in
HTTPJavaImpl#readResponse(HttpResponse<InputStream>, 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.
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.
…rce 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
…ombos 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`
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.
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.
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.
… 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.
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.
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.
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.
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".
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.
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.
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.
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.
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.
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.
…TTPHC5Impl

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.
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.
… 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.
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants