diff --git a/core/src/main/java/org/apache/struts2/conversion/StrutsTypeConverterHolder.java b/core/src/main/java/org/apache/struts2/conversion/StrutsTypeConverterHolder.java index 3013c3bd47..4b3d5ca382 100644 --- a/core/src/main/java/org/apache/struts2/conversion/StrutsTypeConverterHolder.java +++ b/core/src/main/java/org/apache/struts2/conversion/StrutsTypeConverterHolder.java @@ -18,15 +18,24 @@ */ package org.apache.struts2.conversion; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; /** * Default implementation of {@link TypeConverterHolder} */ public class StrutsTypeConverterHolder implements TypeConverterHolder { + private static final Logger LOG = LogManager.getLogger(StrutsTypeConverterHolder.class); + /** * Record class and its type converter mapping. *
@@ -34,7 +43,7 @@ public class StrutsTypeConverterHolder implements TypeConverterHolder {
      * - TypeConverter - instance of TypeConverter
      * 
*/ - private final HashMap defaultMappings = new HashMap<>(); // non-action (eg. returned value) + private final Map defaultMappings = new ConcurrentHashMap<>(); // non-action (eg. returned value) /** * Target class conversion Mappings. @@ -55,25 +64,47 @@ public class StrutsTypeConverterHolder implements TypeConverterHolder { * Element_property=foo.bar.MyObject * */ - private final HashMap> mappings = new HashMap<>(); // action + private final Map> mappings = new ConcurrentHashMap<>(); // action /** - * Unavailable target class conversion mappings, serves as a simple cache. + * Marker stored in {@link #mappings} for classes known to have no conversion mapping, so that + * negative results are cached in the same atomic operation as positive ones. Deliberately a + * distinct instance rather than {@link Collections#emptyMap()}, whose shared singleton could + * collide with an empty mapping supplied by a caller. */ - private final HashSet noMapping = new HashSet<>(); // action + private static final Map NO_MAPPING = Collections.unmodifiableMap(new HashMap<>()); /** * Record classes that doesn't have conversion mapping defined. *
      * - String -> classname as String
      * 
+ * + * @deprecated since 7.3.0, unused - superseded by internal concurrent storage. Retained only + * for binary compatibility with subclasses compiled against earlier versions, and will be + * removed in a future release. + */ + @Deprecated(since = "7.3.0", forRemoval = true) + protected HashSet unknownMappings = new HashSet<>(); + + /** + * Actual storage for classes with no registered converter. Concurrent, so that lock-free + * readers in {@code XWorkConverter.lookup} cannot race writers. */ - protected HashSet unknownMappings = new HashSet<>(); // non-action (eg. returned value) + private final Set unknownMappingsInternal = ConcurrentHashMap.newKeySet(); @Override public void addDefaultMapping(String className, TypeConverter typeConverter) { + if (typeConverter == null) { + LOG.warn("Ignoring null TypeConverter registered for class [{}]", className); + return; + } + // Order is load-bearing: registering the converter before clearing the unknown flag means a + // concurrent XWorkConverter.lookup can never observe (unknown=false, default=false) for this + // class - a state that would otherwise send it down the lookupSuper() path and let it + // overwrite the more specific converter being registered here with a broader one. defaultMappings.put(className, typeConverter); - unknownMappings.remove(className); + unknownMappingsInternal.remove(className); } @Override @@ -86,34 +117,84 @@ public TypeConverter getDefaultMapping(String className) { return defaultMappings.get(className); } + /** + * Returns {@code null} if {@code clazz} has been flagged as having no mapping via + * {@link #addNoMapping(Class)}, even if a real mapping was previously stored for it. + * + * @deprecated since 7.3.0, use {@link #computeMappingIfAbsent(Class, Function)} instead. + */ + // Implementing the deprecated interface primitives is mandatory until they are removed. + @SuppressWarnings("removal") @Override + @Deprecated(since = "7.3.0", forRemoval = true) public Map getMapping(Class clazz) { - return mappings.get(clazz); + Map mapping = mappings.get(clazz); + return mapping == NO_MAPPING ? null : mapping; } + /** + * @deprecated since 7.3.0, use {@link #computeMappingIfAbsent(Class, Function)} instead. + */ + @SuppressWarnings("removal") @Override + @Deprecated(since = "7.3.0", forRemoval = true) public void addMapping(Class clazz, Map mapping) { mappings.put(clazz, mapping); } + /** + * @deprecated since 7.3.0, use {@link #computeMappingIfAbsent(Class, Function)} instead. + */ + @SuppressWarnings("removal") @Override + @Deprecated(since = "7.3.0", forRemoval = true) public boolean containsNoMapping(Class clazz) { - return noMapping.contains(clazz); + return mappings.get(clazz) == NO_MAPPING; } + /** + * Stores the {@link #NO_MAPPING} sentinel for the given class, replacing any mapping previously + * cached for it. This matches the pre-7.3.0 effective behaviour, back when a separate no-mapping + * collection was consulted independently of the mappings map: the only in-tree caller, + * {@code XWorkConverter.getConverter}, checked the no-mapping flag first and short-circuited + * before the cached mapping was ever read, so flagging a class as no-mapping made it behave as + * though it had no mapping, real cached mapping or not. {@link Map#putIfAbsent} is deliberately + * not used here: it would leave a stale mapping being served for a class whose conversion build + * subsequently failed, which is a genuine behaviour change rather than a faithful port. + */ @Override public void addNoMapping(Class clazz) { - noMapping.add(clazz); + mappings.put(clazz, NO_MAPPING); + } + + @Override + public Map computeMappingIfAbsent(Class clazz, Function> builder) { + // Deliberately not implemented with mappings.computeIfAbsent(...): that would run the builder + // while holding the ConcurrentHashMap's internal bin lock. The builder reaches + // ObjectFactory.buildConverter(...), which instantiates (and, under SpringObjectFactory, + // autowires) an arbitrary user-supplied TypeConverter - constructors, @PostConstruct, + // afterPropertiesSet. Running that under a bin lock risks IllegalStateException("Recursive + // update") or a self-deadlock if any of it re-enters conversion. Instead the builder runs + // outside any lock, at the cost of allowing it to run more than once under first-access + // contention; putIfAbsent ensures every caller still converges on the same cached instance. + Map existing = mappings.get(clazz); + if (existing != null) { + return existing; + } + Map built = builder.apply(clazz); + Map value = (built == null || built.isEmpty()) ? NO_MAPPING : built; + Map previous = mappings.putIfAbsent(clazz, value); + return previous != null ? previous : value; } @Override public boolean containsUnknownMapping(String className) { - return unknownMappings.contains(className); + return unknownMappingsInternal.contains(className); } @Override public void addUnknownMapping(String className) { - unknownMappings.add(className); + unknownMappingsInternal.add(className); } } diff --git a/core/src/main/java/org/apache/struts2/conversion/TypeConverterHolder.java b/core/src/main/java/org/apache/struts2/conversion/TypeConverterHolder.java index d20324719d..8fc1c507ae 100644 --- a/core/src/main/java/org/apache/struts2/conversion/TypeConverterHolder.java +++ b/core/src/main/java/org/apache/struts2/conversion/TypeConverterHolder.java @@ -18,7 +18,9 @@ */ package org.apache.struts2.conversion; +import java.util.Collections; import java.util.Map; +import java.util.function.Function; /** * Holds all mappings related to {@link TypeConverter}s @@ -52,9 +54,16 @@ public interface TypeConverterHolder { /** * Target class conversion Mappings. * + *

Returns {@code null} if the class has been flagged as having no mapping via + * {@link #addNoMapping(Class)}, even if a real mapping was previously stored for it with + * {@link #addMapping(Class, Map)}.

+ * * @param clazz class to convert to/from - * @return {@link TypeConverter} for given class + * @return the property-converter mapping for the given class, or {@code null} if none + * @deprecated since 7.3.0, use {@link #computeMappingIfAbsent(Class, Function)}, which resolves + * and caches the mapping in one call instead of requiring a check-then-act at the call site. */ + @Deprecated(since = "7.3.0", forRemoval = true) Map getMapping(Class clazz); /** @@ -62,7 +71,10 @@ public interface TypeConverterHolder { * * @param clazz class to convert to/from * @param mapping property converters + * @deprecated since 7.3.0, use {@link #computeMappingIfAbsent(Class, Function)} which stores + * the built mapping itself. */ + @Deprecated(since = "7.3.0", forRemoval = true) void addMapping(Class clazz, Map mapping); /** @@ -70,11 +82,16 @@ public interface TypeConverterHolder { * * @param clazz class to convert to/from * @return true if mapping couldn't be found + * @deprecated since 7.3.0, use {@link #computeMappingIfAbsent(Class, Function)} which returns + * an empty map for classes known to have no mapping. */ + @Deprecated(since = "7.3.0", forRemoval = true) boolean containsNoMapping(Class clazz); /** - * Adds no mapping flag for give class + * Adds no mapping flag for give class. Flagging a class as having no mapping may replace + * any mapping previously cached for it; callers should treat this flag as authoritative + * over a previously cached mapping. * * @param clazz class to register missing converter */ @@ -97,4 +114,42 @@ public interface TypeConverterHolder { */ void addUnknownMapping(String className); + /** + * Returns the property-converter mapping for the given class, building and caching it on first + * use. Never returns {@code null}: a class known to have no mapping yields an empty map. + * + *

If the builder returns {@code null} or an empty map, the class is recorded in the negative + * cache so the builder is not invoked for it again.

+ * + *

Implementations only guarantee that all callers converge on the same cached mapping + * instance for a given class - not that the builder runs at most once. Under concurrent first + * access, the builder may be invoked more than once (each on a different thread, for the same + * class); only one of the resulting mappings is retained and returned to every caller. The + * builder must therefore be idempotent and free of side effects on the holder itself. The + * default implementation is a non-atomic check-then-act using the deprecated primitives, + * preserving pre-7.3.0 behaviour for third-party holders that do not override it.

+ * + * @param clazz class to convert to/from + * @param builder builds the property-converter mapping for the class when it is not yet cached + * @return the mapping for the class, or an empty map if it has none + * @since 7.3.0 + */ + @SuppressWarnings("deprecation") + default Map computeMappingIfAbsent(Class clazz, Function> builder) { + if (containsNoMapping(clazz)) { + return Collections.emptyMap(); + } + Map mapping = getMapping(clazz); + if (mapping != null) { + return mapping; + } + mapping = builder.apply(clazz); + if (mapping == null || mapping.isEmpty()) { + addNoMapping(clazz); + return Collections.emptyMap(); + } + addMapping(clazz, mapping); + return mapping; + } + } diff --git a/core/src/main/java/org/apache/struts2/conversion/impl/XWorkConverter.java b/core/src/main/java/org/apache/struts2/conversion/impl/XWorkConverter.java index 89d3ecceb4..5579a15d3e 100644 --- a/core/src/main/java/org/apache/struts2/conversion/impl/XWorkConverter.java +++ b/core/src/main/java/org/apache/struts2/conversion/impl/XWorkConverter.java @@ -412,34 +412,43 @@ public TypeConverter lookup(Class clazz) { } protected Object getConverter(Class clazz, String property) { - LOG.debug("Retrieving convert for class [{}] and property [{}]", clazz, property); + LOG.debug("Retrieving converter for class [{}] and property [{}]", clazz, property); - synchronized (clazz) { - if ((property != null) && !converterHolder.containsNoMapping(clazz)) { - try { - Map mapping = converterHolder.getMapping(clazz); - - if (mapping == null) { - mapping = buildConverterMapping(clazz); - } else { - mapping = conditionalReload(clazz, mapping); - } + if (property == null) { + return null; + } + try { + Map mapping = converterHolder.computeMappingIfAbsent(clazz, this::buildConverterMappingUnchecked); + if (!mapping.isEmpty()) { + mapping = conditionalReload(clazz, mapping); + } - Object converter = mapping.get(property); - if (converter == null && LOG.isDebugEnabled()) { - LOG.debug("Converter is null for property [{}]. Mapping size [{}]:", property, mapping.size()); - for (Map.Entry entry : mapping.entrySet()) { - LOG.debug("{}:{}", entry.getKey(), entry.getValue()); - } - } - return converter; - } catch (Throwable t) { - LOG.debug("Got exception trying to resolve convert for class [{}] and property [{}]", clazz, property, t); - converterHolder.addNoMapping(clazz); + Object converter = mapping.get(property); + if (converter == null && LOG.isDebugEnabled()) { + LOG.debug("Converter is null for property [{}]. Mapping size [{}]:", property, mapping.size()); + for (Map.Entry entry : mapping.entrySet()) { + LOG.debug("{}:{}", entry.getKey(), entry.getValue()); } } + return converter; + } catch (Throwable t) { + LOG.debug("Got exception trying to resolve converter for class [{}] and property [{}]", clazz, property, t); + converterHolder.addNoMapping(clazz); + return null; + } + } + + /** + * Adapts {@link #buildConverterMapping(Class)} to {@link java.util.function.Function} by + * rethrowing its checked exception unchecked. The caller's {@code catch (Throwable)} still + * negative-caches the class, so behaviour is unchanged. + */ + private Map buildConverterMappingUnchecked(Class clazz) { + try { + return buildConverterMapping(clazz); + } catch (Exception e) { + throw new IllegalStateException("Could not build converter mapping for " + clazz, e); } - return null; } protected void handleConversionException(Map context, String property, Object value, Object object, Class toClass) { @@ -463,11 +472,11 @@ protected void handleConversionException(Map context, String pro } } - public synchronized void registerConverter(String className, TypeConverter converter) { + public void registerConverter(String className, TypeConverter converter) { converterHolder.addDefaultMapping(className, converter); } - public synchronized void registerConverterNotFound(String className) { + public void registerConverterNotFound(String className) { converterHolder.addUnknownMapping(className); } @@ -547,6 +556,8 @@ protected void addConverterMapping(Map mapping, Class clazz) { * @param clazz the class to look for converter mappings for * @return the converter mappings * @throws Exception in case of any errors + * @since 7.3.0 this method no longer stores the built mapping in the {@link TypeConverterHolder}; + * storage is owned by {@link TypeConverterHolder#computeMappingIfAbsent(Class, java.util.function.Function)}. */ protected Map buildConverterMapping(Class clazz) throws Exception { Map mapping = new HashMap<>(); @@ -568,15 +579,10 @@ protected Map buildConverterMapping(Class clazz) throws Exceptio curClazz = curClazz.getSuperclass(); } - if (!mapping.isEmpty()) { - converterHolder.addMapping(clazz, mapping); - } else { - converterHolder.addNoMapping(clazz); - } - return mapping; } + @SuppressWarnings({"deprecation", "removal"}) private Map conditionalReload(Class clazz, Map oldValues) throws Exception { Map mapping = oldValues; @@ -584,6 +590,13 @@ private Map conditionalReload(Class clazz, Map o URL fileUrl = ClassLoaderUtil.getResource(buildConverterFilename(clazz), clazz); if (fileManager.fileNeedsReloading(fileUrl)) { mapping = buildConverterMapping(clazz); + if (mapping.isEmpty()) { + converterHolder.addNoMapping(clazz); + } else { + // addMapping is deprecated but remains the correct primitive here: + // computeMappingIfAbsent cannot express an unconditional overwrite. + converterHolder.addMapping(clazz, mapping); + } } } diff --git a/core/src/main/java/org/apache/struts2/validator/DefaultActionValidatorManager.java b/core/src/main/java/org/apache/struts2/validator/DefaultActionValidatorManager.java index 2f2b758b79..7da4bccf86 100644 --- a/core/src/main/java/org/apache/struts2/validator/DefaultActionValidatorManager.java +++ b/core/src/main/java/org/apache/struts2/validator/DefaultActionValidatorManager.java @@ -35,13 +35,11 @@ import java.util.ArrayList; import java.util.Collection; import java.util.Collections; -import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Set; import java.util.TreeSet; - -import static java.util.Collections.synchronizedMap; +import java.util.concurrent.ConcurrentHashMap; /** *

@@ -67,8 +65,8 @@ public class DefaultActionValidatorManager implements ActionValidatorManager { */ protected static final String VALIDATION_CONFIG_SUFFIX = "-validation.xml"; - protected final Map> validatorCache = synchronizedMap(new HashMap<>()); - protected final Map> validatorFileCache = synchronizedMap(new HashMap<>()); + protected final Map> validatorCache = new ConcurrentHashMap<>(); + protected final Map> validatorFileCache = new ConcurrentHashMap<>(); private static final Logger LOG = LogManager.getLogger(DefaultActionValidatorManager.class); protected ValidatorFactory validatorFactory; @@ -137,17 +135,19 @@ protected Validator getValidatorFromValidatorConfig(ValidatorConfig config, Valu } @Override - public synchronized List getValidators(Class clazz, String context, String method) { + public List getValidators(Class clazz, String context, String method) { String validatorKey = buildValidatorKey(clazz, context); - if (!validatorCache.containsKey(validatorKey)) { - validatorCache.put(validatorKey, buildValidatorConfigs(clazz, context, false, null)); + List configs = validatorCache.get(validatorKey); + if (configs == null) { + configs = validatorCache.computeIfAbsent(validatorKey, + key -> buildValidatorConfigs(clazz, context, false, null)); } else if (reloadingConfigs) { - validatorCache.put(validatorKey, buildValidatorConfigs(clazz, context, true, null)); + configs = buildValidatorConfigs(clazz, context, true, null); + validatorCache.put(validatorKey, configs); } ValueStack stack = ActionContext.getContext().getValueStack(); - List configs = validatorCache.get(validatorKey); List validators = new ArrayList<>(); for (ValidatorConfig config : configs) { if (method == null || method.equals(config.getParams().get("methodName"))) { @@ -158,7 +158,7 @@ public synchronized List getValidators(Class clazz, String context } @Override - public synchronized List getValidators(Class clazz, String context) { + public List getValidators(Class clazz, String context) { return getValidators(clazz, context, null); } @@ -283,7 +283,7 @@ protected List buildValidatorConfigs(Class clazz, String con if (checked == null) { checked = new TreeSet<>(); } else if (checked.contains(clazz.getName())) { - return validatorConfigs; + return Collections.emptyList(); } if (clazz.isInterface()) { @@ -314,7 +314,7 @@ protected List buildValidatorConfigs(Class clazz, String con } checked.add(clazz.getName()); - return validatorConfigs; + return Collections.unmodifiableList(validatorConfigs); } protected List buildAliasValidatorConfigs(Class aClass, String context, boolean checkFile) { @@ -328,22 +328,35 @@ protected List buildClassValidatorConfigs(Class aClass, bool } protected List loadFile(String fileName, Class clazz, boolean checkFile) { - List retList = Collections.emptyList(); - URL fileUrl = ClassLoaderUtil.getResource(fileName, clazz); - if ((checkFile && fileManager.fileNeedsReloading(fileUrl)) || !validatorFileCache.containsKey(fileName)) { - try (InputStream is = fileManager.loadFile(fileUrl)) { - if (is != null) { - retList = new ArrayList<>(validatorFileParser.parseActionValidatorConfigs(validatorFactory, is, fileName)); - } - } catch (IOException e) { - LOG.error("Caught exception while closing file {}", fileName, e); - } + if (checkFile && fileManager.fileNeedsReloading(fileUrl)) { + List reloaded = parseValidatorConfigs(fileUrl, fileName); + validatorFileCache.put(fileName, reloaded); + return reloaded; + } - validatorFileCache.put(fileName, retList); - } else { - retList = validatorFileCache.get(fileName); + return validatorFileCache.computeIfAbsent(fileName, key -> parseValidatorConfigs(fileUrl, fileName)); + } + + /** + * Parses the validator configs from the given file, returning an unmodifiable list. Returns an + * empty list when the file does not exist or cannot be read. + * + * @param fileUrl URL of the validation config file, may be null + * @param fileName name of the validation config file, used for logging and parser context + * @return an unmodifiable list of validator configs, never null + */ + protected List parseValidatorConfigs(URL fileUrl, String fileName) { + List retList = Collections.emptyList(); + + try (InputStream is = fileManager.loadFile(fileUrl)) { + if (is != null) { + retList = Collections.unmodifiableList( + new ArrayList<>(validatorFileParser.parseActionValidatorConfigs(validatorFactory, is, fileName))); + } + } catch (IOException e) { + LOG.error("Caught exception while closing file {}", fileName, e); } return retList; diff --git a/core/src/main/java/org/apache/struts2/validator/DefaultValidatorFactory.java b/core/src/main/java/org/apache/struts2/validator/DefaultValidatorFactory.java index 4e8a233088..d26971bc2d 100644 --- a/core/src/main/java/org/apache/struts2/validator/DefaultValidatorFactory.java +++ b/core/src/main/java/org/apache/struts2/validator/DefaultValidatorFactory.java @@ -36,10 +36,10 @@ import java.net.URL; import java.util.ArrayList; import java.util.Arrays; -import java.util.HashMap; import java.util.Iterator; import java.util.List; import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; @@ -52,7 +52,7 @@ */ public class DefaultValidatorFactory implements ValidatorFactory, Initializable { - protected Map validators = new HashMap<>(); + protected Map validators = new ConcurrentHashMap<>(); private static final Logger LOG = LogManager.getLogger(DefaultValidatorFactory.class); protected ObjectFactory objectFactory; protected ValidatorFileParser validatorFileParser; diff --git a/core/src/test/java/org/apache/struts2/conversion/StrutsTypeConverterHolderTest.java b/core/src/test/java/org/apache/struts2/conversion/StrutsTypeConverterHolderTest.java new file mode 100644 index 0000000000..df28e2dfa9 --- /dev/null +++ b/core/src/test/java/org/apache/struts2/conversion/StrutsTypeConverterHolderTest.java @@ -0,0 +1,442 @@ +/* + * 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.struts2.conversion; + +import org.apache.struts2.XWorkTestCase; + +import java.lang.reflect.Field; +import java.util.AbstractSet; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; + +public class StrutsTypeConverterHolderTest extends XWorkTestCase { + + private static final int THREADS = 16; + private static final int PER_THREAD = 200; + + private static TypeConverter stubConverter() { + return (context, target, member, propertyName, value, toType) -> null; + } + + public void testConcurrentDefaultMappingRegistrationLosesNothing() throws Exception { + StrutsTypeConverterHolder holder = new StrutsTypeConverterHolder(); + ExecutorService pool = Executors.newFixedThreadPool(THREADS); + CountDownLatch start = new CountDownLatch(1); + List> futures = new ArrayList<>(); + + for (int t = 0; t < THREADS; t++) { + final int threadId = t; + futures.add(pool.submit(() -> { + start.await(); + for (int i = 0; i < PER_THREAD; i++) { + String className = "stub.Class" + threadId + "_" + i; + holder.addDefaultMapping(className, stubConverter()); + // interleave reads with writes to provoke the race + holder.containsDefaultMapping("stub.Class0_0"); + holder.getDefaultMapping(className); + } + return null; + })); + } + + start.countDown(); + for (Future future : futures) { + future.get(60, TimeUnit.SECONDS); + } + pool.shutdown(); + + for (int t = 0; t < THREADS; t++) { + for (int i = 0; i < PER_THREAD; i++) { + String className = "stub.Class" + t + "_" + i; + assertThat(holder.getDefaultMapping(className)) + .as("lost registration for %s", className) + .isNotNull(); + } + } + } + + public void testConcurrentUnknownMappingRegistrationLosesNothing() throws Exception { + StrutsTypeConverterHolder holder = new StrutsTypeConverterHolder(); + ExecutorService pool = Executors.newFixedThreadPool(THREADS); + CountDownLatch start = new CountDownLatch(1); + List> futures = new ArrayList<>(); + + for (int t = 0; t < THREADS; t++) { + final int threadId = t; + futures.add(pool.submit(() -> { + start.await(); + for (int i = 0; i < PER_THREAD; i++) { + holder.addUnknownMapping("stub.Unknown" + threadId + "_" + i); + holder.containsUnknownMapping("stub.Unknown0_0"); + } + return null; + })); + } + + start.countDown(); + for (Future future : futures) { + future.get(60, TimeUnit.SECONDS); + } + pool.shutdown(); + + for (int t = 0; t < THREADS; t++) { + for (int i = 0; i < PER_THREAD; i++) { + String className = "stub.Unknown" + t + "_" + i; + assertThat(holder.containsUnknownMapping(className)) + .as("lost unknown mapping for %s", className) + .isTrue(); + } + } + } + + public void testAddDefaultMappingIgnoresNullConverter() { + StrutsTypeConverterHolder holder = new StrutsTypeConverterHolder(); + + holder.addDefaultMapping("stub.NullConverter", null); + + assertThat(holder.containsDefaultMapping("stub.NullConverter")).isFalse(); + assertThat(holder.getDefaultMapping("stub.NullConverter")).isNull(); + } + + public void testAddDefaultMappingClearsUnknownMapping() { + StrutsTypeConverterHolder holder = new StrutsTypeConverterHolder(); + + holder.addUnknownMapping("stub.Later"); + assertThat(holder.containsUnknownMapping("stub.Later")).isTrue(); + + holder.addDefaultMapping("stub.Later", stubConverter()); + + assertThat(holder.containsUnknownMapping("stub.Later")).isFalse(); + assertThat(holder.getDefaultMapping("stub.Later")).isNotNull(); + } + + /** + * A {@link Map} that pauses the call to {@code put} for one specific key immediately after the + * underlying write has taken effect, so a test can deterministically observe state exactly + * between two writes without depending on lucky thread scheduling. + */ + private static final class PausingAfterPutMap extends ConcurrentHashMap { + private final String watchedKey; + private final CountDownLatch writeStarted; + private final CountDownLatch release; + + PausingAfterPutMap(String watchedKey, CountDownLatch writeStarted, CountDownLatch release) { + this.watchedKey = watchedKey; + this.writeStarted = writeStarted; + this.release = release; + } + + @Override + public TypeConverter put(String key, TypeConverter value) { + TypeConverter result = super.put(key, value); + pauseIfWatched(key, watchedKey, writeStarted, release); + return result; + } + } + + /** + * A {@link Set} that pauses the call to {@code remove} for one specific key immediately after + * the underlying write has taken effect, mirroring {@link PausingAfterPutMap} for the unknown- + * mappings collection. + */ + private static final class PausingAfterRemoveSet extends AbstractSet { + private final Set delegate = ConcurrentHashMap.newKeySet(); + private final String watchedKey; + private final CountDownLatch writeStarted; + private final CountDownLatch release; + + PausingAfterRemoveSet(String watchedKey, CountDownLatch writeStarted, CountDownLatch release) { + this.watchedKey = watchedKey; + this.writeStarted = writeStarted; + this.release = release; + } + + @Override + public boolean add(String s) { + return delegate.add(s); + } + + @Override + public boolean remove(Object o) { + boolean result = delegate.remove(o); + pauseIfWatched(o, watchedKey, writeStarted, release); + return result; + } + + @Override + public boolean contains(Object o) { + return delegate.contains(o); + } + + @Override + public Iterator iterator() { + return delegate.iterator(); + } + + @Override + public int size() { + return delegate.size(); + } + } + + /** + * Signals {@code writeStarted} and blocks on {@code release} the first time it is called for + * {@code watchedKey}. Safe to wire into both backing collections with the same latch pair: + * whichever write executes first pauses here for the test to inspect state; the other write's + * call is a no-op (both latches are already at zero by the time it runs). + */ + private static void pauseIfWatched(Object key, String watchedKey, CountDownLatch writeStarted, CountDownLatch release) { + if (!watchedKey.equals(key)) { + return; + } + writeStarted.countDown(); + try { + if (!release.await(10, TimeUnit.SECONDS)) { + throw new IllegalStateException("release latch was never counted down"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(e); + } + } + + private static void injectField(Object target, String fieldName, Object value) throws Exception { + Field field = StrutsTypeConverterHolder.class.getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } + + /** + * Pins the write ordering inside {@code addDefaultMapping}: registering the converter must + * become visible before the unknown-mapping flag is cleared. If the order were inverted, a + * concurrent reader could observe {@code (containsUnknownMapping == false && containsDefaultMapping + * == false)} for a class that started out flagged unknown - a state that sends + * {@code XWorkConverter.lookup} down the {@code lookupSuper()} path, letting it overwrite the more + * specific converter this method is in the middle of registering with a broader one. + * + *

The window between the two writes is a couple of instructions wide, so rather than relying + * on a lucky interleaving, this replaces the holder's two backing collections with variants that + * deterministically pause immediately after whichever one is written first, letting the test + * inspect the exact intermediate state {@code addDefaultMapping} produces.

+ */ + public void testAddDefaultMappingNeverExposesForbiddenIntermediateStateToReaders() throws Exception { + String className = "stub.OrderingProbe"; + StrutsTypeConverterHolder holder = new StrutsTypeConverterHolder(); + holder.addUnknownMapping(className); + assertThat(holder.containsUnknownMapping(className)).isTrue(); + assertThat(holder.containsDefaultMapping(className)).isFalse(); + + CountDownLatch writeStarted = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + injectField(holder, "defaultMappings", new PausingAfterPutMap(className, writeStarted, release)); + injectField(holder, "unknownMappingsInternal", new PausingAfterRemoveSet(className, writeStarted, release)); + + ExecutorService pool = Executors.newSingleThreadExecutor(); + try { + Future writer = pool.submit(() -> holder.addDefaultMapping(className, stubConverter())); + + assertThat(writeStarted.await(10, TimeUnit.SECONDS)) + .as("addDefaultMapping's first write did not happen in time") + .isTrue(); + + // Snapshot exactly between the two writes, whichever order they run in. + boolean unknownFlagged = holder.containsUnknownMapping(className); + boolean defaultFlagged = holder.containsDefaultMapping(className); + + release.countDown(); + writer.get(10, TimeUnit.SECONDS); + + assertThat(unknownFlagged || defaultFlagged) + .as("a concurrent reader observed (unknown=false, default=false) mid-registration for " + + "[%s] - addDefaultMapping must register the converter before clearing the " + + "unknown flag", className) + .isTrue(); + } finally { + pool.shutdown(); + } + + assertThat(holder.containsUnknownMapping(className)).isFalse(); + assertThat(holder.getDefaultMapping(className)).isNotNull(); + } + + public void testComputeMappingIfAbsentBuildsOnceAndCaches() { + StrutsTypeConverterHolder holder = new StrutsTypeConverterHolder(); + AtomicInteger builds = new AtomicInteger(); + + Map first = holder.computeMappingIfAbsent(String.class, clazz -> { + builds.incrementAndGet(); + Map built = new HashMap<>(); + built.put("someProperty", "someConverter"); + return built; + }); + Map second = holder.computeMappingIfAbsent(String.class, clazz -> { + builds.incrementAndGet(); + return new HashMap<>(); + }); + + assertThat(builds.get()).isEqualTo(1); + assertThat(first).containsEntry("someProperty", "someConverter"); + assertThat(second).isSameAs(first); + } + + public void testComputeMappingIfAbsentNegativeCachesEmptyResult() { + StrutsTypeConverterHolder holder = new StrutsTypeConverterHolder(); + AtomicInteger builds = new AtomicInteger(); + + Map first = holder.computeMappingIfAbsent(String.class, clazz -> { + builds.incrementAndGet(); + return Collections.emptyMap(); + }); + Map second = holder.computeMappingIfAbsent(String.class, clazz -> { + builds.incrementAndGet(); + return Collections.emptyMap(); + }); + + assertThat(first).isEmpty(); + assertThat(second).isEmpty(); + assertThat(builds.get()).as("empty result must be negative cached").isEqualTo(1); + assertThat(holder.containsNoMapping(String.class)).isTrue(); + assertThat(holder.getMapping(String.class)).isNull(); + } + + /** + * Covers the branches of {@code getMapping} and {@code containsNoMapping} that are not the + * {@link StrutsTypeConverterHolder#NO_MAPPING} sentinel: a class with a real cached mapping + * must get that mapping back (not {@code null}), and must not be reported as having no mapping. + */ + public void testGetMappingAndContainsNoMappingReflectRealMapping() { + StrutsTypeConverterHolder holder = new StrutsTypeConverterHolder(); + Map real = new HashMap<>(); + real.put("someProperty", "someConverter"); + + holder.addMapping(String.class, real); + + assertThat(holder.getMapping(String.class)).isSameAs(real); + assertThat(holder.containsNoMapping(String.class)).isFalse(); + } + + public void testAddNoMappingOverridesPreviouslyCachedMapping() { + StrutsTypeConverterHolder holder = new StrutsTypeConverterHolder(); + Map real = new HashMap<>(); + real.put("someProperty", "someConverter"); + holder.addMapping(String.class, real); + + holder.addNoMapping(String.class); + + assertThat(holder.containsNoMapping(String.class)).isTrue(); + assertThat(holder.getMapping(String.class)).isNull(); + assertThat(holder.computeMappingIfAbsent(String.class, clazz -> { + throw new AssertionError("builder must not run when no mapping is set"); + })).isNotNull().isEmpty(); + } + + public void testComputeMappingIfAbsentNegativeCachesNullResult() { + StrutsTypeConverterHolder holder = new StrutsTypeConverterHolder(); + + Map result = holder.computeMappingIfAbsent(String.class, clazz -> null); + + assertThat(result).isNotNull().isEmpty(); + assertThat(holder.containsNoMapping(String.class)).isTrue(); + } + + public void testComputeMappingIfAbsentShortCircuitsOnKnownNoMapping() { + StrutsTypeConverterHolder holder = new StrutsTypeConverterHolder(); + holder.addNoMapping(String.class); + + Map result = holder.computeMappingIfAbsent(String.class, clazz -> { + throw new AssertionError("builder must not run for a negative-cached class"); + }); + + assertThat(result).isNotNull().isEmpty(); + } + + /** + * {@code computeMappingIfAbsent} deliberately does not run the builder inside a lock (see the + * comment on the production method), so under concurrent first access the builder may run more + * than once. What must still hold - and is the property callers actually depend on - is that + * every caller converges on the same cached mapping instance, with the correct content. + */ + public void testComputeMappingIfAbsentConvergesOnSameInstanceUnderConcurrency() throws Exception { + StrutsTypeConverterHolder holder = new StrutsTypeConverterHolder(); + ExecutorService pool = Executors.newFixedThreadPool(THREADS); + CountDownLatch start = new CountDownLatch(1); + List>> futures = new ArrayList<>(); + + for (int t = 0; t < THREADS; t++) { + futures.add(pool.submit(() -> { + start.await(); + return holder.computeMappingIfAbsent(String.class, clazz -> { + Map built = new HashMap<>(); + built.put("someProperty", "someConverter"); + return built; + }); + })); + } + + start.countDown(); + Map expected = futures.get(0).get(60, TimeUnit.SECONDS); + for (Future> future : futures) { + assertThat(future.get(60, TimeUnit.SECONDS)).isSameAs(expected); + } + pool.shutdown(); + + assertThat(expected).containsEntry("someProperty", "someConverter"); + } + + /** + * Same property as above, for the negative-cache path: every caller must converge on the same + * cached empty mapping instance, and the class must end up flagged as having no mapping. + */ + public void testComputeMappingIfAbsentConvergesOnSameInstanceUnderConcurrencyForUnmappedClass() throws Exception { + StrutsTypeConverterHolder holder = new StrutsTypeConverterHolder(); + ExecutorService pool = Executors.newFixedThreadPool(THREADS); + CountDownLatch start = new CountDownLatch(1); + List>> futures = new ArrayList<>(); + + for (int t = 0; t < THREADS; t++) { + futures.add(pool.submit(() -> { + start.await(); + return holder.computeMappingIfAbsent(String.class, clazz -> Collections.emptyMap()); + })); + } + + start.countDown(); + Map expected = futures.get(0).get(60, TimeUnit.SECONDS); + for (Future> future : futures) { + assertThat(future.get(60, TimeUnit.SECONDS)).isSameAs(expected).isEmpty(); + } + pool.shutdown(); + + assertThat(holder.containsNoMapping(String.class)).isTrue(); + } +} diff --git a/core/src/test/java/org/apache/struts2/conversion/TypeConverterHolderTest.java b/core/src/test/java/org/apache/struts2/conversion/TypeConverterHolderTest.java new file mode 100644 index 0000000000..9c05997e8b --- /dev/null +++ b/core/src/test/java/org/apache/struts2/conversion/TypeConverterHolderTest.java @@ -0,0 +1,151 @@ +/* + * 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.struts2.conversion; + +import org.apache.struts2.XWorkTestCase; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Exercises the {@code default} body of {@link TypeConverterHolder#computeMappingIfAbsent}, the + * SPI compatibility fallback kept for third-party {@link TypeConverterHolder} implementations + * written before 7.3.0 that do not override the method. {@link StrutsTypeConverterHolder} - the + * only in-tree implementation - does override it, so nothing else in the codebase exercises the + * default body; these tests do so directly against a minimal implementation that deliberately + * implements only the original (non-default) primitives, using plain, non-concurrent collections, + * mirroring what a pre-7.3.0 holder looks like. + */ +public class TypeConverterHolderTest extends XWorkTestCase { + + /** + * Minimal, non-thread-safe {@link TypeConverterHolder} implementing only the pre-7.3.0 + * primitives. Deliberately does not override {@link TypeConverterHolder#computeMappingIfAbsent}, + * so calls against it run the interface's default check-then-act body. + */ + private static class LegacyTypeConverterHolder implements TypeConverterHolder { + private final Map defaultMappings = new HashMap<>(); + private final Map> mappings = new HashMap<>(); + private final Set noMapping = new HashSet<>(); + private final Set unknownMappings = new HashSet<>(); + + @Override + public void addDefaultMapping(String className, TypeConverter typeConverter) { + defaultMappings.put(className, typeConverter); + } + + @Override + public boolean containsDefaultMapping(String className) { + return defaultMappings.containsKey(className); + } + + @Override + public TypeConverter getDefaultMapping(String className) { + return defaultMappings.get(className); + } + + @Override + public Map getMapping(Class clazz) { + return mappings.get(clazz); + } + + @Override + public void addMapping(Class clazz, Map mapping) { + mappings.put(clazz, mapping); + } + + @Override + public boolean containsNoMapping(Class clazz) { + return noMapping.contains(clazz); + } + + @Override + public void addNoMapping(Class clazz) { + noMapping.add(clazz); + } + + @Override + public boolean containsUnknownMapping(String className) { + return unknownMappings.contains(className); + } + + @Override + public void addUnknownMapping(String className) { + unknownMappings.add(className); + } + } + + public void testDefaultComputeMappingIfAbsentBuildsOnceAndCaches() { + LegacyTypeConverterHolder holder = new LegacyTypeConverterHolder(); + AtomicInteger builds = new AtomicInteger(); + + Map first = holder.computeMappingIfAbsent(String.class, clazz -> { + builds.incrementAndGet(); + Map built = new HashMap<>(); + built.put("someProperty", "someConverter"); + return built; + }); + Map second = holder.computeMappingIfAbsent(String.class, clazz -> { + throw new AssertionError("builder must not run again once the mapping is cached"); + }); + + assertThat(builds.get()).isEqualTo(1); + assertThat(first).containsEntry("someProperty", "someConverter"); + assertThat(second).isSameAs(first); + } + + public void testDefaultComputeMappingIfAbsentNegativeCachesEmptyResult() { + LegacyTypeConverterHolder holder = new LegacyTypeConverterHolder(); + AtomicInteger builds = new AtomicInteger(); + + Map result = holder.computeMappingIfAbsent(String.class, clazz -> { + builds.incrementAndGet(); + return new HashMap<>(); + }); + + assertThat(result).isNotNull().isEmpty(); + assertThat(builds.get()).as("an empty build result must be negative cached").isEqualTo(1); + assertThat(holder.containsNoMapping(String.class)).isTrue(); + } + + public void testDefaultComputeMappingIfAbsentNegativeCachesNullResult() { + LegacyTypeConverterHolder holder = new LegacyTypeConverterHolder(); + + Map result = holder.computeMappingIfAbsent(String.class, clazz -> null); + + assertThat(result).isNotNull().isEmpty(); + assertThat(holder.containsNoMapping(String.class)).isTrue(); + } + + public void testDefaultComputeMappingIfAbsentShortCircuitsOnKnownNoMapping() { + LegacyTypeConverterHolder holder = new LegacyTypeConverterHolder(); + holder.addNoMapping(String.class); + + Map result = holder.computeMappingIfAbsent(String.class, clazz -> { + throw new AssertionError("builder must not run for a class already flagged as having no mapping"); + }); + + assertThat(result).isNotNull().isEmpty(); + } +} diff --git a/core/src/test/java/org/apache/struts2/conversion/impl/XWorkConverterTest.java b/core/src/test/java/org/apache/struts2/conversion/impl/XWorkConverterTest.java index 39fe8e2cf5..9be7c8c482 100644 --- a/core/src/test/java/org/apache/struts2/conversion/impl/XWorkConverterTest.java +++ b/core/src/test/java/org/apache/struts2/conversion/impl/XWorkConverterTest.java @@ -19,6 +19,7 @@ package org.apache.struts2.conversion.impl; import org.apache.struts2.ActionContext; +import org.apache.struts2.FileManager; import org.apache.struts2.ModelDrivenAction; import org.apache.struts2.SimpleAction; import org.apache.struts2.text.StubTextProvider; @@ -37,8 +38,11 @@ import org.apache.struts2.util.reflection.ReflectionContextState; import ognl.OgnlRuntime; import org.apache.struts2.conversion.TypeConverter; +import org.apache.struts2.conversion.StrutsTypeConverterHolder; import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.Field; import java.math.BigDecimal; import java.math.BigInteger; import java.net.URL; @@ -47,6 +51,12 @@ import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.*; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; import static org.junit.Assert.assertArrayEquals; @@ -804,6 +814,248 @@ public void testCollectionConversion() { assertEquals(converted, Arrays.asList(1, 2, 3)); } + public static class CountingXWorkConverter extends XWorkConverter { + final AtomicInteger builds = new AtomicInteger(); + + public CountingXWorkConverter() { + super(); + } + + @Override + protected Map buildConverterMapping(Class clazz) throws Exception { + builds.incrementAndGet(); + return super.buildConverterMapping(clazz); + } + } + + public void testGetConverterBuildsMappingExactlyOncePerClass() { + CountingXWorkConverter countingConverter = container.inject(CountingXWorkConverter.class); + // a cold, dedicated holder so other tests' cached mappings cannot mask the behaviour + countingConverter.setTypeConverterHolder(new StrutsTypeConverterHolder()); + + Object first = countingConverter.getConverter(User.class, "Collection_list"); + Object second = countingConverter.getConverter(User.class, "Collection_list"); + + assertEquals(String.class, first); + assertEquals(String.class, second); + assertEquals(1, countingConverter.builds.get()); + } + + /** + * {@code computeMappingIfAbsent} deliberately does not run the builder inside a lock (see + * {@link org.apache.struts2.conversion.StrutsTypeConverterHolder#computeMappingIfAbsent}), so + * under concurrent first access {@code buildConverterMapping} may run more than once. What must + * still hold is that every caller converges on the same, correct result. + */ + public void testGetConverterConvergesOnSameResultUnderConcurrency() throws Exception { + final int threads = 16; + CountingXWorkConverter countingConverter = container.inject(CountingXWorkConverter.class); + countingConverter.setTypeConverterHolder(new StrutsTypeConverterHolder()); + + ExecutorService pool = Executors.newFixedThreadPool(threads); + CountDownLatch start = new CountDownLatch(1); + List> futures = new ArrayList<>(); + + for (int t = 0; t < threads; t++) { + futures.add(pool.submit(() -> { + start.await(); + return countingConverter.getConverter(User.class, "Collection_list"); + })); + } + + start.countDown(); + for (Future future : futures) { + assertEquals(String.class, future.get(60, TimeUnit.SECONDS)); + } + pool.shutdown(); + } + + public void testGetConverterReturnsNullForUnknownProperty() { + XWorkConverter freshConverter = container.inject(XWorkConverter.class); + freshConverter.setTypeConverterHolder(new StrutsTypeConverterHolder()); + + assertNull(freshConverter.getConverter(User.class, "noSuchPropertyAnywhere")); + } + + public void testGetConverterReturnsNullForNullProperty() { + XWorkConverter freshConverter = container.inject(XWorkConverter.class); + freshConverter.setTypeConverterHolder(new StrutsTypeConverterHolder()); + + assertNull(freshConverter.getConverter(User.class, null)); + } + + /** + * Minimal {@link FileManager} whose {@code fileNeedsReloading} answer is fixed at construction, + * so {@code conditionalReload}'s branches can be driven deterministically without depending on + * real file timestamps or the shared, statically-cached {@link org.apache.struts2.util.fs.DefaultFileManager}. + * {@code loadFile} is never exercised through this seam: {@code XWorkConverter.fileManager} is + * only ever read by {@code conditionalReload}'s {@code fileNeedsReloading} call - actual property + * file parsing goes through {@link org.apache.struts2.conversion.impl.DefaultConversionFileProcessor}'s + * own, separately-injected {@link FileManager}. + */ + private static class StubFileManager implements FileManager { + private final boolean needsReloading; + + StubFileManager(boolean needsReloading) { + this.needsReloading = needsReloading; + } + + @Override + public void setReloadingConfigs(boolean reloadingConfigs) { + // intentionally empty: this stub only controls fileNeedsReloading for the reload + // tests, and nothing in conditionalReload reads back the reloading-configs flag + } + + @Override + public boolean fileNeedsReloading(String fileName) { + return needsReloading; + } + + @Override + public boolean fileNeedsReloading(URL fileUrl) { + return needsReloading; + } + + @Override + public InputStream loadFile(URL fileUrl) { + throw new UnsupportedOperationException("not exercised via XWorkConverter.fileManager"); + } + + @Override + public void monitorFile(URL fileUrl) { + // intentionally empty: this stub only controls fileNeedsReloading for the reload + // tests, and nothing in conditionalReload depends on file monitoring being registered + } + + @Override + public URL normalizeToFileProtocol(URL url) { + return url; + } + + @Override + public boolean support() { + return true; + } + + @Override + public boolean internal() { + return true; + } + + @Override + public Collection getAllPhysicalUrls(URL url) { + return List.of(url); + } + } + + private static void setFileManager(XWorkConverter converter, FileManager fileManager) throws Exception { + Field field = XWorkConverter.class.getDeclaredField("fileManager"); + field.setAccessible(true); + field.set(converter, fileManager); + } + + /** + * Covers {@code conditionalReload}'s {@code reloadingConfigs == true} branch where the rebuilt + * mapping is real (non-empty), so it is stored via {@code addMapping}. {@code User} has a real + * {@code User-conversion.properties} on the test classpath (see {@code Collection_list} used + * elsewhere in this file), so {@code buildConverterMapping} legitimately returns a non-empty + * mapping both for the initial cache population and for the forced reload. + */ + public void testConditionalReloadRebuildsRealMappingAndStoresItViaAddMapping() throws Exception { + CountingXWorkConverter countingConverter = container.inject(CountingXWorkConverter.class); + StrutsTypeConverterHolder holder = new StrutsTypeConverterHolder(); + countingConverter.setTypeConverterHolder(holder); + countingConverter.setReloadingConfigs("true"); + setFileManager(countingConverter, new StubFileManager(true)); + + Object converterForList = countingConverter.getConverter(User.class, "Collection_list"); + + assertEquals(String.class, converterForList); + assertEquals("buildConverterMapping must run once for the initial cache miss and once more " + + "for the forced reload", + 2, countingConverter.builds.get()); + assertFalse("the reload found a real mapping, so the class must not be negative-cached", + holder.containsNoMapping(User.class)); + } + + /** + * Toggles between a real and an empty mapping across successive {@code buildConverterMapping} + * calls, so the forced reload in {@code conditionalReload} can be made to observe an empty + * rebuild independently of any real property file's contents. + */ + public static class ToggleXWorkConverter extends XWorkConverter { + final AtomicInteger calls = new AtomicInteger(); + + public ToggleXWorkConverter() { + super(); + } + + @Override + protected Map buildConverterMapping(Class clazz) { + if (calls.incrementAndGet() == 1) { + Map real = new HashMap<>(); + real.put("someProperty", "someConverter"); + return real; + } + return Collections.emptyMap(); + } + } + + /** + * Covers {@code conditionalReload}'s {@code reloadingConfigs == true} branch where the rebuilt + * mapping is empty, so it is routed to {@code addNoMapping} instead of {@code addMapping}. + */ + public void testConditionalReloadRebuildsEmptyMappingAndStoresItViaAddNoMapping() throws Exception { + ToggleXWorkConverter toggleConverter = container.inject(ToggleXWorkConverter.class); + StrutsTypeConverterHolder holder = new StrutsTypeConverterHolder(); + toggleConverter.setTypeConverterHolder(holder); + toggleConverter.setReloadingConfigs("true"); + setFileManager(toggleConverter, new StubFileManager(true)); + + Object resolvedConverter = toggleConverter.getConverter(User.class, "someProperty"); + + assertNull("the reload rebuilt an empty mapping, so no converter can be found", resolvedConverter); + assertEquals("buildConverterMapping must run once for the initial cache miss (real mapping) " + + "and once more for the forced reload (empty mapping)", + 2, toggleConverter.calls.get()); + assertTrue("an empty rebuild must negative-cache the class", holder.containsNoMapping(User.class)); + } + + /** + * A {@code buildConverterMapping} that always fails with a checked exception, to drive + * {@code buildConverterMappingUnchecked}'s wrapping of it as an unchecked + * {@code IllegalStateException}, and {@code getConverter}'s {@code catch (Throwable)} + * negative-caching of the failure. + */ + public static class ThrowingXWorkConverter extends XWorkConverter { + final AtomicInteger attempts = new AtomicInteger(); + + public ThrowingXWorkConverter() { + super(); + } + + @Override + protected Map buildConverterMapping(Class clazz) throws Exception { + attempts.incrementAndGet(); + throw new Exception("simulated checked failure building converter mapping for " + clazz); + } + } + + public void testGetConverterNegativeCachesOnBuildFailure() { + ThrowingXWorkConverter throwingConverter = container.inject(ThrowingXWorkConverter.class); + StrutsTypeConverterHolder holder = new StrutsTypeConverterHolder(); + throwingConverter.setTypeConverterHolder(holder); + + Object first = throwingConverter.getConverter(User.class, "Collection_list"); + assertNull(first); + assertTrue("a build failure must negative-cache the class", holder.containsNoMapping(User.class)); + + Object second = throwingConverter.getConverter(User.class, "Collection_list"); + assertNull(second); + assertEquals("the negative cache must prevent a second build attempt", + 1, throwingConverter.attempts.get()); + } + public static class Foo1 { public Bar1 getBar() { return new Bar1Impl(); diff --git a/core/src/test/java/org/apache/struts2/validator/DefaultActionValidatorManagerTest.java b/core/src/test/java/org/apache/struts2/validator/DefaultActionValidatorManagerTest.java index 06914d31f5..4cbf05c3c7 100644 --- a/core/src/test/java/org/apache/struts2/validator/DefaultActionValidatorManagerTest.java +++ b/core/src/test/java/org/apache/struts2/validator/DefaultActionValidatorManagerTest.java @@ -18,6 +18,7 @@ */ package org.apache.struts2.validator; +import org.apache.struts2.FileManager; import org.apache.struts2.FileManagerFactory; import org.apache.struts2.SimpleAction; import org.apache.struts2.TestBean; @@ -27,6 +28,8 @@ import org.apache.struts2.test.DataAware2; import org.apache.struts2.test.SimpleAction3; import org.apache.struts2.test.User; +import org.apache.struts2.util.ValueStack; +import org.apache.struts2.util.ValueStackFactory; import org.apache.struts2.validator.validators.DateRangeFieldValidator; import org.apache.struts2.validator.validators.DoubleRangeFieldValidator; import org.apache.struts2.validator.validators.ExpressionValidator; @@ -39,10 +42,21 @@ import org.assertj.core.api.Assertions; import org.xml.sax.SAXParseException; +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; import java.util.ArrayList; +import java.util.Collection; import java.util.Iterator; import java.util.List; import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -375,4 +389,172 @@ public void testFieldErrorsOrder() throws Exception { assertEquals((e.getValue()).get(0), "password hint is required"); } + public void testConcurrentGetValidatorsReturnsConsistentResults() throws Exception { + final int threads = 16; + ExecutorService pool = Executors.newFixedThreadPool(threads); + CountDownLatch start = new CountDownLatch(1); + List> futures = new ArrayList<>(); + + // actionValidatorManager is freshly injected in setUp(), so validatorCache is cold here - + // all 16 threads below race the first build for this key, which is the interesting + // contention this test exists to cover. + // ActionContext is a plain (non-inheritable) ThreadLocal, so each pool thread needs its + // own context bound - with its own ValueStack - before it can call getValidators(), + // mirroring what XWorkTestCaseHelper does for the main test thread. + ValueStackFactory valueStackFactory = container.getInstance(ValueStackFactory.class); + + for (int t = 0; t < threads; t++) { + futures.add(pool.submit(() -> { + ValueStack stack = valueStackFactory.createValueStack(); + stack.getActionContext().withContainer(container).withValueStack(stack).bind(); + start.await(); + return actionValidatorManager.getValidators(SimpleAction.class, alias).size(); + })); + } + + start.countDown(); + int firstSize = futures.get(0).get(60, TimeUnit.SECONDS); + assertThat(firstSize).isGreaterThan(0); + for (Future future : futures) { + assertThat(future.get(60, TimeUnit.SECONDS)).isEqualTo(firstSize); + } + pool.shutdown(); + assertThat(pool.awaitTermination(60, TimeUnit.SECONDS)).isTrue(); + } + + public void testCachedValidatorConfigsAreUnmodifiable() { + actionValidatorManager.getValidators(SimpleAction.class, alias); + + List cached = actionValidatorManager.validatorCache.values().iterator().next(); + + assertThatThrownBy(() -> cached.add(null)) + .isInstanceOf(UnsupportedOperationException.class); + } + + /** + * Covers {@code getValidators}' {@code else if (reloadingConfigs)} branch: reached when the + * validator-config key is already cached and reload mode is on, in which case the + * configs are rebuilt and the cache entry is replaced rather than reused. Rebuilding produces a + * fresh {@link List} instance even though the content is unchanged, so object identity is the + * real, observable signal that this branch - rather than the {@code computeIfAbsent} branch - + * ran. + */ + public void testGetValidatorsRebuildsCacheWhenReloadingConfigsEnabled() { + actionValidatorManager.getValidators(SimpleAction.class, alias); + String key = actionValidatorManager.buildValidatorKey(SimpleAction.class, alias); + List cachedBeforeReload = actionValidatorManager.validatorCache.get(key); + assertThat(cachedBeforeReload).isNotNull().isNotEmpty(); + + actionValidatorManager.reloadingConfigs = true; + actionValidatorManager.getValidators(SimpleAction.class, alias); + + List cachedAfterReload = actionValidatorManager.validatorCache.get(key); + assertThat(cachedAfterReload) + .as("the else-if(reloadingConfigs) branch must rebuild and replace the cache entry, " + + "not reuse the previously cached list") + .isNotSameAs(cachedBeforeReload); + assertThat(cachedAfterReload).hasSameSizeAs(cachedBeforeReload); + } + + /** + * Minimal {@link FileManager} wrapping a real one, whose {@code fileNeedsReloading} answer is + * fixed at construction. {@code loadFile} and everything else delegate to the real instance so + * that the actual validation XML on the test classpath is genuinely parsed - only the reload + * check itself is made deterministic. + */ + private static class ReloadFlagFileManager implements FileManager { + private final FileManager delegate; + private final boolean needsReloading; + + ReloadFlagFileManager(FileManager delegate, boolean needsReloading) { + this.delegate = delegate; + this.needsReloading = needsReloading; + } + + @Override + public void setReloadingConfigs(boolean reloadingConfigs) { + delegate.setReloadingConfigs(reloadingConfigs); + } + + @Override + public boolean fileNeedsReloading(String fileName) { + return needsReloading; + } + + @Override + public boolean fileNeedsReloading(URL fileUrl) { + return needsReloading; + } + + @Override + public InputStream loadFile(URL fileUrl) { + return delegate.loadFile(fileUrl); + } + + @Override + public void monitorFile(URL fileUrl) { + delegate.monitorFile(fileUrl); + } + + @Override + public URL normalizeToFileProtocol(URL url) { + return delegate.normalizeToFileProtocol(url); + } + + @Override + public boolean support() { + return delegate.support(); + } + + @Override + public boolean internal() { + return delegate.internal(); + } + + @Override + public Collection getAllPhysicalUrls(URL url) throws IOException { + return delegate.getAllPhysicalUrls(url); + } + } + + /** + * Covers {@code loadFile}'s {@code checkFile && fileManager.fileNeedsReloading(...)} branch, + * which re-parses and {@code put}s directly rather than using {@code computeIfAbsent}. Forcing + * a real re-parse produces a new {@link List} instance even though {@code SimpleAction- + * validationAlias-validation.xml} hasn't changed, so object identity is the observable proof + * that this branch - rather than the {@code computeIfAbsent} fallback - ran. + */ + public void testLoadFileReparsesWhenCheckFileAndFileNeedsReloading() { + String fileName = SimpleAction.class.getName().replace('.', '/') + "-" + alias + "-validation.xml"; + FileManager realFileManager = actionValidatorManager.fileManager; + + actionValidatorManager.fileManager = new ReloadFlagFileManager(realFileManager, false); + List warm = actionValidatorManager.loadFile(fileName, SimpleAction.class, false); + assertThat(warm).isNotEmpty(); + assertThat(actionValidatorManager.validatorFileCache.get(fileName)).isSameAs(warm); + + actionValidatorManager.fileManager = new ReloadFlagFileManager(realFileManager, true); + List reloaded = actionValidatorManager.loadFile(fileName, SimpleAction.class, true); + + assertThat(reloaded) + .as("checkFile && fileNeedsReloading must re-parse rather than reuse the cached list") + .isNotSameAs(warm); + assertThat(reloaded).hasSameSizeAs(warm); + assertThat(actionValidatorManager.validatorFileCache.get(fileName)).isSameAs(reloaded); + } + + /** + * Covers {@code buildValidatorConfigs} returning {@code Collections.emptyList()} when the class + * was already recorded in the {@code checked} set - the short-circuit that stops the hierarchy + * walk from revisiting a class (e.g. an interface reachable through more than one path). + */ + public void testBuildValidatorConfigsShortCircuitsWhenClassAlreadyChecked() { + Set checked = new TreeSet<>(); + checked.add(SimpleAction.class.getName()); + + List result = actionValidatorManager.buildValidatorConfigs(SimpleAction.class, alias, false, checked); + + assertThat(result).isNotNull().isEmpty(); + } + } diff --git a/docs/superpowers/plans/2026-07-21-WW-5539-concurrency-performance.md b/docs/superpowers/plans/2026-07-21-WW-5539-concurrency-performance.md new file mode 100644 index 0000000000..bb4ef1dc68 --- /dev/null +++ b/docs/superpowers/plans/2026-07-21-WW-5539-concurrency-performance.md @@ -0,0 +1,1215 @@ +# WW-5539 Concurrency Performance Enhancements Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Remove three coarse locks from Struts core's type-conversion and validation caches, replacing them with concurrent collections so that read-mostly cache hits no longer serialise. + +**Architecture:** Make the backing caches genuinely thread-safe (`ConcurrentHashMap` / `ConcurrentHashMap.newKeySet()`), then delete the `synchronized` blocks that were compensating for them. One new `default` SPI method, `TypeConverterHolder.computeMappingIfAbsent`, provides atomic build-once semantics for the single expensive computation where `computeIfAbsent` is provably safe. Everywhere else, check-then-act is retained and the duplicated work is cheap and idempotent. + +**Tech Stack:** Java 17 (`maven.compiler.release=17`), Maven multi-module, JUnit 3-style tests via `junit.framework.TestCase` (through `org.apache.struts2.XWorkTestCase`), AssertJ assertions, Log4j2. + +**Spec:** `docs/superpowers/specs/2026-07-21-WW-5539-concurrency-performance-design.md` + +## Global Constraints + +- **Branch:** all work lands on `WW-5539`. Never commit to `main`. +- **Commit messages:** must be prefixed `WW-5539 `. +- **Target release:** 7.3.0 (minor) — the `TypeConverterHolder` SPI may gain `default` methods but must not break existing third-party implementations. +- **Test style:** core tests extend `org.apache.struts2.XWorkTestCase`, which extends `junit.framework.TestCase`. Test methods are `public void testXxx()` with **no** `@Test` annotation. Setup is `@Override protected void setUp() throws Exception { super.setUp(); ... }`. Do **not** convert these to JUnit 5. +- **Available test fields:** `XWorkTestCase` provides `protected Container container` and `protected ConfigurationManager configurationManager`. +- **`TypeConverter` is a functional interface** (single abstract method `convertValue(Map, Object, Member, String, Object, Class)`), so test stubs may be lambdas. +- **Build command:** `mvn test -DskipAssembly -pl core -Dtest=ClassName#methodName` +- **No binary-compatibility gate** (no japicmp/revapi) and **deprecation warnings do not fail the build** — verified in `pom.xml`. +- **Exactly three methods get `@Deprecated`:** `TypeConverterHolder.getMapping`, `addMapping`, `containsNoMapping`. Do not deprecate `addNoMapping` or any of the five default-mapping methods. + +--- + +### Task 1: Make `StrutsTypeConverterHolder` thread-safe + +This is the correctness fix. The holder is a container singleton whose plain `HashMap`s are read without any lock by `XWorkConverter.lookup()` while being written elsewhere. + +**Files:** +- Modify: `core/src/main/java/org/apache/struts2/conversion/StrutsTypeConverterHolder.java:21-77` +- Test: `core/src/test/java/org/apache/struts2/conversion/StrutsTypeConverterHolderTest.java` (create) + +**Interfaces:** +- Consumes: nothing from earlier tasks. +- Produces: `StrutsTypeConverterHolder` with thread-safe internals and a `public StrutsTypeConverterHolder()` no-arg constructor (already implicit). The original `protected HashSet unknownMappings` field is retained (deprecated, unused) for binary compatibility, with live storage in a new `private` concurrent set. Behaviour change: `addDefaultMapping(className, null)` is now ignored rather than storing a null value. + +- [ ] **Step 1: Write the failing concurrency test** + +Create `core/src/test/java/org/apache/struts2/conversion/StrutsTypeConverterHolderTest.java`: + +```java +/* + * 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.struts2.conversion; + +import org.apache.struts2.XWorkTestCase; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; + +public class StrutsTypeConverterHolderTest extends XWorkTestCase { + + private static final int THREADS = 16; + private static final int PER_THREAD = 200; + + private static TypeConverter stubConverter() { + return (context, target, member, propertyName, value, toType) -> null; + } + + public void testConcurrentDefaultMappingRegistrationLosesNothing() throws Exception { + StrutsTypeConverterHolder holder = new StrutsTypeConverterHolder(); + ExecutorService pool = Executors.newFixedThreadPool(THREADS); + CountDownLatch start = new CountDownLatch(1); + List> futures = new ArrayList<>(); + + for (int t = 0; t < THREADS; t++) { + final int threadId = t; + futures.add(pool.submit(() -> { + start.await(); + for (int i = 0; i < PER_THREAD; i++) { + String className = "stub.Class" + threadId + "_" + i; + holder.addDefaultMapping(className, stubConverter()); + // interleave reads with writes to provoke the race + holder.containsDefaultMapping("stub.Class0_0"); + holder.getDefaultMapping(className); + } + return null; + })); + } + + start.countDown(); + for (Future future : futures) { + future.get(60, TimeUnit.SECONDS); + } + pool.shutdown(); + + for (int t = 0; t < THREADS; t++) { + for (int i = 0; i < PER_THREAD; i++) { + String className = "stub.Class" + t + "_" + i; + assertThat(holder.getDefaultMapping(className)) + .as("lost registration for %s", className) + .isNotNull(); + } + } + } + + public void testConcurrentNoMappingAndUnknownMappingRegistrationLosesNothing() throws Exception { + StrutsTypeConverterHolder holder = new StrutsTypeConverterHolder(); + ExecutorService pool = Executors.newFixedThreadPool(THREADS); + CountDownLatch start = new CountDownLatch(1); + List> futures = new ArrayList<>(); + + for (int t = 0; t < THREADS; t++) { + final int threadId = t; + futures.add(pool.submit(() -> { + start.await(); + for (int i = 0; i < PER_THREAD; i++) { + holder.addUnknownMapping("stub.Unknown" + threadId + "_" + i); + holder.containsUnknownMapping("stub.Unknown0_0"); + } + return null; + })); + } + + start.countDown(); + for (Future future : futures) { + future.get(60, TimeUnit.SECONDS); + } + pool.shutdown(); + + for (int t = 0; t < THREADS; t++) { + for (int i = 0; i < PER_THREAD; i++) { + String className = "stub.Unknown" + t + "_" + i; + assertThat(holder.containsUnknownMapping(className)) + .as("lost unknown mapping for %s", className) + .isTrue(); + } + } + } + + public void testAddDefaultMappingIgnoresNullConverter() { + StrutsTypeConverterHolder holder = new StrutsTypeConverterHolder(); + + holder.addDefaultMapping("stub.NullConverter", null); + + assertThat(holder.containsDefaultMapping("stub.NullConverter")).isFalse(); + assertThat(holder.getDefaultMapping("stub.NullConverter")).isNull(); + } + + public void testAddDefaultMappingClearsUnknownMapping() { + StrutsTypeConverterHolder holder = new StrutsTypeConverterHolder(); + + holder.addUnknownMapping("stub.Later"); + assertThat(holder.containsUnknownMapping("stub.Later")).isTrue(); + + holder.addDefaultMapping("stub.Later", stubConverter()); + + assertThat(holder.containsUnknownMapping("stub.Later")).isFalse(); + assertThat(holder.getDefaultMapping("stub.Later")).isNotNull(); + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `mvn test -DskipAssembly -pl core -Dtest=StrutsTypeConverterHolderTest` + +Expected: `testAddDefaultMappingIgnoresNullConverter` FAILS (currently a null value is stored, so `containsDefaultMapping` returns `true`). + +The two concurrency tests are **expected to be flaky against the unfixed code** — they may pass on a strongly-ordered x86/ARM machine. Record whatever happens; do not treat a pass as evidence the current code is correct. The null-converter test is the deterministic gate for this step. + +- [ ] **Step 3: Make the collections concurrent** + +In `StrutsTypeConverterHolder.java`, replace the imports at lines 21-23: + +```java +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +``` + +Replace the four field declarations (lines 37, 58, 63, 71) with: + +```java + private final Map defaultMappings = new ConcurrentHashMap<>(); // non-action (eg. returned value) +``` + +```java + private final Map> mappings = new ConcurrentHashMap<>(); // action +``` + +```java + private final Set noMapping = ConcurrentHashMap.newKeySet(); // action +``` + +```java + /** + * Record classes that doesn't have conversion mapping defined. + *
+     * - String -> classname as String
+     * 
+ * + * @deprecated since 7.3.0, this field is an implementation detail and will be made private. + */ + @Deprecated + protected final Set unknownMappings = ConcurrentHashMap.newKeySet(); // non-action (eg. returned value) +``` + +Keep the existing Javadoc comments above `defaultMappings`, `mappings` and `noMapping` unchanged. + +- [ ] **Step 4: Add the null-converter guard** + +`ConcurrentHashMap` forbids null values, so `addDefaultMapping` would now throw `NullPointerException` where it previously stored a null. A null converter was never useful — it left `containsDefaultMapping` returning `true` while `getDefaultMapping` returned `null`. `ConverterFactory` is a pluggable SPI, so a third-party implementation returning null is reachable. Skip it explicitly instead. + +Add the logger imports to `StrutsTypeConverterHolder.java`: + +```java +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +``` + +Add the field just inside the class body, before `defaultMappings`: + +```java + private static final Logger LOG = LogManager.getLogger(StrutsTypeConverterHolder.class); +``` + +Replace `addDefaultMapping` (lines 73-77) with: + +```java + @Override + public void addDefaultMapping(String className, TypeConverter typeConverter) { + if (typeConverter == null) { + LOG.warn("Ignoring null TypeConverter registered for class [{}]", className); + return; + } + defaultMappings.put(className, typeConverter); + unknownMappings.remove(className); + } +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `mvn test -DskipAssembly -pl core -Dtest=StrutsTypeConverterHolderTest` + +Expected: PASS, all four tests. + +- [ ] **Step 6: Run the surrounding regression suites** + +Run: `mvn test -DskipAssembly -pl core -Dtest='XWorkConverterTest,AnnotationXWorkConverterTest,StrutsConversionPropertiesProcessorTest,ConfigurationManagerTest'` + +Expected: PASS, no edits to those files. + +- [ ] **Step 7: Commit** + +```bash +git add core/src/main/java/org/apache/struts2/conversion/StrutsTypeConverterHolder.java \ + core/src/test/java/org/apache/struts2/conversion/StrutsTypeConverterHolderTest.java +git commit -m "WW-5539 Make StrutsTypeConverterHolder collections concurrent + +The holder is a container singleton whose HashMaps were read without any +lock by XWorkConverter.lookup() while being written elsewhere, risking +lost updates and torn reads during resize. + +Null TypeConverters are now ignored with a warning rather than stored, +since ConcurrentHashMap forbids null values and a null converter left the +holder in an inconsistent state." +``` + +--- + +### Task 2: Add `computeMappingIfAbsent` to the `TypeConverterHolder` SPI + +**Files:** +- Modify: `core/src/main/java/org/apache/struts2/conversion/TypeConverterHolder.java:19-100` +- Modify: `core/src/main/java/org/apache/struts2/conversion/StrutsTypeConverterHolder.java` +- Test: `core/src/test/java/org/apache/struts2/conversion/StrutsTypeConverterHolderTest.java` (modify) + +**Interfaces:** +- Consumes: `StrutsTypeConverterHolder` with concurrent internals (Task 1). +- Produces: `Map TypeConverterHolder.computeMappingIfAbsent(Class clazz, Function> builder)` — never returns `null`; returns `Collections.emptyMap()` when the class has no mapping. `getMapping`, `addMapping`, `containsNoMapping` are now `@Deprecated`. + +- [ ] **Step 1: Write the failing tests** + +Append to `StrutsTypeConverterHolderTest.java` (and add these imports): + +```java +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +``` + +```java + public void testComputeMappingIfAbsentBuildsOnceAndCaches() { + StrutsTypeConverterHolder holder = new StrutsTypeConverterHolder(); + AtomicInteger builds = new AtomicInteger(); + + Map first = holder.computeMappingIfAbsent(String.class, clazz -> { + builds.incrementAndGet(); + Map built = new HashMap<>(); + built.put("someProperty", "someConverter"); + return built; + }); + Map second = holder.computeMappingIfAbsent(String.class, clazz -> { + builds.incrementAndGet(); + return new HashMap<>(); + }); + + assertThat(builds.get()).isEqualTo(1); + assertThat(first).containsEntry("someProperty", "someConverter"); + assertThat(second).isSameAs(first); + } + + public void testComputeMappingIfAbsentNegativeCachesEmptyResult() { + StrutsTypeConverterHolder holder = new StrutsTypeConverterHolder(); + AtomicInteger builds = new AtomicInteger(); + + Map first = holder.computeMappingIfAbsent(String.class, clazz -> { + builds.incrementAndGet(); + return Collections.emptyMap(); + }); + Map second = holder.computeMappingIfAbsent(String.class, clazz -> { + builds.incrementAndGet(); + return Collections.emptyMap(); + }); + + assertThat(first).isEmpty(); + assertThat(second).isEmpty(); + assertThat(builds.get()).as("empty result must be negative cached").isEqualTo(1); + assertThat(holder.containsNoMapping(String.class)).isTrue(); + } + + public void testComputeMappingIfAbsentNegativeCachesNullResult() { + StrutsTypeConverterHolder holder = new StrutsTypeConverterHolder(); + + Map result = holder.computeMappingIfAbsent(String.class, clazz -> null); + + assertThat(result).isNotNull().isEmpty(); + assertThat(holder.containsNoMapping(String.class)).isTrue(); + } + + public void testComputeMappingIfAbsentShortCircuitsOnKnownNoMapping() { + StrutsTypeConverterHolder holder = new StrutsTypeConverterHolder(); + holder.addNoMapping(String.class); + + Map result = holder.computeMappingIfAbsent(String.class, clazz -> { + throw new AssertionError("builder must not run for a negative-cached class"); + }); + + assertThat(result).isNotNull().isEmpty(); + } + + public void testComputeMappingIfAbsentBuildsOnceUnderConcurrency() throws Exception { + StrutsTypeConverterHolder holder = new StrutsTypeConverterHolder(); + AtomicInteger builds = new AtomicInteger(); + ExecutorService pool = Executors.newFixedThreadPool(THREADS); + CountDownLatch start = new CountDownLatch(1); + List>> futures = new ArrayList<>(); + + for (int t = 0; t < THREADS; t++) { + futures.add(pool.submit(() -> { + start.await(); + return holder.computeMappingIfAbsent(String.class, clazz -> { + builds.incrementAndGet(); + Map built = new HashMap<>(); + built.put("someProperty", "someConverter"); + return built; + }); + })); + } + + start.countDown(); + Map expected = futures.get(0).get(60, TimeUnit.SECONDS); + for (Future> future : futures) { + assertThat(future.get(60, TimeUnit.SECONDS)).isSameAs(expected); + } + pool.shutdown(); + + assertThat(builds.get()).as("mapping must be built exactly once").isEqualTo(1); + } +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `mvn test -DskipAssembly -pl core -Dtest=StrutsTypeConverterHolderTest` + +Expected: COMPILATION FAILURE — `cannot find symbol: method computeMappingIfAbsent`. + +- [ ] **Step 3: Add the `default` method to the SPI** + +In `TypeConverterHolder.java`, add imports: + +```java +import java.util.Collections; +import java.util.Map; +import java.util.function.Function; +``` + +Add `@Deprecated` to exactly three existing methods, keeping their existing Javadoc and adding a `@deprecated` tag to each: + +```java + /** + * Target class conversion Mappings. + * + * @param clazz class to convert to/from + * @return {@link TypeConverter} for given class + * @deprecated since 7.3.0, use {@link #computeMappingIfAbsent(Class, Function)} which resolves + * and caches the mapping atomically instead of requiring a check-then-act at the call site. + */ + @Deprecated + Map getMapping(Class clazz); + + /** + * Assign mapping of converters for given class + * + * @param clazz class to convert to/from + * @param mapping property converters + * @deprecated since 7.3.0, use {@link #computeMappingIfAbsent(Class, Function)} which stores + * the built mapping itself. + */ + @Deprecated + void addMapping(Class clazz, Map mapping); + + /** + * Check if there is no mapping for given class to convert + * + * @param clazz class to convert to/from + * @return true if mapping couldn't be found + * @deprecated since 7.3.0, use {@link #computeMappingIfAbsent(Class, Function)} which returns + * an empty map for classes known to have no mapping. + */ + @Deprecated + boolean containsNoMapping(Class clazz); +``` + +Leave `addNoMapping`, `addDefaultMapping`, `containsDefaultMapping`, `getDefaultMapping`, `containsUnknownMapping` and `addUnknownMapping` **undeprecated**. + +Add the new method at the end of the interface, before the closing brace: + +```java + /** + * Returns the property-converter mapping for the given class, building and caching it on first + * use. Never returns {@code null}: a class known to have no mapping yields + * {@link Collections#emptyMap()}. + * + *

If the builder returns {@code null} or an empty map, the class is recorded in the negative + * cache so the builder is not invoked for it again.

+ * + *

Implementations are expected to make this atomic so that the builder runs at most once per + * class. The default implementation is a non-atomic check-then-act using the deprecated + * primitives, preserving pre-7.3.0 behaviour for third-party holders that do not override it.

+ * + * @param clazz class to convert to/from + * @param builder builds the property-converter mapping for the class when it is not yet cached + * @return the mapping for the class, or an empty map if it has none + * @since 7.3.0 + */ + @SuppressWarnings("deprecation") + default Map computeMappingIfAbsent(Class clazz, Function> builder) { + if (containsNoMapping(clazz)) { + return Collections.emptyMap(); + } + Map mapping = getMapping(clazz); + if (mapping != null) { + return mapping; + } + mapping = builder.apply(clazz); + if (mapping == null || mapping.isEmpty()) { + addNoMapping(clazz); + return Collections.emptyMap(); + } + addMapping(clazz, mapping); + return mapping; + } +``` + +The `@SuppressWarnings("deprecation")` is deliberate: the fallback path must keep using the old primitives, because those are the only methods a third-party implementation is guaranteed to provide. + +- [ ] **Step 4: Override it in `StrutsTypeConverterHolder`** + +Add imports to `StrutsTypeConverterHolder.java`: + +```java +import java.util.Collections; +import java.util.function.Function; +``` + +Add the override after `getMapping`, and mark the three implementing methods `@Deprecated` to match the interface: + +Negative results are stored as a sentinel in the same map, so `computeIfAbsent` deduplicates the builder on **both** paths. This matters because the no-mapping case is the *common* one — an ordinary action class has no `-conversion.properties` and no `@Conversion` annotations, yet `buildConverterMapping` still walks its whole hierarchy doing classpath lookups and reflection. Returning `null` from the mapping function would store nothing, letting every concurrent caller re-run that walk: the exact thundering herd this method exists to prevent. + +Add the sentinel. It MUST be a distinct instance, not `Collections.emptyMap()`, whose shared JDK singleton could collide with an empty map a caller passed to `addMapping`: + +```java + private static final Map NO_MAPPING = Collections.unmodifiableMap(new HashMap<>()); +``` + +Delete the `noMapping` field — the sentinel replaces it. Then: + +```java + @Override + @Deprecated + public Map getMapping(Class clazz) { + Map mapping = mappings.get(clazz); + return mapping == NO_MAPPING ? null : mapping; + } + + @Override + @Deprecated + public boolean containsNoMapping(Class clazz) { + return mappings.get(clazz) == NO_MAPPING; + } + + @Override + public void addNoMapping(Class clazz) { + mappings.put(clazz, NO_MAPPING); + } + + @Override + public Map computeMappingIfAbsent(Class clazz, Function> builder) { + Map existing = mappings.get(clazz); + if (existing != null) { + return existing; + } + Map built = builder.apply(clazz); + Map value = (built == null || built.isEmpty()) ? NO_MAPPING : built; + Map previous = mappings.putIfAbsent(clazz, value); + return previous != null ? previous : value; + } +``` + +The builder deliberately runs **outside** `ConcurrentHashMap.computeIfAbsent`, via `get` → build → `putIfAbsent`. The builder instantiates arbitrary user-supplied `TypeConverter` classes (Spring-autowired under `SpringObjectFactory`), and running that under a CHM bin lock risks `IllegalStateException: Recursive update` or a self-deadlock if any of it re-enters conversion. The trade is that concurrent first access may build more than once; every caller still converges on the single stored mapping, and the build is idempotent. + +`getMapping` and `containsNoMapping` translate the sentinel, so both keep their existing contracts — `getMapping` still returns `null` for an absent *or* negative-cached class. + +Add `@Deprecated` to the `getMapping`, `addMapping` and `containsNoMapping` overrides in this class so they do not emit "overrides deprecated method" noise. + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `mvn test -DskipAssembly -pl core -Dtest=StrutsTypeConverterHolderTest` + +Expected: PASS, all nine tests. + +- [ ] **Step 6: Commit** + +```bash +git add core/src/main/java/org/apache/struts2/conversion/TypeConverterHolder.java \ + core/src/main/java/org/apache/struts2/conversion/StrutsTypeConverterHolder.java \ + core/src/test/java/org/apache/struts2/conversion/StrutsTypeConverterHolderTest.java +git commit -m "WW-5539 Add TypeConverterHolder#computeMappingIfAbsent + +Adds an atomic build-once-and-cache operation so callers no longer need +check-then-act around the class mapping cache, and deprecates the three +primitives it subsumes: getMapping, addMapping and containsNoMapping. + +The method is a default method delegating to those primitives, so +third-party TypeConverterHolder implementations keep working unchanged." +``` + +--- + +### Task 3: Remove the locks from `XWorkConverter` + +**Files:** +- Modify: `core/src/main/java/org/apache/struts2/conversion/impl/XWorkConverter.java:414-443` (`getConverter`), `:466-472` (`registerConverter`, `registerConverterNotFound`), `:551-578` (`buildConverterMapping`) +- Test: `core/src/test/java/org/apache/struts2/conversion/impl/XWorkConverterTest.java` (modify) + +**Interfaces:** +- Consumes: `TypeConverterHolder.computeMappingIfAbsent(Class, Function)` (Task 2). +- Produces: `protected Map buildConverterMapping(Class clazz) throws Exception` — **no longer stores** its result in the holder; it only builds and returns. `getConverter`, `registerConverter` and `registerConverterNotFound` are no longer synchronized. + +- [ ] **Step 1: Write the failing test** + +Append to `XWorkConverterTest.java`. Add these imports: + +```java +import org.apache.struts2.conversion.TypeConverterHolder; +import org.apache.struts2.conversion.StrutsTypeConverterHolder; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +``` + +Add this static nested class inside `XWorkConverterTest` (it is in package `org.apache.struts2.conversion.impl`, so it can call the protected `XWorkConverter()` constructor): + +```java + public static class CountingXWorkConverter extends XWorkConverter { + final AtomicInteger builds = new AtomicInteger(); + + public CountingXWorkConverter() { + super(); + } + + @Override + protected Map buildConverterMapping(Class clazz) throws Exception { + builds.incrementAndGet(); + return super.buildConverterMapping(clazz); + } + } +``` + +And these test methods: + +```java + public void testGetConverterBuildsMappingExactlyOncePerClass() throws Exception { + CountingXWorkConverter countingConverter = container.inject(CountingXWorkConverter.class); + // a cold, dedicated holder so other tests' cached mappings cannot mask the behaviour + countingConverter.setTypeConverterHolder(new StrutsTypeConverterHolder()); + + Object first = countingConverter.getConverter(User.class, "Collection_list"); + Object second = countingConverter.getConverter(User.class, "Collection_list"); + + assertEquals(String.class, first); + assertEquals(String.class, second); + assertEquals(1, countingConverter.builds.get()); + } + + public void testGetConverterBuildsMappingExactlyOnceUnderConcurrency() throws Exception { + final int threads = 16; + CountingXWorkConverter countingConverter = container.inject(CountingXWorkConverter.class); + countingConverter.setTypeConverterHolder(new StrutsTypeConverterHolder()); + + ExecutorService pool = Executors.newFixedThreadPool(threads); + CountDownLatch start = new CountDownLatch(1); + List> futures = new ArrayList<>(); + + for (int t = 0; t < threads; t++) { + futures.add(pool.submit(() -> { + start.await(); + return countingConverter.getConverter(User.class, "Collection_list"); + })); + } + + start.countDown(); + for (Future future : futures) { + assertEquals(String.class, future.get(60, TimeUnit.SECONDS)); + } + pool.shutdown(); + + assertEquals(1, countingConverter.builds.get()); + } + + public void testGetConverterReturnsNullForUnknownProperty() { + XWorkConverter freshConverter = container.inject(XWorkConverter.class); + freshConverter.setTypeConverterHolder(new StrutsTypeConverterHolder()); + + assertNull(freshConverter.getConverter(User.class, "noSuchPropertyAnywhere")); + } + + public void testGetConverterReturnsNullForNullProperty() { + XWorkConverter freshConverter = container.inject(XWorkConverter.class); + freshConverter.setTypeConverterHolder(new StrutsTypeConverterHolder()); + + assertNull(freshConverter.getConverter(User.class, null)); + } +``` + +`org.apache.struts2.test.User` is already imported by this test class and has a +`User-conversion.properties` declaring `Collection_list = java.lang.String`, so the built +mapping is non-empty and `getConverter` returns `String.class`. + +- [ ] **Step 2: Run the tests and record that they PASS** + +Run: `mvn test -DskipAssembly -pl core -Dtest=XWorkConverterTest` + +Expected: **PASS — all four new tests pass against the unmodified code.** + +This is deliberate and is not a mistake in the plan. Unlike Tasks 1 and 2, there is no failing-test-first cycle available here: the existing `synchronized (clazz)` block already guarantees build-once semantics, and `buildConverterMapping` already caches on the cold path. The old code is *correct*; it is only serialised. These tests exist to pin that correctness down **before** the lock is removed, so that Step 6 proves the refactor preserved it. + +If any of them FAIL at this step, stop and investigate — the current behaviour differs from what the design assumed, and the design needs revisiting before any lock comes out. + +- [ ] **Step 3: Rewrite `getConverter`** + +Replace lines 414-443 of `XWorkConverter.java` with: + +```java + protected Object getConverter(Class clazz, String property) { + LOG.debug("Retrieving converter for class [{}] and property [{}]", clazz, property); + + if (property == null) { + return null; + } + try { + Map mapping = converterHolder.computeMappingIfAbsent(clazz, this::buildConverterMappingUnchecked); + mapping = conditionalReload(clazz, mapping); + + Object converter = mapping.get(property); + if (converter == null && LOG.isDebugEnabled()) { + LOG.debug("Converter is null for property [{}]. Mapping size [{}]:", property, mapping.size()); + for (Map.Entry entry : mapping.entrySet()) { + LOG.debug("{}:{}", entry.getKey(), entry.getValue()); + } + } + return converter; + } catch (Throwable t) { + LOG.debug("Got exception trying to resolve converter for class [{}] and property [{}]", clazz, property, t); + converterHolder.addNoMapping(clazz); + return null; + } + } + + /** + * Adapts {@link #buildConverterMapping(Class)} to {@link java.util.function.Function} by + * rethrowing its checked exception unchecked. The caller's {@code catch (Throwable)} still + * negative-caches the class, so behaviour is unchanged. + */ + private Map buildConverterMappingUnchecked(Class clazz) { + try { + return buildConverterMapping(clazz); + } catch (Exception e) { + throw new IllegalStateException("Could not build converter mapping for " + clazz, e); + } + } +``` + +The `containsNoMapping` guard is gone because the holder now owns it: a negative-cached class returns an empty map and `mapping.get(property)` yields `null`, the same outcome as before. + +- [ ] **Step 4: Stop `buildConverterMapping` from storing** + +Replace lines 571-577 of `XWorkConverter.java` — the tail of `buildConverterMapping` — so it only builds and returns: + +```java + return mapping; + } +``` + +That is, delete this block entirely: + +```java + if (!mapping.isEmpty()) { + converterHolder.addMapping(clazz, mapping); + } else { + converterHolder.addNoMapping(clazz); + } +``` + +Now make `conditionalReload` store the rebuilt mapping, which `buildConverterMapping` no longer does. Without this, reload mode returns fresh data but never caches it, rebuilding from disk on every single request. Replace `conditionalReload` (lines 580-591) with: + +```java + @SuppressWarnings("deprecation") + private Map conditionalReload(Class clazz, Map oldValues) throws Exception { + Map mapping = oldValues; + + if (reloadingConfigs) { + URL fileUrl = ClassLoaderUtil.getResource(buildConverterFilename(clazz), clazz); + if (fileManager.fileNeedsReloading(fileUrl)) { + mapping = buildConverterMapping(clazz); + // addMapping is deprecated but remains the correct primitive here: + // computeMappingIfAbsent cannot express an unconditional overwrite. + converterHolder.addMapping(clazz, mapping); + } + } + + return mapping; + } +``` + +Update the method's Javadoc to record the behaviour change, since it is `protected` and visible to subclasses: + +```java + * @param clazz the class to look for converter mappings for + * @return the converter mappings + * @throws Exception in case of any errors + * @since 7.3.0 this method no longer stores the built mapping in the {@link TypeConverterHolder}; + * storage is owned by {@link TypeConverterHolder#computeMappingIfAbsent(Class, java.util.function.Function)}. +``` + +- [ ] **Step 5: Drop `synchronized` from the register methods** + +Replace lines 466-472 with: + +```java + public void registerConverter(String className, TypeConverter converter) { + converterHolder.addDefaultMapping(className, converter); + } + + public void registerConverterNotFound(String className) { + converterHolder.addUnknownMapping(className); + } +``` + +Both are now single delegations to a concurrent map. They are `public`, so the modifier change is externally observable — but any caller relying on them for mutual exclusion was relying on a lock that never covered the readers in `lookup()` anyway. + +Leave `lookup(String, boolean)` structurally unchanged. Its resolver `lookupSuper()` reads `getDefaultMapping()` recursively while walking the class hierarchy, and a recursive read inside `ConcurrentHashMap.computeIfAbsent` is forbidden — it deadlocks or throws `IllegalStateException: Recursive update`. It simply becomes lock-free. + +- [ ] **Step 6: Run the tests to verify they pass** + +Run: `mvn test -DskipAssembly -pl core -Dtest=XWorkConverterTest` + +Expected: PASS, including the four new tests and all pre-existing ones with no edits to them. + +- [ ] **Step 7: Run the wider conversion regression suite** + +Run: `mvn test -DskipAssembly -pl core -Dtest='AnnotationXWorkConverterTest,StrutsConversionPropertiesProcessorTest,StrutsTypeConverterHolderTest,ConfigurationManagerTest'` + +Expected: PASS. + +- [ ] **Step 8: Commit** + +```bash +git add core/src/main/java/org/apache/struts2/conversion/impl/XWorkConverter.java \ + core/src/test/java/org/apache/struts2/conversion/impl/XWorkConverterTest.java +git commit -m "WW-5539 Remove coarse locks from XWorkConverter + +getConverter() synchronized on the Class object being converted, which is +a globally visible monitor any other library may contend on, and which +serialised every conversion for a given action class including cache +hits. It now delegates to TypeConverterHolder#computeMappingIfAbsent. + +registerConverter and registerConverterNotFound drop their synchronized +modifier; they are single delegations to a concurrent map, and the lock +never covered the readers in lookup() in any case. + +buildConverterMapping no longer stores its result - storage is owned by +computeMappingIfAbsent." +``` + +--- + +### Task 4: Remove the global lock from `DefaultActionValidatorManager` + +The expected headline win: today every validated request in the application serialises on this singleton's monitor, and the lock covers per-request `Validator` construction that never needed it. + +**Files:** +- Modify: `core/src/main/java/org/apache/struts2/validator/DefaultActionValidatorManager.java:44` (import), `:70-71` (caches), `:139-163` (`getValidators`), `:280-350` (`buildValidatorConfigs`, `loadFile`) +- Test: `core/src/test/java/org/apache/struts2/validator/DefaultActionValidatorManagerTest.java` (modify) + +**Interfaces:** +- Consumes: nothing from earlier tasks. +- Produces: `getValidators(Class, String, String)` and `getValidators(Class, String)` are no longer synchronized. `protected List parseValidatorConfigs(URL fileUrl, String fileName)` is new. Cached `List` values are unmodifiable. + +- [ ] **Step 1: Write the failing test** + +Append to `DefaultActionValidatorManagerTest.java`. Add these imports: + +```java +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +``` + +```java + public void testConcurrentGetValidatorsReturnsConsistentResults() throws Exception { + final int threads = 16; + ExecutorService pool = Executors.newFixedThreadPool(threads); + CountDownLatch start = new CountDownLatch(1); + List> futures = new ArrayList<>(); + + int expectedCount = actionValidatorManager.getValidators(SimpleAction.class, alias).size(); + + for (int t = 0; t < threads; t++) { + futures.add(pool.submit(() -> { + start.await(); + return actionValidatorManager.getValidators(SimpleAction.class, alias).size(); + })); + } + + start.countDown(); + for (Future future : futures) { + assertThat(future.get(60, TimeUnit.SECONDS)).isEqualTo(expectedCount); + } + pool.shutdown(); + } + + public void testCachedValidatorConfigsAreUnmodifiable() { + actionValidatorManager.getValidators(SimpleAction.class, alias); + + List cached = actionValidatorManager.validatorCache.values().iterator().next(); + + assertThatThrownBy(() -> cached.add(null)) + .isInstanceOf(UnsupportedOperationException.class); + } +``` + +`assertThat` and `assertThatThrownBy` are already statically imported by this test class, and `alias`, `actionValidatorManager`, `SimpleAction` and `ArrayList`/`List` are already available. + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `mvn test -DskipAssembly -pl core -Dtest=DefaultActionValidatorManagerTest` + +Expected: `testCachedValidatorConfigsAreUnmodifiable` FAILS — the cached list is a plain `ArrayList`, so `add(null)` succeeds and no exception is thrown. + +`testConcurrentGetValidatorsReturnsConsistentResults` is expected to PASS at this step; the current code is correct, just serialised. It is a regression guard for the lock removal, not a demonstration of an existing bug. + +- [ ] **Step 3: Make the caches concurrent** + +In `DefaultActionValidatorManager.java`, delete the static import at line 44: + +```java +import static java.util.Collections.synchronizedMap; +``` + +Add: + +```java +import java.util.concurrent.ConcurrentHashMap; +``` + +Replace lines 70-71: + +```java + protected final Map> validatorCache = new ConcurrentHashMap<>(); + protected final Map> validatorFileCache = new ConcurrentHashMap<>(); +``` + +The declared field types stay `Map<...>` and stay `protected`, so `AnnotationActionValidatorManager` and any third-party subclass are unaffected. + +- [ ] **Step 4: Rewrite `getValidators` without the lock** + +Replace lines 139-163 with: + +```java + @Override + public List getValidators(Class clazz, String context, String method) { + String validatorKey = buildValidatorKey(clazz, context); + + List configs = validatorCache.get(validatorKey); + if (configs == null) { + configs = validatorCache.computeIfAbsent(validatorKey, + key -> buildValidatorConfigs(clazz, context, false, null)); + } else if (reloadingConfigs) { + configs = buildValidatorConfigs(clazz, context, true, null); + validatorCache.put(validatorKey, configs); + } + + ValueStack stack = ActionContext.getContext().getValueStack(); + List validators = new ArrayList<>(); + for (ValidatorConfig config : configs) { + if (method == null || method.equals(config.getParams().get("methodName"))) { + validators.add(getValidatorFromValidatorConfig(config, stack)); + } + } + return validators; + } + + @Override + public List getValidators(Class clazz, String context) { + return getValidators(clazz, context, null); + } +``` + +This mirrors the previous `containsKey` / `else if (reloadingConfigs)` logic exactly, including that a first-ever call builds with `checkFile=false` even in reload mode. The `Validator` construction loop is now outside any lock — it is per-request work on per-request objects. + +- [ ] **Step 5: Make `buildValidatorConfigs` return an unmodifiable list** + +Replace the final `return validatorConfigs;` at line 317 with: + +```java + return Collections.unmodifiableList(validatorConfigs); +``` + +`java.util.Collections` is already imported. With `synchronized` gone, several threads now iterate the same cached list concurrently; that is safe only while no cached list is mutated after publication. Wrapping makes it true by construction rather than by accident. + +- [ ] **Step 6: Rewrite `loadFile` and extract `parseValidatorConfigs`** + +Replace lines 330-350 with: + +```java + protected List loadFile(String fileName, Class clazz, boolean checkFile) { + URL fileUrl = ClassLoaderUtil.getResource(fileName, clazz); + + if (checkFile && fileManager.fileNeedsReloading(fileUrl)) { + List reloaded = parseValidatorConfigs(fileUrl, fileName); + validatorFileCache.put(fileName, reloaded); + return reloaded; + } + + return validatorFileCache.computeIfAbsent(fileName, key -> parseValidatorConfigs(fileUrl, fileName)); + } + + /** + * Parses the validator configs from the given file, returning an unmodifiable list. Returns an + * empty list when the file does not exist or cannot be read. + * + * @param fileUrl URL of the validation config file, may be null + * @param fileName name of the validation config file, used for logging and parser context + * @return an unmodifiable list of validator configs, never null + */ + protected List parseValidatorConfigs(URL fileUrl, String fileName) { + List retList = Collections.emptyList(); + + try (InputStream is = fileManager.loadFile(fileUrl)) { + if (is != null) { + retList = Collections.unmodifiableList( + new ArrayList<>(validatorFileParser.parseActionValidatorConfigs(validatorFactory, is, fileName))); + } + } catch (IOException e) { + LOG.error("Caught exception while closing file {}", fileName, e); + } + + return retList; + } +``` + +This `computeIfAbsent` runs inside the one on `validatorCache` (via `buildValidatorConfigs`). They are different maps and nothing calls back the other direction, so there is no lock-ordering cycle. Empty results are still cached, preserving today's negative caching. + +- [ ] **Step 7: Run the tests to verify they pass** + +Run: `mvn test -DskipAssembly -pl core -Dtest=DefaultActionValidatorManagerTest` + +Expected: PASS, including both new tests and all pre-existing ones with no edits to them. + +- [ ] **Step 8: Run the validator regression suite** + +Run: `mvn test -DskipAssembly -pl core -Dtest='ActionValidatorManagerTest,AnnotationActionValidatorManagerTest,DefaultActionValidatorManagerTest'` + +Expected: PASS. `AnnotationActionValidatorManagerTest` is the important one — it exercises the wildcard/alias key logic and the subclass that overrides `buildClassValidatorConfigs`. + +- [ ] **Step 9: Commit** + +```bash +git add core/src/main/java/org/apache/struts2/validator/DefaultActionValidatorManager.java \ + core/src/test/java/org/apache/struts2/validator/DefaultActionValidatorManagerTest.java +git commit -m "WW-5539 Remove global lock from DefaultActionValidatorManager + +getValidators() was synchronized on the singleton manager, so every +validated request in the application serialised on it - and the lock +covered the per-request Validator construction loop, which operates on +per-request objects and never needed mutual exclusion. + +Both caches become ConcurrentHashMap and cached config lists are wrapped +unmodifiable, since several threads now iterate them concurrently." +``` + +--- + +### Task 5: Full build, benchmark, and pull request + +**Files:** +- Create: `/ww5539-benchmark/` — throwaway, **not** committed +- Modify: none + +**Interfaces:** +- Consumes: all changes from Tasks 1-4. +- Produces: before/after throughput numbers for the PR description. + +- [ ] **Step 1: Run the full core test suite** + +Run: `mvn test -DskipAssembly -pl core` + +Expected: BUILD SUCCESS. If anything fails, fix it before proceeding — a green core suite is the gate for this change, since the failure mode of a bad lock removal is silent and load-dependent rather than a compile error. + +- [ ] **Step 2: Run the full multi-module build** + +Run: `mvn test -DskipAssembly` + +Expected: BUILD SUCCESS. This covers the plugins, which is where a third-party-style `TypeConverterHolder` or validator subclass would surface. + +- [ ] **Step 3: Write the throwaway benchmark** + +This runs as a **temporary test class** so it inherits the container bootstrap from `XWorkTestCase` — standing up a Struts container by hand is far more work than the benchmark is worth. It is deleted in Step 5 and never committed. + +Create `core/src/test/java/org/apache/struts2/validator/Ww5539BenchmarkTest.java`: + +```java +package org.apache.struts2.validator; + +import org.apache.struts2.SimpleAction; +import org.apache.struts2.XWorkTestCase; +import org.apache.struts2.conversion.impl.XWorkConverter; +import org.apache.struts2.test.User; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +public class Ww5539BenchmarkTest extends XWorkTestCase { + + private static final int[] THREAD_COUNTS = {1, 4, 16, 64}; + private static final int WARMUP_ITERATIONS = 20_000; + private static final long MEASURE_MILLIS = 3_000; + + public void testBenchmarkValidatorManager() throws Exception { + DefaultActionValidatorManager manager = container.inject(DefaultActionValidatorManager.class); + benchmark("getValidators", () -> manager.getValidators(SimpleAction.class, "validationAlias").size()); + } + + public void testBenchmarkConverter() throws Exception { + XWorkConverter converter = container.getInstance(XWorkConverter.class); + benchmark("getConverter", () -> converter.convertValue(null, new User(), null, "Collection_list", "1", String.class)); + } + + private void benchmark(String label, Callable op) throws Exception { + for (int i = 0; i < WARMUP_ITERATIONS; i++) { + op.call(); + } + System.out.println("=== " + label + " ==="); + for (int threads : THREAD_COUNTS) { + System.out.printf("%s threads=%d ops/sec=%,d%n", label, threads, measure(op, threads)); + } + } + + private long measure(Callable op, int threads) throws Exception { + ExecutorService pool = Executors.newFixedThreadPool(threads); + CountDownLatch start = new CountDownLatch(1); + long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(MEASURE_MILLIS); + List> futures = new ArrayList<>(); + + for (int t = 0; t < threads; t++) { + futures.add(pool.submit(() -> { + start.await(); + long count = 0; + while (System.nanoTime() < deadline) { + for (int i = 0; i < 100; i++) { + op.call(); + } + count += 100; + } + return count; + })); + } + + start.countDown(); + long total = 0; + for (Future future : futures) { + total += future.get(120, TimeUnit.SECONDS); + } + pool.shutdown(); + return total * 1000 / MEASURE_MILLIS; + } +} +``` + +Note the converter benchmark drives `convertValue` rather than the `protected getConverter` directly, so it exercises the real public path including the lock that Task 3 removed. + +- [ ] **Step 4: Capture before/after numbers** + +```bash +cd /Users/lukaszlenart/Projects/Apache/struts +git status --porcelain --untracked-files=no # must be empty + +# AFTER numbers, on the branch +mvn test -DskipAssembly -pl core -Dtest=Ww5539BenchmarkTest | tee /tmp/ww5539-after.txt + +# BEFORE numbers: stash the benchmark, check out main, reapply just the benchmark +git stash push --include-untracked core/src/test/java/org/apache/struts2/validator/Ww5539BenchmarkTest.java +git switch --detach main +git stash pop +mvn test -DskipAssembly -pl core -Dtest=Ww5539BenchmarkTest | tee /tmp/ww5539-before.txt + +# return to the branch +git stash push --include-untracked core/src/test/java/org/apache/struts2/validator/Ww5539BenchmarkTest.java +git switch WW-5539 +git stash pop +``` + +Record both sets in a markdown table: rows = thread counts, columns = before ops/sec, after ops/sec, ratio. + +Run each side twice and use the second run — the first is polluted by JIT and filesystem cache effects even with the warm-up loop. + +- [ ] **Step 5: Delete the benchmark** + +```bash +rm core/src/test/java/org/apache/struts2/validator/Ww5539BenchmarkTest.java +git status --porcelain # must show nothing to commit +``` + +Confirm it is gone before opening the PR. It must not appear in the diff. + +- [ ] **Step 6: Open the pull request** + +Report the benchmark results **faithfully**, including the realistic outcome that the converter changes may not move the needle while the validator lock does — the converter caches warm fast, and the validator lock is the coarser of the two. Do not present a null result as a win. + +The PR description must include: + +- `Fixes [WW-5539](https://issues.apache.org/jira/browse/WW-5539)` +- The before/after benchmark table with thread counts. +- A **Behaviour changes** section listing: + - `XWorkConverter.buildConverterMapping` (protected) no longer stores its result in the `TypeConverterHolder`. + - `StrutsTypeConverterHolder.unknownMappings` (protected) retyped from `HashSet` to `Set` and made `final` — a source-compatibility break for subclasses that assign it or call `HashSet`-specific methods. + - `addDefaultMapping` now ignores null converters with a warning instead of storing them. + - `XWorkConverter.registerConverter` / `registerConverterNotFound` are no longer `synchronized`. + - `TypeConverterHolder.getMapping`, `addMapping`, `containsNoMapping` deprecated. +- A **Known benign race** note: `lookup()` reads `containsUnknownMapping` and `containsDefaultMapping` as two separate atomic calls; the pair is not atomic, so a converter registered between them yields a stale `null` for that one call, self-correcting on the next lookup. No lock was added, because doing so would reintroduce the contention being removed on the hottest read path. +- A **Follow-ups** section: the classloader-pinning issue (`mappings` and `noMapping` hold strong `Class` references in a container singleton, keeping webapp classloaders alive across hot redeploy), and removal of the deprecated methods. + +Follow `~/.claude/pr_guideline.md`. + +--- + +## Notes for the implementer + +**Why `lookup()` keeps check-then-act.** It is tempting to "finish the job" and wrap the default-mappings cache in `computeIfAbsent` too. Do not. Its resolver, `lookupSuper()`, reads `getDefaultMapping()` recursively while walking the class hierarchy, and a recursive read inside `ConcurrentHashMap.computeIfAbsent` is explicitly forbidden — it deadlocks or throws `IllegalStateException: Recursive update`. The duplicated work on a cold miss is a handful of lock-free map reads. + +**Why `computeIfAbsent` on `mappings` is safe.** Its builder, `buildConverterMapping`, reaches `DefaultConversionFileProcessor` (which never touches the holder) and `DefaultConversionAnnotationProcessor` (which writes only to `defaultMappings`, a *different* map). Both were verified during design. If a future change makes the builder write to `mappings`, this becomes a deadlock — worth a comment if you touch that path. + +**Concurrency tests prove presence, not absence.** A green run on a strongly-ordered x86 machine is weak evidence. The primary correctness argument is the reasoning about the data structures; the tests are a backstop. Do not add sleeps or retry loops to make a flaky assertion pass — if one is flaky, the design reasoning is wrong and needs revisiting. + +**`conditionalReload` stays outside the compute** in `getConverter`. It only does work when `struts.configuration.xml.reload` is enabled and it must run on cache *hits* — that is its entire purpose. It stores its own rebuild via the deprecated `addMapping`, because `computeMappingIfAbsent` cannot express an unconditional overwrite. That deprecated call is deliberate and carries a comment saying so; do not "clean it up". diff --git a/docs/superpowers/specs/2026-07-21-WW-5539-concurrency-performance-design.md b/docs/superpowers/specs/2026-07-21-WW-5539-concurrency-performance-design.md new file mode 100644 index 0000000000..dff7af895f --- /dev/null +++ b/docs/superpowers/specs/2026-07-21-WW-5539-concurrency-performance-design.md @@ -0,0 +1,332 @@ +# WW-5539 — Concurrency performance enhancements + +**Jira**: [WW-5539](https://issues.apache.org/jira/browse/WW-5539) +**Fix version**: 7.3.0 +**Component**: Core +**Date**: 2026-07-21 + +## Problem + +WW-5539 names three classes as candidates for improved locking, without further detail. +Inspection confirms a distinct problem in each. + +### `StrutsTypeConverterHolder` — unsynchronised shared mutable state + +All four collections are plain `HashMap`/`HashSet` (`StrutsTypeConverterHolder.java:37-71`). +The holder is a container singleton mutated at runtime through `addDefaultMapping`, +`addMapping`, `addNoMapping` and `addUnknownMapping`. Meanwhile `XWorkConverter.lookup()` +(`XWorkConverter.java:364-368`) reads it under no lock at all, while `registerConverter()` +writes under the `XWorkConverter` monitor. Readers therefore race writers on a `HashMap`: +lost updates, and torn reads during resize. + +This is a correctness defect, not only a contention problem. + +### `XWorkConverter` — two coarse locks + +1. `getConverter()` wraps its body in `synchronized (clazz)` (`XWorkConverter.java:417`). + Locking on a `Class` object is a well-known anti-pattern — the monitor is globally + visible and any other library may contend on it. It also serialises every conversion + for a given action class, including pure cache hits. +2. `registerConverter` and `registerConverterNotFound` are `synchronized` methods + (`XWorkConverter.java:466,470`) on the singleton, so every cache miss takes a + process-wide lock. + +### `DefaultActionValidatorManager` — global lock on the request path + +`getValidators(...)` is `synchronized` on the singleton manager +(`DefaultActionValidatorManager.java:140`). Every validated request in the application +serialises on it. The lock covers not just the cache lookup but the per-request +`Validator` instantiation loop (lines 149-157), which operates on per-request objects +and never needed mutual exclusion. The caches are `synchronizedMap` wrappers with +non-atomic `containsKey`/`get`/`put` sequences layered on top (lines 143-150, 335-347). + +## Approach + +Make the caches genuinely concurrent, then delete the coarse locks. Where a computation +is expensive and provably safe to guard, use `computeIfAbsent`. Where it is not safe, +accept that two threads may occasionally redo cheap idempotent work and converge on the +same answer. + +These caches are read-mostly with a small warm-up burst, which is the workload +`ConcurrentHashMap` is built for. The resulting diff mostly removes code, which matters +for a change whose entire risk profile is the soundness of its concurrency reasoning. + +Two alternatives were considered and rejected: + +- **Striped per-key locking** (interning a lock object per class). Preserves + exactly-once computation everywhere, but adds a lock-object cache that itself needs + eviction reasoning, still blocks readers behind writers, and spreads the same + `Class`-keyed cache across a second map. `computeIfAbsent` provides the same + guarantee where it matters, for free. +- **Copy-on-write immutable snapshots.** Fastest possible reads, but `mappings` is keyed + by every action class in the application, so each cold miss copies the whole map: + O(n) per write, O(n²) to warm. Under `struts.configuration.xml.reload` writes never + stop. Wrong shape for this data. + +## Compatibility constraints + +`TypeConverterHolder` is an SPI: aliased to `struts.converter.holder` +(`StrutsBeanSelectionProvider.java:413`) and bound in `struts-beans.xml:115`. Third +parties can supply their own implementations. 7.3.0 is a minor release, so the interface +may gain `default` methods but must not break existing implementations. + +## Design + +### 1. `TypeConverterHolder` SPI + +One new `default` method: + +```java +default Map computeMappingIfAbsent(Class clazz, + Function> builder) +``` + +Contract: return the class's property-converter mapping, building and caching it on first +use. Returns `Collections.emptyMap()` when the class is known to have none — never +`null`. A builder returning `null` or an empty map means "no mapping", and the holder +records that in the negative cache itself. + +The `default` body implements this with the existing +`containsNoMapping`/`getMapping`/`addMapping`/`addNoMapping` primitives — check-then-act, +matching today's semantics. Third-party holders inherit it unchanged and keep working, +without the atomicity benefit. + +Because the `default` body calls methods this same change deprecates, it carries +`@SuppressWarnings("deprecation")`. That is intentional and not an oversight: the fallback +path must keep using the old primitives, since those are the only methods a third-party +implementation is guaranteed to provide. + +`StrutsTypeConverterHolder` overrides it: + +```java +if (noMapping.contains(clazz)) { + return Collections.emptyMap(); +} +Map mapping = mappings.computeIfAbsent(clazz, c -> { + Map built = builder.apply(c); + return (built == null || built.isEmpty()) ? null : built; +}); +if (mapping == null) { + noMapping.add(clazz); + return Collections.emptyMap(); +} +return mapping; +``` + +A builder returning `null` inside `computeIfAbsent` stores nothing and yields `null`, so +the negative case falls out naturally and `getMapping()` retains its current +"null when absent" meaning. + +**Fields** become `ConcurrentHashMap` and `ConcurrentHashMap.newKeySet()`. This is the +part that fixes the data race. + +**Deprecations.** `getMapping`, `addMapping` and `containsNoMapping` are marked +`@Deprecated`: all three are strictly subsumed by `computeMappingIfAbsent`, and each +invites check-then-act at call sites. + +`addNoMapping` is **not** deprecated. `XWorkConverter.getConverter` catches `Throwable` +and negative-caches the failure; that is a distinct operation ("this class failed to +build, stop retrying"), and folding it into the compute method would mean swallowing +`Throwable` inside the SPI, hiding real failures from implementers. + +The default-mapping methods (`addDefaultMapping`, `containsDefaultMapping`, +`getDefaultMapping`, `containsUnknownMapping`, `addUnknownMapping`) are **not** +deprecated. They back the cache that cannot use `computeIfAbsent` (see below) and have +live external callers in `StrutsConversionPropertiesProcessor` and +`DefaultConversionAnnotationProcessor`. + +Removal of the deprecated methods is tracked separately by the project lead. + +**The `protected HashSet unknownMappings` field** keeps its original +`protected HashSet` declaration, marked `@Deprecated(forRemoval = true)` and left +unused. Retyping it — the original plan — would have changed the field descriptor and thrown +`NoSuchFieldError` at runtime for any subclass compiled against an earlier version, a binary +break not permitted in a minor release. The live storage moves to a new `private` concurrent +set (`unknownMappingsInternal`, backed by `ConcurrentHashMap.newKeySet()`); the deprecated +field is retained solely for binary compatibility until its removal ticket lands. + +### 2. `XWorkConverter` + +**`getConverter(Class, String)` — `synchronized (clazz)` removed:** + +```java +protected Object getConverter(Class clazz, String property) { + if (property == null) { + return null; + } + try { + Map mapping = converterHolder.computeMappingIfAbsent(clazz, this::buildConverterMapping); + mapping = conditionalReload(clazz, mapping); + return mapping.get(property); + } catch (Throwable t) { + LOG.debug("Got exception trying to resolve converter for class [{}] and property [{}]", clazz, property, t); + converterHolder.addNoMapping(clazz); + return null; + } +} +``` + +The `containsNoMapping` guard folds into the holder: a negative-cached class returns an +empty map and `mapping.get(property)` yields `null`, the same outcome. The +`catch (Throwable)` behaviour is preserved exactly. + +Two consequences: + +- **`buildConverterMapping` stops storing.** It currently calls `addMapping`/`addNoMapping` + itself (`XWorkConverter.java:571-575`); with the holder owning storage that becomes a + double write. It reduces to "build and return the map". The method is `protected`, so + this is a behaviour change visible to subclasses and belongs in the release notes. +- **`buildConverterMapping` declares `throws Exception`**, which does not fit `Function`. + It is wrapped in a private lambda that rethrows as unchecked; the outer + `catch (Throwable)` still catches it, so negative-caching semantics are unchanged. + +**`conditionalReload` stays outside the compute.** It only does work when +`struts.configuration.xml.reload` is enabled, and it must run on cache *hits* — that is +its purpose. Under reload it may rebuild concurrently with last-write-wins; that is a +dev-mode-only path where the current code is no better, and rebuilding is idempotent. + +**`registerConverter` / `registerConverterNotFound` — `synchronized` removed.** Both are +now single delegations to a concurrent map. They are `public`, so the modifier is +externally observable; any caller relying on them for mutual exclusion was relying on a +lock that never covered the readers in `lookup()`. + +**`lookup(String, boolean)` keeps check-then-act.** Its resolver is `lookupSuper()` +(`XWorkConverter.java:600-626`), which *reads* `getDefaultMapping()` recursively while +walking the class hierarchy. A recursive read inside `ConcurrentHashMap.computeIfAbsent` +is forbidden — it deadlocks or throws `IllegalStateException: Recursive update`. This +cache therefore simply becomes lock-free. Two threads racing on a cold miss may both walk +the hierarchy and both call `registerConverter` with an equal result; the work is a few +lock-free map reads and the outcome is identical. + +**Known benign race, deliberately not closed.** `lookup` (line 364) reads +`containsUnknownMapping` and `containsDefaultMapping` as two separate calls. Each is +atomic against the concurrent map, but the *pair* is not — a converter registered between +them yields a stale `null` for that one call. Today, against an unsynchronised `HashMap`, +this is outright unsafe; afterwards it is a benign race that self-corrects on the next +lookup. No lock is added: doing so would reintroduce the contention being removed, on the +hottest read path, to close a window that resolves itself. This is recorded so a future +reader does not mistake it for an oversight. + +### 3. `DefaultActionValidatorManager` + +**Both `getValidators` overloads lose `synchronized`.** The three-argument one becomes: + +```java +String validatorKey = buildValidatorKey(clazz, context); +List configs = validatorCache.get(validatorKey); +if (configs == null) { + configs = validatorCache.computeIfAbsent(validatorKey, + k -> buildValidatorConfigs(clazz, context, false, null)); +} else if (reloadingConfigs) { + configs = buildValidatorConfigs(clazz, context, true, null); + validatorCache.put(validatorKey, configs); +} +``` + +This mirrors the existing `containsKey` / `else if (reloadingConfigs)` logic exactly, +including that a first-ever call builds with `checkFile=false` even in reload mode. + +**The substantive win is what now sits outside any lock:** the loop at lines 149-157 that +reads the `ValueStack` and calls `validatorFactory.getValidator(config)` per config. That +is per-request work on per-request objects. Setting caching aside entirely, removing it +from the critical path takes the manager off the serialisation path of every validated +request. + +**`loadFile` gets the same treatment**, with the file-reload branch kept explicit: + +```java +URL fileUrl = ClassLoaderUtil.getResource(fileName, clazz); +if (checkFile && fileManager.fileNeedsReloading(fileUrl)) { + List reloaded = parseValidatorConfigs(fileUrl, fileName); + validatorFileCache.put(fileName, reloaded); + return reloaded; +} +return validatorFileCache.computeIfAbsent(fileName, k -> parseValidatorConfigs(fileUrl, fileName)); +``` + +`parseValidatorConfigs(URL, String)` is a new private helper extracted from the existing +body of `loadFile` — the `fileManager.loadFile` / `validatorFileParser` / +`catch (IOException)` block at lines 336-342, unchanged in behaviour. It is extracted only +so the same logic can serve both branches above without duplication. + +This `computeIfAbsent` runs inside the one on `validatorCache`. They are different maps +and nothing calls back the other direction, so there is no lock-ordering cycle. Empty +results are still cached, preserving today's negative caching. + +**Both caches become `ConcurrentHashMap`.** The declared field types stay +`Map>` and stay `protected`, so `AnnotationActionValidatorManager` +and any third-party subclass are unaffected. + +**Cached lists are wrapped unmodifiable before publication.** With `synchronized` gone, +several threads iterate the same cached `List` concurrently. That is safe +only while no cached list is mutated after publication — true today, but only incidentally, +and `buildValidatorConfigs` does `addAll` into lists that flow around freely. Wrapping makes +it true by construction: a future mutation fails loudly at the mistake instead of becoming +an intermittent production heisenbug. Callers were checked — +`buildClassValidatorConfigs`/`buildAliasValidatorConfigs` results are only read or +`addAll`-ed into a fresh accumulator, and `AnnotationActionValidatorManager` already copies +defensively. + +**Noted limitation, untouched:** `AnnotationActionValidatorManager.buildValidatorKey` calls +`ActionContext.getContext().getActionInvocation()`, so the cache key depends on per-request +state. It works, but the class cannot be reasoned about purely from its own source. + +## Testing + +**Regression gate.** The existing `XWorkConverterTest`, `AnnotationXWorkConverterTest`, +`DefaultActionValidatorManagerTest` and `AnnotationActionValidatorManagerTest` +(`XWorkTestCase`-based, JUnit 4 as core actually uses) must pass untouched. Any need to +*edit* them signals a semantic change that this design says will not happen. + +**New concurrency tests**, added to the existing test classes. Each uses 16 threads +released simultaneously via `CountDownLatch`, and asserts on collected results rather than +on timing, so there is no sleep-based flakiness: + +- `StrutsTypeConverterHolder`: 16 threads registering distinct default mappings while + others read; assert every registration is visible at the end and no read returns a torn + result. This test would plausibly fail against today's code. +- `XWorkConverter.getConverter`: 16 threads resolving converters for the same class + concurrently; assert equal results and no exception. Additionally assert + `buildConverterMapping` runs exactly once per class via a counting subclass — the + concrete payoff of `computeMappingIfAbsent`. +- `DefaultActionValidatorManager.getValidators`: 16 threads against the same class and + context; assert consistent validator lists and a single build. + +**Caveat.** These tests can demonstrate a race but never its absence; a green run on a +strongly-ordered x86 machine is weak evidence. The primary correctness argument is the +reasoning about the data structures — reads on concurrent collections, both +`computeIfAbsent` call sites provably not re-entering their own maps, and the remaining +races benign and self-correcting. The tests are a backstop for that argument, not a +substitute. + +**Benchmark, not committed.** A throwaway harness measuring `getValidators` and +`getConverter` throughput at 1/4/16/64 threads, before and after, warmed. Numbers go in +the PR description. The result will be reported faithfully, including the realistic +outcome that the converter changes do not move the needle while the validator lock does — +the converter caches warm fast, and the validator lock is the coarser of the two. + +## Out of scope + +1. **Clearing the caches on cleanup (defense-in-depth).** `mappings` and the negative cache hold + strong references to application `Class` objects. This does **not** independently pin the webapp + classloader — the holder is a container-scoped singleton with no static, thread, or otherwise + external reference, so it is reachable only through the container and is collected together with it. + The classloader's survival is governed by whatever retains the *container*, which is out of scope + here and handled by the WW-5537 cleanup work. As a tidiness measure, clearing these caches during + `Dispatcher.cleanup()` (via `InternalDestroyable`) is folded into WW-5537 as Task 5b; it is not a + leak fix. +2. **The `FIXME lukaszlenart` in `TypeConverterHolder`** about merging `unknownMappings` + into `noMapping` — a semantic consolidation, unrelated to locking. +3. **Removal of the deprecated methods** — tracked by the project lead for a later release. + +## Risk + +The change is mostly deletion, but it deletes locks, so the failure mode is silent and +load-dependent rather than a failing build. Two things warrant the closest review: + +- No `computeIfAbsent` builder can re-enter its own map. Verified for both call sites; + the `lookupSuper` case is precisely why the default-mapping cache keeps check-then-act. + Confirmed that `DefaultConversionFileProcessor` never touches the holder and + `DefaultConversionAnnotationProcessor` writes only to `defaultMappings`, a different map. +- No cached collection is mutated after publication — enforced by unmodifiable wrapping in + `DefaultActionValidatorManager`.