diff --git a/.github/workflows/gradle-dependency-submit.yaml b/.github/workflows/gradle-dependency-submit.yaml index 4eff276b4e6..acff5c0505b 100644 --- a/.github/workflows/gradle-dependency-submit.yaml +++ b/.github/workflows/gradle-dependency-submit.yaml @@ -12,6 +12,7 @@ permissions: read-all jobs: dependency-submission: + if: github.repository == 'apache/jmeter' name: Submit dependencies runs-on: ubuntu-latest permissions: diff --git a/.github/workflows/gradle-wrapper-validation.yml b/.github/workflows/gradle-wrapper-validation.yml index 5d924bd25c5..7be36178f03 100644 --- a/.github/workflows/gradle-wrapper-validation.yml +++ b/.github/workflows/gradle-wrapper-validation.yml @@ -3,6 +3,7 @@ on: [push, pull_request] jobs: validation: + if: github.repository == 'apache/jmeter' name: "Validation" runs-on: ubuntu-latest steps: diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index b645c63dae8..b7ccec761b8 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,5 +1,8 @@ name: CI +# Forks: skip all jobs (secrets, dependency graph, etc. are not fork-friendly). +# PRs into apache/jmeter still run CI in the base repo, where github.repository is apache/jmeter. + on: push: branches: @@ -19,6 +22,7 @@ concurrency: jobs: matrix_prep: + if: github.repository == 'apache/jmeter' name: Matrix Preparation runs-on: ubuntu-latest outputs: @@ -33,6 +37,7 @@ jobs: node .github/workflows/matrix.js test: + if: github.repository == 'apache/jmeter' needs: matrix_prep name: '${{ matrix.name }}' runs-on: ${{ matrix.os }} @@ -89,6 +94,7 @@ jobs: DEVELOCITY_ACCESS_KEY: ${{ secrets.DEVELOCITY_ACCESS_KEY }} errorprone: + if: github.repository == 'apache/jmeter' name: 'Error Prone (JDK 21)' runs-on: ubuntu-latest steps: diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index 664c65d35ac..ae4c37b5671 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -13,8 +13,8 @@ permissions: read-all jobs: update_release_draft: - # Skip release drafts in forks - if: vars.RUN_RELEASE_DRAFTER == 'true' + # Skip outside apache/jmeter; on Apache, opt-in via repo variable. + if: github.repository == 'apache/jmeter' && vars.RUN_RELEASE_DRAFTER == 'true' name: Update Release Draft runs-on: ubuntu-latest permissions: diff --git a/.gitignore b/.gitignore index 13f89d12a06..9f591e5d961 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,15 @@ /buildSrc/subprojects/*/build/ /buildSrc/subprojects/*/bin/ /buildSrc/subprojects/*/out/ +# Included Gradle build logic: ignore outputs only (sources under build-logic* must stay tracked) +/build-logic/build/ +/build-logic/*/build/ +/build-logic/*/bin/ +/build-logic/*/out/ +/build-logic-commons/build/ +/build-logic-commons/*/build/ +/build-logic-commons/*/bin/ +/build-logic-commons/*/out/ /src/build/ /src/bin/ /src/out/ @@ -49,9 +58,13 @@ .pmd .project .settings +.metals +.vscode # bin/ folder is used to launch JMeter, so we ignore certain files there /bin/ApacheJMeter.jar +# Local JMeter Plugins Manager CLI wrappers (not part of Apache distribution) +/bin/PluginsManagerCMD.* # Below are the results of "batch test" execution /bin/*.csv /bin/*.jmx @@ -63,6 +76,8 @@ build-local.properties jmeter-fb.* +# Local packaging script output (see create_jmeter_archive.sh) +/jmeter_*.tgz # ignore generated keystore and certs /bin/proxyserver.jks diff --git a/bin/jmeter.properties b/bin/jmeter.properties index 58c540cfd9c..7caa7ea4bd5 100644 --- a/bin/jmeter.properties +++ b/bin/jmeter.properties @@ -1173,6 +1173,18 @@ cookies=cookies # How often to check for shutdown during ramp-up (milliseconds) #jmeterthread.rampup.granularity=1000 +# How often to check for shutdown during timer delay (milliseconds) +#jmeterthread.timer.granularity=1000 + +# Enable Java 21 Virtual Threads for JMeter threads +# When enabled, virtual user threads use lightweight virtual threads instead of platform threads +# Benefits: Lower memory per thread, better I/O scalability for high thread counts +# Requires: Java 21+ (automatically falls back to platform threads on older Java versions) +# Default: true +jmeter.threads.virtual.enabled=true + +# Whether to enable lightweight clone for test elements to preserve memory. Disable when running tests with complex scenarios with variables. +#jmeter.clone.lightweight.enabled=true #Should JMeter expand the tree when loading a test plan? # default value is false since JMeter 2.7 diff --git a/build-logic/verification/src/main/kotlin/build-logic.autostyle.gradle.kts b/build-logic/verification/src/main/kotlin/build-logic.autostyle.gradle.kts index ffcf7b8888e..a5c9dce78e3 100644 --- a/build-logic/verification/src/main/kotlin/build-logic.autostyle.gradle.kts +++ b/build-logic/verification/src/main/kotlin/build-logic.autostyle.gradle.kts @@ -101,7 +101,12 @@ plugins.withId("org.jetbrains.kotlin.jvm") { kotlin { license() trimTrailingWhitespace() - ktlint("0.40.0") + ktlint("0.40.0") { + filter { + // TODO: remove exclusion when update ktlint + exclude("**/org/apache/jorphan/locale/PlainValue.kt") + } + } endWithNewline() } } diff --git a/create_jmeter_archive.sh b/create_jmeter_archive.sh new file mode 100755 index 00000000000..38fec35cb1f --- /dev/null +++ b/create_jmeter_archive.sh @@ -0,0 +1,43 @@ +#!/bin/bash + +# Script to create a compressed tar archive of bin and lib directories +# Excludes bin/examples and bin/testfiles +# Archives files under apache-jmeter/ directory + +# Generate datetime string in format YYYYMMDD_HHMMSS +DATETIME=$(date +%Y%m%d_%H%M%S) + +# Archive name +ARCHIVE_NAME="jmeter_${DATETIME}.tgz" + +# Create temporary directory +TEMP_DIR=$(mktemp -d) +trap "rm -rf ${TEMP_DIR}" EXIT + +# Create apache-jmeter directory structure +mkdir -p "${TEMP_DIR}/apache-jmeter" + +# Copy bin directory excluding examples and testfiles +# Using rsync for better exclusion control +rsync -av --exclude='examples' --exclude='testfiles' \ + bin/ "${TEMP_DIR}/apache-jmeter/bin/" + +# Copy lib directory +cp -R lib "${TEMP_DIR}/apache-jmeter/" + +# Create the archive from temp directory +# -C: change to directory before archiving +# -c: create archive +# -z: compress with gzip +# -f: specify filename +tar -czf "${ARCHIVE_NAME}" -C "${TEMP_DIR}" apache-jmeter + +# Check if tar command succeeded +if [ $? -eq 0 ]; then + echo "Successfully created archive: ${ARCHIVE_NAME}" + # Show archive size + ls -lh "${ARCHIVE_NAME}" +else + echo "Error: Failed to create archive" + exit 1 +fi diff --git a/src/components/src/main/java/org/apache/jmeter/config/CSVDataSet.java b/src/components/src/main/java/org/apache/jmeter/config/CSVDataSet.java index 05f5f8f6f05..285810c5bcc 100644 --- a/src/components/src/main/java/org/apache/jmeter/config/CSVDataSet.java +++ b/src/components/src/main/java/org/apache/jmeter/config/CSVDataSet.java @@ -32,7 +32,7 @@ import org.apache.jmeter.threads.JMeterContext; import org.apache.jmeter.threads.JMeterVariables; import org.apache.jmeter.util.JMeterUtils; -import org.apache.jorphan.util.EnumUtils; +import org.apache.jorphan.locale.ResourceKeyed; import org.apache.jorphan.util.JMeterStopThreadException; import org.apache.jorphan.util.JOrphanUtils; import org.apache.jorphan.util.StringUtilities; @@ -70,20 +70,20 @@ public class CSVDataSet extends ConfigTestElement implements TestBean, LoopIterationListener, NoConfigMerge { - public enum ShareMode { + public enum ShareMode implements ResourceKeyed { ALL("shareMode.all"), GROUP("shareMode.group"), THREAD("shareMode.thread"); - private final String value; + private final String propertyName; - ShareMode(String value) { - this.value = value; + ShareMode(String propertyName) { + this.propertyName = propertyName; } @Override - public String toString() { - return value; + public String getResourceKey() { + return propertyName; } } diff --git a/src/components/src/main/java/org/apache/jmeter/timers/ConstantThroughputTimer.java b/src/components/src/main/java/org/apache/jmeter/timers/ConstantThroughputTimer.java index 21a6edb7aea..6384500b7e0 100644 --- a/src/components/src/main/java/org/apache/jmeter/timers/ConstantThroughputTimer.java +++ b/src/components/src/main/java/org/apache/jmeter/timers/ConstantThroughputTimer.java @@ -32,6 +32,7 @@ import org.apache.jmeter.threads.JMeterContextService; import org.apache.jmeter.util.JMeterUtils; import org.apache.jorphan.collections.IdentityKey; +import org.apache.jorphan.locale.ResourceKeyed; import org.apache.jorphan.util.EnumUtils; import org.apiguardian.api.API; @@ -71,7 +72,7 @@ private static class ThroughputInfo{ /** * This enum defines the calculation modes used by the ConstantThroughputTimer. */ - public enum Mode { + public enum Mode implements ResourceKeyed { ThisThreadOnly("calcMode.1"), // NOSONAR Keep naming for compatibility AllActiveThreads("calcMode.2"), // NOSONAR Keep naming for compatibility AllActiveThreadsInCurrentThreadGroup("calcMode.3"), // NOSONAR Keep naming for compatibility @@ -89,6 +90,11 @@ public enum Mode { public String toString() { return propertyName; } + + @Override + public String getResourceKey() { + return propertyName; + } } /** @@ -161,15 +167,13 @@ public Mode getMode() { } @Deprecated - @SuppressWarnings("EnumOrdinal") public void setCalcMode(int mode) { - setMode(EnumUtils.values(Mode.class).get(mode)); + setMode(EnumUtils.getEnumValues(Mode.class).get(mode)); } - @SuppressWarnings("EnumOrdinal") @API(status = API.Status.MAINTAINED, since = "6.0.0") public void setMode(Mode newMode) { - getSchema().getCalcMode().set(this, newMode.toString()); + getSchema().getCalcMode().set(this, newMode.getResourceKey()); } /** diff --git a/src/core/build.gradle.kts b/src/core/build.gradle.kts index e654f4d8343..9b5285a86e2 100644 --- a/src/core/build.gradle.kts +++ b/src/core/build.gradle.kts @@ -111,6 +111,7 @@ dependencies { isTransitive = false } implementation("org.apache.xmlgraphics:xmlgraphics-commons") + implementation("org.brotli:dec") implementation("org.freemarker:freemarker") implementation("org.jodd:jodd-core") implementation("org.jodd:jodd-props") diff --git a/src/core/src/main/java/org/apache/jmeter/config/ConfigTestElement.java b/src/core/src/main/java/org/apache/jmeter/config/ConfigTestElement.java index 5e867945b4b..2a54fbb9da4 100644 --- a/src/core/src/main/java/org/apache/jmeter/config/ConfigTestElement.java +++ b/src/core/src/main/java/org/apache/jmeter/config/ConfigTestElement.java @@ -19,11 +19,20 @@ import java.io.Serializable; +import org.apache.jmeter.engine.util.LightweightClone; import org.apache.jmeter.testelement.AbstractTestElement; import org.apache.jmeter.testelement.TestElement; import org.apache.jmeter.testelement.schema.PropertiesAccessor; -public class ConfigTestElement extends AbstractTestElement implements Serializable, ConfigElement { +/** + * Base class for configuration elements that can share properties across threads. + *

+ * ConfigTestElements implementing {@link LightweightClone} will only use lightweight + * cloning if they have no properties containing JMeter variables (${...}) or functions (__()). + * This provides significant memory savings for elements like HeaderManager with static data. + *

+ */ +public class ConfigTestElement extends AbstractTestElement implements Serializable, ConfigElement, LightweightClone { private static final long serialVersionUID = 240L; public static final String USERNAME = "ConfigTestElement.username"; diff --git a/src/core/src/main/java/org/apache/jmeter/control/gui/TestPlanGui.java b/src/core/src/main/java/org/apache/jmeter/control/gui/TestPlanGui.java index 74b9b0f3fae..8528b531cde 100644 --- a/src/core/src/main/java/org/apache/jmeter/control/gui/TestPlanGui.java +++ b/src/core/src/main/java/org/apache/jmeter/control/gui/TestPlanGui.java @@ -57,17 +57,20 @@ public class TestPlanGui extends AbstractJMeterGuiComponent { private final JBooleanPropertyEditor functionalMode = new JBooleanPropertyEditor( TestPlanSchema.INSTANCE.getFunctionalMode(), - JMeterUtils.getResString("functional_mode")); + "functional_mode", + JMeterUtils::getResString); private final JBooleanPropertyEditor serializedMode = new JBooleanPropertyEditor( TestPlanSchema.INSTANCE.getSerializeThreadgroups(), - JMeterUtils.getResString("testplan.serialized")); + "testplan.serialized", + JMeterUtils::getResString); private final JBooleanPropertyEditor tearDownOnShutdown = new JBooleanPropertyEditor( TestPlanSchema.INSTANCE.getTearDownOnShutdown(), - JMeterUtils.getResString("teardown_on_shutdown")); + "teardown_on_shutdown", + JMeterUtils::getResString); /** A panel allowing the user to define variables. */ private final ArgumentsPanel argsPanel; diff --git a/src/core/src/main/java/org/apache/jmeter/control/gui/TransactionControllerGui.java b/src/core/src/main/java/org/apache/jmeter/control/gui/TransactionControllerGui.java index ee513facade..c54984b47c1 100644 --- a/src/core/src/main/java/org/apache/jmeter/control/gui/TransactionControllerGui.java +++ b/src/core/src/main/java/org/apache/jmeter/control/gui/TransactionControllerGui.java @@ -39,13 +39,15 @@ public class TransactionControllerGui extends AbstractControllerGui { private final JBooleanPropertyEditor generateParentSample = new JBooleanPropertyEditor( TransactionControllerSchema.INSTANCE.getGenearteParentSample(), - JMeterUtils.getResString("transaction_controller_parent")); + "transaction_controller_parent", + JMeterUtils::getResString); /** if selected, add duration of timers to total runtime */ private final JBooleanPropertyEditor includeTimers = new JBooleanPropertyEditor( TransactionControllerSchema.INSTANCE.getIncludeTimers(), - JMeterUtils.getResString("transaction_controller_include_timers")); + "transaction_controller_parent", + JMeterUtils::getResString); /** * Create a new TransactionControllerGui instance. diff --git a/src/core/src/main/java/org/apache/jmeter/engine/TreeCloner.java b/src/core/src/main/java/org/apache/jmeter/engine/TreeCloner.java index 82918cf72c7..baa2ea0b0a2 100644 --- a/src/core/src/main/java/org/apache/jmeter/engine/TreeCloner.java +++ b/src/core/src/main/java/org/apache/jmeter/engine/TreeCloner.java @@ -20,17 +20,28 @@ import java.util.ArrayList; import java.util.List; +import org.apache.jmeter.engine.util.LightweightClone; import org.apache.jmeter.engine.util.NoThreadClone; +import org.apache.jmeter.testelement.AbstractTestElement; import org.apache.jmeter.testelement.TestElement; +import org.apache.jmeter.util.JMeterUtils; import org.apache.jorphan.collections.HashTree; import org.apache.jorphan.collections.HashTreeTraverser; import org.apache.jorphan.collections.ListedHashTree; /** * Clones the test tree, skipping test elements that implement {@link NoThreadClone} by default. + * Elements implementing {@link LightweightClone} will share properties instead of deep cloning. */ public class TreeCloner implements HashTreeTraverser { + /** + * Property to enable/disable lightweight cloning for LightweightClone elements. + * Can be disabled by setting {@code jmeter.clone.lightweight.enabled=false} in jmeter.properties. + */ + private static final boolean LIGHTWEIGHT_CLONE_ENABLED = + JMeterUtils.getPropDefault("jmeter.clone.lightweight.enabled", true); + private final ListedHashTree newTree; private final List objects = new ArrayList<>(); @@ -80,6 +91,8 @@ protected Object addNodeToTree(Object node) { newTree.add(objects, node); return node; } + newTree.add(objects, node); + return node; } /** diff --git a/src/core/src/main/java/org/apache/jmeter/engine/util/LightweightClone.java b/src/core/src/main/java/org/apache/jmeter/engine/util/LightweightClone.java new file mode 100644 index 00000000000..4c00eaaa07f --- /dev/null +++ b/src/core/src/main/java/org/apache/jmeter/engine/util/LightweightClone.java @@ -0,0 +1,41 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.jmeter.engine.util; + +/** + * Marker interface for elements that can share properties across threads. + * Elements implementing this interface will have their properties shared + * (not deep cloned) when the test tree is cloned for each thread. + * + *

The element must: + *

+ * + *

This optimization reduces memory usage for multi-threaded tests by + * avoiding redundant property cloning. Elements that need per-thread + * property state should not implement this interface. + * + *

This can be disabled globally by setting the property + * {@code jmeter.clone.lightweight.enabled=false} in jmeter.properties. + * + * @see NoThreadClone + */ +public interface LightweightClone { +} diff --git a/src/core/src/main/java/org/apache/jmeter/samplers/AbstractSampler.java b/src/core/src/main/java/org/apache/jmeter/samplers/AbstractSampler.java index 66dc3bd7903..e3a4b2ae9c8 100644 --- a/src/core/src/main/java/org/apache/jmeter/samplers/AbstractSampler.java +++ b/src/core/src/main/java/org/apache/jmeter/samplers/AbstractSampler.java @@ -19,9 +19,18 @@ import org.apache.jmeter.config.ConfigTestElement; import org.apache.jmeter.engine.util.ConfigMergabilityIndicator; +import org.apache.jmeter.engine.util.LightweightClone; import org.apache.jmeter.testelement.AbstractTestElement; -public abstract class AbstractSampler extends AbstractTestElement implements Sampler, ConfigMergabilityIndicator { +/** + * Base class for samplers that can share properties across threads for memory efficiency. + *

+ * Samplers implementing {@link LightweightClone} will only use lightweight cloning if + * they have no properties containing JMeter variables (${...}) or functions (__()). + * Elements with variables are fully cloned to ensure proper per-thread variable evaluation. + *

+ */ +public abstract class AbstractSampler extends AbstractTestElement implements Sampler, ConfigMergabilityIndicator, LightweightClone { private static final long serialVersionUID = 240L; /** diff --git a/src/core/src/main/java/org/apache/jmeter/samplers/SampleResult.java b/src/core/src/main/java/org/apache/jmeter/samplers/SampleResult.java index 4888d250fcb..c713b728963 100644 --- a/src/core/src/main/java/org/apache/jmeter/samplers/SampleResult.java +++ b/src/core/src/main/java/org/apache/jmeter/samplers/SampleResult.java @@ -17,6 +17,7 @@ package org.apache.jmeter.samplers; +import java.io.IOException; import java.io.Serializable; import java.io.UnsupportedEncodingException; import java.net.HttpURLConnection; @@ -25,9 +26,13 @@ import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; +import java.util.Locale; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeUnit; +import java.util.zip.GZIPInputStream; +import java.util.zip.Inflater; +import java.util.zip.InflaterInputStream; import org.apache.jmeter.assertions.AssertionResult; import org.apache.jmeter.gui.Searchable; @@ -136,6 +141,14 @@ public class SampleResult implements Serializable, Cloneable, Searchable { private static final String NULL_FILENAME = "NULL"; + // Reuse ByteArrayOutputStream instances to reduce memory allocations + private static final ThreadLocal threadLocalBAOS = + ThreadLocal.withInitial(ByteArrayOutputStream::new); + + // Reuse Inflater instances to minimize object creation + private static final ThreadLocal threadLocalInflater = + ThreadLocal.withInitial(() -> new Inflater(true)); + static { if (START_TIMESTAMP) { log.info("Note: Sample TimeStamps are START times"); @@ -161,6 +174,8 @@ public class SampleResult implements Serializable, Cloneable, Searchable { private byte[] responseData = EMPTY_BA; + private String contentEncoding; // Stores gzip/deflate encoding if response is compressed + private String responseCode = "";// Never return null private String label = "";// Never return null @@ -218,7 +233,7 @@ public class SampleResult implements Serializable, Cloneable, Searchable { // TODO do contentType and/or dataEncoding belong in HTTPSampleResult instead? private String dataEncoding;// (is this really the character set?) e.g. - // ISO-8895-1, UTF-8 + // ISO-8895-1, UTF-8 private String contentType = ""; // e.g. text/html; charset=utf-8 @@ -277,6 +292,11 @@ public class SampleResult implements Serializable, Cloneable, Searchable { */ private transient volatile String responseDataAsString; + private static final String GZIP_ENCODING = "gzip"; + private static final String X_GZIP_ENCODING = "x-gzip"; + private static final String DEFLATE_ENCODING = "deflate"; + private static final String BROTLI_ENCODING = "br"; + public SampleResult() { this(USE_NANO_TIME, NANOTHREAD_SLEEP); } @@ -792,6 +812,16 @@ public void setResponseData(final String response, final String encoding) { * @return the responseData value (cannot be null) */ public byte[] getResponseData() { + if (responseData == null) { + return EMPTY_BA; + } + if (contentEncoding != null && responseData.length > 0) { + try { + return ResponseDecoderRegistry.decode(contentEncoding, responseData); + } catch (IOException e) { + log.warn("Failed to decompress response data", e); + } + } return responseData; } @@ -803,12 +833,12 @@ public byte[] getResponseData() { public String getResponseDataAsString() { try { if(responseDataAsString == null) { - responseDataAsString= new String(responseData,getDataEncodingWithDefault()); + responseDataAsString= new String(getResponseData(),getDataEncodingWithDefault()); } return responseDataAsString; } catch (UnsupportedEncodingException e) { log.warn("Using platform default as {} caused {}", getDataEncodingWithDefault(), e.getLocalizedMessage()); - return new String(responseData,Charset.defaultCharset()); // N.B. default charset is used deliberately here + return new String(getResponseData(),Charset.defaultCharset()); // N.B. default charset is used deliberately here } } @@ -1666,4 +1696,15 @@ public TestLogicalAction getTestLogicalAction() { public void setTestLogicalAction(TestLogicalAction testLogicalAction) { this.testLogicalAction = testLogicalAction; } + + /** + * Sets the response data and its contentEncoding. + * @param data The response data + * @param contentEncoding The content contentEncoding (e.g. gzip, deflate) + */ + public void setResponseData(byte[] data, String contentEncoding) { + responseData = data == null ? EMPTY_BA : data; + this.contentEncoding = contentEncoding; + responseDataAsString = null; + } } diff --git a/src/core/src/main/java/org/apache/jmeter/testbeans/gui/EnumEditor.java b/src/core/src/main/java/org/apache/jmeter/testbeans/gui/EnumEditor.java index 7c241aa4255..a77ac353f1d 100644 --- a/src/core/src/main/java/org/apache/jmeter/testbeans/gui/EnumEditor.java +++ b/src/core/src/main/java/org/apache/jmeter/testbeans/gui/EnumEditor.java @@ -31,6 +31,7 @@ import javax.swing.JList; import org.apache.jmeter.gui.ClearGui; +import org.apache.jorphan.locale.ResourceKeyed; import org.apache.jorphan.util.EnumUtils; /** @@ -41,14 +42,13 @@ * The provided GUI is a combo box with an option for each value in the enum. *

*/ -class EnumEditor extends PropertyEditorSupport implements ClearGui { +class EnumEditor & ResourceKeyed> extends PropertyEditorSupport implements ClearGui { + private final JComboBox combo; - private final JComboBox> combo; + private final T defaultValue; - private final Enum defaultValue; - - public EnumEditor(final PropertyDescriptor descriptor, final Class> enumClazz, final ResourceBundle rb) { - DefaultComboBoxModel> model = new DefaultComboBoxModel<>(); + public EnumEditor(final PropertyDescriptor descriptor, final Class enumClass, final ResourceBundle rb) { + DefaultComboBoxModel model = new DefaultComboBoxModel<>(); combo = new JComboBox<>(model); combo.setEditable(false); combo.setRenderer( @@ -56,19 +56,18 @@ public EnumEditor(final PropertyDescriptor descriptor, final Class list, Object value, int index, boolean isSelected, boolean cellHasFocus) { JLabel label = (JLabel) super.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus); - Enum enumValue = (Enum) value; - label.setText(rb.getString(EnumUtils.getStringValue(enumValue))); + label.setText(rb.getString(enumClass.cast(value).getResourceKey())); return label; } } ); - List> values = EnumUtils.values(enumClazz); - for(Enum e : values) { + List values = EnumUtils.getEnumValues(enumClass); + for (T e : values) { model.addElement(e); } Object def = descriptor.getValue(GenericTestBeanCustomizer.DEFAULT); if (def instanceof Enum enumValue) { - defaultValue = enumValue; + defaultValue = enumClass.cast(enumValue); } else if (def instanceof Integer index) { defaultValue = values.get(index); } else { @@ -99,25 +98,24 @@ public void setValue(Object value) { } else if (value instanceof Integer integer) { combo.setSelectedIndex(integer); } else if (value instanceof String string) { - ComboBoxModel> model = combo.getModel(); - for (int i = 0; i < model.getSize(); i++) { - Enum element = model.getElementAt(i); - if (EnumUtils.getStringValue(element).equals(string)) { - combo.setSelectedItem(element); - return; - } - } + setAsText(string); } } @Override public void setAsText(String value) { - throw new UnsupportedOperationException("Not supported yet. Use enum value rather than text, got " + value); + ComboBoxModel model = combo.getModel(); + for (int i = 0; i < model.getSize(); i++) { + T element = model.getElementAt(i); + if (value.equals(element.getResourceKey())) { + combo.setSelectedItem(element); + return; + } + } } @Override public void clearGui() { combo.setSelectedItem(defaultValue); } - } diff --git a/src/core/src/main/java/org/apache/jmeter/testbeans/gui/GenericTestBeanCustomizer.java b/src/core/src/main/java/org/apache/jmeter/testbeans/gui/GenericTestBeanCustomizer.java index 9f25190d335..b53bfca541b 100644 --- a/src/core/src/main/java/org/apache/jmeter/testbeans/gui/GenericTestBeanCustomizer.java +++ b/src/core/src/main/java/org/apache/jmeter/testbeans/gui/GenericTestBeanCustomizer.java @@ -43,7 +43,6 @@ import javax.swing.JScrollPane; import javax.swing.SwingConstants; -import org.apache.jmeter.JMeter; import org.apache.jmeter.gui.ClearGui; import org.apache.jmeter.testbeans.TestBeanHelper; import org.apache.jmeter.testelement.property.IntegerProperty; @@ -51,6 +50,7 @@ import org.apache.jmeter.testelement.property.LongProperty; import org.apache.jmeter.testelement.property.StringProperty; import org.apache.jmeter.util.JMeterUtils; +import org.apache.jorphan.locale.ResourceKeyed; import org.apache.jorphan.util.EnumUtils; import org.apiguardian.api.API; import org.jspecify.annotations.Nullable; @@ -338,9 +338,9 @@ public GenericTestBeanCustomizer(){ * @return a StringProperty containing the normalized enum value, or null if the property is invalid or unrecognized */ @API(status = API.Status.INTERNAL, since = "6.0.0") - public static > @Nullable JMeterProperty normalizeEnumProperty( + public static & ResourceKeyed> @Nullable JMeterProperty normalizeEnumProperty( Class klass, Class enumKlass, JMeterProperty property) { - List values = EnumUtils.values(enumKlass); + List values = EnumUtils.getEnumValues(enumKlass); T value; if (property instanceof IntegerProperty intProperty) { int index = intProperty.getIntValue(); @@ -362,26 +362,26 @@ public GenericTestBeanCustomizer(){ return null; } value = normalizeEnumStringValue(stringValue, klass, enumKlass); - if (stringValue.equals(EnumUtils.getStringValue(value))) { + if (stringValue.equals(value.getResourceKey())) { // If the input property was good enough, keep it return property; } } else { return null; } - return new StringProperty(property.getName(), EnumUtils.getStringValue(value)); + return new StringProperty(property.getName(), value.getResourceKey()); } @API(status = API.Status.INTERNAL, since = "6.0.0") - public static > T normalizeEnumStringValue(String value, Class klass, Class enumKlass) { + public static & ResourceKeyed> T normalizeEnumStringValue(String value, Class klass, Class enumKlass) { T enumValue = EnumUtils.valueOf(enumKlass, value); if (enumValue != null) { return enumValue; } - return normalizeEnumStringValue(value, klass, EnumUtils.values(enumKlass)); + return normalizeEnumStringValue(value, klass, EnumUtils.getEnumValues(enumKlass)); } - private static > T normalizeEnumStringValue(String value, Class klass, List values) { + private static & ResourceKeyed> T normalizeEnumStringValue(String value, Class klass, List values) { // Fallback: the value might be localized, thus check the current and root locales String bundleName = null; try { @@ -408,9 +408,9 @@ private static > T normalizeEnumStringValue(String value, Clas return findEnumValue(value, rootBundle, values); } - private static > @Nullable T findEnumValue(String stringValue, ResourceBundle rb, List values) { + private static & ResourceKeyed> @Nullable T findEnumValue(String stringValue, ResourceBundle rb, List values) { for (T enumValue : values) { - if (stringValue.equals(rb.getObject(enumValue.toString()))) { + if (stringValue.equals(rb.getObject(enumValue.getResourceKey()))) { log.debug("Converted {} to {} using Locale: {}", stringValue, enumValue, rb.getLocale()); return enumValue; } diff --git a/src/core/src/main/java/org/apache/jmeter/testelement/AbstractTestElement.java b/src/core/src/main/java/org/apache/jmeter/testelement/AbstractTestElement.java index 71823d6a24d..cd12f1ab088 100644 --- a/src/core/src/main/java/org/apache/jmeter/testelement/AbstractTestElement.java +++ b/src/core/src/main/java/org/apache/jmeter/testelement/AbstractTestElement.java @@ -29,10 +29,12 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.locks.ReentrantReadWriteLock; +import org.apache.jmeter.engine.util.LightweightClone; import org.apache.jmeter.engine.util.NoThreadClone; import org.apache.jmeter.gui.Searchable; import org.apache.jmeter.testelement.property.BooleanProperty; import org.apache.jmeter.testelement.property.CollectionProperty; +import org.apache.jmeter.testelement.property.FunctionProperty; import org.apache.jmeter.testelement.property.IntegerProperty; import org.apache.jmeter.testelement.property.JMeterProperty; import org.apache.jmeter.testelement.property.LongProperty; @@ -142,6 +144,12 @@ static ResourceLock releaseWrite(ReentrantReadWriteLock lock) { private transient boolean runningVersion = false; + /** + * Indicates whether this element's properties are shared with another element. + * When true, the propMap is shared and should be treated as read-only. + */ + private transient boolean propertiesShared = false; + // Thread-specific variables saved here to save recalculation private transient JMeterContext threadContext = null; @@ -172,6 +180,179 @@ public Object clone() { } } + /** + * Creates a lightweight clone that shares properties with this element. + * This is more memory-efficient than a full clone for elements that implement + * {@link LightweightClone} and don't modify properties during test execution. + * + *

The lightweight clone: + *

    + *
  • Creates a new instance with default constructor
  • + *
  • Shares the property map with the original element (no deep copy)
  • + *
  • Has fresh transient state (threadContext, threadName, etc.)
  • + *
+ * + * @return a lightweight clone of this element + * @throws LinkageError if cloning fails due to reflection errors + */ + public Object lightweightClone() { + try { + AbstractTestElement clone = this.getClass().getDeclaredConstructor().newInstance(); + // Clear default properties set by constructor + clone.clear(); + // Share property map reference with this element + clone.sharePropertiesFrom(this); + clone.setRunningVersion(isRunningVersion()); + return clone; + } catch (ReflectiveOperationException e) { + throw new LinkageError(e.getMessage(), e); + } + } + + /** + * Shares the property map from the source element. + *

+ * This method is called only for elements that have NO variable properties + * (verified by {@link #hasVariableProperties()} in TreeCloner before calling + * {@link #lightweightClone()}). Therefore, it's safe to share property references + * directly without cloning - this provides significant memory savings. + *

+ *

+ * TestElementProperty is still cloned because nested TestElements may have + * transient runtime state that needs to be per-thread. + *

+ * + * @param source the element to share properties from + */ + void sharePropertiesFrom(AbstractTestElement source) { + try (ResourceLock ignored = writeLock()) { + // Share property references directly for maximum memory efficiency + // This is safe because TreeCloner only calls lightweightClone() for + // elements that have no variable properties (hasVariableProperties() == false) + for (Map.Entry entry : source.propMap.entrySet()) { + JMeterProperty prop = entry.getValue(); + // Only clone TestElementProperty because nested TestElements + // may have transient state that needs to be per-thread + if (prop instanceof TestElementProperty) { + prop = prop.clone(); + } + // All other properties are shared by reference - same String objects, + // same property instances across all threads + this.propMap.put(entry.getKey(), prop); + } + Map propMapConcurrent = this.propMapConcurrent; + if (propMapConcurrent != null) { + propMapConcurrent.putAll(this.propMap); + } + } + // Mark as shared to skip recoverRunningVersion and enable copy-on-write + this.propertiesShared = true; + } + + /** + * Returns whether this element's properties are shared with another element. + * + * @return true if properties are shared (element was created via lightweightClone) + */ + public boolean isPropertiesShared() { + return propertiesShared; + } + + /** + * Checks if this element has any properties containing JMeter variables or functions. + * Elements with variable properties should not use lightweight cloning because + * the variable evaluation needs per-thread state. + * + * @return true if any property contains variables (${...}) or functions (__()) + */ + public boolean hasVariableProperties() { + try (ResourceLock ignored = readLock()) { + for (JMeterProperty prop : propMap.values()) { + if (propertyHasVariables(prop)) { + return true; + } + } + } + return false; + } + + /** + * Checks if a single property contains variables or functions. + */ + private static boolean propertyHasVariables(JMeterProperty prop) { + // FunctionProperty is created when StringProperty with ${...} is transformed + if (prop instanceof FunctionProperty) { + return true; + } + // Check StringProperty for variable patterns (before transformation) + if (prop instanceof StringProperty stringProperty) { + String value = stringProperty.getStringValue(); + if (value != null && (value.contains("${") || value.contains("__("))) { + return true; + } + } + // Check nested properties in collections + if (prop instanceof CollectionProperty collectionProperty) { + for (JMeterProperty nested : collectionProperty) { + if (propertyHasVariables(nested)) { + return true; + } + } + } + // Check nested properties in maps + if (prop instanceof MapProperty mapProperty) { + PropertyIterator iter = mapProperty.valueIterator(); + while (iter.hasNext()) { + if (propertyHasVariables(iter.next())) { + return true; + } + } + } + // Check nested TestElement + if (prop instanceof TestElementProperty testElementProperty) { + TestElement nested = testElementProperty.getElement(); + if (nested instanceof AbstractTestElement abstractNested) { + if (abstractNested.hasVariableProperties()) { + return true; + } + } + } + return false; + } + + /** + * Ensures this element has its own copy of properties (copy-on-write). + * If properties are currently shared with another element, this method + * creates deep clones of all properties before any modification can occur. + *

+ * This allows lightweight clones to share properties for memory efficiency, + * while still supporting runtime modifications when needed (e.g., when a + * Header Manager has a variable like ${updateme} that gets modified). + */ + protected void ensureOwnProperties() { + if (!propertiesShared) { + return; + } + try (ResourceLock ignored = writeLock()) { + // Double-check after acquiring lock + if (!propertiesShared) { + return; + } + // Create deep copies of all properties + Map newPropMap = new LinkedHashMap<>(); + for (Map.Entry entry : propMap.entrySet()) { + newPropMap.put(entry.getKey(), entry.getValue().clone()); + } + propMap.clear(); + propMap.putAll(newPropMap); + if (propMapConcurrent != null) { + propMapConcurrent.clear(); + propMapConcurrent.putAll(propMap); + } + propertiesShared = false; + } + } + /** * {@inheritDoc} */ @@ -221,6 +402,10 @@ public void clearTestElementChildren(){ */ @Override public void removeProperty(String key) { + // Copy-on-write: ensure we have our own properties before modifying + if (isRunningVersion()) { + ensureOwnProperties(); + } try (ResourceLock ignored = writeLock()) { propMap.remove(key); Map propMapConcurrent = this.propMapConcurrent; @@ -425,6 +610,8 @@ protected void addProperty(JMeterProperty property, boolean clone) { propertyToPut = property.clone(); } if (isRunningVersion()) { + // Copy-on-write: ensure we have our own properties before modifying + ensureOwnProperties(); setTemporary(propertyToPut); } else { clearTemporary(property); @@ -483,6 +670,8 @@ protected void logProperties() { @Override public void setProperty(JMeterProperty property) { if (isRunningVersion()) { + // Copy-on-write: ensure we have our own properties before modifying + ensureOwnProperties(); if (getProperty(property.getName()) instanceof NullProperty) { addProperty(property); } else { @@ -649,6 +838,9 @@ public boolean isRunningVersion() { public void setRunningVersion(boolean runningVersion) { try (ResourceLock ignored = writeLock()) { this.runningVersion = runningVersion; + // Note: Even for shared properties (lightweight clones), we must call + // property.setRunningVersion() to ensure variable substitution works. + // The property transformation is idempotent and thread-safe. PropertyIterator iter = propertyIterator(); Map propMapConcurrent = this.propMapConcurrent; while (iter.hasNext()) { @@ -671,6 +863,11 @@ public void recoverRunningVersion() { // See https://github.com/apache/jmeter/issues/5875 return; } + if (propertiesShared) { + // Properties are shared with other elements (lightweight clone), + // so there's nothing to recover - the properties are read-only + return; + } try (ResourceLock ignored = writeLock()) { Iterator> iter = propMap.entrySet().iterator(); while (iter.hasNext()) { diff --git a/src/core/src/main/java/org/apache/jmeter/threads/JMeterThread.java b/src/core/src/main/java/org/apache/jmeter/threads/JMeterThread.java index 35f94b26982..c6498ba8cd5 100644 --- a/src/core/src/main/java/org/apache/jmeter/threads/JMeterThread.java +++ b/src/core/src/main/java/org/apache/jmeter/threads/JMeterThread.java @@ -80,6 +80,10 @@ public class JMeterThread implements Runnable, Interruptible { private static final int RAMPUP_GRANULARITY = JMeterUtils.getPropDefault("jmeterthread.rampup.granularity", 1000); // $NON-NLS-1$ + /** How often to check for shutdown during timer delay, default 1000ms */ + private static final int TIMER_GRANULARITY = + JMeterUtils.getPropDefault("jmeterthread.timer.granularity", 1000); // $NON-NLS-1$ + private static final float TIMER_FACTOR = JMeterUtils.getPropDefault("timer.factor", 1.0f); private static final TimerService TIMER_SERVICE = TimerService.getInstance(); @@ -998,21 +1002,36 @@ private void delay(List timers) { totalDelay += delay; } if (totalDelay > 0) { - try { - if (scheduler) { - // We reduce pause to ensure end of test is not delayed by a sleep ending after test scheduled end - // See Bug 60049 - totalDelay = TIMER_SERVICE.adjustDelay(totalDelay, endTime, false); - if (totalDelay < 0) { - log.debug("The delay would be longer than the scheduled period, so stop thread now."); - running = false; - return; + if (scheduler) { + // We reduce pause to ensure end of test is not delayed by a sleep ending after test scheduled end + // See Bug 60049 + totalDelay = TIMER_SERVICE.adjustDelay(totalDelay, endTime, false); + if (totalDelay < 0) { + log.debug("The delay would be longer than the scheduled period, so stop thread now."); + running = false; + return; + } + } + // Use granular sleeps to allow quick response to shutdown + long start = System.currentTimeMillis(); + long end = start + totalDelay; + long now; + long pause = TIMER_GRANULARITY; + while (running && (now = System.currentTimeMillis()) < end) { + long togo = end - now; + if (togo < pause) { + pause = togo; + } + try { + TimeUnit.MILLISECONDS.sleep(pause); + } catch (InterruptedException e) { + if (running) { // NOSONAR running may have been changed from another thread + log.warn("The delay timer was interrupted - Loss of delay for {} was {}ms out of {}ms", + threadName, System.currentTimeMillis() - start, totalDelay); } + Thread.currentThread().interrupt(); + break; } - TimeUnit.MILLISECONDS.sleep(totalDelay); - } catch (InterruptedException e) { - log.warn("The delay timer was interrupted - probably did not wait as long as intended."); - Thread.currentThread().interrupt(); } } } diff --git a/src/core/src/main/java/org/apache/jmeter/threads/TestCompiler.java b/src/core/src/main/java/org/apache/jmeter/threads/TestCompiler.java index 14b537a0e19..9e10fbbf480 100644 --- a/src/core/src/main/java/org/apache/jmeter/threads/TestCompiler.java +++ b/src/core/src/main/java/org/apache/jmeter/threads/TestCompiler.java @@ -115,10 +115,20 @@ public SamplePackage configureTransactionSampler(TransactionSampler transactionS } /** - * Reset pack to its initial state + * Reset pack to its initial state and clean up transaction results if needed * @param pack the {@link SamplePackage} to reset */ public void done(SamplePackage pack) { + Sampler sampler = pack.getSampler(); + if (sampler instanceof TransactionSampler transactionSampler) { + TransactionController controller = transactionSampler.getTransactionController(); + if (transactionSampler.isTransactionDone()) { + // Create new sampler for next iteration + TransactionSampler newSampler = new TransactionSampler(controller, transactionSampler.getName()); + SamplePackage newPack = transactionControllerConfigMap.get(controller); + newPack.setSampler(newSampler); + } + } pack.recoverRunningVersion(); } diff --git a/src/core/src/main/java/org/apache/jmeter/threads/ThreadGroup.java b/src/core/src/main/java/org/apache/jmeter/threads/ThreadGroup.java index 9599fe0e1c4..de2bf55263b 100644 --- a/src/core/src/main/java/org/apache/jmeter/threads/ThreadGroup.java +++ b/src/core/src/main/java/org/apache/jmeter/threads/ThreadGroup.java @@ -54,6 +54,48 @@ public class ThreadGroup extends AbstractThreadGroup { private static final int RAMPUP_GRANULARITY = JMeterUtils.getPropDefault("jmeterthread.rampup.granularity", 1000); // $NON-NLS-1$ + /** Whether to use Java 21 Virtual Threads for JMeter threads */ + private static final boolean VIRTUAL_THREADS_ENABLED = + JMeterUtils.getPropDefault("jmeter.threads.virtual.enabled", true); // $NON-NLS-1$ + + /** Cached reference to Thread.ofVirtual() method for Java 21+ support, null if not available */ + private static final java.lang.invoke.MethodHandle VIRTUAL_THREAD_BUILDER; + + /** Cached reference to Thread.Builder.OfVirtual.name() method */ + private static final java.lang.invoke.MethodHandle VIRTUAL_BUILDER_NAME; + + /** Cached reference to Thread.Builder.OfVirtual.unstarted() method */ + private static final java.lang.invoke.MethodHandle VIRTUAL_BUILDER_UNSTARTED; + + static { + java.lang.invoke.MethodHandle ofVirtual = null; + java.lang.invoke.MethodHandle builderName = null; + java.lang.invoke.MethodHandle builderUnstarted = null; + if (VIRTUAL_THREADS_ENABLED) { + try { + // Try to get Thread.ofVirtual() method (Java 21+) + java.lang.invoke.MethodHandles.Lookup lookup = java.lang.invoke.MethodHandles.lookup(); + java.lang.reflect.Method ofVirtualMethod = Thread.class.getMethod("ofVirtual"); + ofVirtual = lookup.unreflect(ofVirtualMethod); + + // Get the builder class and its methods + Class builderClass = ofVirtualMethod.getReturnType(); + java.lang.reflect.Method nameMethod = builderClass.getMethod("name", String.class); + builderName = lookup.unreflect(nameMethod); + + java.lang.reflect.Method unstartedMethod = builderClass.getMethod("unstarted", Runnable.class); + builderUnstarted = lookup.unreflect(unstartedMethod); + + log.info("Virtual threads support enabled (Java 21+)"); + } catch (NoSuchMethodException | IllegalAccessException e) { + log.warn("Virtual threads requested but not available (requires Java 21+), falling back to platform threads"); + } + } + VIRTUAL_THREAD_BUILDER = ofVirtual; + VIRTUAL_BUILDER_NAME = builderName; + VIRTUAL_BUILDER_UNSTARTED = builderUnstarted; + } + //+ JMX entries - do not change the string values /** Ramp-up time */ @@ -257,6 +299,29 @@ public void start(int groupNum, ListenerNotifier notifier, ListedHashTree thread log.info("Started thread group number {}", groupNumber); } + /** + * Creates a thread (virtual or platform) based on configuration. + * When {@code jmeter.threads.virtual.enabled} is true and running on Java 21+, + * creates a virtual thread. Otherwise creates a platform thread. + * + * @param runnable the runnable to execute + * @param name the thread name + * @return an unstarted Thread + */ + private static Thread createThread(Runnable runnable, String name) { + if (VIRTUAL_THREAD_BUILDER != null) { + try { + // Thread.ofVirtual().name(name).unstarted(runnable) + Object builder = VIRTUAL_THREAD_BUILDER.invoke(); + builder = VIRTUAL_BUILDER_NAME.invoke(builder, name); + return (Thread) VIRTUAL_BUILDER_UNSTARTED.invoke(builder, runnable); + } catch (Throwable t) { + log.warn("Failed to create virtual thread, falling back to platform thread", t); + } + } + return new Thread(runnable, name); + } + /** * Start a new {@link JMeterThread} and registers it * @param notifier {@link ListenerNotifier} @@ -273,7 +338,7 @@ private JMeterThread startNewThread(ListenerNotifier notifier, ListedHashTree th JMeterThread jmThread = makeThread(engine, this, notifier, groupNumber, threadNum, cloneTree(threadGroupTree), variables); scheduleThread(jmThread, now); // set start and end time jmThread.setInitialDelay(delay); - Thread newThread = new Thread(jmThread, jmThread.getThreadName()); + Thread newThread = createThread(jmThread, jmThread.getThreadName()); registerStartedThread(jmThread, newThread); newThread.start(); return jmThread; @@ -587,8 +652,10 @@ public void run() { jmThread.setScheduled(true); jmThread.setEndTime(endtime); } - Thread newThread = new Thread(jmThread, jmThread.getThreadName()); - newThread.setDaemon(false); // ThreadStarter is daemon, but we don't want sampler threads to be so too + Thread newThread = createThread(jmThread, jmThread.getThreadName()); + if (VIRTUAL_THREAD_BUILDER == null) { + newThread.setDaemon(false); // ThreadStarter is daemon, but we don't want sampler threads to be so too + } registerStartedThread(jmThread, newThread); newThread.start(); } diff --git a/src/core/src/main/java/org/apache/jmeter/threads/gui/ThreadGroupGui.java b/src/core/src/main/java/org/apache/jmeter/threads/gui/ThreadGroupGui.java index 3ddcd1f96ee..3c096d0b0eb 100644 --- a/src/core/src/main/java/org/apache/jmeter/threads/gui/ThreadGroupGui.java +++ b/src/core/src/main/java/org/apache/jmeter/threads/gui/ThreadGroupGui.java @@ -65,7 +65,8 @@ public class ThreadGroupGui extends AbstractThreadGroupGui implements ItemListen private final JBooleanPropertyEditor scheduler = new JBooleanPropertyEditor( ThreadGroupSchema.INSTANCE.getUseScheduler(), - JMeterUtils.getResString("scheduler")); + "scheduler", + JMeterUtils::getResString); private final JTextField duration = new JTextField(); private final JLabel durationLabel = labelFor(duration, "duration"); @@ -76,7 +77,8 @@ public class ThreadGroupGui extends AbstractThreadGroupGui implements ItemListen private final JBooleanPropertyEditor sameUserBox = new JBooleanPropertyEditor( AbstractThreadGroupSchema.INSTANCE.getSameUserOnNextIteration(), - JMeterUtils.getResString("threadgroup_same_user")); + "threadgroup_same_user", + JMeterUtils::getResString); public ThreadGroupGui() { this(true); @@ -199,7 +201,8 @@ private void init() { // WARNING: called from ctor so must not be overridden (i. if (showDelayedStart) { delayedStart = new JBooleanPropertyEditor( ThreadGroupSchema.INSTANCE.getDelayedStart(), - JMeterUtils.getResString("delayed_start")); // $NON-NLS-1$ + "delayed_start", + JMeterUtils::getResString); // $NON-NLS-1$ threadPropsPanel.add(delayedStart, "span 2"); } scheduler.addPropertyChangeListener( diff --git a/src/core/src/main/kotlin/org/apache/jmeter/gui/JBooleanPropertyEditor.kt b/src/core/src/main/kotlin/org/apache/jmeter/gui/JBooleanPropertyEditor.kt index 1b4e70af789..a410eb700d1 100644 --- a/src/core/src/main/kotlin/org/apache/jmeter/gui/JBooleanPropertyEditor.kt +++ b/src/core/src/main/kotlin/org/apache/jmeter/gui/JBooleanPropertyEditor.kt @@ -20,9 +20,12 @@ package org.apache.jmeter.gui import org.apache.jmeter.testelement.TestElement import org.apache.jmeter.testelement.property.BooleanProperty import org.apache.jmeter.testelement.schema.BooleanPropertyDescriptor -import org.apache.jmeter.util.JMeterUtils import org.apache.jorphan.gui.JEditableCheckBox +import org.apache.jorphan.locale.LocalizedString +import org.apache.jorphan.locale.PlainValue +import org.apache.jorphan.locale.ResourceLocalizer import org.apiguardian.api.API +import org.jetbrains.annotations.NonNls /** * Provides editor component for boolean properties that accommodate both true/false and expression string. @@ -31,19 +34,21 @@ import org.apiguardian.api.API @API(status = API.Status.EXPERIMENTAL, since = "5.6") public class JBooleanPropertyEditor( private val propertyDescriptor: BooleanPropertyDescriptor<*>, - label: String, -) : JEditableCheckBox(label, DEFAULT_CONFIGURATION), Binding { + label: @NonNls String, + resourceLocalizer: ResourceLocalizer, +) : JEditableCheckBox(label, createConfiguration(resourceLocalizer), resourceLocalizer), Binding { private companion object { - @JvmField - val DEFAULT_CONFIGURATION: Configuration = Configuration( - startEditing = JMeterUtils.getResString("editable_checkbox.use_expression"), - trueValue = "true", - falseValue = "false", - extraValues = listOf( - "\${__P(property_name)}", - "\${variable_name}", + private fun createConfiguration(resourceLocalizer: ResourceLocalizer) = + Configuration( + useExpression = LocalizedString("edit_as_expression_action", resourceLocalizer), + useExpressionTooltip = LocalizedString("edit_as_expression_tooltip", resourceLocalizer), + trueValue = LocalizedString("editable_checkbox.true", resourceLocalizer), + falseValue = LocalizedString("editable_checkbox.false", resourceLocalizer), + extraValues = listOf( + PlainValue("\${__P(property_name)}"), + PlainValue("\${variable_name}"), + ) ) - ) } public fun reset() { diff --git a/src/core/src/main/kotlin/org/apache/jmeter/gui/JEnumPropertyEditor.kt b/src/core/src/main/kotlin/org/apache/jmeter/gui/JEnumPropertyEditor.kt new file mode 100644 index 00000000000..1bc31d068fd --- /dev/null +++ b/src/core/src/main/kotlin/org/apache/jmeter/gui/JEnumPropertyEditor.kt @@ -0,0 +1,136 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.jmeter.gui + +import org.apache.jmeter.testelement.TestElement +import org.apache.jmeter.testelement.property.StringProperty +import org.apache.jmeter.testelement.schema.StringPropertyDescriptor +import org.apache.jorphan.gui.JEditableComboBox +import org.apache.jorphan.locale.LocalizedString +import org.apache.jorphan.locale.LocalizedValue +import org.apache.jorphan.locale.PlainValue +import org.apache.jorphan.locale.ResourceKeyed +import org.apache.jorphan.locale.ResourceLocalizer +import org.apache.jorphan.util.enumValues +import org.apiguardian.api.API +import org.jetbrains.annotations.NonNls + +/** + * Property editor for enum values that implements [ResourceKeyed]. + * + * This editor provides a combo box that displays localized enum values and can also + * accept custom expressions like `${__P(property)}` for dynamic configuration. + * + * The editor: + * - Displays predefined enum values with localized text + * - Stores enum resource keys (not enum names) in the test element property + * - Allows switching to an editable text field for expressions + * - Automatically detects and handles both enum values and custom expressions + * + * Example usage in a GUI class: + * ```java + * private final JEnumPropertyEditor modeEditor; + * + * modeEditor = new JEnumPropertyEditor<>( + * schema.getResponseProcessingMode(), + * "response_mode_label", + * ResponseProcessingMode.class, + * JMeterUtils::getResString + * ); + * bindingGroup.add(modeEditor); + * ``` + * + * @param E the enum type that implements [ResourceKeyed] + * @property propertyDescriptor the property descriptor for the enum property + * @property label the label text to display next to the combo box + * @property enumClass the class of the enum type + * @since 6.0.0 + */ +@API(status = API.Status.EXPERIMENTAL, since = "6.0.0") +public class JEnumPropertyEditor( + private val propertyDescriptor: StringPropertyDescriptor<*>, + label: @NonNls String, + enumClass: Class, + resourceLocalizer: ResourceLocalizer, +) : JEditableComboBox(label, createConfiguration(enumClass, resourceLocalizer), resourceLocalizer), Binding + where E : Enum, E : ResourceKeyed { + + private companion object { + @JvmStatic + private fun createConfiguration( + enumClass: Class, + resourceLocalizer: ResourceLocalizer + ): Configuration + where E : Enum, E : ResourceKeyed { + val resourceKeys = enumClass.enumValues.map { + LocalizedValue(it, resourceLocalizer) + } + + return Configuration( + useExpression = LocalizedString("edit_as_expression_action", resourceLocalizer), + useExpressionTooltip = LocalizedString("edit_as_expression_tooltip", resourceLocalizer), + values = resourceKeys, + extraValues = listOf( + PlainValue("\${__P(property_name)}"), + PlainValue("\${variable_name}"), + ) + ) + } + } + + /** + * Resets the editor to the default value specified in the property descriptor. + */ + public fun reset() { + value = PlainValue(propertyDescriptor.defaultValue ?: "") + } + + /** + * Updates the test element with the current value from the editor. + * + * The value is stored as-is (either a resource key or a custom expression). + */ + override fun updateElement(testElement: TestElement) { + val currentValue = value + if ((currentValue as? PlainValue)?.value.isNullOrBlank() && propertyDescriptor.defaultValue == null) { + // Remove property if empty and no default + testElement.removeProperty(propertyDescriptor.name) + } else { + testElement[propertyDescriptor] = when (currentValue) { + is ResourceKeyed -> currentValue.resourceKey + else -> currentValue.toString() + } + } + } + + /** + * Updates the editor UI from the test element's property value. + * + * Handles both enum resource keys and custom expression strings. + */ + override fun updateUi(testElement: TestElement) { + val property = testElement.getPropertyOrNull(propertyDescriptor) + value = PlainValue( + when (property) { + is StringProperty -> property.stringValue + null -> propertyDescriptor.defaultValue ?: "" + else -> property.stringValue + } + ) + } +} diff --git a/src/core/src/main/kotlin/org/apache/jmeter/samplers/ResponseDecoder.kt b/src/core/src/main/kotlin/org/apache/jmeter/samplers/ResponseDecoder.kt new file mode 100644 index 00000000000..98954392729 --- /dev/null +++ b/src/core/src/main/kotlin/org/apache/jmeter/samplers/ResponseDecoder.kt @@ -0,0 +1,95 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.jmeter.samplers + +import org.apache.jorphan.io.DirectAccessByteArrayOutputStream +import org.apache.jorphan.reflect.JMeterService +import org.apiguardian.api.API +import java.io.ByteArrayInputStream +import java.io.InputStream + +/** + * Interface for response data decoders that handle different content encodings. + * Implementations can be automatically discovered via [java.util.ServiceLoader]. + * + * To add a custom decoder: + * 1. Implement this interface + * 2. Create `META-INF/services/org.apache.jmeter.samplers.ResponseDecoder` file + * 4. Add your implementation's fully qualified class name to the file + * + * Example decoders: gzip, deflate, brotli + * + * @since 6.0.0 + */ +@JMeterService +@API(status = API.Status.EXPERIMENTAL, since = "6.0.0") +public interface ResponseDecoder { + + /** + * Returns the content encodings handled by this decoder. + * These should match Content-Encoding header values (case-insensitive). + * + * A decoder can handle multiple encoding names (e.g., "gzip" and "x-gzip"). + * + * Examples: ["gzip", "x-gzip"], ["deflate"], ["br"] + * + * @return list of encoding names this decoder handles (must not be null or empty) + */ + public val encodings: List + + /** + * Decodes (decompresses) the given compressed data. + * + * @param compressed the compressed data to decode + * @return the decompressed data + * @throws java.io.IOException if decompression fails + */ + public fun decode(compressed: ByteArray): ByteArray { + val out = DirectAccessByteArrayOutputStream() + decodeStream(ByteArrayInputStream(compressed)).use { + it.transferTo(out) + } + return out.toByteArray() + } + + /** + * Creates a decompressing InputStream that wraps the given compressed input stream. + * This allows streaming decompression without buffering the entire response in memory. + * + * Used for scenarios like MD5 computation on decompressed data, where we want to + * compute the hash on-the-fly without storing the entire decompressed response. + * + * @param input the compressed input stream to wrap + * @return an InputStream that decompresses data as it's read + * @throws java.io.IOException if the decompressing stream cannot be created + */ + public fun decodeStream(input: InputStream): InputStream + + /** + * Returns the priority of this decoder. + * When multiple decoders are registered for the same encoding, + * the one with the highest priority is used. + * + * Default priority is 0. Built-in decoders use priority 0. + * Plugins can override built-in decoders by returning a higher priority. + * + * @return priority value (higher = preferred), default is 0 + */ + public val priority: Int + get() = 0 +} diff --git a/src/core/src/main/kotlin/org/apache/jmeter/samplers/ResponseDecoderRegistry.kt b/src/core/src/main/kotlin/org/apache/jmeter/samplers/ResponseDecoderRegistry.kt new file mode 100644 index 00000000000..f7fb3d22cb9 --- /dev/null +++ b/src/core/src/main/kotlin/org/apache/jmeter/samplers/ResponseDecoderRegistry.kt @@ -0,0 +1,188 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.jmeter.samplers + +import org.apache.jmeter.samplers.decoders.DeflateDecoder +import org.apache.jmeter.samplers.decoders.GzipDecoder +import org.apache.jmeter.util.JMeterUtils +import org.apache.jorphan.reflect.LogAndIgnoreServiceLoadExceptionHandler +import org.apiguardian.api.API +import org.slf4j.LoggerFactory +import java.io.IOException +import java.io.InputStream +import java.util.Locale +import java.util.ServiceLoader +import java.util.concurrent.ConcurrentHashMap + +/** + * Registry for [ResponseDecoder] implementations. + * Provides centralized management of response decoders for different content encodings. + * + * Decoders are discovered via: + * - Built-in decoders (gzip, deflate) + * - ServiceLoader mechanism (META-INF/services) + * + * Thread-safe singleton registry. + * + * @since 6.0.0 + */ +@API(status = API.Status.EXPERIMENTAL, since = "6.0.0") +public object ResponseDecoderRegistry { + + private val log = LoggerFactory.getLogger(ResponseDecoderRegistry::class.java) + + /** + * Map of encoding name (lowercase) to decoder implementation. + * Uses ConcurrentHashMap for thread-safe access. + */ + private val decoders = ConcurrentHashMap() + + init { + // Register built-in decoders, this ensures the decoders are there even if service registration fails + registerDecoder(GzipDecoder()) + registerDecoder(DeflateDecoder()) + + // Load decoders via ServiceLoader + loadServiceLoaderDecoders() + } + + /** + * Loads decoders using ServiceLoader mechanism. + */ + private fun loadServiceLoaderDecoders() { + try { + JMeterUtils.loadServicesAndScanJars( + ResponseDecoder::class.java, + ServiceLoader.load(ResponseDecoder::class.java), + Thread.currentThread().contextClassLoader, + LogAndIgnoreServiceLoadExceptionHandler(log) + ).forEach { registerDecoder(it) } + } catch (e: Exception) { + log.error("Error loading ResponseDecoder services", e) + } + } + + /** + * Registers a decoder for all its encoding types. + * If a decoder already exists for an encoding, the one with higher priority is kept. + * + * @param decoder the decoder to register + */ + @JvmStatic + public fun registerDecoder(decoder: ResponseDecoder) { + val encodings = decoder.encodings + if (encodings.isEmpty()) { + log.warn("Decoder {} has null or empty encodings list, skipping registration", decoder.javaClass.name) + return + } + + for (encoding in encodings) { + val key = encoding.lowercase(Locale.ROOT) + + decoders.merge(key, decoder) { existing, newDecoder -> + // Keep the decoder with higher priority + if (newDecoder.priority > existing.priority) { + log.info( + "Replacing decoder for '{}': {} (priority {}) -> {} (priority {})", + encoding, + existing.javaClass.simpleName, existing.priority, + newDecoder.javaClass.simpleName, newDecoder.priority + ) + newDecoder + } else { + log.debug( + "Keeping existing decoder for '{}': {} (priority {}) over {} (priority {})", + encoding, + existing.javaClass.simpleName, existing.priority, + newDecoder.javaClass.simpleName, newDecoder.priority + ) + existing + } + } + } + } + + /** + * Decodes the given data using the decoder registered for the specified encoding. + * If no decoder is found for the encoding, returns the data unchanged. + * + * @param encoding the content encoding (e.g., "gzip", "deflate", "br") + * @param data the data to decode + * @return decoded data, or original data if no decoder found or encoding is null + * @throws IOException if decoding fails + */ + @JvmStatic + @Throws(IOException::class) + public fun decode(encoding: String?, data: ByteArray?): ByteArray { + if (encoding.isNullOrEmpty() || data == null || data.isEmpty()) { + return data ?: ByteArray(0) + } + + val decoder = decoders[encoding] ?: decoders[encoding.lowercase(Locale.ROOT)] + + if (decoder == null) { + log.debug("No decoder found for encoding '{}', returning data unchanged", encoding) + return data + } + + return decoder.decode(data) + } + + /** + * Creates a decompressing InputStream that wraps the given input stream using the decoder + * registered for the specified encoding. + * + * This enables streaming decompression without buffering the entire response in memory, + * which is useful for computing checksums on decompressed data or processing large responses. + * + * If no decoder is found for the encoding, returns the original input stream unchanged. + * + * @param encoding the content encoding (e.g., "gzip", "deflate", "br") + * @param input the input stream to wrap with decompression + * @return a decompressing InputStream, or the original stream if no decoder found or encoding is null + * @throws IOException if the decompressing stream cannot be created + * @since 6.0.0 + */ + @JvmStatic + @Throws(IOException::class) + public fun decodeStream(encoding: String?, input: InputStream): InputStream { + if (encoding.isNullOrEmpty()) { + return input + } + + val decoder = decoders[encoding] ?: decoders[encoding.lowercase(Locale.ROOT)] + + if (decoder == null) { + log.debug("No decoder found for encoding '{}', returning input stream unchanged", encoding) + return input + } + + return decoder.decodeStream(input) + } + + /** + * Checks if a decoder is registered for the given encoding. + * Primarily for testing purposes. + * + * @param encoding the encoding to check + * @return true if a decoder is registered for this encoding + */ + @JvmStatic + public fun hasDecoder(encoding: String): Boolean = + decoders.containsKey(encoding.lowercase(Locale.ROOT)) +} diff --git a/src/core/src/main/kotlin/org/apache/jmeter/samplers/decoders/DeflateDecoder.kt b/src/core/src/main/kotlin/org/apache/jmeter/samplers/decoders/DeflateDecoder.kt new file mode 100644 index 00000000000..db2653bb2ae --- /dev/null +++ b/src/core/src/main/kotlin/org/apache/jmeter/samplers/decoders/DeflateDecoder.kt @@ -0,0 +1,72 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.jmeter.samplers.decoders + +import org.apache.jmeter.samplers.ResponseDecoder +import org.apache.jorphan.io.DirectAccessByteArrayOutputStream +import org.apiguardian.api.API +import java.io.ByteArrayInputStream +import java.io.IOException +import java.io.InputStream +import java.util.zip.Inflater +import java.util.zip.InflaterInputStream + +/** + * Decoder for deflate compressed response data. + * Attempts decompression with ZLIB wrapper first, falls back to raw DEFLATE if that fails. + * + * @since 6.0.0 + */ +@API(status = API.Status.INTERNAL, since = "6.0.0") +public class DeflateDecoder : ResponseDecoder { + override val encodings: List + get() = listOf("deflate") + + override fun decode(compressed: ByteArray): ByteArray { + // Try with ZLIB wrapper first + return try { + decompressWithInflater(compressed, nowrap = false) + } catch (e: IOException) { + // If that fails, try with NO_WRAP for raw DEFLATE + decompressWithInflater(compressed, nowrap = true) + } + } + + override fun decodeStream(input: InputStream): InputStream { + // For streaming, use ZLIB wrapper (nowrap=false) which is the most common case. + // The fallback to raw DEFLATE is only available in the byte array version + // since we cannot retry with a stream without buffering it first. + return InflaterInputStream(input, Inflater(false)) + } + + /** + * Decompresses data using Inflater with specified nowrap setting. + * + * @param compressed the compressed data + * @param nowrap if true, uses raw DEFLATE (no ZLIB wrapper) + * @return decompressed data + * @throws IOException if decompression fails + */ + private fun decompressWithInflater(compressed: ByteArray, nowrap: Boolean): ByteArray { + val out = DirectAccessByteArrayOutputStream() + InflaterInputStream(ByteArrayInputStream(compressed), Inflater(nowrap)).use { + it.transferTo(out) + } + return out.toByteArray() + } +} diff --git a/src/core/src/main/kotlin/org/apache/jmeter/samplers/decoders/GzipDecoder.kt b/src/core/src/main/kotlin/org/apache/jmeter/samplers/decoders/GzipDecoder.kt new file mode 100644 index 00000000000..b636f48fd80 --- /dev/null +++ b/src/core/src/main/kotlin/org/apache/jmeter/samplers/decoders/GzipDecoder.kt @@ -0,0 +1,39 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.jmeter.samplers.decoders + +import org.apache.jmeter.samplers.ResponseDecoder +import org.apiguardian.api.API +import java.io.InputStream +import java.util.zip.GZIPInputStream + +/** + * Decoder for gzip compressed response data. + * Handles both "gzip" and "x-gzip" content encodings. + * + * @since 6.0.0 + */ +@API(status = API.Status.INTERNAL, since = "6.0.0") +public class GzipDecoder : ResponseDecoder { + override val encodings: List + get() = listOf("gzip", "x-gzip") + + override fun decodeStream(input: InputStream): InputStream { + return GZIPInputStream(input) + } +} diff --git a/src/core/src/main/kotlin/org/apache/jmeter/threads/openmodel/OpenModelThreadGroup.kt b/src/core/src/main/kotlin/org/apache/jmeter/threads/openmodel/OpenModelThreadGroup.kt index 45c0f6cef6b..f8fe8e0d7d1 100644 --- a/src/core/src/main/kotlin/org/apache/jmeter/threads/openmodel/OpenModelThreadGroup.kt +++ b/src/core/src/main/kotlin/org/apache/jmeter/threads/openmodel/OpenModelThreadGroup.kt @@ -27,17 +27,21 @@ import org.apache.jmeter.threads.JMeterThread import org.apache.jmeter.threads.JMeterThreadMonitor import org.apache.jmeter.threads.ListenerNotifier import org.apache.jmeter.threads.TestCompilerHelper +import org.apache.jmeter.util.JMeterUtils import org.apache.jorphan.collections.ListedHashTree import org.apiguardian.api.API import org.slf4j.LoggerFactory import java.io.Serializable import java.lang.Thread.sleep +import java.lang.invoke.MethodHandles import java.util.Random import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ExecutorService import java.util.concurrent.Executors import java.util.concurrent.Future +import java.util.concurrent.ThreadFactory import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.atomic.AtomicReference import kotlin.math.roundToLong @@ -57,6 +61,45 @@ public class OpenModelThreadGroup : public companion object { private val log = LoggerFactory.getLogger(OpenModelThreadGroup::class.java) + /** Whether to use Java 21 Virtual Threads */ + private val VIRTUAL_THREADS_ENABLED = + JMeterUtils.getPropDefault("jmeter.threads.virtual.enabled", true) + + /** Counter for naming virtual threads */ + private val virtualThreadCounter = AtomicLong(0) + + /** Cached MethodHandle for creating virtual thread executor (Java 21+) */ + private val newVirtualThreadExecutor: java.lang.invoke.MethodHandle? = run { + if (!VIRTUAL_THREADS_ENABLED) { + return@run null + } + try { + val lookup = MethodHandles.lookup() + // Verify Thread.ofVirtual() exists (Java 21+) + Thread::class.java.getMethod("ofVirtual") + // Get the newThreadPerTaskExecutor method + val method = Executors::class.java.getMethod( + "newThreadPerTaskExecutor", + ThreadFactory::class.java + ) + val handle = lookup.unreflect(method) + log.info("Virtual threads support enabled for OpenModelThreadGroup (Java 21+)") + handle + } catch (e: NoSuchMethodException) { + log.warn( + "Virtual threads requested but not available for OpenModelThreadGroup " + + "(requires Java 21+), falling back to platform threads" + ) + null + } catch (e: IllegalAccessException) { + log.warn( + "Virtual threads requested but not available for OpenModelThreadGroup " + + "(requires Java 21+), falling back to platform threads" + ) + null + } + } + /** Thread group schedule. See [ThreadSchedule]. */ @Deprecated( message = "Use OpenModelThreadGroupSchema instead", @@ -86,6 +129,82 @@ public class OpenModelThreadGroup : private val houseKeepingThreadPool = Executors.newCachedThreadPool() private const val serialVersionUID: Long = 1L + + /** + * Creates an ExecutorService that uses virtual threads on Java 21+, + * or falls back to a cached thread pool on older versions. + */ + private fun createExecutorService(): ExecutorService { + val executor = newVirtualThreadExecutor + if (executor != null) { + try { + val factory = ThreadFactory { r -> + createVirtualThread(r, "OpenModel-vt-${virtualThreadCounter.incrementAndGet()}") + ?: Thread(r).apply { name = "OpenModel-$name" } + } + val result = executor.invoke(factory) as ExecutorService + log.debug("Created virtual thread executor for OpenModelThreadGroup") + return result + } catch (t: Throwable) { + log.warn("Failed to create virtual thread executor, falling back to cached thread pool", t) + } + } + return Executors.newCachedThreadPool() + } + + /** + * Creates a virtual thread using reflection (for Java 21+ compatibility). + */ + private fun createVirtualThread(runnable: Runnable, name: String): Thread? { + return try { + val lookup = MethodHandles.lookup() + val ofVirtualMethod = Thread::class.java.getMethod("ofVirtual") + var builder = lookup.unreflect(ofVirtualMethod).invoke() + + val builderClass = builder.javaClass + val nameMethod = findMethodInHierarchy(builderClass, "name", String::class.java) + if (nameMethod != null) { + builder = lookup.unreflect(nameMethod).invoke(builder, name) + } + + val unstartedMethod = findMethodInHierarchy(builderClass, "unstarted", Runnable::class.java) + if (unstartedMethod != null) { + lookup.unreflect(unstartedMethod).invoke(builder, runnable) as Thread + } else { + null + } + } catch (t: Throwable) { + log.trace("Failed to create virtual thread", t) + null + } + } + + /** + * Finds a method in the class hierarchy, including interfaces. + */ + private fun findMethodInHierarchy( + clazz: Class<*>, + methodName: String, + vararg paramTypes: Class<*> + ): java.lang.reflect.Method? { + var c: Class<*>? = clazz + while (c != null) { + try { + return c.getMethod(methodName, *paramTypes) + } catch (ignored: NoSuchMethodException) { + // Try interfaces + for (iface in c.interfaces) { + try { + return iface.getMethod(methodName, *paramTypes) + } catch (e: NoSuchMethodException) { + // Continue searching + } + } + } + c = c.superclass + } + return null + } } // A thread pool that executes main workload. @@ -205,7 +324,7 @@ public class OpenModelThreadGroup : val rnd = if (seed == 0L) Random() else Random(seed) val gen = ThreadScheduleProcessGenerator(rnd, parsedSchedule) val testStartTime = this.startTime - val executorService = Executors.newCachedThreadPool() + val executorService = createExecutorService() this.executorService = executorService val starter = ThreadsStarter(testStartTime, executorService, activeThreads, gen) { threadNumber -> val clonedTree = cloneTree(threadGroupTree) diff --git a/src/core/src/main/resources/org/apache/jmeter/resources/messages.properties b/src/core/src/main/resources/org/apache/jmeter/resources/messages.properties index 23721c4b4c9..5791663bd1e 100644 --- a/src/core/src/main/resources/org/apache/jmeter/resources/messages.properties +++ b/src/core/src/main/resources/org/apache/jmeter/resources/messages.properties @@ -315,7 +315,10 @@ duration_assertion_label=Duration in milliseconds\: duration_assertion_title=Duration Assertion duration_tooltip=Elapsed time of current running Test edit=Edit -editable_checkbox.use_expression=Use Expression +edit_as_expression_action=Use Expression +edit_as_expression_tooltip=Switch to expression mode to use variables like ${__P(property)} +editable_checkbox.true=True +editable_checkbox.false=False email_results_title=Email Results en=English enable=Enable @@ -999,6 +1002,13 @@ reportgenerator_summary_total=Total request_data=Request Data reset=Reset response_save_as_md5=Save response as MD5 hash? +expression_mode_button_tooltip=Switch to expression mode to use variables like ${__P(property)} +response_processing_title=Response Processing +response_processing_mode=Processing mode\: +response_processing_store_compressed=Store response (decompress on access) +response_processing_fetch_discard=Fetch and discard (headers only) +response_processing_checksum_encoded_md5=Checksum (MD5 of compressed) +response_processing_checksum_decoded_md5=Checksum (MD5 of decompressed) response_time_distribution_satisfied_label=Requests having \nresponse time <= {0}ms response_time_distribution_tolerated_label= Requests having \nresponse time > {0}ms and <= {1}ms response_time_distribution_untolerated_label=Requests having \nresponse time > {0}ms diff --git a/src/core/src/main/resources/org/apache/jmeter/resources/messages_fr.properties b/src/core/src/main/resources/org/apache/jmeter/resources/messages_fr.properties index ed0f7be54d1..1fb8d18ea7b 100644 --- a/src/core/src/main/resources/org/apache/jmeter/resources/messages_fr.properties +++ b/src/core/src/main/resources/org/apache/jmeter/resources/messages_fr.properties @@ -310,7 +310,8 @@ duration_assertion_label=Durée en millisecondes \: duration_assertion_title=Assertion Durée duration_tooltip=Temps passé depuis le début du test en cours edit=Editer -editable_checkbox.use_expression=Utiliser l'expression +edit_as_expression_action=Utiliser l'expression +edit_as_expression_tooltip=Passer en mode expression pour utiliser des variables comme ${__P(property)} email_results_title=Résultat d'email en=Anglais enable=Activer diff --git a/src/core/src/test/kotlin/org/apache/jmeter/samplers/ResponseDecoderRegistryTest.kt b/src/core/src/test/kotlin/org/apache/jmeter/samplers/ResponseDecoderRegistryTest.kt new file mode 100644 index 00000000000..441f61b818e --- /dev/null +++ b/src/core/src/test/kotlin/org/apache/jmeter/samplers/ResponseDecoderRegistryTest.kt @@ -0,0 +1,184 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.jmeter.samplers + +import org.junit.jupiter.api.Assertions.assertArrayEquals +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.io.ByteArrayOutputStream +import java.io.InputStream +import java.util.zip.GZIPOutputStream + +class ResponseDecoderRegistryTest { + @Test + fun testBuiltInDecodersAreRegistered() { + assertTrue(ResponseDecoderRegistry.hasDecoder("gzip"), "gzip decoder should be registered") + assertTrue(ResponseDecoderRegistry.hasDecoder("x-gzip"), "x-gzip decoder should be registered") + assertTrue(ResponseDecoderRegistry.hasDecoder("deflate"), "deflate decoder should be registered") + } + + @Test + fun testDecodeWithGzip() { + val originalText = "Hello, World! This is a test of gzip compression." + val originalData = originalText.toByteArray(Charsets.UTF_8) + + // Compress with gzip + val compressed = compressGzip(originalData) + + // Decode using registry + val decoded = ResponseDecoderRegistry.decode("gzip", compressed) + + assertArrayEquals(originalData, decoded, "Decoded data should match original") + } + + @Test + fun testDecodeWithXGzip() { + val originalText = "Testing x-gzip encoding" + val originalData = originalText.toByteArray(Charsets.UTF_8) + + // Compress with gzip (x-gzip uses same compression) + val compressed = compressGzip(originalData) + + // Decode using registry with x-gzip encoding + val decoded = ResponseDecoderRegistry.decode("x-gzip", compressed) + + assertArrayEquals(originalData, decoded, "Decoded data should match original for x-gzip") + } + + @Test + fun testDecodeWithUnknownEncoding() { + val originalData = "Test data".toByteArray(Charsets.UTF_8) + + // Decode with unknown encoding should return original data + val result = ResponseDecoderRegistry.decode("unknown-encoding", originalData) + + assertArrayEquals(originalData, result, "Unknown encoding should return data unchanged") + } + + @Test + fun testDecodeWithNullEncoding() { + val originalData = "Test data".toByteArray(Charsets.UTF_8) + + // Decode with null encoding should return original data + val result = ResponseDecoderRegistry.decode(null, originalData) + + assertArrayEquals(originalData, result, "Null encoding should return data unchanged") + } + + @Test + fun testDecodeWithEmptyData() { + val emptyData = ByteArray(0) + + val result = ResponseDecoderRegistry.decode("gzip", emptyData) + + assertArrayEquals(emptyData, result, "Empty data should return empty data") + } + + @Test + fun testCaseInsensitiveEncoding() { + val originalText = "Case insensitive test" + val originalData = originalText.toByteArray(Charsets.UTF_8) + val compressed = compressGzip(originalData) + + // Test various case combinations + val decoded1 = ResponseDecoderRegistry.decode("GZIP", compressed) + val decoded2 = ResponseDecoderRegistry.decode("GZip", compressed) + val decoded3 = ResponseDecoderRegistry.decode("gzip", compressed) + + assertArrayEquals(originalData, decoded1, "GZIP should decode correctly") + assertArrayEquals(originalData, decoded2, "GZip should decode correctly") + assertArrayEquals(originalData, decoded3, "gzip should decode correctly") + } + + @Test + fun testRegisterCustomDecoder() { + // Create a custom decoder that reverses bytes (for testing) + val reverseDecoder = object : ResponseDecoder { + override val encodings: List + get() = listOf("test-reverse") + + override fun decode(compressed: ByteArray): ByteArray = + compressed.reversedArray() + + override fun decodeStream(input: InputStream): InputStream { + TODO("Not yet implemented") + } + } + + ResponseDecoderRegistry.registerDecoder(reverseDecoder) + + val data = "ABC".toByteArray(Charsets.UTF_8) + val decoded = ResponseDecoderRegistry.decode("test-reverse", data) + + assertEquals("CBA", decoded.toString(Charsets.UTF_8), "Custom decoder should reverse bytes") + } + + @Test + fun testDecoderPriority() { + // Register a low priority decoder + val lowPriorityDecoder = object : ResponseDecoder { + override val encodings: List + get() = listOf("priority-test") + + override fun decode(compressed: ByteArray): ByteArray = + "low".toByteArray(Charsets.UTF_8) + + override fun decodeStream(input: InputStream): InputStream { + TODO("Not yet implemented") + } + + override val priority: Int + get() = 1 + } + + // Register a high priority decoder for same encoding + val highPriorityDecoder = object : ResponseDecoder { + override val encodings: List + get() = listOf("priority-test") + + override fun decode(compressed: ByteArray): ByteArray = + "high".toByteArray(Charsets.UTF_8) + + override fun decodeStream(input: InputStream): InputStream { + TODO("Not yet implemented") + } + + override val priority: Int + get() = 10 + } + + ResponseDecoderRegistry.registerDecoder(lowPriorityDecoder) + ResponseDecoderRegistry.registerDecoder(highPriorityDecoder) + + val result = ResponseDecoderRegistry.decode("priority-test", "test".toByteArray(Charsets.UTF_8)) + + assertEquals("high", result.toString(Charsets.UTF_8), "Higher priority decoder should be used") + } + + /** + * Helper method to compress data with gzip + */ + private fun compressGzip(data: ByteArray): ByteArray { + val baos = ByteArrayOutputStream() + GZIPOutputStream(baos).use { gzipOut -> + gzipOut.write(data) + } + return baos.toByteArray() + } +} diff --git a/src/core/src/test/kotlin/org/apache/jmeter/samplers/decoders/DeflateDecoderTest.kt b/src/core/src/test/kotlin/org/apache/jmeter/samplers/decoders/DeflateDecoderTest.kt new file mode 100644 index 00000000000..add44e26304 --- /dev/null +++ b/src/core/src/test/kotlin/org/apache/jmeter/samplers/decoders/DeflateDecoderTest.kt @@ -0,0 +1,88 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.jmeter.samplers.decoders + +import org.junit.jupiter.api.Assertions.assertArrayEquals +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import java.io.ByteArrayOutputStream +import java.util.zip.Deflater +import java.util.zip.DeflaterOutputStream + +class DeflateDecoderTest { + private val decoder = DeflateDecoder() + + @Test + fun testGetEncodings() { + assertEquals(listOf("deflate"), decoder.encodings, "encodings") + } + + @Test + fun testGetPriority() { + assertEquals(0, decoder.priority, "Default priority should be 0") + } + + @Test + fun testDecodeDeflateWithZlibWrapper() { + val originalText = "Hello, World! This is a test message for deflate compression with ZLIB wrapper." + val originalData = originalText.toByteArray(Charsets.UTF_8) + + // Compress with ZLIB wrapper (default) + val compressed = compressDeflate(originalData, nowrap = false) + + // Decode + val decoded = decoder.decode(compressed) + + assertArrayEquals(originalData, decoded, "Decoded data should match original (ZLIB wrapper)") + } + + @Test + fun testDecodeDeflateRaw() { + val originalText = "Testing raw deflate without ZLIB wrapper." + val originalData = originalText.toByteArray(Charsets.UTF_8) + + // Compress with NO_WRAP (raw deflate) + val compressed = compressDeflate(originalData, nowrap = true) + + // Decode - should fallback to raw deflate + val decoded = decoder.decode(compressed) + + assertArrayEquals(originalData, decoded, "Decoded data should match original (raw deflate)") + } + + @Test + fun testDecodeEmptyData() { + val emptyCompressed = compressDeflate(ByteArray(0), nowrap = false) + val decoded = decoder.decode(emptyCompressed) + + assertEquals(0, decoded.size, "Empty data should decode to empty array") + } + + /** + * Helper method to compress data with deflate + * @param data the data to compress + * @param nowrap if true, uses raw deflate (no ZLIB wrapper) + */ + private fun compressDeflate(data: ByteArray, nowrap: Boolean): ByteArray { + val baos = ByteArrayOutputStream() + DeflaterOutputStream(baos, Deflater(Deflater.DEFAULT_COMPRESSION, nowrap)).use { deflaterOut -> + deflaterOut.write(data) + } + return baos.toByteArray() + } +} diff --git a/src/core/src/test/kotlin/org/apache/jmeter/samplers/decoders/GzipDecoderTest.kt b/src/core/src/test/kotlin/org/apache/jmeter/samplers/decoders/GzipDecoderTest.kt new file mode 100644 index 00000000000..9be1f83cbae --- /dev/null +++ b/src/core/src/test/kotlin/org/apache/jmeter/samplers/decoders/GzipDecoderTest.kt @@ -0,0 +1,82 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.jmeter.samplers.decoders + +import org.junit.jupiter.api.Assertions.assertArrayEquals +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test +import java.io.ByteArrayOutputStream +import java.util.zip.GZIPOutputStream + +class GzipDecoderTest { + private val decoder = GzipDecoder() + + @Test + fun testGetEncodings() { + assertEquals(listOf("gzip", "x-gzip"), decoder.encodings, "encodings") + } + + @Test + fun testGetPriority() { + assertEquals(0, decoder.priority, "Default priority should be 0") + } + + @Test + fun testDecodeGzipData() { + val originalText = "Hello, World! This is a test message for gzip compression." + val originalData = originalText.toByteArray(Charsets.UTF_8) + + // Compress data with gzip + val compressed = compressGzip(originalData) + + // Decode + val decoded = decoder.decode(compressed) + + assertArrayEquals(originalData, decoded, "Decoded data should match original") + assertEquals(originalText, decoded.toString(Charsets.UTF_8), "Decoded text should match original") + } + + @Test + fun testDecodeEmptyData() { + val emptyCompressed = compressGzip(ByteArray(0)) + val decoded = decoder.decode(emptyCompressed) + + assertEquals(0, decoded.size, "Empty data should decode to empty array") + } + + @Test + fun testDecodeInvalidData() { + val invalidData = "This is not gzip compressed data".toByteArray(Charsets.UTF_8) + + assertThrows(Exception::class.java) { + decoder.decode(invalidData) + } + } + + /** + * Helper method to compress data with gzip + */ + private fun compressGzip(data: ByteArray): ByteArray { + val baos = ByteArrayOutputStream() + GZIPOutputStream(baos).use { gzipOut -> + gzipOut.write(data) + } + return baos.toByteArray() + } +} diff --git a/src/dist-check/src/test/kotlin/org/apache/jmeter/gui/action/HtmlReportGeneratorTest.kt b/src/dist-check/src/test/kotlin/org/apache/jmeter/gui/action/HtmlReportGeneratorTest.kt index f0740bb0efe..6f65c555dd7 100644 --- a/src/dist-check/src/test/kotlin/org/apache/jmeter/gui/action/HtmlReportGeneratorTest.kt +++ b/src/dist-check/src/test/kotlin/org/apache/jmeter/gui/action/HtmlReportGeneratorTest.kt @@ -21,6 +21,8 @@ import com.fasterxml.jackson.databind.ObjectMapper import org.apache.jmeter.junit.JMeterTestCase import org.apache.jmeter.util.JMeterUtils import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import org.junit.jupiter.api.fail import org.junit.jupiter.api.io.TempDir @@ -36,6 +38,22 @@ class HtmlReportGeneratorTest : JMeterTestCase() { data class CheckArgumentsCase(val csvPath: String, val userPropertiesPath: String, val outputDirectoryPath: String, val expected: List) + /** + * Assert that a file exists at the given path relative to a base directory + */ + private fun assertFileExists(baseDir: File, relativePath: String, message: String? = null) { + val file = File(baseDir, relativePath) + assertTrue(file.exists()) { message ?: "$relativePath should exist" } + } + + /** + * Assert that a file does NOT exist at the given path relative to a base directory + */ + private fun assertFileNotExists(baseDir: File, relativePath: String, message: String? = null) { + val file = File(baseDir, relativePath) + assertFalse(file.exists()) { message ?: "$relativePath should NOT exist" } + } + companion object { /** * Combine the given path parts to one path with the correct path separator of the current platform. @@ -141,4 +159,42 @@ class HtmlReportGeneratorTest : JMeterTestCase() { fail("First result message should contain '$expectedError', but was '$firstMessage'") } } + + @Test + fun `report generation creates correct directory structure for HTML and JS files`() { + val htmlReportGenerator = HtmlReportGenerator( + combine("testfiles", "HTMLReportTestFile.csv"), + combine("user.properties"), + testDirectory.toString() + ) + htmlReportGenerator.run() + + // Verify directory structure exists + assertFileExists(testDirectory, "content") + assertFileExists(testDirectory, "content/pages") + assertFileExists(testDirectory, "content/js") + + // Verify HTML pages are in correct location (content/pages/) + assertFileExists(testDirectory, "content/pages/OverTime.html") + assertFileExists(testDirectory, "content/pages/ResponseTimes.html") + assertFileExists(testDirectory, "content/pages/Throughput.html") + assertFileExists(testDirectory, "content/pages/CustomsGraphs.html") + + // Verify JavaScript files are in correct location (content/js/) + assertFileExists(testDirectory, "content/js/dashboard.js") + assertFileExists(testDirectory, "content/js/graph.js") + assertFileExists(testDirectory, "content/js/dashboard-commons.js") + assertFileExists(testDirectory, "content/js/customGraph.js") + + // Verify files are NOT at root level (catches the bug!) + assertFileNotExists(testDirectory, "OverTime.html", "OverTime.html should NOT be at root level") + assertFileNotExists(testDirectory, "ResponseTimes.html", "ResponseTimes.html should NOT be at root level") + assertFileNotExists(testDirectory, "Throughput.html", "Throughput.html should NOT be at root level") + assertFileNotExists(testDirectory, "CustomsGraphs.html", "CustomsGraphs.html should NOT be at root level") + assertFileNotExists(testDirectory, "dashboard.js", "dashboard.js should NOT be at root level") + assertFileNotExists(testDirectory, "graph.js", "graph.js should NOT be at root level") + + // Verify index.html is at root (this should be correct) + assertFileExists(testDirectory, "index.html", "index.html should exist at root level") + } } diff --git a/src/jorphan/src/main/kotlin/org/apache/jorphan/gui/JEditableCheckBox.kt b/src/jorphan/src/main/kotlin/org/apache/jorphan/gui/JEditableCheckBox.kt index ea825cf9206..30a01d1af44 100644 --- a/src/jorphan/src/main/kotlin/org/apache/jorphan/gui/JEditableCheckBox.kt +++ b/src/jorphan/src/main/kotlin/org/apache/jorphan/gui/JEditableCheckBox.kt @@ -17,7 +17,13 @@ package org.apache.jorphan.gui +import org.apache.jorphan.locale.ComboBoxValue +import org.apache.jorphan.locale.LocalizedString +import org.apache.jorphan.locale.PlainValue +import org.apache.jorphan.locale.ResourceKeyed +import org.apache.jorphan.locale.ResourceLocalizer import org.apiguardian.api.API +import org.jetbrains.annotations.NonNls import java.awt.Container import java.awt.FlowLayout import java.awt.event.ActionEvent @@ -28,7 +34,6 @@ import javax.swing.JComboBox import javax.swing.JLabel import javax.swing.JPanel import javax.swing.JPopupMenu -import javax.swing.SwingUtilities import javax.swing.event.ChangeEvent /** @@ -37,8 +42,9 @@ import javax.swing.event.ChangeEvent */ @API(status = API.Status.EXPERIMENTAL, since = "5.6") public open class JEditableCheckBox( - label: String, - private val configuration: Configuration + label: @NonNls String, + private val configuration: Configuration, + resourceLocalizer: ResourceLocalizer, ) : JPanel() { public companion object { public const val CHECKBOX_CARD: String = "checkbox" @@ -82,28 +88,31 @@ public open class JEditableCheckBox( /** * Supplies the parameters to [JEditableCheckBox]. */ + @API(status = API.Status.EXPERIMENTAL, since = "5.6.0") public data class Configuration( /** Menu item title to "start editing" the checkbox value. */ - val startEditing: String = "Use Expression", + val useExpression: LocalizedString, + /** Tooltip for "start editing" button. */ + val useExpressionTooltip: LocalizedString, /** The title to be used for "true" value in the checkbox. */ - val trueValue: String = "true", + val trueValue: LocalizedString, /** The title to be used for "false" value in the checkbox. */ - val falseValue: String = "false", + val falseValue: LocalizedString, /** Extra values to be added for the combobox. */ - val extraValues: List = listOf(), + val extraValues: List = listOf(), ) private val cards = CardLayoutWithSizeOfCurrentVisibleElement() - private val useExpressionAction = object : AbstractAction(configuration.startEditing) { + private val useExpressionAction = object : AbstractAction(configuration.useExpression.toString()) { override fun actionPerformed(e: ActionEvent?) { cards.next(this@JEditableCheckBox) + comboBox.selectedItem = if (checkbox.isSelected) configuration.trueValue else configuration.falseValue comboBox.requestFocusInWindow() - fireValueChanged() } } - private val checkbox: JCheckBox = JCheckBox(label).apply { + private val checkbox: JCheckBox = JCheckBox(resourceLocalizer.localize(label)).apply { val cb = this componentPopupMenu = JPopupMenu().apply { add(useExpressionAction) @@ -113,37 +122,25 @@ public open class JEditableCheckBox( } } - private val comboBox: JComboBox = JComboBox().apply { + private val comboBox: JComboBox = JComboBox().apply { isEditable = true configuration.extraValues.forEach { addItem(it) } addItem(configuration.trueValue) addItem(configuration.falseValue) - addActionListener { - val jComboBox = it.source as JComboBox<*> - SwingUtilities.invokeLater { - if (jComboBox.isPopupVisible) { - fireValueChanged() - return@invokeLater - } - when (val value = jComboBox.selectedItem as String) { - configuration.trueValue, configuration.falseValue -> { - checkbox.isSelected = value == configuration.trueValue - cards.show(this@JEditableCheckBox, CHECKBOX_CARD) - checkbox.requestFocusInWindow() - fireValueChanged() - } - } - } - } - // TODO: trigger value changed when the text is changed } - private val textFieldLabel = JLabel(label).apply { + private val textFieldLabel = JLabel(resourceLocalizer.localize(label)).apply { labelFor = comboBox } + private val expressionButton = JEllipsisButton().apply { + // Tooltip will be set via configuration or use default + toolTipText = configuration.useExpressionTooltip.toString() + addActionListener(useExpressionAction) + } + @Transient private var changeEvent: ChangeEvent? = null @@ -154,6 +151,7 @@ public open class JEditableCheckBox( Container().apply { layout = FlowLayout(FlowLayout.LEADING, 0, 0) add(checkbox) + add(expressionButton) }, CHECKBOX_CARD ) @@ -176,6 +174,7 @@ public open class JEditableCheckBox( super.setEnabled(enabled) checkbox.isEnabled = enabled comboBox.isEnabled = enabled + expressionButton.isEnabled = enabled useExpressionAction.isEnabled = enabled } @@ -190,19 +189,26 @@ public open class JEditableCheckBox( public var value: Value get() = when (components.indexOfFirst { it.isVisible }) { 0 -> if (checkbox.isSelected) Value.Boolean.TRUE else Value.Boolean.FALSE - else -> Value.Text(comboBox.selectedItem as String) + else -> + when (val value = comboBox.selectedItem) { + is ResourceKeyed -> + when (value.resourceKey) { + configuration.trueValue.resourceKey -> Value.Boolean.TRUE + configuration.falseValue.resourceKey -> Value.Boolean.FALSE + else -> Value.Text(value.resourceKey) + } + else -> Value.Text(value?.toString() ?: "") + } } set(value) { when (value) { is Value.Boolean -> { - comboBox.selectedItem = "" checkbox.isSelected = value.value cards.show(this, CHECKBOX_CARD) } is Value.Text -> { - checkbox.isSelected = false - comboBox.selectedItem = value.value + comboBox.selectedItem = PlainValue(value.value) cards.show(this, EDITABLE_CARD) } } @@ -227,6 +233,7 @@ public open class JEditableCheckBox( public fun makeSmall() { JFactory.small(checkbox) + JFactory.small(expressionButton) // We do not make combobox small as the expression migh be hard to read } } diff --git a/src/jorphan/src/main/kotlin/org/apache/jorphan/gui/JEditableComboBox.kt b/src/jorphan/src/main/kotlin/org/apache/jorphan/gui/JEditableComboBox.kt new file mode 100644 index 00000000000..b1a220679c5 --- /dev/null +++ b/src/jorphan/src/main/kotlin/org/apache/jorphan/gui/JEditableComboBox.kt @@ -0,0 +1,229 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.jorphan.gui + +import org.apache.jorphan.locale.ComboBoxValue +import org.apache.jorphan.locale.LocalizedString +import org.apache.jorphan.locale.LocalizedValue +import org.apache.jorphan.locale.PlainValue +import org.apache.jorphan.locale.ResourceKeyed +import org.apache.jorphan.locale.ResourceLocalizer +import org.apiguardian.api.API +import org.jetbrains.annotations.NonNls +import java.awt.Container +import java.awt.FlowLayout +import java.awt.event.ActionEvent +import javax.swing.AbstractAction +import javax.swing.Box +import javax.swing.JComboBox +import javax.swing.JLabel +import javax.swing.JPanel +import javax.swing.JPopupMenu +import javax.swing.event.ChangeEvent + +/** + * A combo box that can display predefined enum values (with localized text) or switch to + * an editable text field for custom expressions like `${__P(property)}`. + * + * This component uses a CardLayout to switch between: + * - A non-editable combo box showing predefined values with localized display text + * - An editable combo box allowing custom text input (expressions) + * + * The component stores resource keys (non-localized) as values, but displays localized + * text to the user via a cell renderer. + * + * Example usage: + * ```kotlin + * val config = JEditableComboBox.Configuration( + * startEditing = JMeterUtils.getResString("editable_combobox_use_expression"), + * values = listOf("option_key_1", "option_key_2"), + * extraValues = listOf("\${__P(my_property)}", "\${variable_name}") + * ) + * val comboBox = JEditableComboBox("Label:", config) + * ``` + * + * @since 6.0.0 + */ +@API(status = API.Status.EXPERIMENTAL, since = "6.0.0") +public open class JEditableComboBox( + label: @NonNls String, + private val configuration: Configuration, + localizer: ResourceLocalizer, +) : JPanel() { + public companion object { + public const val COMBO_CARD: String = "combo" + public const val EDITABLE_CARD: String = "editable" + public const val VALUE_PROPERTY: String = "value" + } + + /** + * Configuration for the editable combo box. + * + * @property useExpression Text for the menu item to switch to editable mode + * @property values List of predefined resource keys (stored values) + * @property extraValues Additional template values to show in editable mode (like expressions) + */ + public data class Configuration( + val useExpression: LocalizedString, + val useExpressionTooltip: LocalizedString, + val values: List>, + val extraValues: List = listOf() + ) + + private val cards = CardLayoutWithSizeOfCurrentVisibleElement() + + private val useExpressionAction = object : AbstractAction(configuration.useExpression.toString()) { + override fun actionPerformed(e: ActionEvent?) { + editableCombo.selectedItem = nonEditableCombo.selectedItem + cards.show(this@JEditableComboBox, EDITABLE_CARD) + editableCombo.requestFocusInWindow() + } + } + + private val nonEditableCombo: JComboBox = JComboBox().apply { + isEditable = false + componentPopupMenu = JPopupMenu().apply { + add(useExpressionAction) + } + + // Add predefined values + configuration.values.forEach { + addItem(it) + } + + addActionListener { + fireValueChanged() + } + } + + private val editableCombo: JComboBox = JComboBox().apply { + isEditable = true + + // Add template expressions first, then predefined values + configuration.extraValues.forEach { + addItem(it) + } + configuration.values.forEach { + addItem(it) + } + } + + private val comboLabel = JLabel(localizer.localize(label)).apply { + labelFor = nonEditableCombo + } + + private val editableLabel = JLabel(localizer.localize(label)).apply { + labelFor = editableCombo + } + + private val expressionButton = JEllipsisButton().apply { + toolTipText = configuration.useExpressionTooltip.toString() + addActionListener(useExpressionAction) + } + + @Transient + private var changeEvent: ChangeEvent? = null + + init { + layout = cards + add( + Container().apply { + layout = FlowLayout(FlowLayout.LEADING, 0, 0) + add(comboLabel) + add(Box.createHorizontalStrut(5)) + add(nonEditableCombo) + add(Box.createHorizontalStrut(3)) + add(expressionButton) + }, + COMBO_CARD + ) + add( + Container().apply { + layout = FlowLayout(FlowLayout.LEADING, 0, 0) + add(editableLabel) + add(Box.createHorizontalStrut(5)) + add(editableCombo) + }, + EDITABLE_CARD + ) + } + + private var oldValue = value + + override fun setEnabled(enabled: Boolean) { + super.setEnabled(enabled) + nonEditableCombo.isEnabled = enabled + editableCombo.isEnabled = enabled + expressionButton.isEnabled = enabled + useExpressionAction.isEnabled = enabled + } + + private fun fireValueChanged() { + val newValue = value + if (value != oldValue) { + firePropertyChange(VALUE_PROPERTY, oldValue, newValue) + oldValue = newValue + } + } + + /** + * Gets or sets the current value (resource key or custom expression). + */ + public var value: ComboBoxValue? + get() = when (components.indexOfFirst { it.isVisible }) { + 0 -> nonEditableCombo.selectedItem as? ComboBoxValue + else -> when (val value = editableCombo.selectedItem) { + is ComboBoxValue -> value + is String -> PlainValue(value) + else -> null + } + } + set(value) { + // The user might provide a free-text value which coincides with a resourceKey + // For instance, it might be the case when loading a value from jmx test plan + val knownValue = value?.let { findKnownValue(it) } + if (knownValue != null) { + // Predefined value - use non-editable combo + nonEditableCombo.selectedItem = knownValue + cards.show(this, COMBO_CARD) + } else { + // Custom expression - use editable combo + editableCombo.selectedItem = value + cards.show(this, EDITABLE_CARD) + } + fireValueChanged() + } + + private fun findKnownValue(value: ComboBoxValue): ComboBoxValue? { + return when (value) { + is PlainValue -> { + // Plain value might match with one of the known resource keys + configuration.values.find { it.value.resourceKey == value.value } + } + else -> { + configuration.values.find { it == value } + } + } + } + + public fun makeSmall() { + JFactory.small(comboLabel) + JFactory.small(editableLabel) + // Note: JFactory.small() doesn't support JComboBox + } +} diff --git a/src/jorphan/src/main/kotlin/org/apache/jorphan/gui/JEllipsisButton.kt b/src/jorphan/src/main/kotlin/org/apache/jorphan/gui/JEllipsisButton.kt new file mode 100644 index 00000000000..6e1f0da4c6d --- /dev/null +++ b/src/jorphan/src/main/kotlin/org/apache/jorphan/gui/JEllipsisButton.kt @@ -0,0 +1,28 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.jorphan.gui + +import javax.swing.plaf.nimbus.NimbusStyle + +public class JEllipsisButton : JSquareButton("⋮") { + init { + putClientProperty("JComponent.sizeVariant", NimbusStyle.SMALL_KEY) + putClientProperty("JButton.square", true) + putClientProperty("JButton.thin", true) + } +} diff --git a/src/jorphan/src/main/kotlin/org/apache/jorphan/gui/JSquareButton.kt b/src/jorphan/src/main/kotlin/org/apache/jorphan/gui/JSquareButton.kt new file mode 100644 index 00000000000..dbf40351dde --- /dev/null +++ b/src/jorphan/src/main/kotlin/org/apache/jorphan/gui/JSquareButton.kt @@ -0,0 +1,29 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.jorphan.gui + +import org.jetbrains.annotations.Nls +import java.awt.Dimension +import javax.swing.JButton + +public open class JSquareButton(title: @Nls String) : JButton(title) { + override fun getPreferredSize(): Dimension { + val dimension = super.getPreferredSize() + return Dimension(dimension.height + 15, dimension.height) + } +} diff --git a/src/jorphan/src/main/kotlin/org/apache/jorphan/locale/ComboBoxValue.kt b/src/jorphan/src/main/kotlin/org/apache/jorphan/locale/ComboBoxValue.kt new file mode 100644 index 00000000000..02ac6fbc174 --- /dev/null +++ b/src/jorphan/src/main/kotlin/org/apache/jorphan/locale/ComboBoxValue.kt @@ -0,0 +1,40 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.jorphan.locale + +import org.apiguardian.api.API + +/** + * Marker interface for values that can be used in a combo box. + * + * This interface is intended to standardize handling of values for GUI components + * like combo boxes. Classes implementing this interface can define how their + * values are represented and localized, providing flexibility for different types + * of combo box content. + * + * Implementers may include: + * - Simple values (e.g., strings or plain objects). + * - Values with additional localization support for display in different locales. + * - Wrapper types that encapsulate richer behaviors for combo box items. + * + * This interface is marked as experimental and may change in future releases. + * + * @since 6.0.0 + */ +@API(status = API.Status.EXPERIMENTAL, since = "6.0.0") +public interface ComboBoxValue diff --git a/src/jorphan/src/main/kotlin/org/apache/jorphan/locale/LocalizedString.kt b/src/jorphan/src/main/kotlin/org/apache/jorphan/locale/LocalizedString.kt new file mode 100644 index 00000000000..97edda99918 --- /dev/null +++ b/src/jorphan/src/main/kotlin/org/apache/jorphan/locale/LocalizedString.kt @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.jorphan.locale + +import org.apiguardian.api.API +import org.jetbrains.annotations.NonNls + +@API(status = API.Status.EXPERIMENTAL, since = "6.0.0") +public class LocalizedString( + override val resourceKey: @NonNls String, + private val resourceLocalizer: ResourceLocalizer, +) : ResourceKeyed, ComboBoxValue { + override fun toString(): String = + resourceLocalizer.localize(resourceKey) + + override fun equals(other: Any?): Boolean = + other is LocalizedString && other.resourceKey == resourceKey + + override fun hashCode(): Int = + resourceKey.hashCode() +} diff --git a/src/jorphan/src/main/kotlin/org/apache/jorphan/locale/LocalizedValue.kt b/src/jorphan/src/main/kotlin/org/apache/jorphan/locale/LocalizedValue.kt new file mode 100644 index 00000000000..46ac72cacb2 --- /dev/null +++ b/src/jorphan/src/main/kotlin/org/apache/jorphan/locale/LocalizedValue.kt @@ -0,0 +1,51 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.jorphan.locale + +import org.apiguardian.api.API + +/** + * Represents a localized value that combines an object implementing the [ResourceKeyed] interface + * with a localization function. This allows the object's resource key to be localized dynamically + * into a string representation. + * + * @param T The type of the value, which must implement the [ResourceKeyed] interface. + * @property value The object implementing [ResourceKeyed], which holds a resource key for localization. + * @property localizer A function that takes a resource key string and returns its localized string representation. + * + * The localized representation of this object is produced by applying the `localizer` function + * to the resource key of the `value`. + * + * The class provides equality and hash code implementations based on the underlying `value`. + * + * @since 6.0.0 + */ +@API(status = API.Status.EXPERIMENTAL, since = "6.0.0") +public class LocalizedValue( + public val value: T, + private val localizer: ResourceLocalizer, +) : ResourceKeyed by value, ComboBoxValue { + override fun toString(): String = + localizer.localize(value.resourceKey) + + override fun equals(other: Any?): Boolean = + other is LocalizedValue<*> && other.value == value + + override fun hashCode(): Int = + value.hashCode() +} diff --git a/src/jorphan/src/main/kotlin/org/apache/jorphan/locale/PlainValue.kt b/src/jorphan/src/main/kotlin/org/apache/jorphan/locale/PlainValue.kt new file mode 100644 index 00000000000..8523be8769a --- /dev/null +++ b/src/jorphan/src/main/kotlin/org/apache/jorphan/locale/PlainValue.kt @@ -0,0 +1,26 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.jorphan.locale + +import org.apiguardian.api.API + +@JvmInline +@API(status = API.Status.EXPERIMENTAL, since = "6.0.0") +public value class PlainValue(public val value: String): ComboBoxValue { + override fun toString(): String = value +} diff --git a/src/jorphan/src/main/kotlin/org/apache/jorphan/locale/ResourceKeyed.kt b/src/jorphan/src/main/kotlin/org/apache/jorphan/locale/ResourceKeyed.kt new file mode 100644 index 00000000000..eaaca37e81e --- /dev/null +++ b/src/jorphan/src/main/kotlin/org/apache/jorphan/locale/ResourceKeyed.kt @@ -0,0 +1,63 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.jorphan.locale + +import org.apiguardian.api.API +import org.jetbrains.annotations.NonNls + +/** + * Interface for enum types that provide their own localization resource key for GUI display. + * + * When an enum implements this interface, the [resourceKey] will be used for: + * - Storing the value in JMeter properties (instead of the enum name) + * - Looking up localized display text via `JMeterUtils.getResString(resourceKey)` + * - Binding to GUI components + * + * This allows enums to have stable, localization-friendly identifiers that are independent + * of the enum constant names. + * + * Example: + * ```java + * public enum ResponseMode implements ResourceKeyed { + * STORE_COMPRESSED("response_mode_store"), + * FETCH_DISCARD("response_mode_discard"); + * + * private final String key; + * ResponseMode(String key) { this.key = key; } + * + * @Override + * public String getResourceKey() { return key; } + * } + * ``` + * + * @since 6.0.0 + */ +@API(status = API.Status.EXPERIMENTAL, since = "6.0.0") +public interface ResourceKeyed { + /** + * Returns the resource key used for localization and property storage. + * + * This key should: + * - Be stable across JMeter versions (don't change it) + * - Have a corresponding entry in messages.properties + * - Use lowercase with underscores by convention + * + * @return the resource key for this enum value + */ + public val resourceKey: @NonNls String +} diff --git a/src/jorphan/src/main/kotlin/org/apache/jorphan/locale/ResourceLocalizer.kt b/src/jorphan/src/main/kotlin/org/apache/jorphan/locale/ResourceLocalizer.kt new file mode 100644 index 00000000000..05bea83838a --- /dev/null +++ b/src/jorphan/src/main/kotlin/org/apache/jorphan/locale/ResourceLocalizer.kt @@ -0,0 +1,40 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.jorphan.locale + +import org.apiguardian.api.API +import org.jetbrains.annotations.Nls +import org.jetbrains.annotations.NonNls + +/** + * Functional interface for localizing resource strings. + * + * This interface provides a single method, [localize], which takes a non-localized input string + * and returns its localized representation. Implementations of this interface define how the + * localization is performed, such as looking up translations in a resource bundle or applying + * other localization mechanisms. + * + * The input string is expected to be a resource identifier or key, and the output is a string + * appropriate for display in a user interface or other localized context. + * + * @since 6.0.0 + */ +@API(status = API.Status.EXPERIMENTAL, since = "6.0.0") +public fun interface ResourceLocalizer { + public fun localize(input: @NonNls String): @Nls String +} diff --git a/src/jorphan/src/main/kotlin/org/apache/jorphan/util/EnumUtils.kt b/src/jorphan/src/main/kotlin/org/apache/jorphan/util/EnumUtils.kt index ecee2105e5d..27213ee68f7 100644 --- a/src/jorphan/src/main/kotlin/org/apache/jorphan/util/EnumUtils.kt +++ b/src/jorphan/src/main/kotlin/org/apache/jorphan/util/EnumUtils.kt @@ -18,6 +18,7 @@ @file:JvmName("EnumUtils") package org.apache.jorphan.util +import org.apache.jorphan.locale.ResourceKeyed import org.apiguardian.api.API import java.util.Collections.unmodifiableList import java.util.Collections.unmodifiableMap @@ -39,34 +40,30 @@ private val VALUE_MAP = object : ClassValue>>() { require(type.isEnum) { "Class $type is not an enum" } - @Suppress("UNCHECKED_CAST") - type as Class> return unmodifiableMap( - VALUES.get(type).associateBy { it.stringValue } + VALUES.get(type).associateBy { + (it as ResourceKeyed).resourceKey + } ) } } @Suppress("UNCHECKED_CAST") -@API(status = API.Status.EXPERIMENTAL, since = "6.0.0") -public fun > values(klass: Class): List = - VALUES.get(klass) as List +@get:API(status = API.Status.EXPERIMENTAL, since = "6.0.0") +public val > Class.enumValues: List + get() = VALUES.get(this) as List @Suppress("UNCHECKED_CAST") -@API(status = API.Status.EXPERIMENTAL, since = "6.0.0") -public fun > valueMap(klass: Class): List = - VALUES.get(klass) as List +@get:API(status = API.Status.EXPERIMENTAL, since = "6.0.0") +public val Class.enumValueMap: Map where T : Enum<*>, T : ResourceKeyed + get() = VALUE_MAP.get(this) as Map @Suppress("UNCHECKED_CAST") @API(status = API.Status.EXPERIMENTAL, since = "6.0.0") -public inline fun > valueOf(value: String): T? = - valueOf(T::class.java, value) +public inline fun valueOf(value: String): T? where T : Enum<*>, T : ResourceKeyed = + T::class.java.valueOf(value) @Suppress("UNCHECKED_CAST") @API(status = API.Status.EXPERIMENTAL, since = "6.0.0") -public fun > valueOf(klass: Class, value: String): T? = - VALUE_MAP.get(klass)[value] as T? - -@get:API(status = API.Status.EXPERIMENTAL, since = "6.0.0") -public val Enum<*>.stringValue: String - get() = toString() +public fun Class.valueOf(value: String): T? where T : Enum<*>, T : ResourceKeyed = + enumValueMap[value] diff --git a/src/protocol/http/build.gradle.kts b/src/protocol/http/build.gradle.kts index af6b3482f5d..7f2d78fa474 100644 --- a/src/protocol/http/build.gradle.kts +++ b/src/protocol/http/build.gradle.kts @@ -63,10 +63,12 @@ dependencies { implementation("dnsjava:dnsjava") implementation("org.apache.httpcomponents:httpmime") implementation("org.apache.httpcomponents:httpcore") - implementation("org.brotli:dec") implementation("com.miglayout:miglayout-swing") implementation("com.fasterxml.jackson.core:jackson-core") implementation("com.fasterxml.jackson.core:jackson-databind") + implementation("org.brotli:dec") { + because("BrotliDecoder for HTTP response decompression") + } testImplementation(testFixtures(projects.src.core)) testImplementation(testFixtures(projects.src.testkitWiremock)) testImplementation("org.wiremock:wiremock") diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/config/gui/HttpDefaultsGui.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/config/gui/HttpDefaultsGui.java index 8b49bcbb4ec..ae8a93272be 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/config/gui/HttpDefaultsGui.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/config/gui/HttpDefaultsGui.java @@ -33,6 +33,7 @@ import org.apache.jmeter.config.gui.AbstractConfigGui; import org.apache.jmeter.gui.GUIMenuSortOrder; import org.apache.jmeter.gui.JBooleanPropertyEditor; +import org.apache.jmeter.gui.JEnumPropertyEditor; import org.apache.jmeter.gui.JTextComponentBinding; import org.apache.jmeter.gui.TestElementMetadata; import org.apache.jmeter.gui.util.HorizontalPanel; @@ -61,14 +62,20 @@ public class HttpDefaultsGui extends AbstractConfigGui { private UrlConfigGui urlConfigGui; private final JBooleanPropertyEditor retrieveEmbeddedResources = new JBooleanPropertyEditor( HTTPSamplerBaseSchema.INSTANCE.getRetrieveEmbeddedResources(), - JMeterUtils.getResString("web_testing_retrieve_images")); + "web_testing_retrieve_images", + JMeterUtils::getResString); private final JBooleanPropertyEditor concurrentDwn = new JBooleanPropertyEditor( HTTPSamplerBaseSchema.INSTANCE.getConcurrentDownload(), - JMeterUtils.getResString("web_testing_concurrent_download")); + "web_testing_concurrent_download", + JMeterUtils::getResString); private JTextField concurrentPool; - private final JBooleanPropertyEditor useMD5 = new JBooleanPropertyEditor( - HTTPSamplerBaseSchema.INSTANCE.getStoreAsMD5(), - JMeterUtils.getResString("response_save_as_md5")); // $NON-NLS-1$ + private final JEnumPropertyEditor responseProcessingMode = + new JEnumPropertyEditor<>( + HTTPSamplerBaseSchema.INSTANCE.getResponseProcessingMode(), + "response_processing_mode", + HTTPSamplerBase.ResponseProcessingMode.class, + JMeterUtils::getResString + ); private JTextField embeddedAllowRE; // regular expression used to match against embedded resource URLs to allow private JTextField embeddedExcludeRE; // regular expression used to match against embedded resource URLs to discard private JTextField sourceIpAddr; // does not apply to Java implementation @@ -91,7 +98,7 @@ public HttpDefaultsGui() { retrieveEmbeddedResources, concurrentDwn, new JTextComponentBinding(concurrentPool, schema.getConcurrentDownloadPoolSize()), - useMD5, + responseProcessingMode, new JTextComponentBinding(embeddedAllowRE, schema.getEmbeddedUrlAllowRegex()), new JTextComponentBinding(embeddedExcludeRE, schema.getEmbeddedUrlExcludeRegex()), new JTextComponentBinding(sourceIpAddr, schema.getIpSource()), @@ -188,7 +195,7 @@ private void init() { // WARNING: called from ctor so must not be overridden (i. advancedPanel.add(createEmbeddedRsrcPanel()); advancedPanel.add(createSourceAddrPanel()); advancedPanel.add(getProxyServerPanel()); - advancedPanel.add(createOptionalTasksPanel()); + advancedPanel.add(createResponseProcessingPanel()); JTabbedPane tabbedPane = new JTabbedPane(); tabbedPane.add(JMeterUtils @@ -294,13 +301,12 @@ protected JPanel createSourceAddrPanel() { return sourceAddrPanel; } - protected JPanel createOptionalTasksPanel() { - // OPTIONAL TASKS - final JPanel checkBoxPanel = new VerticalPanel(); - checkBoxPanel.setBorder(BorderFactory.createTitledBorder( - JMeterUtils.getResString("optional_tasks"))); // $NON-NLS-1$ - checkBoxPanel.add(useMD5); - return checkBoxPanel; + protected JPanel createResponseProcessingPanel() { + JPanel panel = new JPanel(new MigLayout()); + panel.setBorder(BorderFactory.createTitledBorder( + JMeterUtils.getResString("response_processing_title"))); // $NON-NLS-1$ + panel.add(responseProcessingMode); + return panel; } @Override diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/config/gui/UrlConfigGui.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/config/gui/UrlConfigGui.java index 2cb82f654c8..055a81551b6 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/config/gui/UrlConfigGui.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/config/gui/UrlConfigGui.java @@ -380,19 +380,22 @@ protected Component getPathPanel() { useKeepAlive = new JBooleanPropertyEditor( HTTPSamplerBaseSchema.INSTANCE.getUseKeepalive(), - JMeterUtils.getResString("use_keepalive")); + "use_keepalive", + JMeterUtils::getResString); JFactory.small(useKeepAlive); useKeepAlive.setVisible(getUrlConfigDefaults().isUseKeepAliveVisible()); useMultipart = new JBooleanPropertyEditor( HTTPSamplerBaseSchema.INSTANCE.getUseMultipartPost(), - JMeterUtils.getResString("use_multipart_for_http_post")); // $NON-NLS-1$ + "use_multipart_for_http_post", + JMeterUtils::getResString); JFactory.small(useMultipart); useMultipart.setVisible(getUrlConfigDefaults().isUseMultipartVisible()); useBrowserCompatibleMultipartMode = new JBooleanPropertyEditor( HTTPSamplerBaseSchema.INSTANCE.getUseBrowserCompatibleMultipart(), - JMeterUtils.getResString("use_multipart_mode_browser")); // $NON-NLS-1$ + "use_multipart_mode_browser", + JMeterUtils::getResString); JFactory.small(useBrowserCompatibleMultipartMode); useBrowserCompatibleMultipartMode.setVisible(getUrlConfigDefaults().isUseBrowserCompatibleMultipartModeVisible()); } diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/CookieManager.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/CookieManager.java index a810405de86..2a2b936fe50 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/CookieManager.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/CookieManager.java @@ -126,6 +126,16 @@ public Object clone(){ return clone; } + /** {@inheritDoc} */ + @Override + public Object lightweightClone() { + CookieManager clone = (CookieManager) super.lightweightClone(); + // Copy transient fields that are initialized in testStarted() + clone.initialCookies = initialCookies; + clone.cookieHandler = cookieHandler; + return clone; + } + public String getPolicy() { return getPropertyAsString(POLICY, DEFAULT_POLICY); } diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/DNSCacheManager.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/DNSCacheManager.java index 7af898b28d5..834dc126c5d 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/DNSCacheManager.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/DNSCacheManager.java @@ -116,6 +116,16 @@ public Object clone() { return clone; } + /** + * {@inheritDoc} + */ + @Override + public Object lightweightClone() { + DNSCacheManager clone = (DNSCacheManager) super.lightweightClone(); + clone.resolver = createResolver(); + return clone; + } + @VisibleForTesting Resolver getResolver() { return resolver; diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/gui/HttpTestSampleGui.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/gui/HttpTestSampleGui.java index 77863a286bd..c195930cf76 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/gui/HttpTestSampleGui.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/gui/HttpTestSampleGui.java @@ -32,6 +32,7 @@ import org.apache.jmeter.gui.GUIMenuSortOrder; import org.apache.jmeter.gui.JBooleanPropertyEditor; +import org.apache.jmeter.gui.JEnumPropertyEditor; import org.apache.jmeter.gui.JTextComponentBinding; import org.apache.jmeter.gui.TestElementMetadata; import org.apache.jmeter.gui.util.HorizontalPanel; @@ -63,14 +64,20 @@ public class HttpTestSampleGui extends AbstractSamplerGui { private UrlConfigGui urlConfigGui; private final JBooleanPropertyEditor retrieveEmbeddedResources = new JBooleanPropertyEditor( HTTPSamplerBaseSchema.INSTANCE.getRetrieveEmbeddedResources(), - JMeterUtils.getResString("web_testing_retrieve_images")); + "web_testing_retrieve_images", + JMeterUtils::getResString); private final JBooleanPropertyEditor concurrentDwn = new JBooleanPropertyEditor( HTTPSamplerBaseSchema.INSTANCE.getConcurrentDownload(), - JMeterUtils.getResString("web_testing_concurrent_download")); + "web_testing_concurrent_download", + JMeterUtils::getResString); private JTextField concurrentPool; - private final JBooleanPropertyEditor useMD5 = new JBooleanPropertyEditor( - HTTPSamplerBaseSchema.INSTANCE.getStoreAsMD5(), - JMeterUtils.getResString("response_save_as_md5")); // $NON-NLS-1$ + private final JEnumPropertyEditor responseProcessingMode = + new JEnumPropertyEditor<>( + HTTPSamplerBaseSchema.INSTANCE.getResponseProcessingMode(), + "response_processing_mode", + HTTPSamplerBase.ResponseProcessingMode.class, + JMeterUtils::getResString + ); private JTextField embeddedAllowRE; // regular expression used to match against embedded resource URLs to allow private JTextField embeddedExcludeRE; // regular expression used to match against embedded resource URLs to exclude private JTextField sourceIpAddr; // does not apply to Java implementation @@ -100,7 +107,7 @@ protected HttpTestSampleGui(boolean ajp) { retrieveEmbeddedResources, concurrentDwn, new JTextComponentBinding(concurrentPool, schema.getConcurrentDownloadPoolSize()), - useMD5, + responseProcessingMode, new JTextComponentBinding(embeddedAllowRE, schema.getEmbeddedUrlAllowRegex()), new JTextComponentBinding(embeddedExcludeRE, schema.getEmbeddedUrlExcludeRegex()) ) @@ -257,7 +264,7 @@ private JPanel createAdvancedConfigPanel() { advancedPanel.add(getProxyServerPanel()); } - advancedPanel.add(createOptionalTasksPanel()); + advancedPanel.add(createResponseProcessingPanel()); return advancedPanel; } @@ -352,15 +359,12 @@ protected final JPanel getImplementationPanel(){ return implPanel; } - protected JPanel createOptionalTasksPanel() { - // OPTIONAL TASKS - final JPanel checkBoxPanel = new VerticalPanel(); - checkBoxPanel.setBorder(BorderFactory.createTitledBorder( - JMeterUtils.getResString("optional_tasks"))); // $NON-NLS-1$ - - checkBoxPanel.add(useMD5); - - return checkBoxPanel; + protected JPanel createResponseProcessingPanel() { + JPanel panel = new JPanel(new MigLayout()); + panel.setBorder(BorderFactory.createTitledBorder( + JMeterUtils.getResString("response_processing_title"))); // $NON-NLS-1$ + panel.add(responseProcessingMode, "span"); + return panel; } @SuppressWarnings("EnumOrdinal") diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/AccessLogSampler.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/AccessLogSampler.java index 6e4ed9188bf..14c396baeb0 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/AccessLogSampler.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/AccessLogSampler.java @@ -322,6 +322,18 @@ public Object clone() { AccessLogSampler s = (AccessLogSampler) super.clone(); if (started && StringUtilities.isNotBlank(filterClassName)) { + /** + * {@inheritDoc} + */ + @Override + public Object lightweightClone() { + AccessLogSampler s = (AccessLogSampler) super.lightweightClone(); + cloneFilterAndParser(s); + return s; + } + + private void cloneFilterAndParser(AccessLogSampler s) { + if (started && StringUtilities.isNotBlank(filterClassName)) { try { if (TestCloneable.class.isAssignableFrom(Class.forName(filterClassName))) { initFilter(); @@ -340,7 +352,6 @@ public Object clone() { log.warn("Could not clone cloneable filter", e); } } - return s; } /** diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPAbstractImpl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPAbstractImpl.java index 9c724a01727..7d4ead5dfe4 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPAbstractImpl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPAbstractImpl.java @@ -17,7 +17,6 @@ package org.apache.jmeter.protocol.http.sampler; -import java.io.BufferedInputStream; import java.io.IOException; import java.io.InputStream; import java.net.Inet4Address; @@ -28,6 +27,7 @@ import java.net.SocketException; import java.net.URL; import java.net.UnknownHostException; +import java.util.List; import java.util.function.Predicate; import org.apache.jmeter.config.Arguments; @@ -42,6 +42,8 @@ import org.apache.jmeter.samplers.Interruptible; import org.apache.jmeter.samplers.SampleResult; import org.apache.jmeter.util.JMeterUtils; +import org.apache.jorphan.util.EnumUtils; +import org.jspecify.annotations.Nullable; /** * Base class for HTTP implementations used by the HTTPSamplerProxy sampler. @@ -235,7 +237,8 @@ protected InetAddress getIpSourceAddress() throws UnknownHostException, SocketEx final String ipSource = getIpSource(); if (!ipSource.isBlank()) { Class ipClass = null; - final SourceType sourceType = HTTPSamplerBase.SourceType.values()[testElement.getIpSourceType()]; + List sourceTypes = EnumUtils.getEnumValues(SourceType.class); + final SourceType sourceType = sourceTypes.get(testElement.getIpSourceType()); switch (sourceType) { case DEVICE -> ipClass = InetAddress.class; case DEVICE_IPV4 -> ipClass = Inet4Address.class; @@ -428,7 +431,7 @@ protected boolean isSuccessCode(int errorLevel) { * Closes the inputStream *

* Invokes - * {@link HTTPSamplerBase#readResponse(SampleResult, InputStream, long)} + * {@link HTTPSamplerBase#readResponse(SampleResult, InputStream, long, String)} * * @param res * sample to store information about the response into @@ -436,93 +439,12 @@ protected boolean isSuccessCode(int errorLevel) { * input stream from which to read the response * @param responseContentLength * expected input length or zero - * @return the response or the MD5 of the response * @throws IOException * if reading the result fails */ - protected byte[] readResponse(SampleResult res, InputStream instream, - int responseContentLength) throws IOException { - return readResponse(res, instream, (long)responseContentLength); - } - /** - * Read response from the input stream, converting to MD5 digest if the - * useMD5 property is set. - *

- * For the MD5 case, the result byte count is set to the size of the - * original response. - *

- * Closes the inputStream - *

- * Invokes - * {@link HTTPSamplerBase#readResponse(SampleResult, InputStream, long)} - * - * @param res - * sample to store information about the response into - * @param instream - * input stream from which to read the response - * @param responseContentLength - * expected input length or zero - * @return the response or the MD5 of the response - * @throws IOException - * if reading the result fails - */ - protected byte[] readResponse(SampleResult res, InputStream instream, - long responseContentLength) throws IOException { - return testElement.readResponse(res, instream, responseContentLength); - } - - /** - * Read response from the input stream, converting to MD5 digest if the - * useMD5 property is set. - *

- * For the MD5 case, the result byte count is set to the size of the - * original response. - *

- * Closes the inputStream - *

- * Invokes {@link HTTPSamplerBase#readResponse(SampleResult, InputStream, long)} - * - * @param res - * sample to store information about the response into - * @param in - * input stream from which to read the response - * @param contentLength - * expected input length or zero - * @return the response or the MD5 of the response - * @throws IOException - * when reading the result fails - * @deprecated use {@link HTTPAbstractImpl#readResponse(SampleResult, BufferedInputStream, long)} - */ - @Deprecated - protected byte[] readResponse(SampleResult res, BufferedInputStream in, - int contentLength) throws IOException { - return testElement.readResponse(res, in, contentLength); - } - - /** - * Read response from the input stream, converting to MD5 digest if the - * useMD5 property is set. - *

- * For the MD5 case, the result byte count is set to the size of the - * original response. - *

- * Closes the inputStream - *

- * Invokes {@link HTTPSamplerBase#readResponse(SampleResult, InputStream, long)} - * - * @param res - * sample to store information about the response into - * @param in - * input stream from which to read the response - * @param contentLength - * expected input length or zero - * @return the response or the MD5 of the response - * @throws IOException - * when reading the result fails - */ - protected byte[] readResponse(SampleResult res, BufferedInputStream in, - long contentLength) throws IOException { - return testElement.readResponse(res, in, contentLength); + protected void readResponse(SampleResult res, InputStream instream, + long responseContentLength, @Nullable String contentEncoding) throws IOException { + testElement.readResponse(res, instream, responseContentLength, contentEncoding); } /** diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC4Impl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC4Impl.java index 569bb164b6a..11db6bcb0e9 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC4Impl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC4Impl.java @@ -20,6 +20,7 @@ import java.io.ByteArrayOutputStream; import java.io.File; import java.io.IOException; +import java.io.InputStream; import java.io.OutputStream; import java.io.UnsupportedEncodingException; import java.net.InetAddress; @@ -55,7 +56,6 @@ import org.apache.http.HttpRequest; import org.apache.http.HttpRequestInterceptor; import org.apache.http.HttpResponse; -import org.apache.http.HttpResponseInterceptor; import org.apache.http.NameValuePair; import org.apache.http.StatusLine; import org.apache.http.auth.AuthSchemeProvider; @@ -70,7 +70,6 @@ import org.apache.http.client.config.AuthSchemes; import org.apache.http.client.config.CookieSpecs; import org.apache.http.client.config.RequestConfig; -import org.apache.http.client.entity.InputStreamFactory; import org.apache.http.client.entity.UrlEncodedFormEntity; import org.apache.http.client.methods.CloseableHttpResponse; import org.apache.http.client.methods.HttpEntityEnclosingRequestBase; @@ -84,7 +83,6 @@ import org.apache.http.client.methods.HttpTrace; import org.apache.http.client.methods.HttpUriRequest; import org.apache.http.client.protocol.HttpClientContext; -import org.apache.http.client.protocol.ResponseContentEncoding; import org.apache.http.config.Lookup; import org.apache.http.config.Registry; import org.apache.http.config.RegistryBuilder; @@ -146,8 +144,6 @@ import org.apache.jmeter.protocol.http.control.DynamicKerberosSchemeFactory; import org.apache.jmeter.protocol.http.control.DynamicSPNegoSchemeFactory; import org.apache.jmeter.protocol.http.control.HeaderManager; -import org.apache.jmeter.protocol.http.sampler.hc.LaxDeflateInputStream; -import org.apache.jmeter.protocol.http.sampler.hc.LaxGZIPInputStream; import org.apache.jmeter.protocol.http.sampler.hc.LazyLayeredConnectionSocketFactory; import org.apache.jmeter.protocol.http.util.ConversionUtils; import org.apache.jmeter.protocol.http.util.HTTPArgument; @@ -165,7 +161,6 @@ import org.apache.jmeter.util.SSLManager; import org.apache.jorphan.util.JOrphanUtils; import org.apache.jorphan.util.StringUtilities; -import org.brotli.dec.BrotliInputStream; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -194,20 +189,8 @@ public class HTTPHC4Impl extends HTTPHCAbstractImpl { private static final boolean DISABLE_DEFAULT_UA = JMeterUtils.getPropDefault("httpclient4.default_user_agent_disabled", false); - private static final boolean GZIP_RELAX_MODE = JMeterUtils.getPropDefault("httpclient4.gzip_relax_mode", false); - - private static final boolean DEFLATE_RELAX_MODE = JMeterUtils.getPropDefault("httpclient4.deflate_relax_mode", false); - private static final Logger log = LoggerFactory.getLogger(HTTPHC4Impl.class); - private static final InputStreamFactory GZIP = - instream -> new LaxGZIPInputStream(instream, GZIP_RELAX_MODE); - - private static final InputStreamFactory DEFLATE = - instream -> new LaxDeflateInputStream(instream, DEFLATE_RELAX_MODE); - - private static final InputStreamFactory BROTLI = BrotliInputStream::new; - private static final class ManagedCredentialsProvider implements CredentialsProvider { private final AuthManager authManager; private final Credentials proxyCredentials; @@ -464,55 +447,6 @@ protected HttpResponse doSendRequest( } }; - private static final String[] HEADERS_TO_SAVE = new String[]{ - "content-length", - "content-encoding", - "content-md5" - }; - - /** - * Custom implementation that backups headers related to Compressed responses - * that HC core {@link ResponseContentEncoding} removes after uncompressing - * See Bug 59401 - */ - @SuppressWarnings("UnnecessaryAnonymousClass") - private static final HttpResponseInterceptor RESPONSE_CONTENT_ENCODING = new ResponseContentEncoding(createLookupRegistry()) { - @Override - public void process(HttpResponse response, HttpContext context) - throws HttpException, IOException { - ArrayList headersToSave = null; - - final HttpEntity entity = response.getEntity(); - final HttpClientContext clientContext = HttpClientContext.adapt(context); - final RequestConfig requestConfig = clientContext.getRequestConfig(); - // store the headers if necessary - if (requestConfig.isContentCompressionEnabled() && entity != null && entity.getContentLength() != 0) { - final Header ceheader = entity.getContentEncoding(); - if (ceheader != null) { - headersToSave = new ArrayList<>(3); - for(String name : HEADERS_TO_SAVE) { - Header[] hdr = response.getHeaders(name); // empty if none - headersToSave.add(hdr); - } - } - } - - // Now invoke original parent code - super.process(response, clientContext); - // Should this be in a finally ? - if(headersToSave != null) { - for (Header[] headers : headersToSave) { - for (Header headerToRestore : headers) { - if (response.containsHeader(headerToRestore.getName())) { - break; - } - response.addHeader(headerToRestore); - } - } - } - } - }; - /** * 1 HttpClient instance per combination of (HttpClient,HttpClientKey) */ @@ -549,19 +483,6 @@ protected HTTPHC4Impl(HTTPSamplerBase testElement) { super(testElement); } - /** - * Customize to plug Brotli - * @return {@link Lookup} - */ - private static Lookup createLookupRegistry() { - return - RegistryBuilder.create() - .register("br", BROTLI) - .register("gzip", GZIP) - .register("x-gzip", GZIP) - .register("deflate", DEFLATE).build(); - } - /** * Implementation that allows GET method to have a body */ @@ -665,8 +586,10 @@ protected HTTPSampleResult sample(URL url, String method, res.setEncodingAndType(ct); } HttpEntity entity = httpResponse.getEntity(); - if (entity != null) { - res.setResponseData(readResponse(res, entity.getContent(), entity.getContentLength())); + try (InputStream instream = entity.getContent()) { + Header contentEncodingHeader = entity.getContentEncoding(); + String contentEncoding = contentEncodingHeader != null ? contentEncodingHeader.getValue() : null; + readResponse(res, instream, entity.getContentLength(), contentEncoding); } res.sampleEnd(); // Done with the sampling proper. @@ -1147,7 +1070,7 @@ private HttpClientState setupClient(HttpClientKey key, JMeterVariables jMeterVar } builder.setDefaultCredentialsProvider(credsProvider); } - builder.disableContentCompression().addInterceptorLast(RESPONSE_CONTENT_ENCODING); + builder.disableContentCompression(); // Disable automatic decompression if(BASIC_AUTH_PREEMPTIVE) { builder.addInterceptorFirst(PREEMPTIVE_AUTH_INTERCEPTOR); } else { diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java index 527ed485aad..f2505416101 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java @@ -31,7 +31,6 @@ import java.util.List; import java.util.Map; import java.util.function.Predicate; -import java.util.zip.GZIPInputStream; import org.apache.jmeter.protocol.http.control.AuthManager; import org.apache.jmeter.protocol.http.control.Authorization; @@ -219,15 +218,11 @@ protected HttpURLConnection setupConnection(URL u, String method, HTTPSampleResu /** * Reads the response from the URL connection. * - * @param conn - * URL from which to read response - * @param res - * {@link SampleResult} to read response into - * @return response content - * @exception IOException - * if an I/O exception occurs + * @param res {@link SampleResult} to read response into + * @param conn URL from which to read response + * @throws IOException if an I/O exception occurs */ - protected byte[] readResponse(HttpURLConnection conn, SampleResult res) throws IOException { + protected void readResponse(SampleResult res, HttpURLConnection conn) throws IOException { InputStream in; final long contentLength = conn.getContentLength(); @@ -236,26 +231,19 @@ protected byte[] readResponse(HttpURLConnection conn, SampleResult res) throws I log.info("Content-Length: 0, not reading http-body"); res.setResponseHeaders(getResponseHeaders(conn)); res.latencyEnd(); - return NULL_BA; + res.setResponseData(NULL_BA); + return; } - // works OK even if ContentEncoding is null - boolean gzipped = HTTPConstants.ENCODING_GZIP.equals(conn.getContentEncoding()); - - CountingInputStream instream = null; + CountingInputStream counterStream = null; try { - instream = new CountingInputStream(conn.getInputStream()); - if (gzipped) { - in = new GZIPInputStream(instream); - } else { - in = instream; - } + counterStream = new CountingInputStream(conn.getInputStream()); + in = counterStream; } catch (IOException e) { - if (! (e.getCause() instanceof FileNotFoundException)) - { + if (!(e.getCause() instanceof FileNotFoundException)) { log.error("readResponse: {}", e.toString()); Throwable cause = e.getCause(); - if (cause != null){ + if (cause != null) { log.error("Cause: {}", cause.toString()); if(cause instanceof Error error) { throw error; @@ -270,36 +258,21 @@ protected byte[] readResponse(HttpURLConnection conn, SampleResult res) throws I } res.setResponseHeaders(getResponseHeaders(conn)); res.latencyEnd(); - return NULL_BA; + res.setResponseData(NULL_BA); + return; } if(log.isInfoEnabled()) { log.info("Error Response Code: {}", conn.getResponseCode()); } - if (gzipped) { - in = new GZIPInputStream(errorStream); - } else { - in = errorStream; - } - } catch (Exception e) { - log.error("readResponse: {}", e.toString()); - Throwable cause = e.getCause(); - if (cause != null){ - log.error("Cause: {}", cause.toString()); - if(cause instanceof Error error) { - throw error; - } - } - in = conn.getErrorStream(); + in = errorStream; } - // N.B. this closes 'in' - byte[] responseData = readResponse(res, in, contentLength); - if (instream != null) { - res.setBodySize(instream.getBytesRead()); - instream.close(); + + readResponse(res, in, contentLength, conn.getContentEncoding()); + if (counterStream != null) { + res.setBodySize(counterStream.getBytesRead()); } - return responseData; } /** @@ -565,15 +538,10 @@ protected HTTPSampleResult sample(URL url, String method, boolean areFollowingRe res.setQueryString(putBody); } // Request sent. Now get the response: - byte[] responseData = readResponse(conn, res); + readResponse(res, conn); res.sampleEnd(); // Done with the sampling proper. - - // Now collect the results into the HTTPSampleResult: - - res.setResponseData(responseData); - int errorLevel = conn.getResponseCode(); String respMsg = conn.getResponseMessage(); String hdr=conn.getHeaderField(0); diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPSamplerBase.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPSamplerBase.java index 6c3ef08bdd3..384b8a5aa9c 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPSamplerBase.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPSamplerBase.java @@ -17,7 +17,6 @@ package org.apache.jmeter.protocol.http.sampler; -import java.io.Closeable; import java.io.IOException; import java.io.InputStream; import java.io.Serializable; @@ -69,11 +68,13 @@ import org.apache.jmeter.report.utils.MetricUtils; import org.apache.jmeter.samplers.AbstractSampler; import org.apache.jmeter.samplers.Entry; +import org.apache.jmeter.samplers.ResponseDecoderRegistry; import org.apache.jmeter.samplers.SampleResult; import org.apache.jmeter.testelement.TestElement; import org.apache.jmeter.testelement.TestIterationListener; import org.apache.jmeter.testelement.TestStateListener; import org.apache.jmeter.testelement.ThreadListener; +import org.apache.jmeter.testelement.property.BooleanProperty; import org.apache.jmeter.testelement.property.CollectionProperty; import org.apache.jmeter.testelement.property.JMeterProperty; import org.apache.jmeter.testelement.schema.PropertiesAccessor; @@ -82,13 +83,15 @@ import org.apache.jmeter.threads.JMeterContextService; import org.apache.jmeter.util.JMeterUtils; import org.apache.jorphan.io.DirectAccessByteArrayOutputStream; +import org.apache.jorphan.locale.ResourceKeyed; +import org.apache.jorphan.util.EnumUtils; import org.apache.jorphan.util.ExceptionUtils; import org.apache.jorphan.util.JOrphanUtils; import org.apache.jorphan.util.StringUtilities; import org.apache.oro.text.MalformedCachePatternException; import org.apache.oro.text.regex.Pattern; import org.apache.oro.text.regex.Perl5Matcher; -import org.jetbrains.annotations.Nullable; +import org.jspecify.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -206,6 +209,7 @@ public abstract class HTTPSamplerBase extends AbstractSampler private static final boolean IGNORE_FAILED_EMBEDDED_RESOURCES = JMeterUtils.getPropDefault("httpsampler.ignore_failed_embedded_resources", false); // $NON-NLS-1$ // default value: false + // TODO: replace with responseProcessingMode enum? private static final boolean IGNORE_EMBEDDED_RESOURCES_DATA = JMeterUtils.getPropDefault("httpsampler.embedded_resources_use_md5", false); // $NON-NLS-1$ // default value: false @@ -377,6 +381,54 @@ public static String[] getSourceTypeList() { } return displayStrings; } + + /** + * Enum for response processing modes that control how HTTP response data is handled. + * Supports different strategies for storing, discarding, or checksumming responses. + * + * @since 6.0.0 + */ + public enum ResponseProcessingMode implements ResourceKeyed { + /** + * Store compressed response data, decompress on-demand when accessed. + * Default mode for normal operation. Saves memory and supports lazy decompression. + */ + STORE_COMPRESSED("response_processing_store_compressed"), //$NON-NLS-1$ + + /** + * Fetch response data but discard it immediately. + * Useful when you only care about response code/headers, not the body. + * Avoids storing large responses in memory. + */ + FETCH_AND_DISCARD("response_processing_fetch_discard"), //$NON-NLS-1$ + + /** + * Compute MD5 checksum on the compressed response stream. + * Stores MD5 hash instead of full response. Useful for validating + * that compressed data hasn't been modified in transit. + */ + CHECKSUM_ENCODED_MD5("response_processing_checksum_encoded_md5"), //$NON-NLS-1$ + + /** + * Compute MD5 checksum on the decompressed response stream. + * Stores MD5 hash instead of full response. Uses streaming decompression + * to avoid buffering entire response in memory. This is the traditional + * "Store as MD5" mode from earlier versions. + */ + CHECKSUM_DECODED_MD5("response_processing_checksum_decoded_md5"); //$NON-NLS-1$ + + public final String propertyName; + + ResponseProcessingMode(String propertyName) { + this.propertyName = propertyName; + } + + @Override + public String getResourceKey() { + return propertyName; + } + } + /** * Determine if the file should be sent as the entire Content body, * i.e. without any additional wrapping. @@ -640,11 +692,56 @@ public String getImplementation() { return get(getSchema().getImplementation()); } + /** + * Gets the response processing mode for this sampler. + * Controls how response data is handled (stored, discarded, or checksummed). + * + * @return the current response processing mode + * @since 6.0.0 + */ + public ResponseProcessingMode getResponseProcessingMode() { + String value = get(getSchema().getResponseProcessingMode()); + return EnumUtils.valueOf(ResponseProcessingMode.class, value); + } + + /** + * Sets the response processing mode for this sampler. + * Controls how response data is handled (stored, discarded, or checksummed). + * + * @param mode the response processing mode to set + * @since 6.0.0 + */ + public void setResponseProcessingMode(ResponseProcessingMode mode) { + set(getSchema().getResponseProcessingMode(), mode.getResourceKey()); + } + + /** + * Returns whether this sampler should store response data as MD5 hash. + * + * @return true if MD5 mode is enabled (CHECKSUM_DECODED_MD5 or CHECKSUM_ENCODED_MD5) + * @deprecated Use {@link #getResponseProcessingMode()} instead. + * This method returns true if mode is any checksum mode. + */ + @Deprecated public boolean useMD5() { - return get(getSchema().getStoreAsMD5()); + ResponseProcessingMode mode = getResponseProcessingMode(); + return mode == ResponseProcessingMode.CHECKSUM_DECODED_MD5; } + /** + * Sets whether this sampler should store response data as MD5 hash. + * + * @param value true to enable MD5 mode (CHECKSUM_DECODED_MD5), + * false to use default mode (STORE_COMPRESSED) + * @deprecated Use {@link #setResponseProcessingMode(ResponseProcessingMode)} instead. + * This method sets mode to CHECKSUM_DECODED_MD5 if true, STORE_COMPRESSED if false. + */ + @Deprecated public void setMD5(boolean value) { + setResponseProcessingMode( + value ? ResponseProcessingMode.CHECKSUM_DECODED_MD5 : ResponseProcessingMode.STORE_COMPRESSED + ); + // Also set old property for backward compatibility with older code set(getSchema().getStoreAsMD5(), value); } @@ -1914,80 +2011,80 @@ public void testIterationStart(LoopIterationEvent event) { * @param sampleResult sample to store information about the response into * @param in input stream from which to read the response * @param length expected input length or zero - * @return the response or the MD5 of the response * @throws IOException if reading the result fails */ - public byte[] readResponse(SampleResult sampleResult, InputStream in, long length) throws IOException { + public void readResponse(SampleResult sampleResult, InputStream in, long length, @Nullable String contentEncoding) throws IOException { + ResponseProcessingMode responseProcessingMode = getResponseProcessingMode(); + if (responseProcessingMode == ResponseProcessingMode.CHECKSUM_DECODED_MD5) { + in = ResponseDecoderRegistry.decodeStream(contentEncoding, in); + contentEncoding = null; // already decoded + } - DirectAccessByteArrayOutputStream w = null; - try (Closeable ignore = in) { // NOSONAR No try with resource as performance is critical here - byte[] readBuffer = new byte[8192]; // 8kB is the (max) size to have the latency ('the first packet') - int bufferSize = 32;// Enough for MD5 + // 8kB is the (max) size to have the latency ('the first packet') + byte[] readBuffer = new byte[Math.toIntExact(length > 0 ? Math.min(length, 8192) : 8192)]; - MessageDigest md = null; - boolean knownResponseLength = length > 0;// may also happen if long value > int.max - if (useMD5()) { + MessageDigest md = null; + DirectAccessByteArrayOutputStream w = null; + switch (responseProcessingMode) { + case FETCH_AND_DISCARD -> { + } + case STORE_COMPRESSED -> { + w = new DirectAccessByteArrayOutputStream(Math.toIntExact(length > 0 ? Math.min(length, MAX_BUFFER_SIZE) : MAX_BUFFER_SIZE)); + } + case CHECKSUM_DECODED_MD5, CHECKSUM_ENCODED_MD5 -> { try { md = MessageDigest.getInstance("MD5"); //$NON-NLS-1$ } catch (NoSuchAlgorithmException e) { - log.error("Should not happen - could not find MD5 digest", e); - } - } else { - if (!knownResponseLength) { - bufferSize = 4 * 1024; - } else { - bufferSize = (int) Math.min(MAX_BUFFER_SIZE, length); + throw new IllegalStateException("MD5 digest algorithm not supported", e); } } + } - - int bytesReadInBuffer = 0; - long totalBytes = 0; - boolean first = true; - boolean storeInBOS = true; - while ((bytesReadInBuffer = in.read(readBuffer)) > -1) { - if (first) { - sampleResult.latencyEnd(); - first = false; - if (md == null) { - w = new DirectAccessByteArrayOutputStream(knownResponseLength ? bufferSize : 8192); - } - } - - if (md == null) { - if(storeInBOS) { - if(MAX_BYTES_TO_STORE_PER_REQUEST <= 0 || - (totalBytes+bytesReadInBuffer<=MAX_BYTES_TO_STORE_PER_REQUEST) || - JMeterContextService.getContext().isRecording()) { - w.write(readBuffer, 0, bytesReadInBuffer); - } else { - log.debug("Big response, truncating it to {} bytes", MAX_BYTES_TO_STORE_PER_REQUEST); - w.write(readBuffer, 0, (int)(MAX_BYTES_TO_STORE_PER_REQUEST-totalBytes)); - storeInBOS = false; - } - } - } else { - md.update(readBuffer, 0, bytesReadInBuffer); - } - totalBytes += bytesReadInBuffer; + int bytesReadInBuffer; + long totalBytes = 0; + boolean first = true; + boolean storeInBOS = true; + while ((bytesReadInBuffer = in.read(readBuffer)) != -1) { + if (bytesReadInBuffer == 0) { + continue; } - - if (first) { // Bug 46838 - if there was no data, still need to set latency + if (first) { sampleResult.latencyEnd(); - return new byte[0]; + first = false; } - if (md == null) { - return w.toByteArray(); - } else { - byte[] md5Result = md.digest(); - sampleResult.setBytes(totalBytes); - return JOrphanUtils.baToHexBytes(md5Result); + if (md != null) { + md.update(readBuffer, 0, bytesReadInBuffer); + } else if (storeInBOS && w != null) { + if (MAX_BYTES_TO_STORE_PER_REQUEST <= 0 || + (totalBytes + bytesReadInBuffer <= MAX_BYTES_TO_STORE_PER_REQUEST) || + JMeterContextService.getContext().isRecording()) { + w.write(readBuffer, 0, bytesReadInBuffer); + } else { + log.debug("Big response, truncating it to {} bytes", MAX_BYTES_TO_STORE_PER_REQUEST); + w.write(readBuffer, 0, (int) (MAX_BYTES_TO_STORE_PER_REQUEST - totalBytes)); + storeInBOS = false; + } } + totalBytes += bytesReadInBuffer; + } + + if (first) { // Bug 46838 - if there was no data, still need to set latency + sampleResult.latencyEnd(); + sampleResult.setResponseData(new byte[0]); + return; + } - } finally { - JOrphanUtils.closeQuietly(w); + byte[] resultBody; + if (w != null) { + resultBody = w.toByteArray(); + } else if (md != null) { + byte[] md5Result = md.digest(); + resultBody = JOrphanUtils.baToHexBytes(md5Result); + } else { + resultBody = new byte[0]; } + sampleResult.setResponseData(resultBody, contentEncoding); } /** @@ -2041,6 +2138,23 @@ void mergeFileProperties() { removeProperty(MIMETYPE); } + @Override + public void setProperty(JMeterProperty property) { + @SuppressWarnings("deprecation") + PropertyDescriptor storeAsMD5 = HTTPSamplerBaseSchema.INSTANCE.getStoreAsMD5(); + if (property.getName().equals(storeAsMD5.getName())) { + if (property instanceof BooleanProperty booleanProperty) { + setResponseProcessingMode( + booleanProperty.getBooleanValue() ? ResponseProcessingMode.CHECKSUM_DECODED_MD5 : ResponseProcessingMode.STORE_COMPRESSED + ); + } else { + setResponseProcessingMode(ResponseProcessingMode.STORE_COMPRESSED); + } + // keep usemd5 property for backward compatibility + } + super.setProperty(property); + } + /** * set IP source to use - does not apply to Java HTTP implementation currently * @@ -2133,7 +2247,10 @@ private static class ASyncSample implements Callable { CookieManager clonedCookieManager = (CookieManager) cookieManager.clone(); this.sampler.setCookieManagerProperty(clonedCookieManager); } - this.sampler.setMD5(this.sampler.useMD5() || IGNORE_EMBEDDED_RESOURCES_DATA); + ResponseProcessingMode responseProcessingMode = base.getResponseProcessingMode(); + this.sampler.setResponseProcessingMode( + IGNORE_EMBEDDED_RESOURCES_DATA ? ResponseProcessingMode.CHECKSUM_DECODED_MD5 : responseProcessingMode + ); this.jmeterContextOfParentThread = JMeterContextService.getContext(); } diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/ResourcesDownloader.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/ResourcesDownloader.java index 3155b738f3a..57e09d54759 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/ResourcesDownloader.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/ResourcesDownloader.java @@ -23,10 +23,12 @@ import java.util.concurrent.CompletionService; import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorCompletionService; +import java.util.concurrent.ExecutorService; import java.util.concurrent.Future; import java.util.concurrent.SynchronousQueue; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; import org.apache.jmeter.testelement.property.CollectionProperty; import org.apache.jmeter.util.JMeterUtils; @@ -76,20 +78,142 @@ public class ResourcesDownloader { private static final int MIN_POOL_SIZE = 1; private static final int MAX_POOL_SIZE = Integer.MAX_VALUE; + /** Whether to use Java 21 Virtual Threads for resource downloads */ + private static final boolean VIRTUAL_THREADS_ENABLED = + JMeterUtils.getPropDefault("jmeter.threads.virtual.enabled", true); // $NON-NLS-1$ + + /** Cached reference to Executors.newThreadPerTaskExecutor() method for Java 21+ virtual thread support */ + private static final java.lang.invoke.MethodHandle NEW_VIRTUAL_THREAD_EXECUTOR; + + /** Counter for naming virtual threads */ + private static final AtomicLong VIRTUAL_THREAD_COUNTER = new AtomicLong(0); + + static { + java.lang.invoke.MethodHandle newExecutor = null; + if (VIRTUAL_THREADS_ENABLED) { + try { + // Try to get Executors.newThreadPerTaskExecutor(ThreadFactory) method (Java 21+) + java.lang.invoke.MethodHandles.Lookup lookup = java.lang.invoke.MethodHandles.lookup(); + + // Verify Thread.ofVirtual() exists (Java 21+) + Thread.class.getMethod("ofVirtual"); + + // Get the newThreadPerTaskExecutor method + java.lang.reflect.Method newExecutorMethod = java.util.concurrent.Executors.class + .getMethod("newThreadPerTaskExecutor", java.util.concurrent.ThreadFactory.class); + newExecutor = lookup.unreflect(newExecutorMethod); + + LOG.info("Virtual threads support enabled for ResourcesDownloader (Java 21+)"); + } catch (NoSuchMethodException | IllegalAccessException e) { + LOG.warn("Virtual threads requested but not available for ResourcesDownloader (requires Java 21+), " + + "falling back to platform threads"); + } + } + NEW_VIRTUAL_THREAD_EXECUTOR = newExecutor; + } + private static final ResourcesDownloader INSTANCE = new ResourcesDownloader(); public static ResourcesDownloader getInstance() { return INSTANCE; } - private ThreadPoolExecutor concurrentExecutor = null; + private ExecutorService concurrentExecutor = null; + private final boolean usingVirtualThreads; private ResourcesDownloader() { - init(); + usingVirtualThreads = initVirtualThreadExecutor(); + if (!usingVirtualThreads) { + initPlatformThreadExecutor(); + } + } + + /** + * Finds a method in the class hierarchy, including interfaces. + * @param clazz the class to search + * @param methodName the method name + * @param paramTypes the parameter types + * @return the method, or null if not found + */ + private static java.lang.reflect.Method findMethodInHierarchy( + Class clazz, String methodName, Class... paramTypes) { + for (Class c = clazz; c != null; c = c.getSuperclass()) { + java.lang.reflect.Method method = findMethodInClassOrInterfaces(c, methodName, paramTypes); + if (method != null) { + return method; + } + } + return null; + } + + /** + * Finds a method in a class or its interfaces. + */ + private static java.lang.reflect.Method findMethodInClassOrInterfaces( + Class clazz, String methodName, Class... paramTypes) { + try { + return clazz.getMethod(methodName, paramTypes); + } catch (NoSuchMethodException ignored) { + // Method not found in this class, try interfaces + for (Class iface : clazz.getInterfaces()) { + try { + return iface.getMethod(methodName, paramTypes); + } catch (NoSuchMethodException e) { + LOG.trace("Method {} not found in interface {}", methodName, iface.getName()); + } + } + } + return null; + } + + /** + * Try to initialize a virtual thread executor (Java 21+). + * @return true if virtual threads are enabled and available, false otherwise + */ + private boolean initVirtualThreadExecutor() { + if (NEW_VIRTUAL_THREAD_EXECUTOR == null) { + return false; + } + try { + // Create a ThreadFactory that creates virtual threads with custom names + java.util.concurrent.ThreadFactory virtualFactory = r -> { + try { + java.lang.invoke.MethodHandles.Lookup lookup = java.lang.invoke.MethodHandles.lookup(); + java.lang.reflect.Method ofVirtualMethod = Thread.class.getMethod("ofVirtual"); + Object builder = lookup.unreflect(ofVirtualMethod).invoke(); + + Class builderClass = builder.getClass(); + java.lang.reflect.Method nameMethod = findMethodInHierarchy(builderClass, "name", String.class); + if (nameMethod != null) { + builder = lookup.unreflect(nameMethod).invoke(builder, + "ResDownload-vt-" + VIRTUAL_THREAD_COUNTER.incrementAndGet()); + } + + java.lang.reflect.Method unstartedMethod = findMethodInHierarchy(builderClass, "unstarted", Runnable.class); + if (unstartedMethod != null) { + return (Thread) lookup.unreflect(unstartedMethod).invoke(builder, r); + } + } catch (Throwable t) { + LOG.warn("Failed to create virtual thread in factory, creating platform thread", t); + } + // Fallback to platform thread + Thread t = new Thread(r); + t.setName("ResDownload-" + t.getName()); + t.setDaemon(true); + return t; + }; + + concurrentExecutor = (ExecutorService) NEW_VIRTUAL_THREAD_EXECUTOR.invoke(virtualFactory); + LOG.info("Created ResourcesDownloader with virtual threads"); + return true; + } catch (Throwable t) { + LOG.warn("Failed to create virtual thread executor, falling back to platform threads", t); + return false; + } } - private void init() { - LOG.info("Creating ResourcesDownloader with keepalive_inseconds : {}", THREAD_KEEP_ALIVE_TIME); + private void initPlatformThreadExecutor() { + LOG.info("Creating ResourcesDownloader with platform threads, keepalive_inseconds : {}", THREAD_KEEP_ALIVE_TIME); concurrentExecutor = new ThreadPoolExecutor( MIN_POOL_SIZE, MAX_POOL_SIZE, THREAD_KEEP_ALIVE_TIME, TimeUnit.SECONDS, new SynchronousQueue<>(), @@ -98,8 +222,7 @@ private void init() { t.setName("ResDownload-" + t.getName()); //$NON-NLS-1$ t.setDaemon(true); return t; - }) { - }; + }); } /** @@ -107,13 +230,22 @@ private void init() { * it should be called at the end of a test */ public void shrink() { - if (concurrentExecutor.getPoolSize() <= MIN_POOL_SIZE) { + // Virtual thread executors don't need shrinking - threads are very lightweight + if (usingVirtualThreads) { + return; + } + + if (!(concurrentExecutor instanceof ThreadPoolExecutor poolExecutor)) { + return; + } + + if (poolExecutor.getPoolSize() <= MIN_POOL_SIZE) { return; } // drain the queue - concurrentExecutor.purge(); + poolExecutor.purge(); List drainList = new ArrayList<>(); - concurrentExecutor.getQueue().drainTo(drainList); + poolExecutor.getQueue().drainTo(drainList); if (!drainList.isEmpty()) { LOG.warn("the pool executor workqueue is not empty size={}", drainList.size()); for (Runnable runnable : drainList) { @@ -127,7 +259,7 @@ public void shrink() { // this will force the release of the extra threads that are idle // the remaining extra threads will be released with the keepAliveTime of the thread - concurrentExecutor.setMaximumPoolSize(MIN_POOL_SIZE); + poolExecutor.setMaximumPoolSize(MIN_POOL_SIZE); // do not immediately restore the MaximumPoolSize as it will block the release of the threads } @@ -151,12 +283,13 @@ public List> invokeAllAndAwaitTermination( return submittedTasks; } - // restore MaximumPoolSize original value - concurrentExecutor.setMaximumPoolSize(MAX_POOL_SIZE); - - if (LOG.isDebugEnabled()) { - LOG.debug("PoolSize={} LargestPoolSize={}", - concurrentExecutor.getPoolSize(), concurrentExecutor.getLargestPoolSize()); + // restore MaximumPoolSize original value (only for platform thread pools) + if (concurrentExecutor instanceof ThreadPoolExecutor poolExecutor) { + poolExecutor.setMaximumPoolSize(MAX_POOL_SIZE); + if (LOG.isDebugEnabled()) { + LOG.debug("PoolSize={} LargestPoolSize={}", + poolExecutor.getPoolSize(), poolExecutor.getLargestPoolSize()); + } } CompletionService completionService = diff --git a/src/protocol/http/src/main/kotlin/org/apache/jmeter/protocol/http/sampler/HTTPSamplerBaseSchema.kt b/src/protocol/http/src/main/kotlin/org/apache/jmeter/protocol/http/sampler/HTTPSamplerBaseSchema.kt index 89bb2f58e28..d5e1b6cdf16 100644 --- a/src/protocol/http/src/main/kotlin/org/apache/jmeter/protocol/http/sampler/HTTPSamplerBaseSchema.kt +++ b/src/protocol/http/src/main/kotlin/org/apache/jmeter/protocol/http/sampler/HTTPSamplerBaseSchema.kt @@ -123,9 +123,16 @@ public abstract class HTTPSamplerBaseSchema : TestElementSchema() { public val embeddedUrlExcludeRegex: StringPropertyDescriptor by string("HTTPSampler.embedded_url_exclude_re") + @Deprecated(message = "Use responseProcessingMode instead") public val storeAsMD5: BooleanPropertyDescriptor by boolean("HTTPSampler.md5", default = false) + public val responseProcessingMode: StringPropertyDescriptor + by string( + "HTTPSampler.responseProcessingMode", + default = HTTPSamplerBase.ResponseProcessingMode.STORE_COMPRESSED.resourceKey + ) + public val postBodyRaw: BooleanPropertyDescriptor by boolean("HTTPSampler.postBodyRaw", default = false) diff --git a/src/protocol/http/src/main/kotlin/org/apache/jmeter/protocol/http/sampler/decoders/BrotliDecoder.kt b/src/protocol/http/src/main/kotlin/org/apache/jmeter/protocol/http/sampler/decoders/BrotliDecoder.kt new file mode 100644 index 00000000000..3e34d13972d --- /dev/null +++ b/src/protocol/http/src/main/kotlin/org/apache/jmeter/protocol/http/sampler/decoders/BrotliDecoder.kt @@ -0,0 +1,41 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.jmeter.protocol.http.sampler.decoders + +import com.google.auto.service.AutoService +import org.apache.jmeter.samplers.ResponseDecoder +import org.apiguardian.api.API +import org.brotli.dec.BrotliInputStream +import java.io.InputStream + +/** + * Decoder for Brotli compressed response data. + * Handles "br" content encoding. + * + * @since 6.0.0 + */ +@AutoService(ResponseDecoder::class) +@API(status = API.Status.INTERNAL, since = "6.0.0") +public class BrotliDecoder : ResponseDecoder { + override val encodings: List + get() = listOf("br") + + override fun decodeStream(input: InputStream): InputStream { + return BrotliInputStream(input) + } +} diff --git a/src/protocol/http/src/test/kotlin/org/apache/jmeter/protocol/http/sampler/decoders/BrotliDecoderTest.kt b/src/protocol/http/src/test/kotlin/org/apache/jmeter/protocol/http/sampler/decoders/BrotliDecoderTest.kt new file mode 100644 index 00000000000..9eb2bcc11b9 --- /dev/null +++ b/src/protocol/http/src/test/kotlin/org/apache/jmeter/protocol/http/sampler/decoders/BrotliDecoderTest.kt @@ -0,0 +1,62 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.jmeter.protocol.http.sampler.decoders + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test +import java.io.IOException +import java.util.Base64 + +/** + * Basic tests for BrotliDecoder. + * Full integration tests for brotli decompression are covered by HTTP sampler tests. + */ +class BrotliDecoderTest { + private val decoder = BrotliDecoder() + + @Test + fun testGetEncodings() { + assertEquals(listOf("br"), decoder.encodings, "encodings") + } + + @Test + fun testGetPriority() { + assertEquals(0, decoder.priority, "Default priority should be 0") + } + + @Test + fun testDecodeBrotliData() { + // Pre-compressed "Hello World" with Brotli + // Generated using: printf 'Hello World' | brotli | base64 + val compressed = Base64.getDecoder().decode("DwWASGVsbG8gV29ybGQD") + + val decoded = decoder.decode(compressed) + + assertEquals("Hello World", decoded.toString(Charsets.UTF_8), "Decoded text should match original") + } + + @Test + fun testDecodeInvalidData() { + val invalidData = "This is not brotli compressed data".toByteArray(Charsets.UTF_8) + + assertThrows(IOException::class.java) { + decoder.decode(invalidData) + } + } +} diff --git a/src/protocol/java/src/main/java/org/apache/jmeter/protocol/java/control/gui/BeanShellSamplerGui.java b/src/protocol/java/src/main/java/org/apache/jmeter/protocol/java/control/gui/BeanShellSamplerGui.java index 019347e7952..739c92f1230 100644 --- a/src/protocol/java/src/main/java/org/apache/jmeter/protocol/java/control/gui/BeanShellSamplerGui.java +++ b/src/protocol/java/src/main/java/org/apache/jmeter/protocol/java/control/gui/BeanShellSamplerGui.java @@ -49,7 +49,8 @@ public class BeanShellSamplerGui extends AbstractSamplerGui { private final JBooleanPropertyEditor resetInterpreter = new JBooleanPropertyEditor( BeanShellSamplerSchema.INSTANCE.getResetInterpreter(), - JMeterUtils.getResString("bsh_script_reset_interpreter")); + "bsh_script_reset_interpreter", + JMeterUtils::getResString); private final FilePanelEntry filename = new FilePanelEntry(JMeterUtils.getResString("bsh_script_file"),".bsh"); // script file name (if present) diff --git a/src/protocol/java/src/main/java/org/apache/jmeter/protocol/java/sampler/JavaSampler.java b/src/protocol/java/src/main/java/org/apache/jmeter/protocol/java/sampler/JavaSampler.java index 7c553a02453..4b8e9f38fa8 100644 --- a/src/protocol/java/src/main/java/org/apache/jmeter/protocol/java/sampler/JavaSampler.java +++ b/src/protocol/java/src/main/java/org/apache/jmeter/protocol/java/sampler/JavaSampler.java @@ -115,6 +115,17 @@ public Object clone() { return clone; } + /** + * {@inheritDoc} + */ + @Override + public Object lightweightClone() { + JavaSampler clone = (JavaSampler) super.lightweightClone(); + clone.javaClass = this.javaClass; + clone.isToBeRegistered = this.isToBeRegistered; + return clone; + } + private void initClass() { String name = getClassname().trim(); try { diff --git a/xdocs/usermanual/properties_reference.xml b/xdocs/usermanual/properties_reference.xml index a40ae5027a6..b23605dc4fa 100644 --- a/xdocs/usermanual/properties_reference.xml +++ b/xdocs/usermanual/properties_reference.xml @@ -1484,6 +1484,13 @@ JMETER-SERVER How often to check for shutdown during ramp-up (milliseconds).
Defaults to: 1000 + + Enable Java 21 Virtual Threads for JMeter threads.
+ When enabled, virtual user threads use lightweight virtual threads instead of platform threads.
+ Benefits: Lower memory per thread, better I/O scalability for high thread counts.
+ Requires: Java 21+ (automatically falls back to platform threads on older Java versions).
+ Defaults to: true +
Should JMeter expand the tree when loading a test plan?
Default value is false since JMeter 2.7