This is an automated email from the ASF dual-hosted git repository.
lukaszlenart pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/struts.git
The following commit(s) were added to refs/heads/main by this push:
new a6570b769 WW-5668 Bound the localized-text provider caches and align
request-locale resolution (#1821)
a6570b769 is described below
commit a6570b769bef8dfc4d7cb132d94eba11bab1bec4
Author: Lukasz Lenart <[email protected]>
AuthorDate: Sat Aug 1 10:04:40 2026 +0200
WW-5668 Bound the localized-text provider caches and align request-locale
resolution (#1821)
* WW-5668 docs: design spec for bounded i18n caches and request-locale
resolution
Follow-up to WW-5540. Bound the AbstractLocalizedTextProvider caches via the
existing OgnlCache abstraction (configurable
struts.i18n.cacheType/cacheMaxSize),
and add opt-in request-locale resolution consistency between Dispatcher and
I18nInterceptor (struts.locale.validateRequestLocale, default off).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
* WW-5668 docs: implementation plan for bounded i18n caches and
request-locale resolution
Co-Authored-By: Claude Opus 4.8 <[email protected]>
* WW-5668 Add remove(key) to the OgnlCache abstraction
Co-Authored-By: Claude Opus 4.8 <[email protected]>
* WW-5668 Bound the localized-text provider caches with configurable size
Co-Authored-By: Claude Opus 4.8 <[email protected]>
* WW-5668 Fix reassignable-lock hazard and add volatile to i18n cache fields
synchronized (bundlesMap) locked on a monitor that rebuildI18nCaches()
can reassign; introduce a dedicated bundlesMapLock and lock on that
instead. Mark the five i18n cache fields volatile for safe publication
across the reassignment.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
* WW-5668 Add opt-in request-locale resolution consistency to Dispatcher
Co-Authored-By: Claude Opus 4.8 <[email protected]>
* WW-5668 Keep the localized-text caches transient so the provider stays
serializable
Co-Authored-By: Claude Opus 4.8 <[email protected]>
* WW-5668 Add missing Javadoc and cache-rebuild coverage for i18n provider
Add the one-line Javadoc that sibling fields/setters carry to
validateRequestLocale and its @Inject setter in Dispatcher, and add two
tests covering StrutsLocalizedTextProvider's serialize/deserialize cache
rebuild and cacheType selection behaviour.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
* WW-5668 Pin explicit serialVersionUID on the localized-text providers
Co-Authored-By: Claude Opus 4.8 <[email protected]>
* WW-5668 Suppress false-positive Sonar S3077 on the thread-safe i18n caches
Co-Authored-By: Claude Opus 4.8 <[email protected]>
* WW-5668 Drop the unused throws Exception from the new Dispatcher locale
tests
Co-Authored-By: Claude Opus 4.8 <[email protected]>
---------
Co-authored-by: Claude Opus 4.8 <[email protected]>
---
.../java/org/apache/struts2/StrutsConstants.java | 24 +
.../org/apache/struts2/dispatcher/Dispatcher.java | 41 +-
.../java/org/apache/struts2/ognl/OgnlCache.java | 9 +
.../org/apache/struts2/ognl/OgnlCaffeineCache.java | 5 +
.../org/apache/struts2/ognl/OgnlDefaultCache.java | 5 +
.../java/org/apache/struts2/ognl/OgnlLRUCache.java | 5 +
.../text/AbstractLocalizedTextProvider.java | 100 +++-
.../struts2/text/GlobalLocalizedTextProvider.java | 2 +
.../struts2/text/StrutsLocalizedTextProvider.java | 2 +
.../org/apache/struts2/default.properties | 10 +
.../apache/struts2/dispatcher/DispatcherTest.java | 40 ++
.../apache/struts2/ognl/OgnlCacheRemoveTest.java | 50 ++
.../text/StrutsLocalizedTextProviderTest.java | 73 +++
...-WW-5668-i18n-cache-bounds-locale-resolution.md | 617 +++++++++++++++++++++
...8-i18n-cache-bounds-locale-resolution-design.md | 215 +++++++
15 files changed, 1181 insertions(+), 17 deletions(-)
diff --git a/core/src/main/java/org/apache/struts2/StrutsConstants.java
b/core/src/main/java/org/apache/struts2/StrutsConstants.java
index 5a09cfa78..9c1245248 100644
--- a/core/src/main/java/org/apache/struts2/StrutsConstants.java
+++ b/core/src/main/java/org/apache/struts2/StrutsConstants.java
@@ -135,6 +135,14 @@ public final class StrutsConstants {
*/
public static final String STRUTS_LOCALE = "struts.locale";
+ /**
+ * When enabled, request-derived locales (from {@code Accept-Language},
used when {@code struts.locale} is
+ * unset) are restricted to the JVM's available-locale set; unavailable
values fall back to the default.
+ *
+ * @since 7.3.0
+ */
+ public static final String STRUTS_LOCALE_VALIDATE_REQUEST =
"struts.locale.validateRequestLocale";
+
/**
* Whether to use a Servlet request parameter workaround necessary for
some versions of WebLogic
*/
@@ -544,6 +552,22 @@ public final class StrutsConstants {
*/
public static final String STRUTS_OGNL_EXPRESSION_CACHE_MAXSIZE =
"struts.ognl.expressionCacheMaxSize";
+ /**
+ * Specifies the type of cache to use for the localized-text provider
caches. Valid values defined in
+ * {@link org.apache.struts2.ognl.OgnlCacheFactory.CacheType}.
+ *
+ * @since 7.3.0
+ */
+ public static final String STRUTS_I18N_CACHE_TYPE =
"struts.i18n.cacheType";
+
+ /**
+ * Specifies the maximum size of each localized-text provider cache.
Configure based on the cache type
+ * chosen and application-specific needs.
+ *
+ * @since 7.3.0
+ */
+ public static final String STRUTS_I18N_CACHE_MAXSIZE =
"struts.i18n.cacheMaxSize";
+
/**
* Specifies the type of cache to use for proxy detection. Valid values
defined in
* {@link org.apache.struts2.ognl.OgnlCacheFactory.CacheType}.
diff --git a/core/src/main/java/org/apache/struts2/dispatcher/Dispatcher.java
b/core/src/main/java/org/apache/struts2/dispatcher/Dispatcher.java
index 34d6c13c8..5b7d90556 100644
--- a/core/src/main/java/org/apache/struts2/dispatcher/Dispatcher.java
+++ b/core/src/main/java/org/apache/struts2/dispatcher/Dispatcher.java
@@ -151,6 +151,11 @@ public class Dispatcher {
*/
private String defaultLocale;
+ /**
+ * Store state of {@link StrutsConstants#STRUTS_LOCALE_VALIDATE_REQUEST}
setting.
+ */
+ private boolean validateRequestLocale = false;
+
/**
* Store state of {@link StrutsConstants#STRUTS_MULTIPART_SAVE_DIR}
setting.
*/
@@ -310,6 +315,16 @@ public class Dispatcher {
defaultLocale = val;
}
+ /**
+ * Modify state of {@link StrutsConstants#STRUTS_LOCALE_VALIDATE_REQUEST}
setting.
+ *
+ * @param val New setting
+ */
+ @Inject(value = StrutsConstants.STRUTS_LOCALE_VALIDATE_REQUEST, required =
false)
+ public void setValidateRequestLocale(String val) {
+ validateRequestLocale = Boolean.parseBoolean(val);
+ }
+
/**
* Modify state of StrutsConstants.STRUTS_I18N_ENCODING setting.
*
@@ -929,7 +944,7 @@ public class Dispatcher {
locale = LocaleUtils.toLocale(defaultLocale);
} catch (IllegalArgumentException e) {
try {
- locale = request.getLocale();
+ locale = resolveRequestLocale(request);
LOG.warn(new ParameterizedMessage("Cannot convert
'struts.locale' = [{}] to proper locale, defaulting to request locale [{}]",
defaultLocale, locale), e);
} catch (RuntimeException rex) {
@@ -940,7 +955,7 @@ public class Dispatcher {
}
} else {
try {
- locale = request.getLocale();
+ locale = resolveRequestLocale(request);
} catch (RuntimeException rex) {
LOG.warn("Cannot get locale from HTTP Request, falling back to
system default locale", rex);
locale = Locale.getDefault();
@@ -949,6 +964,28 @@ public class Dispatcher {
return locale;
}
+ /**
+ * Resolves the request locale. When {@code
struts.locale.validateRequestLocale} is enabled and the
+ * request locale is not part of the JVM's available-locale set, falls
back to the configured
+ * {@code struts.locale} when set and parseable, otherwise the JVM
default. When disabled (default),
+ * returns the request locale unchanged.
+ */
+ protected Locale resolveRequestLocale(HttpServletRequest request) {
+ Locale locale = request.getLocale();
+ if (!validateRequestLocale || LocaleUtils.isAvailableLocale(locale)) {
+ return locale;
+ }
+ if (defaultLocale != null) {
+ try {
+ return LocaleUtils.toLocale(defaultLocale);
+ } catch (IllegalArgumentException e) {
+ LOG.debug("Configured 'struts.locale' = [{}] is not parseable;
falling back to system default", defaultLocale);
+ }
+ }
+ LOG.debug("Request locale [{}] is not available; falling back to
system default locale", locale);
+ return Locale.getDefault();
+ }
+
/**
* Return the path to save uploaded files to (this is configurable).
*
diff --git a/core/src/main/java/org/apache/struts2/ognl/OgnlCache.java
b/core/src/main/java/org/apache/struts2/ognl/OgnlCache.java
index d7c88b336..64c7d9eb5 100644
--- a/core/src/main/java/org/apache/struts2/ognl/OgnlCache.java
+++ b/core/src/main/java/org/apache/struts2/ognl/OgnlCache.java
@@ -47,6 +47,15 @@ public interface OgnlCache<K, V> {
void putIfAbsent(K key, V value);
+ /**
+ * Removes the mapping for the given key, if present.
+ *
+ * @param key the key to remove
+ * @return the previous value associated with the key, or {@code null} if
none
+ * @since 7.3.0
+ */
+ V remove(K key);
+
int size();
void clear();
diff --git a/core/src/main/java/org/apache/struts2/ognl/OgnlCaffeineCache.java
b/core/src/main/java/org/apache/struts2/ognl/OgnlCaffeineCache.java
index b7f5b2750..883e6645f 100644
--- a/core/src/main/java/org/apache/struts2/ognl/OgnlCaffeineCache.java
+++ b/core/src/main/java/org/apache/struts2/ognl/OgnlCaffeineCache.java
@@ -63,6 +63,11 @@ public class OgnlCaffeineCache<K, V> implements OgnlCache<K,
V> {
cache.asMap().putIfAbsent(key, value);
}
+ @Override
+ public V remove(K key) {
+ return cache.asMap().remove(key);
+ }
+
@Override
public int size() {
return cache.asMap().size();
diff --git a/core/src/main/java/org/apache/struts2/ognl/OgnlDefaultCache.java
b/core/src/main/java/org/apache/struts2/ognl/OgnlDefaultCache.java
index 391944a10..70239cdc3 100644
--- a/core/src/main/java/org/apache/struts2/ognl/OgnlDefaultCache.java
+++ b/core/src/main/java/org/apache/struts2/ognl/OgnlDefaultCache.java
@@ -63,6 +63,11 @@ public class OgnlDefaultCache<K, V> implements OgnlCache<K,
V> {
clearIfEvictionLimitExceeded();
}
+ @Override
+ public V remove(K key) {
+ return ognlCache.remove(key);
+ }
+
@Override
public int size() {
return ognlCache.size();
diff --git a/core/src/main/java/org/apache/struts2/ognl/OgnlLRUCache.java
b/core/src/main/java/org/apache/struts2/ognl/OgnlLRUCache.java
index fc995d438..f1f49467f 100644
--- a/core/src/main/java/org/apache/struts2/ognl/OgnlLRUCache.java
+++ b/core/src/main/java/org/apache/struts2/ognl/OgnlLRUCache.java
@@ -70,6 +70,11 @@ public class OgnlLRUCache<K, V> implements OgnlCache<K, V> {
ognlLRUCache.putIfAbsent(key, value);
}
+ @Override
+ public V remove(K key) {
+ return ognlLRUCache.remove(key);
+ }
+
@Override
public int size() {
return ognlLRUCache.size();
diff --git
a/core/src/main/java/org/apache/struts2/text/AbstractLocalizedTextProvider.java
b/core/src/main/java/org/apache/struts2/text/AbstractLocalizedTextProvider.java
index 2873e311e..2aaebaee4 100644
---
a/core/src/main/java/org/apache/struts2/text/AbstractLocalizedTextProvider.java
+++
b/core/src/main/java/org/apache/struts2/text/AbstractLocalizedTextProvider.java
@@ -18,12 +18,16 @@
*/
package org.apache.struts2.text;
+import org.apache.commons.lang3.EnumUtils;
import org.apache.commons.lang3.ObjectUtils;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.apache.struts2.ActionContext;
import org.apache.struts2.StrutsConstants;
import org.apache.struts2.inject.Inject;
+import org.apache.struts2.ognl.DefaultOgnlCacheFactory;
+import org.apache.struts2.ognl.OgnlCache;
+import org.apache.struts2.ognl.OgnlCacheFactory.CacheType;
import org.apache.struts2.util.TextParseUtil;
import org.apache.struts2.util.ValueStack;
@@ -46,6 +50,8 @@ import java.util.concurrent.CopyOnWriteArrayList;
abstract class AbstractLocalizedTextProvider implements LocalizedTextProvider {
+ private static final long serialVersionUID = 1L;
+
private static final Logger LOG =
LogManager.getLogger(AbstractLocalizedTextProvider.class);
public static final String XWORK_MESSAGES_BUNDLE =
"org/apache/struts2/xwork-messages";
@@ -59,17 +65,38 @@ abstract class AbstractLocalizedTextProvider implements
LocalizedTextProvider {
@SuppressWarnings("java:S2129") // deliberate: a non-interned instance is
required for an identity (==) sentinel
private static final String NOT_FOUND = new
String("__STRUTS_TEXT_NOT_FOUND__"); // unique identity sentinel; compared with
==
- protected final ConcurrentMap<String, ResourceBundle> bundlesMap = new
ConcurrentHashMap<>();
protected boolean devMode = false;
protected boolean reloadBundles = false;
protected boolean searchDefaultBundlesFirst = false; // Search default
resource bundles first. Note: This flag may not be meaningful to all
implementations.
- private final ConcurrentMap<MessageFormatKey, MessageFormat>
messageFormats = new ConcurrentHashMap<>();
private final ConcurrentMap<Integer, List<String>> classLoaderMap = new
ConcurrentHashMap<>();
- private final Set<String> missingBundles = ConcurrentHashMap.newKeySet();
private final ConcurrentMap<Integer, ClassLoader> delegatedClassLoaderMap
= new ConcurrentHashMap<>();
- private final ConcurrentMap<TextCacheKey, String> classHierarchyCache =
new ConcurrentHashMap<>();
- private final ConcurrentMap<TextCacheKey, String> packageHierarchyCache =
new ConcurrentHashMap<>();
+
+ // Dedicated monitor for bundlesMap-related synchronization: bundlesMap is
reassigned by
+ // rebuildI18nCaches(), so locking on it directly would lock on a monitor
that can change identity.
+ // transient + reinitialised in readObject: a bare Object is not
Serializable.
+ private transient Object bundlesMapLock = new Object();
+
+ private volatile CacheType i18nCacheType = CacheType.WTLFU;
+ private volatile int i18nCacheMaxSize = 10000;
+
+ private <K, V> OgnlCache<K, V> buildI18nCache() {
+ return new DefaultOgnlCacheFactory<K, V>(i18nCacheMaxSize,
i18nCacheType).buildOgnlCache();
+ }
+
+ // The OgnlCache implementations are themselves thread-safe; volatile only
safely publishes the
+ // reference when rebuildI18nCaches() replaces a cache (during injection /
readObject), so S3077
+ // ("volatile is not enough") does not apply here.
+ @SuppressWarnings("java:S3077")
+ protected transient volatile OgnlCache<String, ResourceBundle> bundlesMap
= buildI18nCache();
+ @SuppressWarnings("java:S3077")
+ private transient volatile OgnlCache<MessageFormatKey, MessageFormat>
messageFormats = buildI18nCache();
+ @SuppressWarnings("java:S3077")
+ private transient volatile OgnlCache<String, Boolean> missingBundles =
buildI18nCache();
+ @SuppressWarnings("java:S3077")
+ private transient volatile OgnlCache<TextCacheKey, String>
classHierarchyCache = buildI18nCache();
+ @SuppressWarnings("java:S3077")
+ private transient volatile OgnlCache<TextCacheKey, String>
packageHierarchyCache = buildI18nCache();
@Override
public void addDefaultResourceBundle(String bundleName) {
@@ -110,6 +137,21 @@ abstract class AbstractLocalizedTextProvider implements
LocalizedTextProvider {
return packageHierarchyCache.size();
}
+ /** Test-support accessor: current number of cached resource bundles. */
+ protected int bundlesMapSize() {
+ return bundlesMap.size();
+ }
+
+ /** Test-support accessor: current number of cached missing-bundle
markers. */
+ protected int missingBundlesSize() {
+ return missingBundles.size();
+ }
+
+ /** Test-support accessor: current number of cached message formats. */
+ protected int messageFormatsSize() {
+ return messageFormats.size();
+ }
+
@Inject(value = StrutsConstants.STRUTS_CUSTOM_I18N_RESOURCES, required =
false)
public void setCustomI18NResources(String bundles) {
if (bundles == null || bundles.isEmpty()) {
@@ -191,7 +233,7 @@ abstract class AbstractLocalizedTextProvider implements
LocalizedTextProvider {
* @param classLoader a {@link ClassLoader} to look up the bundle from if
none can be found on the current thread's classloader
*/
public void setDelegatedClassLoader(final ClassLoader classLoader) {
- synchronized (bundlesMap) {
+ synchronized (bundlesMapLock) {
delegatedClassLoaderMap.put(getCurrentThreadContextClassLoader().hashCode(),
classLoader);
}
}
@@ -404,39 +446,67 @@ abstract class AbstractLocalizedTextProvider implements
LocalizedTextProvider {
this.searchDefaultBundlesFirst =
Boolean.parseBoolean(searchDefaultBundlesFirst);
}
+ @Inject(value = StrutsConstants.STRUTS_I18N_CACHE_TYPE, required = false)
+ public void setI18nCacheType(String cacheType) {
+ this.i18nCacheType = EnumUtils.getEnumIgnoreCase(CacheType.class,
cacheType, CacheType.WTLFU);
+ rebuildI18nCaches();
+ }
+
+ @Inject(value = StrutsConstants.STRUTS_I18N_CACHE_MAXSIZE, required =
false)
+ public void setI18nCacheMaxSize(String cacheMaxSize) {
+ this.i18nCacheMaxSize = Integer.parseInt(cacheMaxSize);
+ rebuildI18nCaches();
+ }
+
+ /**
+ * Rebuilds the localized-text caches from the current type/size. Called
during dependency injection
+ * (single-threaded startup, before the provider serves lookups); discards
any warm-up entries.
+ */
+ private void rebuildI18nCaches() {
+ bundlesMap = buildI18nCache();
+ messageFormats = buildI18nCache();
+ missingBundles = buildI18nCache();
+ classHierarchyCache = buildI18nCache();
+ packageHierarchyCache = buildI18nCache();
+ }
+
+ private void readObject(java.io.ObjectInputStream in) throws
java.io.IOException, ClassNotFoundException {
+ in.defaultReadObject();
+ bundlesMapLock = new Object();
+ rebuildI18nCaches();
+ }
+
@Override
public ResourceBundle findResourceBundle(String bundleName, Locale locale)
{
ClassLoader classLoader = getCurrentThreadContextClassLoader();
String key = createMissesKey(String.valueOf(classLoader.hashCode()),
bundleName, locale);
- if (missingBundles.contains(key)) {
+ if (missingBundles.get(key) != null) {
return null;
}
ResourceBundle bundle = null;
try {
- if (bundlesMap.containsKey(key)) {
- bundle = bundlesMap.get(key);
- } else {
+ bundle = bundlesMap.get(key);
+ if (bundle == null) {
bundle = ResourceBundle.getBundle(bundleName, locale,
classLoader);
bundlesMap.putIfAbsent(key, bundle);
}
} catch (MissingResourceException ex) {
if (delegatedClassLoaderMap.containsKey(classLoader.hashCode())) {
try {
- if (bundlesMap.containsKey(key)) {
- bundle = bundlesMap.get(key);
- } else {
+ bundle = bundlesMap.get(key);
+ if (bundle == null) {
bundle = ResourceBundle.getBundle(bundleName, locale,
delegatedClassLoaderMap.get(classLoader.hashCode()));
bundlesMap.putIfAbsent(key, bundle);
}
} catch (MissingResourceException e) {
LOG.debug("Missing resource bundle [{}]!", bundleName, e);
- missingBundles.add(key);
+ missingBundles.put(key, Boolean.TRUE);
}
} else {
LOG.debug("Missing resource bundle [{}]!", bundleName);
- missingBundles.add(key);
+ missingBundles.put(key, Boolean.TRUE);
}
}
return bundle;
diff --git
a/core/src/main/java/org/apache/struts2/text/GlobalLocalizedTextProvider.java
b/core/src/main/java/org/apache/struts2/text/GlobalLocalizedTextProvider.java
index 31ea21650..72383de5e 100644
---
a/core/src/main/java/org/apache/struts2/text/GlobalLocalizedTextProvider.java
+++
b/core/src/main/java/org/apache/struts2/text/GlobalLocalizedTextProvider.java
@@ -34,6 +34,8 @@ import java.util.ResourceBundle;
*/
public class GlobalLocalizedTextProvider extends AbstractLocalizedTextProvider
{
+ private static final long serialVersionUID = 1L;
+
private static final Logger LOG =
LogManager.getLogger(GlobalLocalizedTextProvider.class);
public GlobalLocalizedTextProvider() {
diff --git
a/core/src/main/java/org/apache/struts2/text/StrutsLocalizedTextProvider.java
b/core/src/main/java/org/apache/struts2/text/StrutsLocalizedTextProvider.java
index 71340ffe8..1a0976889 100644
---
a/core/src/main/java/org/apache/struts2/text/StrutsLocalizedTextProvider.java
+++
b/core/src/main/java/org/apache/struts2/text/StrutsLocalizedTextProvider.java
@@ -38,6 +38,8 @@ import java.util.ResourceBundle;
*/
public class StrutsLocalizedTextProvider extends AbstractLocalizedTextProvider
{
+ private static final long serialVersionUID = 1L;
+
private static final Logger LOG =
LogManager.getLogger(StrutsLocalizedTextProvider.class);
private transient ReflectionProvider reflectionProvider;
diff --git a/core/src/main/resources/org/apache/struts2/default.properties
b/core/src/main/resources/org/apache/struts2/default.properties
index 51c613a95..a14c84bcc 100644
--- a/core/src/main/resources/org/apache/struts2/default.properties
+++ b/core/src/main/resources/org/apache/struts2/default.properties
@@ -24,6 +24,9 @@
### This can be used to set your default locale and encoding scheme
# struts.locale=en_US
+### When true, restrict request-derived locales (Accept-Language, used when
struts.locale is unset) to the
+### JVM's available-locale set; unavailable values fall back to the default
locale. Defaults to false.
+struts.locale.validateRequestLocale=false
struts.i18n.encoding=UTF-8
### if specified, the default object factory can be overridden here
@@ -296,6 +299,13 @@ struts.ognl.expressionCacheType=wtlfu
### chosen and application-specific needs.
struts.ognl.expressionCacheMaxSize=10000
+### Specifies the type of cache to use for the localized-text provider caches.
See StrutsConstants for details.
+struts.i18n.cacheType=wtlfu
+
+### Specifies the maximum size of each localized-text provider cache. This
should be configured based on the
+### cache type chosen and application-specific needs.
+struts.i18n.cacheMaxSize=10000
+
### Specifies the type of cache to use for BeanInfo objects. See
StrutsConstants class for further information.
struts.ognl.beanInfoCacheType=wtlfu
diff --git
a/core/src/test/java/org/apache/struts2/dispatcher/DispatcherTest.java
b/core/src/test/java/org/apache/struts2/dispatcher/DispatcherTest.java
index cdfe64b2c..ab1191cbe 100644
--- a/core/src/test/java/org/apache/struts2/dispatcher/DispatcherTest.java
+++ b/core/src/test/java/org/apache/struts2/dispatcher/DispatcherTest.java
@@ -571,6 +571,46 @@ public class DispatcherTest extends
StrutsJUnit4InternalTestCase {
assertEquals(Locale.getDefault(), context.getLocale()); // Expect the
system default value when Mock request access fails.
}
+ @Test
+ public void testValidateRequestLocaleOffPassesThrough() {
+ initDispatcher(new HashMap<>());
+ dispatcher.setDefaultLocale(null); // Force struts.locale unset; the
test-config default would otherwise mask the request locale.
+ HttpServletRequest request = mock(HttpServletRequest.class);
+ // A syntactically valid but not JVM-available locale.
+ Locale exotic = new Locale("en", "US", "xzz99");
+ when(request.getLocale()).thenReturn(exotic);
+
+ assertEquals("Default off must pass the request locale through
unchanged",
+ exotic, dispatcher.getLocale(request));
+ }
+
+ @Test
+ public void testValidateRequestLocaleOnKeepsAvailableLocale() {
+ Map<String, String> params = new HashMap<>();
+ params.put(StrutsConstants.STRUTS_LOCALE_VALIDATE_REQUEST, "true");
+ initDispatcher(params);
+ dispatcher.setDefaultLocale(null); // Force struts.locale unset; the
test-config default would otherwise mask the request locale.
+ HttpServletRequest request = mock(HttpServletRequest.class);
+ when(request.getLocale()).thenReturn(Locale.UK);
+
+ assertEquals("Available request locale must be kept", Locale.UK,
dispatcher.getLocale(request));
+ }
+
+ @Test
+ public void testValidateRequestLocaleOnFallsBackForUnavailableLocale() {
+ Map<String, String> params = new HashMap<>();
+ params.put(StrutsConstants.STRUTS_LOCALE_VALIDATE_REQUEST, "true");
+ initDispatcher(params);
+ dispatcher.setDefaultLocale(null); // Force struts.locale unset; the
test-config default would otherwise mask the request locale.
+ HttpServletRequest request = mock(HttpServletRequest.class);
+ Locale exotic = new Locale("en", "US", "xzz99");
+ when(request.getLocale()).thenReturn(exotic);
+
+ // struts.locale unset in this dispatcher -> fall back to the JVM
default.
+ assertEquals("Unavailable request locale must fall back to system
default",
+ Locale.getDefault(), dispatcher.getLocale(request));
+ }
+
@Test
public void dispatcherReinjectedAfterReload() {
HttpServletRequest request = mock(HttpServletRequest.class);
diff --git
a/core/src/test/java/org/apache/struts2/ognl/OgnlCacheRemoveTest.java
b/core/src/test/java/org/apache/struts2/ognl/OgnlCacheRemoveTest.java
new file mode 100644
index 000000000..f84d0f787
--- /dev/null
+++ b/core/src/test/java/org/apache/struts2/ognl/OgnlCacheRemoveTest.java
@@ -0,0 +1,50 @@
+/*
+ * 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.ognl;
+
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+
+public class OgnlCacheRemoveTest {
+
+ private void assertRemoveContract(OgnlCache<String, String> cache) {
+ cache.put("k", "v");
+ assertEquals("v", cache.get("k"));
+ assertEquals("remove returns previous value", "v", cache.remove("k"));
+ assertNull("entry gone after remove", cache.get("k"));
+ assertNull("remove of absent key returns null",
cache.remove("absent"));
+ }
+
+ @Test
+ public void caffeineCacheRemove() {
+ assertRemoveContract(new OgnlCaffeineCache<>(10, 16));
+ }
+
+ @Test
+ public void defaultCacheRemove() {
+ assertRemoveContract(new OgnlDefaultCache<>(10, 16, 0.75f));
+ }
+
+ @Test
+ public void lruCacheRemove() {
+ assertRemoveContract(new OgnlLRUCache<>(10, 16, 0.75f));
+ }
+}
diff --git
a/core/src/test/java/org/apache/struts2/text/StrutsLocalizedTextProviderTest.java
b/core/src/test/java/org/apache/struts2/text/StrutsLocalizedTextProviderTest.java
index d7157c552..076403dc2 100644
---
a/core/src/test/java/org/apache/struts2/text/StrutsLocalizedTextProviderTest.java
+++
b/core/src/test/java/org/apache/struts2/text/StrutsLocalizedTextProviderTest.java
@@ -748,6 +748,79 @@ public class StrutsLocalizedTextProviderTest extends
XWorkTestCase {
assertEquals("A different index should create its own cache entry ?",
2, provider.classHierarchyCacheSize());
}
+ public void testCachesAreBoundedByConfiguredMaxSize() {
+ TestStrutsLocalizedTextProvider provider = new
TestStrutsLocalizedTextProvider();
+ provider.setI18nCacheMaxSize("100");
+ ValueStack valueStack = ActionContext.getContext().getValueStack();
+
+ for (int i = 0; i < 20000; i++) {
+ Locale locale = Locale.forLanguageTag("en-US-x" +
String.format("%05d", i));
+ provider.findText(CacheFixture.class, "cache.missing", locale,
"Fallback", null, valueStack);
+ }
+
+ assertTrue("classHierarchyCache not bounded ?",
provider.classHierarchyCacheSize() <= 2000);
+ assertTrue("packageHierarchyCache not bounded ?",
provider.packageHierarchyCacheSize() <= 2000);
+ assertTrue("bundlesMap not bounded ?", provider.bundlesMapSize() <=
2000);
+ assertTrue("missingBundles not bounded ?",
provider.missingBundlesSize() <= 2000);
+ assertTrue("messageFormats not bounded ?",
provider.messageFormatsSize() <= 2000);
+ }
+
+ public void testCorrectTextStillReturnedUnderEviction() {
+ TestStrutsLocalizedTextProvider provider = new
TestStrutsLocalizedTextProvider();
+ provider.setI18nCacheMaxSize("50");
+ ValueStack valueStack = ActionContext.getContext().getValueStack();
+
+ // Force heavy eviction with many distinct locales.
+ for (int i = 0; i < 5000; i++) {
+ Locale locale = Locale.forLanguageTag("en-US-x" +
String.format("%05d", i));
+ provider.findText(CacheFixture.class, "cache.missing", locale,
"Fallback", null, valueStack);
+ }
+
+ // A real key in a real locale still resolves correctly after eviction
pressure.
+ String result = provider.findText(CacheFixture.class, "cache.static",
Locale.ENGLISH, null, null, valueStack);
+ assertEquals("Static cached value", result);
+ }
+
+ public void testReloadClearsBoundedCaches() {
+ TestStrutsLocalizedTextProvider provider = new
TestStrutsLocalizedTextProvider();
+ ValueStack valueStack = ActionContext.getContext().getValueStack();
+
+ provider.findText(CacheFixture.class, "cache.missing", Locale.ENGLISH,
"Fallback", null, valueStack);
+ assertTrue("missingBundles not populated ?",
provider.missingBundlesSize() > 0);
+
+ provider.callReloadBundlesForceReload();
+ assertEquals("reload did not clear bundlesMap ?", 0,
provider.bundlesMapSize());
+ }
+
+ public void testProviderIsUsableAfterDeserialization() throws Exception {
+ TestStrutsLocalizedTextProvider provider = new
TestStrutsLocalizedTextProvider();
+ ValueStack valueStack = ActionContext.getContext().getValueStack();
+ provider.findText(CacheFixture.class, "cache.static", Locale.ENGLISH,
null, null, valueStack);
+
+ java.io.ByteArrayOutputStream baos = new
java.io.ByteArrayOutputStream();
+ try (java.io.ObjectOutputStream oos = new
java.io.ObjectOutputStream(baos)) {
+ oos.writeObject(provider);
+ }
+ Object restored;
+ try (java.io.ObjectInputStream ois = new java.io.ObjectInputStream(new
java.io.ByteArrayInputStream(baos.toByteArray()))) {
+ restored = ois.readObject();
+ }
+ TestStrutsLocalizedTextProvider deserialized =
(TestStrutsLocalizedTextProvider) restored;
+ // Caches were transient (null right after defaultReadObject) but
readObject rebuilds them:
+ assertEquals("Deserialized caches not rebuilt empty", 0,
deserialized.classHierarchyCacheSize());
+ String result = deserialized.findText(CacheFixture.class,
"cache.static", Locale.ENGLISH, null, null, valueStack);
+ assertEquals("Static cached value", result);
+ }
+
+ public void testCacheTypeSelectionKeepsProviderWorking() {
+ TestStrutsLocalizedTextProvider provider = new
TestStrutsLocalizedTextProvider();
+ provider.setI18nCacheType("basic");
+ ValueStack valueStack = ActionContext.getContext().getValueStack();
+ String result = provider.findText(CacheFixture.class, "cache.static",
Locale.ENGLISH, null, null, valueStack);
+ assertEquals("Static cached value", result);
+ assertTrue("class hierarchy cache should populate",
provider.classHierarchyCacheSize() >= 1);
+ }
+
@Override
protected void setUp() throws Exception {
super.setUp();
diff --git
a/docs/superpowers/plans/2026-07-30-WW-5668-i18n-cache-bounds-locale-resolution.md
b/docs/superpowers/plans/2026-07-30-WW-5668-i18n-cache-bounds-locale-resolution.md
new file mode 100644
index 000000000..b7a463eb7
--- /dev/null
+++
b/docs/superpowers/plans/2026-07-30-WW-5668-i18n-cache-bounds-locale-resolution.md
@@ -0,0 +1,617 @@
+# WW-5668 — Bounded i18n caches and consistent request-locale resolution —
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:** Bound the five `AbstractLocalizedTextProvider` caches with
configurable size/eviction via the existing `OgnlCache` abstraction, and add an
opt-in consistency check that resolves request-derived locales against the JVM
available-locale set in `Dispatcher`.
+
+**Architecture:** Reuse `OgnlCache` + `DefaultOgnlCacheFactory` (add an
additive `remove`) for the i18n caches; drive them from two new `@Inject`
constants. Add an opt-in `Dispatcher` helper mirroring `I18nInterceptor`'s
availability check. The two parts are independent; Part 1 is always on, Part 2
defaults off.
+
+**Tech Stack:** Java 17, Struts core, `org.apache.struts2.ognl` cache classes
(Caffeine/WTLFU), commons-lang3 `LocaleUtils`/`EnumUtils`, JUnit 4 /
`XWorkTestCase`, Mockito.
+
+**Spec:**
`docs/superpowers/specs/2026-07-30-WW-5668-i18n-cache-bounds-locale-resolution-design.md`
+
+## Global Constraints
+
+- Ticket prefix on every commit: `WW-5668`. Neutral framing only — no
security/DoS/attacker/exhaustion language in code, comments, tests, or commit
messages.
+- Core tests are JUnit 3/4. Tests extending `XWorkTestCase` use `public void
testXxx()` methods (JUnit 3 style); standalone tests use JUnit 4
(`org.junit.Test`, `org.junit.Assert`). Never JUnit 5 `@Test` — it silently
does not run.
+- Build/test command: `mvn test -DskipAssembly -pl core -Dtest=<ClassName>`
(append `#<method>` for a single method).
+- New config constant defaults must match existing cache conventions: `wtlfu`
/ `10000`.
+- `struts.locale.validateRequestLocale` default `false` — current behaviour
must be preserved byte-for-byte when unset.
+
+---
+
+### Task 1: Add `remove` to the `OgnlCache` abstraction
+
+**Files:**
+- Modify: `core/src/main/java/org/apache/struts2/ognl/OgnlCache.java`
+- Modify: `core/src/main/java/org/apache/struts2/ognl/OgnlCaffeineCache.java`
+- Modify: `core/src/main/java/org/apache/struts2/ognl/OgnlDefaultCache.java`
+- Modify: `core/src/main/java/org/apache/struts2/ognl/OgnlLRUCache.java`
+- Test (create):
`core/src/test/java/org/apache/struts2/ognl/OgnlCacheRemoveTest.java`
+
+**Interfaces:**
+- Produces: `V OgnlCache.remove(K key)` — removes the mapping for `key`,
returning the previous value or `null`. Implemented by all three cache classes.
+
+- [ ] **Step 1: Write the failing test**
+
+Create `core/src/test/java/org/apache/struts2/ognl/OgnlCacheRemoveTest.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.ognl;
+
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+
+public class OgnlCacheRemoveTest {
+
+ private void assertRemoveContract(OgnlCache<String, String> cache) {
+ cache.put("k", "v");
+ assertEquals("v", cache.get("k"));
+ assertEquals("remove returns previous value", "v", cache.remove("k"));
+ assertNull("entry gone after remove", cache.get("k"));
+ assertNull("remove of absent key returns null",
cache.remove("absent"));
+ }
+
+ @Test
+ public void caffeineCacheRemove() {
+ assertRemoveContract(new OgnlCaffeineCache<>(10, 16));
+ }
+
+ @Test
+ public void defaultCacheRemove() {
+ assertRemoveContract(new OgnlDefaultCache<>(10, 16, 0.75f));
+ }
+
+ @Test
+ public void lruCacheRemove() {
+ assertRemoveContract(new OgnlLRUCache<>(10, 16, 0.75f));
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `mvn test -DskipAssembly -pl core -Dtest=OgnlCacheRemoveTest`
+Expected: COMPILE FAILURE — `OgnlCache` has no `remove` method.
+
+- [ ] **Step 3: Add `remove` to the interface**
+
+In `OgnlCache.java`, after the `void put(K key, V value);` / `void
putIfAbsent(...)` declarations, add:
+
+```java
+ /**
+ * Removes the mapping for the given key, if present.
+ *
+ * @param key the key to remove
+ * @return the previous value associated with the key, or {@code null} if
none
+ * @since 7.3.0
+ */
+ V remove(K key);
+```
+
+- [ ] **Step 4: Implement in all three cache classes**
+
+`OgnlCaffeineCache.java` — add:
+
+```java
+ @Override
+ public V remove(K key) {
+ return cache.asMap().remove(key);
+ }
+```
+
+`OgnlDefaultCache.java` — add:
+
+```java
+ @Override
+ public V remove(K key) {
+ return ognlCache.remove(key);
+ }
+```
+
+`OgnlLRUCache.java` — add:
+
+```java
+ @Override
+ public V remove(K key) {
+ return ognlLRUCache.remove(key);
+ }
+```
+
+- [ ] **Step 5: Run test to verify it passes**
+
+Run: `mvn test -DskipAssembly -pl core -Dtest=OgnlCacheRemoveTest`
+Expected: PASS (3 tests).
+
+- [ ] **Step 6: Verify no other implementors broke**
+
+Run: `mvn test -DskipAssembly -pl core -Dtest=OgnlUtilTest`
+Expected: PASS — the only `OgnlCache` implementors are the three modified
classes (confirmed by design), so nothing else needed the new method.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add core/src/main/java/org/apache/struts2/ognl/OgnlCache.java \
+ core/src/main/java/org/apache/struts2/ognl/OgnlCaffeineCache.java \
+ core/src/main/java/org/apache/struts2/ognl/OgnlDefaultCache.java \
+ core/src/main/java/org/apache/struts2/ognl/OgnlLRUCache.java \
+ core/src/test/java/org/apache/struts2/ognl/OgnlCacheRemoveTest.java
+git commit -m "WW-5668 Add remove(key) to the OgnlCache abstraction
+
+Co-Authored-By: Claude Opus 4.8 <[email protected]>"
+```
+
+---
+
+### Task 2: Bound the localized-text provider caches (configurable)
+
+**Files:**
+- Modify: `core/src/main/java/org/apache/struts2/StrutsConstants.java` (add
two constants)
+- Modify: `core/src/main/resources/org/apache/struts2/default.properties`
(document them)
+- Modify:
`core/src/main/java/org/apache/struts2/text/AbstractLocalizedTextProvider.java`
(cache fields, setters, call sites, size accessors)
+- Test:
`core/src/test/java/org/apache/struts2/text/StrutsLocalizedTextProviderTest.java`
(add tests; existing 33 must stay green)
+
+**Interfaces:**
+- Consumes: `OgnlCache<K,V>` with
`get`/`put`/`putIfAbsent`/`remove`/`clear`/`size` (Task 1);
`DefaultOgnlCacheFactory<K,V>(int cacheMaxSize, CacheType type)` with
`buildOgnlCache()`; `OgnlCacheFactory.CacheType` enum (`BASIC`/`LRU`/`WTLFU`).
+- Produces: constants `STRUTS_I18N_CACHE_TYPE = "struts.i18n.cacheType"`,
`STRUTS_I18N_CACHE_MAXSIZE = "struts.i18n.cacheMaxSize"`; provider setters
`setI18nCacheType(String)`, `setI18nCacheMaxSize(String)`; `protected int` size
accessors `bundlesMapSize()`, `missingBundlesSize()`, `messageFormatsSize()`
(alongside the existing
`classHierarchyCacheSize()`/`packageHierarchyCacheSize()`).
+
+- [ ] **Step 1: Add the constants**
+
+In `StrutsConstants.java`, after `STRUTS_OGNL_EXPRESSION_CACHE_MAXSIZE` (line
~545), add:
+
+```java
+ /**
+ * Specifies the type of cache to use for the localized-text provider
caches. Valid values defined in
+ * {@link org.apache.struts2.ognl.OgnlCacheFactory.CacheType}.
+ *
+ * @since 7.3.0
+ */
+ public static final String STRUTS_I18N_CACHE_TYPE =
"struts.i18n.cacheType";
+
+ /**
+ * Specifies the maximum size of each localized-text provider cache.
Configure based on the cache type
+ * chosen and application-specific needs.
+ *
+ * @since 7.3.0
+ */
+ public static final String STRUTS_I18N_CACHE_MAXSIZE =
"struts.i18n.cacheMaxSize";
+```
+
+- [ ] **Step 2: Document them in default.properties**
+
+In `core/src/main/resources/org/apache/struts2/default.properties`, after the
`struts.ognl.expressionCacheMaxSize=10000` block (line ~297), add:
+
+```properties
+### Specifies the type of cache to use for the localized-text provider caches.
See StrutsConstants for details.
+struts.i18n.cacheType=wtlfu
+
+### Specifies the maximum size of each localized-text provider cache. This
should be configured based on the
+### cache type chosen and application-specific needs.
+struts.i18n.cacheMaxSize=10000
+```
+
+- [ ] **Step 3: Write the failing tests**
+
+In `StrutsLocalizedTextProviderTest.java`, add these methods (JUnit 3 style,
matching the file). They use the existing `TestStrutsLocalizedTextProvider`
subclass and `CacheFixture` fixture already present in this test class:
+
+```java
+ public void testCachesAreBoundedByConfiguredMaxSize() {
+ TestStrutsLocalizedTextProvider provider = new
TestStrutsLocalizedTextProvider();
+ provider.setI18nCacheMaxSize("100");
+ ValueStack valueStack = ActionContext.getContext().getValueStack();
+
+ for (int i = 0; i < 20000; i++) {
+ Locale locale = Locale.forLanguageTag("en-US-x" +
String.format("%05d", i));
+ provider.findText(CacheFixture.class, "cache.missing", locale,
"Fallback", null, valueStack);
+ }
+
+ assertTrue("classHierarchyCache not bounded ?",
provider.classHierarchyCacheSize() <= 2000);
+ assertTrue("packageHierarchyCache not bounded ?",
provider.packageHierarchyCacheSize() <= 2000);
+ assertTrue("bundlesMap not bounded ?", provider.bundlesMapSize() <=
2000);
+ assertTrue("missingBundles not bounded ?",
provider.missingBundlesSize() <= 2000);
+ assertTrue("messageFormats not bounded ?",
provider.messageFormatsSize() <= 2000);
+ }
+
+ public void testCorrectTextStillReturnedUnderEviction() {
+ TestStrutsLocalizedTextProvider provider = new
TestStrutsLocalizedTextProvider();
+ provider.setI18nCacheMaxSize("50");
+ ValueStack valueStack = ActionContext.getContext().getValueStack();
+
+ // Force heavy eviction with many distinct locales.
+ for (int i = 0; i < 5000; i++) {
+ Locale locale = Locale.forLanguageTag("en-US-x" +
String.format("%05d", i));
+ provider.findText(CacheFixture.class, "cache.missing", locale,
"Fallback", null, valueStack);
+ }
+
+ // A real key in a real locale still resolves correctly after eviction
pressure.
+ String result = provider.findText(CacheFixture.class, "cache.static",
Locale.ENGLISH, null, null, valueStack);
+ assertEquals("Static cached value", result);
+ }
+
+ public void testReloadClearsBoundedCaches() {
+ TestStrutsLocalizedTextProvider provider = new
TestStrutsLocalizedTextProvider();
+ ValueStack valueStack = ActionContext.getContext().getValueStack();
+
+ provider.findText(CacheFixture.class, "cache.missing", Locale.ENGLISH,
"Fallback", null, valueStack);
+ assertTrue("missingBundles not populated ?",
provider.missingBundlesSize() > 0);
+
+ provider.reloadBundles(ActionContext.getContext().getContextMap());
+ assertEquals("reload did not clear bundlesMap ?", 0,
provider.bundlesMapSize());
+ }
+```
+
+- [ ] **Step 4: Run the new tests to verify they fail**
+
+Run: `mvn test -DskipAssembly -pl core
-Dtest=StrutsLocalizedTextProviderTest#testCachesAreBoundedByConfiguredMaxSize+testCorrectTextStillReturnedUnderEviction+testReloadClearsBoundedCaches`
+Expected: COMPILE FAILURE — `setI18nCacheMaxSize`, `bundlesMapSize`,
`missingBundlesSize`, `messageFormatsSize` do not exist yet.
+
+- [ ] **Step 5: Add imports and replace the cache fields**
+
+In `AbstractLocalizedTextProvider.java`, add imports:
+
+```java
+import org.apache.commons.lang3.EnumUtils;
+import org.apache.struts2.ognl.DefaultOgnlCacheFactory;
+import org.apache.struts2.ognl.OgnlCache;
+import org.apache.struts2.ognl.OgnlCacheFactory.CacheType;
+```
+
+Replace the five cache field declarations (currently lines ~62, 67, 69, 71,
72):
+
+```java
+ protected final ConcurrentMap<String, ResourceBundle> bundlesMap = new
ConcurrentHashMap<>();
+ ...
+ private final ConcurrentMap<MessageFormatKey, MessageFormat>
messageFormats = new ConcurrentHashMap<>();
+ ...
+ private final Set<String> missingBundles = ConcurrentHashMap.newKeySet();
+ ...
+ private final ConcurrentMap<TextCacheKey, String> classHierarchyCache =
new ConcurrentHashMap<>();
+ private final ConcurrentMap<TextCacheKey, String> packageHierarchyCache =
new ConcurrentHashMap<>();
+```
+
+with (keeping `classLoaderMap` and `delegatedClassLoaderMap` as they are —
they still use `ConcurrentMap`):
+
+```java
+ private volatile CacheType i18nCacheType = CacheType.WTLFU;
+ private volatile int i18nCacheMaxSize = 10000;
+
+ private <K, V> OgnlCache<K, V> buildI18nCache() {
+ return new DefaultOgnlCacheFactory<K, V>(i18nCacheMaxSize,
i18nCacheType).buildOgnlCache();
+ }
+
+ protected OgnlCache<String, ResourceBundle> bundlesMap = buildI18nCache();
+ private OgnlCache<MessageFormatKey, MessageFormat> messageFormats =
buildI18nCache();
+ private OgnlCache<String, Boolean> missingBundles = buildI18nCache();
+ private OgnlCache<TextCacheKey, String> classHierarchyCache =
buildI18nCache();
+ private OgnlCache<TextCacheKey, String> packageHierarchyCache =
buildI18nCache();
+```
+
+Keep the field ordering so `i18nCacheType`/`i18nCacheMaxSize` and
`buildI18nCache()` are declared before the five cache fields (field
initializers run top-to-bottom). Leave `boolean
devMode`/`reloadBundles`/`searchDefaultBundlesFirst` where they are.
+
+- [ ] **Step 6: Add the injectable setters and rebuild helper**
+
+Near the other `@Inject` setters (e.g. after `setSearchDefaultBundlesFirst`,
line ~405), add:
+
+```java
+ @Inject(value = StrutsConstants.STRUTS_I18N_CACHE_TYPE, required = false)
+ public void setI18nCacheType(String cacheType) {
+ this.i18nCacheType = EnumUtils.getEnumIgnoreCase(CacheType.class,
cacheType, CacheType.WTLFU);
+ rebuildI18nCaches();
+ }
+
+ @Inject(value = StrutsConstants.STRUTS_I18N_CACHE_MAXSIZE, required =
false)
+ public void setI18nCacheMaxSize(String cacheMaxSize) {
+ this.i18nCacheMaxSize = Integer.parseInt(cacheMaxSize);
+ rebuildI18nCaches();
+ }
+
+ /**
+ * Rebuilds the localized-text caches from the current type/size. Called
during dependency injection
+ * (single-threaded startup, before the provider serves lookups); discards
any warm-up entries.
+ */
+ private void rebuildI18nCaches() {
+ bundlesMap = buildI18nCache();
+ messageFormats = buildI18nCache();
+ missingBundles = buildI18nCache();
+ classHierarchyCache = buildI18nCache();
+ packageHierarchyCache = buildI18nCache();
+ }
+```
+
+- [ ] **Step 7: Update the `bundlesMap` / `missingBundles` call sites in
`findResourceBundle`**
+
+Replace the body of `findResourceBundle` (lines ~408-442) that uses
`missingBundles.contains` / `bundlesMap.containsKey` / `bundlesMap.get` /
`putIfAbsent` / `missingBundles.add`. New body:
+
+```java
+ @Override
+ public ResourceBundle findResourceBundle(String bundleName, Locale locale)
{
+ ClassLoader classLoader = getCurrentThreadContextClassLoader();
+ String key = createMissesKey(String.valueOf(classLoader.hashCode()),
bundleName, locale);
+
+ if (missingBundles.get(key) != null) {
+ return null;
+ }
+
+ ResourceBundle bundle = null;
+ try {
+ bundle = bundlesMap.get(key);
+ if (bundle == null) {
+ bundle = ResourceBundle.getBundle(bundleName, locale,
classLoader);
+ bundlesMap.putIfAbsent(key, bundle);
+ }
+ } catch (MissingResourceException ex) {
+ if (delegatedClassLoaderMap.containsKey(classLoader.hashCode())) {
+ try {
+ bundle = bundlesMap.get(key);
+ if (bundle == null) {
+ bundle = ResourceBundle.getBundle(bundleName, locale,
delegatedClassLoaderMap.get(classLoader.hashCode()));
+ bundlesMap.putIfAbsent(key, bundle);
+ }
+ } catch (MissingResourceException e) {
+ LOG.debug("Missing resource bundle [{}]!", bundleName, e);
+ missingBundles.put(key, Boolean.TRUE);
+ }
+ } else {
+ LOG.debug("Missing resource bundle [{}]!", bundleName);
+ missingBundles.put(key, Boolean.TRUE);
+ }
+ }
+ return bundle;
+ }
+```
+
+`clearBundle` (line ~209) needs no change: `bundlesMap.remove(key)` now calls
`OgnlCache.remove` and still returns the removed `ResourceBundle`.
`clearMissingBundlesCache` (`missingBundles.clear()`), and `reloadBundles`
(`bundlesMap.clear()` etc.) also need no change — `clear()` is unchanged on
`OgnlCache`. `buildMessageFormat` (`messageFormats.get`/`put`),
`resolveClassHierarchyRaw`/`resolvePackageHierarchyRaw` (`get`/`putIfAbsent`)
are signature-compatible and need no change.
+
+- [ ] **Step 8: Add the three size accessors**
+
+Next to the existing `classHierarchyCacheSize()`/`packageHierarchyCacheSize()`
(lines ~103-111), add:
+
+```java
+ /** Test-support accessor: current number of cached resource bundles. */
+ protected int bundlesMapSize() {
+ return bundlesMap.size();
+ }
+
+ /** Test-support accessor: current number of cached missing-bundle
markers. */
+ protected int missingBundlesSize() {
+ return missingBundles.size();
+ }
+
+ /** Test-support accessor: current number of cached message formats. */
+ protected int messageFormatsSize() {
+ return messageFormats.size();
+ }
+```
+
+- [ ] **Step 9: Run the new tests to verify they pass**
+
+Run: `mvn test -DskipAssembly -pl core
-Dtest=StrutsLocalizedTextProviderTest#testCachesAreBoundedByConfiguredMaxSize+testCorrectTextStillReturnedUnderEviction+testReloadClearsBoundedCaches`
+Expected: PASS (3 tests).
+
+- [ ] **Step 10: Run the full provider suite to confirm no regression**
+
+Run: `mvn test -DskipAssembly -pl core -Dtest=StrutsLocalizedTextProviderTest`
+Expected: PASS (all existing tests + 3 new).
+
+- [ ] **Step 11: Commit**
+
+```bash
+git add core/src/main/java/org/apache/struts2/StrutsConstants.java \
+ core/src/main/resources/org/apache/struts2/default.properties \
+
core/src/main/java/org/apache/struts2/text/AbstractLocalizedTextProvider.java \
+
core/src/test/java/org/apache/struts2/text/StrutsLocalizedTextProviderTest.java
+git commit -m "WW-5668 Bound the localized-text provider caches with
configurable size
+
+Co-Authored-By: Claude Opus 4.8 <[email protected]>"
+```
+
+---
+
+### Task 3: Opt-in request-locale resolution consistency in Dispatcher
+
+**Files:**
+- Modify: `core/src/main/java/org/apache/struts2/StrutsConstants.java` (add
one constant)
+- Modify: `core/src/main/resources/org/apache/struts2/default.properties`
(document it)
+- Modify: `core/src/main/java/org/apache/struts2/dispatcher/Dispatcher.java`
(flag field, setter, helper, `getLocale` call sites)
+- Test: `core/src/test/java/org/apache/struts2/dispatcher/DispatcherTest.java`
(add tests)
+
+**Interfaces:**
+- Consumes: `org.apache.commons.lang3.LocaleUtils.isAvailableLocale(Locale)`
(LocaleUtils already imported in `Dispatcher`).
+- Produces: constant `STRUTS_LOCALE_VALIDATE_REQUEST =
"struts.locale.validateRequestLocale"`;
`Dispatcher.setValidateRequestLocale(String)`; private `Locale
resolveRequestLocale(HttpServletRequest)`.
+
+- [ ] **Step 1: Add the constant**
+
+In `StrutsConstants.java`, after `STRUTS_LOCALE` (line 136), add:
+
+```java
+ /**
+ * When enabled, request-derived locales (from {@code Accept-Language},
used when {@code struts.locale} is
+ * unset) are restricted to the JVM's available-locale set; unavailable
values fall back to the default.
+ *
+ * @since 7.3.0
+ */
+ public static final String STRUTS_LOCALE_VALIDATE_REQUEST =
"struts.locale.validateRequestLocale";
+```
+
+- [ ] **Step 2: Document it in default.properties**
+
+In `default.properties`, after the `# struts.locale=en_US` line (line ~26),
add:
+
+```properties
+### When true, restrict request-derived locales (Accept-Language, used when
struts.locale is unset) to the
+### JVM's available-locale set; unavailable values fall back to the default
locale. Defaults to false.
+struts.locale.validateRequestLocale=false
+```
+
+- [ ] **Step 3: Write the failing tests**
+
+In `DispatcherTest.java` (JUnit 4, uses Mockito), add — modelled on the
existing `getLocale` tests around lines 481-532:
+
+```java
+ @Test
+ public void testValidateRequestLocaleOffPassesThrough() throws Exception {
+ Dispatcher du = initDispatcher(new HashMap<>());
+ HttpServletRequest request = mock(HttpServletRequest.class);
+ // A syntactically valid but not JVM-available locale.
+ Locale exotic = new Locale("en", "US", "xzz99");
+ when(request.getLocale()).thenReturn(exotic);
+
+ assertEquals("Default off must pass the request locale through
unchanged",
+ exotic, du.getLocale(request));
+ }
+
+ @Test
+ public void testValidateRequestLocaleOnKeepsAvailableLocale() throws
Exception {
+ Map<String, String> params = new HashMap<>();
+ params.put(StrutsConstants.STRUTS_LOCALE_VALIDATE_REQUEST, "true");
+ Dispatcher du = initDispatcher(params);
+ HttpServletRequest request = mock(HttpServletRequest.class);
+ when(request.getLocale()).thenReturn(Locale.UK);
+
+ assertEquals("Available request locale must be kept", Locale.UK,
du.getLocale(request));
+ }
+
+ @Test
+ public void testValidateRequestLocaleOnFallsBackForUnavailableLocale()
throws Exception {
+ Map<String, String> params = new HashMap<>();
+ params.put(StrutsConstants.STRUTS_LOCALE_VALIDATE_REQUEST, "true");
+ Dispatcher du = initDispatcher(params);
+ HttpServletRequest request = mock(HttpServletRequest.class);
+ Locale exotic = new Locale("en", "US", "xzz99");
+ when(request.getLocale()).thenReturn(exotic);
+
+ // struts.locale unset in this dispatcher -> fall back to the JVM
default.
+ assertEquals("Unavailable request locale must fall back to system
default",
+ Locale.getDefault(), du.getLocale(request));
+ }
+```
+
+Note: reuse the test's existing helper for building a `Dispatcher` with init
params. If the existing tests use a different constructor pattern than
`initDispatcher(Map)`, match whatever those `getLocale` tests already use to
build `du` (check the top of the nearest existing `getLocale` test and copy its
setup verbatim).
+
+- [ ] **Step 4: Run the new tests to verify they fail**
+
+Run: `mvn test -DskipAssembly -pl core
-Dtest=DispatcherTest#testValidateRequestLocaleOffPassesThrough+testValidateRequestLocaleOnKeepsAvailableLocale+testValidateRequestLocaleOnFallsBackForUnavailableLocale`
+Expected: COMPILE FAILURE — `STRUTS_LOCALE_VALIDATE_REQUEST` / setter not
present.
+
+- [ ] **Step 5: Add the flag field and setter**
+
+In `Dispatcher.java`, near `defaultLocale` (line ~152) add a field:
+
+```java
+ private boolean validateRequestLocale = false;
+```
+
+Near `setDefaultLocale` (line ~308) add:
+
+```java
+ @Inject(value = StrutsConstants.STRUTS_LOCALE_VALIDATE_REQUEST, required =
false)
+ public void setValidateRequestLocale(String val) {
+ validateRequestLocale = Boolean.parseBoolean(val);
+ }
+```
+
+- [ ] **Step 6: Add the resolution helper and route both call sites through
it**
+
+In `Dispatcher.getLocale(HttpServletRequest)` (lines ~925-950), replace the
two `locale = request.getLocale();` calls (lines ~932 and ~943) with `locale =
resolveRequestLocale(request);`. Then add the helper directly below `getLocale`:
+
+```java
+ /**
+ * Resolves the request locale. When {@code
struts.locale.validateRequestLocale} is enabled and the
+ * request locale is not part of the JVM's available-locale set, falls
back to the configured
+ * {@code struts.locale} when set and parseable, otherwise the JVM
default. When disabled (default),
+ * returns the request locale unchanged.
+ */
+ protected Locale resolveRequestLocale(HttpServletRequest request) {
+ Locale locale = request.getLocale();
+ if (!validateRequestLocale || LocaleUtils.isAvailableLocale(locale)) {
+ return locale;
+ }
+ if (defaultLocale != null) {
+ try {
+ return LocaleUtils.toLocale(defaultLocale);
+ } catch (IllegalArgumentException e) {
+ LOG.debug("Configured 'struts.locale' = [{}] is not parseable;
falling back to system default", defaultLocale);
+ }
+ }
+ LOG.debug("Request locale [{}] is not available; falling back to
system default locale", locale);
+ return Locale.getDefault();
+ }
+```
+
+(The `defaultLocale`-set-and-parseable branch is inert at the two current call
sites — see the spec's fallback note — but is retained as the helper's general
contract. Do not add logic assuming it fires.)
+
+- [ ] **Step 7: Run the new tests to verify they pass**
+
+Run: `mvn test -DskipAssembly -pl core
-Dtest=DispatcherTest#testValidateRequestLocaleOffPassesThrough+testValidateRequestLocaleOnKeepsAvailableLocale+testValidateRequestLocaleOnFallsBackForUnavailableLocale`
+Expected: PASS (3 tests).
+
+- [ ] **Step 8: Run the full Dispatcher suite to confirm no regression**
+
+Run: `mvn test -DskipAssembly -pl core -Dtest=DispatcherTest`
+Expected: PASS — existing `getLocale` tests unaffected (flag defaults off).
+
+- [ ] **Step 9: Commit**
+
+```bash
+git add core/src/main/java/org/apache/struts2/StrutsConstants.java \
+ core/src/main/resources/org/apache/struts2/default.properties \
+ core/src/main/java/org/apache/struts2/dispatcher/Dispatcher.java \
+ core/src/test/java/org/apache/struts2/dispatcher/DispatcherTest.java
+git commit -m "WW-5668 Add opt-in request-locale resolution consistency to
Dispatcher
+
+Co-Authored-By: Claude Opus 4.8 <[email protected]>"
+```
+
+---
+
+### Task 4: Whole-change verification
+
+- [ ] **Step 1: Run both affected suites together**
+
+Run: `mvn test -DskipAssembly -pl core
-Dtest=OgnlCacheRemoveTest,StrutsLocalizedTextProviderTest,DispatcherTest,OgnlUtilTest`
+Expected: all PASS.
+
+- [ ] **Step 2: Run the broader i18n/dispatcher regression set**
+
+Run: `mvn test -DskipAssembly -pl core
-Dtest="*LocalizedText*,*Dispatcher*,*Ognl*"`
+Expected: all PASS. If any pre-existing unrelated flake appears (e.g.
jasperreports temp-file), note it and re-run the specific class.
+
+- [ ] **Step 3: Confirm the working tree is clean and the branch is ready**
+
+Run: `git status --short && git log --oneline main..HEAD`
+Expected: no uncommitted changes; three `WW-5668` commits (Task 1-3).
+
+---
+
+## Self-Review
+
+**Spec coverage:**
+- Part 1 bounded caches (reuse OgnlCache, add `remove`, five caches, config
constants, default.properties, correctness-safe eviction) → Tasks 1 + 2. ✓
+- Part 1 sizing default `wtlfu`/`10000` single shared size → Task 2 Steps 1-2,
5. ✓
+- Part 2 opt-in `struts.locale.validateRequestLocale` default false, helper
mirroring `I18nInterceptor`, fallback to configured `struts.locale` else
`Locale.getDefault()`, inertness caveat → Task 3. ✓
+- Testing: bound invariant, correctness under eviction, clear/remove, flag
off/on/fallback → Task 2 Step 3, Task 3 Step 3. ✓
+- Out-of-scope (rename, per-cache sizes, path consolidation) → not
implemented, correctly deferred. ✓
+
+**Placeholder scan:** No TBD/TODO; all steps contain concrete code or exact
commands. The one soft spot — Task 3 Step 3's note to match the existing
`getLocale` test's dispatcher-construction helper — is a real instruction to
copy verified local code, not a placeholder, because the surrounding
`getLocale` tests already build `du` that way.
+
+**Type consistency:** `OgnlCache.remove(K)→V` used identically in Task 1
(definition) and Task 2 (clearBundle relies on it). `CacheType`,
`DefaultOgnlCacheFactory(int, CacheType)`, `buildOgnlCache()` match the real
signatures. Size accessors
`bundlesMapSize`/`missingBundlesSize`/`messageFormatsSize` defined in Task 2
Step 8, used in Task 2 Step 3. `setI18nCacheMaxSize(String)` defined Step 6,
used Step 3. `STRUTS_I18N_CACHE_*` and `STRUTS_LOCALE_VALIDATE_REQUEST` defined
and consumed con [...]
diff --git
a/docs/superpowers/specs/2026-07-30-WW-5668-i18n-cache-bounds-locale-resolution-design.md
b/docs/superpowers/specs/2026-07-30-WW-5668-i18n-cache-bounds-locale-resolution-design.md
new file mode 100644
index 000000000..3a53ece5b
--- /dev/null
+++
b/docs/superpowers/specs/2026-07-30-WW-5668-i18n-cache-bounds-locale-resolution-design.md
@@ -0,0 +1,215 @@
+# WW-5668 — Bounded localized-text caches and consistent request-locale
resolution
+
+- **Ticket:** [WW-5668](https://issues.apache.org/jira/browse/WW-5668)
+- **Type:** Improvement
+- **Fix version:** 7.3.0
+- **Date:** 2026-07-30
+
+> Framing note: this is a follow-up to WW-5540. Keep all wording — spec, code,
+> commits, tests — in terms of cache bounds, eviction, configurability, and
+> resolution consistency. No security/DoS/attacker language.
+
+## Summary
+
+Two related changes to the localized-text subsystem:
+
+1. Give the `AbstractLocalizedTextProvider` caches a configurable maximum size
and
+ eviction, using the framework's existing cache abstraction, so their
footprint
+ is bounded like the OGNL expression, BeanInfo, and proxy caches already are.
+2. Make request-derived locale resolution consistent between `Dispatcher` and
+ `I18nInterceptor`, behind an opt-in flag, so an operator can restrict
+ request-derived locales to the JVM's available-locale set.
+
+The two parts are independent. Part 1 is always on. Part 2 defaults off.
+
+## Part 1 — Bounded localized-text caches
+
+### Current state
+
+`AbstractLocalizedTextProvider` holds five internal caches as plain
+`ConcurrentHashMap` / `ConcurrentHashMap.newKeySet`:
+
+| Field | Type | Key |
+|---|---|---|
+| `bundlesMap` | `ConcurrentMap<String, ResourceBundle>` | classloader +
bundleName + locale |
+| `missingBundles` | `Set<String>` | classloader + bundleName + locale |
+| `messageFormats` | `ConcurrentMap<MessageFormatKey, MessageFormat>` |
pattern + locale |
+| `classHierarchyCache` | `ConcurrentMap<TextCacheKey, String>` | classloader
+ className + key + locale |
+| `packageHierarchyCache` | `ConcurrentMap<TextCacheKey, String>` |
classloader + className + key + locale |
+
+None has a configurable upper bound or eviction. Elsewhere the framework
+standardises on bounded caches via `OgnlCacheFactory` /
`DefaultOgnlCacheFactory`
+(`struts.ognl.expressionCacheMaxSize=10000`, `struts.proxy.cacheMaxSize=10000`,
+both `wtlfu`). These five are the outlier.
+
+### Approach
+
+Reuse the existing cache abstraction (`OgnlCache` + `DefaultOgnlCacheFactory`)
+rather than introducing a new one or calling Caffeine directly.
+
+**Interface change (additive):** add `V remove(K key)` to `OgnlCache<K, V>`.
+- `OgnlCaffeineCache`: `return cache.asMap().remove(key);`
+- `OgnlDefaultCache`, `OgnlLRUCache`: delegate to the backing map's `remove`.
+- A `default` implementation is acceptable if it keeps existing impls
compiling,
+ but each impl should override with the native map removal so `clearBundle`
+ keeps returning the removed value for its debug log.
+
+This is the only change to shared OGNL code, and it is purely additive.
+
+**Provider change:** replace the five fields with `OgnlCache` instances built
from a
+`DefaultOgnlCacheFactory`, mapping call sites:
+
+| Field | New type | Call-site mapping |
+|---|---|---|
+| `bundlesMap` | `OgnlCache<String, ResourceBundle>` | `containsKey` → `get(k)
!= null`; keep `putIfAbsent`; `remove` |
+| `missingBundles` | `OgnlCache<String, Boolean>` | `contains` → `get(k) !=
null`; `add` → `put(k, Boolean.TRUE)` |
+| `messageFormats` | `OgnlCache<MessageFormatKey, MessageFormat>` | `get` /
`put` |
+| `classHierarchyCache` | `OgnlCache<TextCacheKey, String>` | `get` /
`putIfAbsent` / `clear` / `size` |
+| `packageHierarchyCache` | `OgnlCache<TextCacheKey, String>` | `get` /
`putIfAbsent` / `clear` / `size` |
+
+`reloadBundles`, `clearBundle`, and `clearMissingBundlesCache` keep calling
+`clear()` / `remove()` on these caches exactly as before. The `NOT_FOUND`
identity
+sentinel in the hierarchy caches is unchanged.
+
+### Why eviction is correctness-safe
+
+All five are pure caches: every entry is fully reconstructible on a miss
(reload the
+bundle, re-record a miss, rebuild the `MessageFormat`, re-walk the
class/package
+hierarchy). WTLFU eviction can therefore only cause an occasional recompute,
never a
+wrong or stale localized result. This preserves the WW-5540 behaviour while
bounding
+memory.
+
+### Configuration
+
+Two new constants, applied independently to each of the five caches:
+
+| Constant | Values | Default |
+|---|---|---|
+| `struts.i18n.cacheType` | `basic` \| `lru` \| `wtlfu` | `wtlfu` |
+| `struts.i18n.cacheMaxSize` | integer | `10000` |
+
+- Injected via `@Inject(..., required = false)` setters on
+ `AbstractLocalizedTextProvider`, following the existing i18n setters
+ (`setReloadBundles`, `setDevMode`, `setSearchDefaultBundlesFirst`).
+- New keys in `StrutsConstants`.
+- Documented in `default.properties` beside the OGNL/proxy cache settings.
+- A single `cacheMaxSize` governs all five caches for now. Per-cache tuning is
+ deliberately out of scope — see Out of scope / follow-ups.
+
+### Sizing note (accepted trade-off)
+
+The WW-5540 hierarchy caches are keyed by `(classloader, className, key,
locale)`.
+A very large application under normal single-locale traffic could have more
than
+10,000 distinct `(class, key)` pairs, in which case a 10,000 bound causes
eviction
+churn and partially erodes WW-5540's caching benefit (correctness unaffected,
only
+recompute cost). WTLFU retains the hot entries, so typical applications are
+unaffected, and the bound is configurable. A single default of 10,000
(matching the
+OGNL/proxy caches) is accepted for this ticket; per-cache tuning is a
follow-up.
+
+## Part 2 — Consistent request-locale resolution
+
+### Current state
+
+Struts resolves a request locale in more than one place, inconsistently:
+
+- `I18nInterceptor.getLocaleFromParam(...)` resolves the `request_locale`
+ parameter / cookie / session value, then checks it against the
available-locale
+ set via `LocaleProvider.isValidLocale(...)` (→
`LocaleUtils.isAvailableLocale`),
+ falling back to the default locale when it is not available.
+- `Dispatcher.getLocale(HttpServletRequest)` — used when `struts.locale` is
unset,
+ and as the fallback when a configured `struts.locale` is malformed — returns
+ `request.getLocale()` directly, with no availability check.
+
+### Approach
+
+Add an opt-in flag and a single resolution helper in `Dispatcher`.
+
+**New constant:**
+
+| Constant | Values | Default |
+|---|---|---|
+| `struts.locale.validateRequestLocale` | boolean | `false` |
+
+Default `false` preserves current behaviour byte-for-byte.
+
+**Helper** `resolveRequestLocale(HttpServletRequest request)`, used at both
+`request.getLocale()` sites in `Dispatcher.getLocale` (the
`struts.locale`-unset
+branch and the malformed-`struts.locale` fallback branch):
+
+1. `locale = request.getLocale()`.
+2. If `!validateRequestLocale` → return `locale` (current behaviour).
+3. Else if `LocaleUtils.isAvailableLocale(locale)` → return `locale`.
+4. Else → fall back to the configured `struts.locale` if set and parseable,
+ otherwise `Locale.getDefault()`; log at debug.
+
+The existing `RuntimeException` handling around `request.getLocale()` (falling
back
+to `Locale.getDefault()`) is retained.
+
+**Note on the fallback at the current call sites.** Both call sites live inside
+branches that only execute when `struts.locale` is unset (branch 1) or set but
+malformed/unparseable (branch 2). In neither case is a configured
`struts.locale`
+usable as a fallback, so in practice step 4 resolves to `Locale.getDefault()`
today.
+The "configured `struts.locale` if set and parseable" clause is retained as the
+helper's general contract (matching the `I18nInterceptor` spirit and keeping
the
+helper self-contained), but it is inert at the present call sites — the
+implementation must not add dead logic that assumes it fires.
+
+**Reuse vs. duplication.** `Dispatcher` runs before an `ActionContext` /
+`LocaleProvider` is necessarily available, so the helper calls
+`LocaleUtils.isAvailableLocale(...)` directly — the same underlying check
+`I18nInterceptor` reaches through `LocaleProvider.isValidLocale`. The two
paths share
+the same semantics without sharing a code path. Consolidating them into one
shared
+locale-resolution component is deliberately out of scope.
+
+**Injection:** `@Inject(value = "struts.locale.validateRequestLocale",
+required = false)` setter on `Dispatcher`, parsed to boolean, consistent with
how
+`struts.locale` (`defaultLocale`) is already injected.
+
+**Docs:** new constant in `default.properties`, commented, near `struts.locale`
+("restrict request-derived locales to the JVM's available-locale set").
+
+## Testing
+
+Core tests extend `XWorkTestCase` and are JUnit 3/4 — no JUnit-5 `@Test`
+annotations (they would silently not run). Test names stay neutral
+(bounds/eviction/resolution).
+
+### Part 1
+
+- **Bound invariant** (the regression test): with a small configured
+ `cacheMaxSize` (e.g. 1000), drive `findText` with many distinct locales
+ (e.g. 50,000); assert each of the five caches stays on the order of the
bound,
+ not the number of distinct locales.
+- **Correctness under eviction:** normal and repeated locales still return the
+ correct localized text; a real bundle key still resolves under eviction
+ pressure; the `NOT_FOUND` sentinel path still works.
+- **Configurability:** `cacheMaxSize` changes the ceiling; `cacheType` selects
the
+ implementation.
+- **Clear/remove:** `reloadBundles` empties the caches; `clearBundle` removes
the
+ targeted entry.
+
+### Part 2 (extend the existing `Dispatcher` test)
+
+- Flag **off** (default): any request locale passes through unchanged,
including a
+ valid-but-unavailable one (proves zero behaviour change).
+- Flag **on** + available locale → returned unchanged.
+- Flag **on** + valid-but-unavailable locale → falls back to configured
+ `struts.locale` when set, otherwise `Locale.getDefault()`.
+
+## Backward compatibility
+
+- Part 1 changes internal cache implementations only; no public API or
behavioural
+ change for normal localized-text usage. Eviction is correctness-safe. Default
+ `cacheMaxSize=10000` matches existing cache conventions. `protected` cache
fields
+ change type — acceptable for internal framework state; noted for subclasses.
+- Part 2 is fully opt-in; default `false` preserves current behaviour exactly.
+- The added `OgnlCache.remove` is additive.
+
+## Out of scope / follow-ups (separate tickets)
+
+- Rename `OgnlCache*` → `StrutsCache*` to remove the `Ognl` name leak now that
the
+ abstraction is used outside OGNL. (Separate ticket.)
+- Per-cache size constants (distinct bounds for the hierarchy caches vs.
+ bundle/format/missing caches). (Separate ticket.)
+- Consolidating `Dispatcher` and `I18nInterceptor` locale resolution into one
+ shared component. (Not planned.)