Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,9 @@
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<jmeter.version>5.4.1</jmeter.version>
<jetty.version>12.1.7</jetty.version>
<!-- Matches jetty-project ${brotli4j.version} for Jetty 12.1.7 -->
<brotli4j.version>1.20.0</brotli4j.version>
<jetty.version>12.1.11</jetty.version>
<!-- Matches jetty-project ${brotli4j.version} for Jetty 12.1.11 -->
<brotli4j.version>1.23.0</brotli4j.version>
<checkstyle.skip>false</checkstyle.skip>
</properties>
<dependencies>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import com.blazemeter.jmeter.http2.core.jetty.CustomWwwAuthenticationProtocolHandler;
import com.blazemeter.jmeter.http2.core.jetty.custom.http2.CustomClientConnectionFactoryOverHTTP2;
import com.blazemeter.jmeter.http2.core.jetty.custom.http2.CustomHttpClientTransportOverHTTP2;
import com.blazemeter.jmeter.http2.core.jetty.custom.http3.CustomClientConnectionFactoryOverHTTP3;
import com.blazemeter.jmeter.http2.sampler.HTTP2Sampler;
import com.blazemeter.jmeter.http2.util.BzmHttpPluginProperties;
Expand Down Expand Up @@ -100,7 +101,6 @@
import org.eclipse.jetty.http2.HTTP2Session;
import org.eclipse.jetty.http2.api.Session;
import org.eclipse.jetty.http2.client.HTTP2Client;
import org.eclipse.jetty.http2.client.transport.HttpClientTransportOverHTTP2;
import org.eclipse.jetty.http2.frames.Frame;
import org.eclipse.jetty.http2.frames.GoAwayFrame;
import org.eclipse.jetty.http2.frames.HeadersFrame;
Expand Down Expand Up @@ -538,7 +538,7 @@ public HTTP2JettyClient(boolean http1UpgradeRequired, String name,
} else {
http2cClient.setMaxConcurrentPushedStreams(maxConcurrentPushedStreams);
}
HttpClientTransport h2cTransport = new HttpClientTransportOverHTTP2(http2cClient);
HttpClientTransport h2cTransport = new CustomHttpClientTransportOverHTTP2(http2cClient);
configureTransport(h2cTransport);
this.httpClientH2cPrior = new HttpClient(h2cTransport);
configureHttpClient(this.httpClientH2cPrior, h2cConnector);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import org.eclipse.jetty.client.transport.HttpClientConnectionFactory;
import org.eclipse.jetty.client.transport.HttpDestination;
import org.eclipse.jetty.http2.client.HTTP2Client;
import org.eclipse.jetty.http2.client.HTTP2ClientConnectionFactory;
import org.eclipse.jetty.http2.client.transport.CustomHttp2ClientConfigurer;
import org.eclipse.jetty.http2.client.transport.internal.HttpConnectionOverHTTP2;
import org.eclipse.jetty.io.ClientConnectionFactory;
import org.eclipse.jetty.io.EndPoint;
Expand All @@ -22,7 +22,7 @@
*/
public class CustomClientConnectionFactoryOverHTTP2 extends ContainerLifeCycle
implements ClientConnectionFactory, HttpClient.Aware {
private final ClientConnectionFactory factory = new HTTP2ClientConnectionFactory();
private final ClientConnectionFactory factory = new CustomHTTP2ClientConnectionFactory();
private final HTTP2Client http2Client;

public CustomClientConnectionFactoryOverHTTP2(HTTP2Client http2Client) {
Expand All @@ -32,15 +32,7 @@ public CustomClientConnectionFactoryOverHTTP2(HTTP2Client http2Client) {

@Override
public void setHttpClient(HttpClient httpClient) {
try {
java.lang.reflect.Method configureMethod = org.eclipse.jetty.http2.client.transport
.HttpClientTransportOverHTTP2.class.getDeclaredMethod(
"configure", HttpClient.class, HTTP2Client.class);
configureMethod.setAccessible(true);
configureMethod.invoke(null, httpClient, http2Client);
} catch (ReflectiveOperationException e) {
throw new IllegalStateException("Unable to configure HTTP/2 transport", e);
}
CustomHttp2ClientConfigurer.configure(httpClient, http2Client);
}

@Override
Expand Down

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.

What do you think about modularizing this a bit? Maybe initialization and configuration of required instances could be managed separately so we can have a quick overview of what is needed for a newConnection?

Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
package com.blazemeter.jmeter.http2.core.jetty.custom.http2;

import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.eclipse.jetty.http2.FlowControlStrategy;
import org.eclipse.jetty.http2.HTTP2Connection;
import org.eclipse.jetty.http2.HTTP2Session;
import org.eclipse.jetty.http2.api.Session;
import org.eclipse.jetty.http2.client.CustomHttp2SessionContainerAccessor;
import org.eclipse.jetty.http2.client.HTTP2Client;
import org.eclipse.jetty.http2.client.internal.HTTP2ClientSession;
import org.eclipse.jetty.http2.frames.Frame;
import org.eclipse.jetty.http2.frames.PrefaceFrame;
import org.eclipse.jetty.http2.frames.SettingsFrame;
import org.eclipse.jetty.http2.frames.WindowUpdateFrame;
import org.eclipse.jetty.http2.generator.Generator;
import org.eclipse.jetty.http2.hpack.HpackContext;
import org.eclipse.jetty.io.ByteBufferPool;
import org.eclipse.jetty.io.ClientConnectionFactory;
import org.eclipse.jetty.io.Connection;
import org.eclipse.jetty.io.EndPoint;
import org.eclipse.jetty.util.Callback;
import org.eclipse.jetty.util.Promise;

/**
* Jetty 12.1.11 {@code HTTP2ClientConnectionFactory} variant that installs
* {@link CustomParser} and {@link org.eclipse.jetty.http2.hpack.CustomHpackDecoder}
* for Firefox-inspired soft header handling.
*/
public class CustomHTTP2ClientConnectionFactory implements ClientConnectionFactory {

@Override
public Connection newConnection(EndPoint endPoint, Map<String, Object> context) {
HTTP2Client client = (HTTP2Client) context.get(HTTP2Client.CONTEXT_KEY);
ByteBufferPool bufferPool = client.getByteBufferPool();
Session.Listener listener =
(Session.Listener) context.get(HTTP2Client.SESSION_LISTENER_CONTEXT_KEY);
@SuppressWarnings("unchecked")
Promise<Session> sessionPromise =
(Promise<Session>) context.get(HTTP2Client.SESSION_PROMISE_CONTEXT_KEY);

Generator generator = new Generator(bufferPool, client.isUseOutputDirectByteBuffers(),
client.getMaxHeaderBlockFragment());
generator.getHpackEncoder().setMaxHeaderListSize(client.getMaxRequestHeadersSize());

FlowControlStrategy flowControl = client.getFlowControlStrategyFactory()
.newFlowControlStrategy();

CustomParser parser = new CustomParser(bufferPool, client.getMaxResponseHeadersSize());
parser.setMaxSettingsKeys(client.getMaxSettingsKeys());

HTTP2ClientSession session = new HTTP2ClientSession(client.getScheduler(), endPoint, parser,
generator, listener, flowControl);
session.setMaxLocalStreams(client.getMaxLocalStreams());
session.setMaxRemoteStreams(client.getMaxConcurrentPushedStreams());
session.setMaxEncoderTableCapacity(client.getMaxEncoderTableCapacity());
long streamIdleTimeout = client.getStreamIdleTimeout();
if (streamIdleTimeout > 0) {
session.setStreamIdleTimeout(streamIdleTimeout);
}

HTTP2ClientConnection connection =
new HTTP2ClientConnection(client, endPoint, session, sessionPromise, listener);
context.put(HTTP2Connection.class.getName(), connection);
connection.addEventListener(CustomHttp2SessionContainerAccessor.getSessionContainer(client));
client.getEventListeners().forEach(session::addEventListener);
parser.init(connection);

return customize(connection, context);
}

private static class HTTP2ClientConnection extends HTTP2Connection implements Callback {
private final HTTP2Client client;
private final Promise<Session> promise;
private final Session.Listener listener;

private HTTP2ClientConnection(HTTP2Client client, EndPoint endpoint,
HTTP2ClientSession session, Promise<Session> sessionPromise,
Session.Listener listener) {
super(client.getByteBufferPool(), client.getExecutor(), endpoint, session,
client.getInputBufferSize(), -1);
this.client = client;
this.promise = sessionPromise;
this.listener = listener;
setUseInputDirectByteBuffers(client.isUseInputDirectByteBuffers());
setUseOutputDirectByteBuffers(client.isUseOutputDirectByteBuffers());
}

@Override
public void onOpen() {
HTTP2Session session = getSession();
session.notifyLifeCycleOpen();

Map<Integer, Integer> settings = listener.onPreface(session);
settings = settings == null ? new HashMap<>() : new HashMap<>(settings);

settings.compute(SettingsFrame.HEADER_TABLE_SIZE, (k, v) -> {
if (v == null) {
v = client.getMaxDecoderTableCapacity();
if (v == HpackContext.DEFAULT_MAX_TABLE_CAPACITY) {
v = null;
}
}
return v;
});
settings.computeIfAbsent(SettingsFrame.MAX_CONCURRENT_STREAMS,
k -> client.getMaxConcurrentPushedStreams());
settings.compute(SettingsFrame.INITIAL_WINDOW_SIZE, (k, v) -> {
if (v == null) {
v = client.getInitialStreamRecvWindow();
if (v == FlowControlStrategy.DEFAULT_WINDOW_SIZE) {
v = null;
}
}
return v;
});
settings.compute(SettingsFrame.MAX_FRAME_SIZE, (k, v) -> {
if (v == null) {
v = client.getMaxFrameSize();
if (v == Frame.DEFAULT_MAX_SIZE) {
v = null;
}
}
return v;
});
settings.compute(SettingsFrame.MAX_HEADER_LIST_SIZE, (k, v) -> {
if (v == null) {
v = client.getMaxResponseHeadersSize();
if (v <= 0) {
v = null;
}
}
return v;
});
Comment on lines +98 to +135

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.

What do you think about modulizing these ones as well? I think that we could benefit of some nice function names in roder to better understand what are these compute doing rather than slowing down to read the actual implementation?


PrefaceFrame prefaceFrame = new PrefaceFrame();
SettingsFrame settingsFrame = new SettingsFrame(settings, false);

int windowDelta =
client.getInitialSessionRecvWindow() - FlowControlStrategy.DEFAULT_WINDOW_SIZE;
session.updateRecvWindow(windowDelta);
if (windowDelta > 0) {
session.frames(null,
List.of(prefaceFrame, settingsFrame, new WindowUpdateFrame(0, windowDelta)), this);
} else {
session.frames(null, List.of(prefaceFrame, settingsFrame), this);
}
}

@Override
public void succeeded() {
super.onOpen();
promise.succeeded(getSession());
produce();
}

@Override
public void failed(Throwable ex) {
close();
promise.failed(ex);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package com.blazemeter.jmeter.http2.core.jetty.custom.http2;

import org.eclipse.jetty.http.HttpHeader;
import org.eclipse.jetty.http.HttpTokens;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
* Firefox-inspired HTTP/2 header softening for load-testing clients.
*
* <p>Browsers (especially Chrome) reject non-numeric {@code :status} values. This helper is more
* permissive: it extracts a leading status code (Chrome HTTP/1 {@code ParseStatus} style) and
* trims soft-illegal field values so sampling can continue while logging the anomaly.
*/
public final class CustomHttp2HeaderNormalizer {

private static final Logger LOG = LoggerFactory.getLogger(CustomHttp2HeaderNormalizer.class);

private CustomHttp2HeaderNormalizer() {
}

/**
* Normalizes a decoded header value before Jetty builds/emits the field.
*
* @return the value to use (possibly unchanged)
*/
public static String normalize(HttpHeader header, String name, String value) {
if (value == null) {
return null;
}
if (isStatusPseudoHeader(header, name)) {
return normalizeStatus(value);
}
return softenFieldValue(name, value);
}

static boolean isStatusPseudoHeader(HttpHeader header, String name) {
if (header == HttpHeader.C_STATUS) {
return true;
}
return name != null && ":status".equalsIgnoreCase(name);
}

/**
* Accepts {@code 299}, {@code 299 Akamai}, or leading spaces before digits.
* Returns the original value when no leading status code can be recovered.
*/
static String normalizeStatus(String value) {
String trimmedLeading = trimLeadingSpaces(value);
int digits = 0;
while (digits < trimmedLeading.length()
&& trimmedLeading.charAt(digits) >= '0'
&& trimmedLeading.charAt(digits) <= '9') {
digits++;
}
if (digits == 0) {
return value;
}
String code = trimmedLeading.substring(0, digits);
if (code.equals(value)) {
return value;
}
String reason = trimmedLeading.substring(digits).trim();
if (reason.isEmpty() && code.equals(trimmedLeading)) {
if (!code.equals(value)) {
LOG.warn("Normalized HTTP/2 :status from [{}] to [{}]", value, code);
}
return code;
}
LOG.warn("Normalized HTTP/2 :status from [{}] to [{}] (ignored reason [{}])",
value, code, reason);
return code;
}

/**
* Softens values that fail only because of leading/trailing SP/HTAB (RFC 9113).
* Control characters and other illegal octets are left untouched so Jetty can still reject them.
*/
static String softenFieldValue(String name, String value) {
if (HttpTokens.isLegalFieldValue(value)) {
return value;
}
String trimmed = trimHttpWhitespace(value);
if (trimmed.equals(value)) {
return value;
}
if (HttpTokens.isLegalFieldValue(trimmed)) {
LOG.warn("Trimmed illegal HTTP/2 header value for [{}]: [{}] -> [{}]",
name, value, trimmed);
return trimmed;
}
return value;
}

private static String trimLeadingSpaces(String value) {
int i = 0;
while (i < value.length() && value.charAt(i) == ' ') {
i++;
}
return i == 0 ? value : value.substring(i);
}

private static String trimHttpWhitespace(String value) {
int start = 0;
int end = value.length();
while (start < end && isHttpWhitespace(value.charAt(start))) {
start++;
}
while (end > start && isHttpWhitespace(value.charAt(end - 1))) {
end--;
}
return (start == 0 && end == value.length()) ? value : value.substring(start, end);
}

private static boolean isHttpWhitespace(char c) {
return c == ' ' || c == '\t';
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.blazemeter.jmeter.http2.core.jetty.custom.http2;

import java.io.IOException;
import java.util.Map;
import org.eclipse.jetty.http2.client.HTTP2Client;
import org.eclipse.jetty.http2.client.transport.HttpClientTransportOverHTTP2;
import org.eclipse.jetty.io.Connection;
import org.eclipse.jetty.io.EndPoint;

/**
* Custom HTTP/2 client transport that uses {@link CustomHTTP2ClientConnectionFactory}.
*/
public class CustomHttpClientTransportOverHTTP2 extends HttpClientTransportOverHTTP2 {

private final CustomHTTP2ClientConnectionFactory connectionFactory =
new CustomHTTP2ClientConnectionFactory();

public CustomHttpClientTransportOverHTTP2(HTTP2Client http2Client) {
super(http2Client);
}

@Override
public Connection newConnection(EndPoint endPoint, Map<String, Object> context)
throws IOException {
return connectionFactory.newConnection(endPoint, context);
}
}
Loading