This is an automated email from the ASF dual-hosted git repository.
lukaszlenart pushed a commit to branch support/struts-6-x-x
in repository https://gitbox.apache.org/repos/asf/struts.git
The following commit(s) were added to refs/heads/support/struts-6-x-x by this
push:
new 7ce27107e WW-5668 Make the localized-text provider caches size-bounded
and align request-locale resolution (6.x) (#1823)
7ce27107e is described below
commit 7ce27107e2d40ac7619205e6de9ae7106d3ee455
Author: Lukasz Lenart <[email protected]>
AuthorDate: Sat Aug 1 11:55:00 2026 +0200
WW-5668 Make the localized-text provider caches size-bounded and align
request-locale resolution (6.x) (#1823)
* WW-5668 Add remove(key) to the OgnlCache abstraction
Co-Authored-By: Claude Opus 5 <[email protected]>
* WW-5668 Bound the localized-text provider caches with configurable size
Converts bundlesMap, messageFormats and missingBundles to the existing
OgnlCache abstraction, configurable via struts.i18n.cacheType and
struts.i18n.cacheMaxSize (wtlfu / 10000 by default). The caches are kept
transient and rebuilt in readObject so the providers stay serializable,
and bundlesMap-related synchronization moves to a dedicated monitor since
the field is now reassignable.
Co-Authored-By: Claude Opus 5 <[email protected]>
* WW-5668 Add opt-in request-locale resolution consistency to Dispatcher
Adds struts.locale.validateRequestLocale (default false) so request-derived
locales can be restricted to the JVM's available-locale set, matching what
I18nInterceptor already applies to its own locale sources.
Co-Authored-By: Claude Opus 5 <[email protected]>
* WW-5668 Keep the localized-text providers deserializable across a version
upgrade
Pins serialVersionUID to the value implicitly computed for the pre-6.11.0
class
shape instead of 1L, so a session serialized by a 6.10.0 node still loads
on a
6.11.0 one during a rolling upgrade rather than failing with
InvalidClassException.
Such a stream carries no value for the new cache settings, and field
initialisers
do not run during deserialization, so readObject restores their defaults
before
rebuilding the caches - without that guard it failed with a
NullPointerException.
Co-Authored-By: Claude Opus 5 <[email protected]>
---------
Co-authored-by: Claude Opus 5 <[email protected]>
---
.../com/opensymphony/xwork2/ognl/OgnlCache.java | 9 ++
.../xwork2/ognl/OgnlCaffeineCache.java | 5 +
.../opensymphony/xwork2/ognl/OgnlDefaultCache.java | 5 +
.../com/opensymphony/xwork2/ognl/OgnlLRUCache.java | 5 +
.../xwork2/util/AbstractLocalizedTextProvider.java | 115 ++++++++++++++++++---
.../xwork2/util/GlobalLocalizedTextProvider.java | 3 +
.../xwork2/util/StrutsLocalizedTextProvider.java | 3 +
.../java/org/apache/struts2/StrutsConstants.java | 24 +++++
.../org/apache/struts2/dispatcher/Dispatcher.java | 48 ++++++++-
.../org/apache/struts2/default.properties | 10 ++
.../xwork2/ognl/OgnlCacheRemoveTest.java | 50 +++++++++
.../com/opensymphony/xwork2/util/CacheFixture.java | 28 +++++
.../util/StrutsLocalizedTextProviderTest.java | 108 +++++++++++++++++++
.../apache/struts2/dispatcher/DispatcherTest.java | 40 +++++++
.../xwork2/util/CacheFixture.properties | 19 ++++
15 files changed, 457 insertions(+), 15 deletions(-)
diff --git a/core/src/main/java/com/opensymphony/xwork2/ognl/OgnlCache.java
b/core/src/main/java/com/opensymphony/xwork2/ognl/OgnlCache.java
index fc8366699..908e18be7 100644
--- a/core/src/main/java/com/opensymphony/xwork2/ognl/OgnlCache.java
+++ b/core/src/main/java/com/opensymphony/xwork2/ognl/OgnlCache.java
@@ -31,6 +31,15 @@ public interface OgnlCache<Key, Value> {
void putIfAbsent(Key key, Value 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 6.11.0
+ */
+ Value remove(Key key);
+
int size();
void clear();
diff --git
a/core/src/main/java/com/opensymphony/xwork2/ognl/OgnlCaffeineCache.java
b/core/src/main/java/com/opensymphony/xwork2/ognl/OgnlCaffeineCache.java
index b7a0241e3..9e2040176 100644
--- a/core/src/main/java/com/opensymphony/xwork2/ognl/OgnlCaffeineCache.java
+++ b/core/src/main/java/com/opensymphony/xwork2/ognl/OgnlCaffeineCache.java
@@ -56,6 +56,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/com/opensymphony/xwork2/ognl/OgnlDefaultCache.java
b/core/src/main/java/com/opensymphony/xwork2/ognl/OgnlDefaultCache.java
index 920403a5d..c631b8cb0 100644
--- a/core/src/main/java/com/opensymphony/xwork2/ognl/OgnlDefaultCache.java
+++ b/core/src/main/java/com/opensymphony/xwork2/ognl/OgnlDefaultCache.java
@@ -57,6 +57,11 @@ public class OgnlDefaultCache<K, V> implements OgnlCache<K,
V> {
this.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/com/opensymphony/xwork2/ognl/OgnlLRUCache.java
b/core/src/main/java/com/opensymphony/xwork2/ognl/OgnlLRUCache.java
index e324418ce..cbc5461f3 100644
--- a/core/src/main/java/com/opensymphony/xwork2/ognl/OgnlLRUCache.java
+++ b/core/src/main/java/com/opensymphony/xwork2/ognl/OgnlLRUCache.java
@@ -64,6 +64,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/com/opensymphony/xwork2/util/AbstractLocalizedTextProvider.java
b/core/src/main/java/com/opensymphony/xwork2/util/AbstractLocalizedTextProvider.java
index 2d6a7c678..6b972d923 100644
---
a/core/src/main/java/com/opensymphony/xwork2/util/AbstractLocalizedTextProvider.java
+++
b/core/src/main/java/com/opensymphony/xwork2/util/AbstractLocalizedTextProvider.java
@@ -21,6 +21,10 @@ package com.opensymphony.xwork2.util;
import com.opensymphony.xwork2.ActionContext;
import com.opensymphony.xwork2.LocalizedTextProvider;
import com.opensymphony.xwork2.inject.Inject;
+import com.opensymphony.xwork2.ognl.DefaultOgnlCacheFactory;
+import com.opensymphony.xwork2.ognl.OgnlCache;
+import com.opensymphony.xwork2.ognl.OgnlCacheFactory.CacheType;
+import org.apache.commons.lang3.EnumUtils;
import org.apache.commons.lang3.ObjectUtils;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
@@ -45,6 +49,11 @@ import java.util.concurrent.CopyOnWriteArrayList;
abstract class AbstractLocalizedTextProvider implements LocalizedTextProvider {
+ // Pinned to the value implicitly computed for the pre-6.11.0 class shape,
so sessions serialized by
+ // an older node still deserialize here during a rolling upgrade. The
caches this change made transient
+ // are simply discarded from such a stream and rebuilt by readObject.
+ private static final long serialVersionUID = -4563130226985473584L;
+
private static final Logger LOG =
LogManager.getLogger(AbstractLocalizedTextProvider.class);
public static final String XWORK_MESSAGES_BUNDLE =
"com/opensymphony/xwork2/xwork-messages";
@@ -56,16 +65,37 @@ abstract class AbstractLocalizedTextProvider implements
LocalizedTextProvider {
private static final String TOMCAT_WEBAPP_CLASSLOADER_BASE =
"org.apache.catalina.loader.WebappClassLoaderBase";
private static final String RELOADED =
"com.opensymphony.xwork2.util.LocalizedTextProvider.reloaded";
- 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<>();
+ // 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 static final int DEFAULT_I18N_CACHE_MAX_SIZE = 10000;
+
+ private volatile CacheType i18nCacheType = CacheType.WTLFU;
+ private volatile int i18nCacheMaxSize = DEFAULT_I18N_CACHE_MAX_SIZE;
+
+ 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();
+
/**
* Adds the bundle to the internal list of default bundles.
* If the bundle already exists in the list it will be re-added.
@@ -99,6 +129,21 @@ abstract class AbstractLocalizedTextProvider implements
LocalizedTextProvider {
return Thread.currentThread().getContextClassLoader();
}
+ /** 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.length() > 0) {
@@ -221,7 +266,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);
}
}
@@ -443,6 +488,52 @@ abstract class AbstractLocalizedTextProvider implements
LocalizedTextProvider {
this.searchDefaultBundlesFirst =
Boolean.parseBoolean(searchDefaultBundlesFirst);
}
+ /**
+ * @param cacheType the type of cache to use for the localized-text caches
+ *
+ * @since 6.11.0
+ */
+ @Inject(value = StrutsConstants.STRUTS_I18N_CACHE_TYPE, required = false)
+ public void setI18nCacheType(String cacheType) {
+ this.i18nCacheType = EnumUtils.getEnumIgnoreCase(CacheType.class,
cacheType, CacheType.WTLFU);
+ rebuildI18nCaches();
+ }
+
+ /**
+ * @param cacheMaxSize the maximum size of each localized-text cache
+ *
+ * @since 6.11.0
+ */
+ @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();
+ }
+
+ private void readObject(java.io.ObjectInputStream in) throws
java.io.IOException, ClassNotFoundException {
+ in.defaultReadObject();
+ bundlesMapLock = new Object();
+ // Field initialisers do not run during deserialization, so a stream
written before these settings
+ // existed (an older node in a rolling upgrade) leaves them at null/0.
Restore the defaults.
+ if (i18nCacheType == null) {
+ i18nCacheType = CacheType.WTLFU;
+ }
+ if (i18nCacheMaxSize <= 0) {
+ i18nCacheMaxSize = DEFAULT_I18N_CACHE_MAX_SIZE;
+ }
+ rebuildI18nCaches();
+ }
+
/**
* Finds the given resource bundle by it's name.
* <p>
@@ -458,34 +549,32 @@ abstract class AbstractLocalizedTextProvider implements
LocalizedTextProvider {
ClassLoader classLoader = getCurrentThreadContextClassLoader();
String key = createMissesKey(String.valueOf(classLoader.hashCode()),
aBundleName, 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(aBundleName, 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(aBundleName, locale,
delegatedClassLoaderMap.get(classLoader.hashCode()));
bundlesMap.putIfAbsent(key, bundle);
}
} catch (MissingResourceException e) {
LOG.debug("Missing resource bundle [{}]!", aBundleName, e);
- missingBundles.add(key);
+ missingBundles.put(key, Boolean.TRUE);
}
} else {
LOG.debug("Missing resource bundle [{}]!", aBundleName);
- missingBundles.add(key);
+ missingBundles.put(key, Boolean.TRUE);
}
}
return bundle;
diff --git
a/core/src/main/java/com/opensymphony/xwork2/util/GlobalLocalizedTextProvider.java
b/core/src/main/java/com/opensymphony/xwork2/util/GlobalLocalizedTextProvider.java
index d86682fd1..e568dfbc1 100644
---
a/core/src/main/java/com/opensymphony/xwork2/util/GlobalLocalizedTextProvider.java
+++
b/core/src/main/java/com/opensymphony/xwork2/util/GlobalLocalizedTextProvider.java
@@ -33,6 +33,9 @@ import java.util.ResourceBundle;
*/
public class GlobalLocalizedTextProvider extends AbstractLocalizedTextProvider
{
+ // Pinned to the value implicitly computed for the pre-6.11.0 class shape,
see AbstractLocalizedTextProvider.
+ private static final long serialVersionUID = 7569216885652454296L;
+
private static final Logger LOG =
LogManager.getLogger(GlobalLocalizedTextProvider.class);
public GlobalLocalizedTextProvider() {
diff --git
a/core/src/main/java/com/opensymphony/xwork2/util/StrutsLocalizedTextProvider.java
b/core/src/main/java/com/opensymphony/xwork2/util/StrutsLocalizedTextProvider.java
index 60ef3477a..963654ac3 100644
---
a/core/src/main/java/com/opensymphony/xwork2/util/StrutsLocalizedTextProvider.java
+++
b/core/src/main/java/com/opensymphony/xwork2/util/StrutsLocalizedTextProvider.java
@@ -36,6 +36,9 @@ import java.util.ResourceBundle;
*/
public class StrutsLocalizedTextProvider extends AbstractLocalizedTextProvider
{
+ // Pinned to the value implicitly computed for the pre-6.11.0 class shape,
see AbstractLocalizedTextProvider.
+ private static final long serialVersionUID = -4377984952850818176L;
+
private static final Logger LOG =
LogManager.getLogger(StrutsLocalizedTextProvider.class);
/**
diff --git a/core/src/main/java/org/apache/struts2/StrutsConstants.java
b/core/src/main/java/org/apache/struts2/StrutsConstants.java
index 7138aa3c7..31b82d059 100644
--- a/core/src/main/java/org/apache/struts2/StrutsConstants.java
+++ b/core/src/main/java/org/apache/struts2/StrutsConstants.java
@@ -100,6 +100,14 @@ public final class StrutsConstants {
/** The default locale for the Struts application */
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 6.11.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 */
public static final String STRUTS_DISPATCHER_PARAMETERSWORKAROUND =
"struts.dispatcher.parametersWorkaround";
@@ -288,6 +296,22 @@ public final class StrutsConstants {
*/
public static final String STRUTS_OGNL_BEANINFO_CACHE_FACTORY =
"struts.ognl.beanInfoCacheFactory";
+ /**
+ * Specifies the type of cache to use for the localized-text provider
caches. Valid values defined in
+ * {@link com.opensymphony.xwork2.ognl.OgnlCacheFactory.CacheType}.
+ *
+ * @since 6.11.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 6.11.0
+ */
+ public static final String STRUTS_I18N_CACHE_MAXSIZE =
"struts.i18n.cacheMaxSize";
+
/**
* Specifies the type of cache to use for BeanInfo objects.
* @since 6.4.0
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 55f0228ba..4b774dedf 100644
--- a/core/src/main/java/org/apache/struts2/dispatcher/Dispatcher.java
+++ b/core/src/main/java/org/apache/struts2/dispatcher/Dispatcher.java
@@ -152,6 +152,11 @@ public class Dispatcher {
*/
private String defaultLocale;
+ /**
+ * Store state of {@link StrutsConstants#STRUTS_LOCALE_VALIDATE_REQUEST}
setting.
+ */
+ private boolean validateRequestLocale = false;
+
/**
* Store state of StrutsConstants.STRUTS_MULTIPART_SAVEDIR setting.
*/
@@ -311,6 +316,18 @@ public class Dispatcher {
defaultLocale = val;
}
+ /**
+ * Modify state of {@link StrutsConstants#STRUTS_LOCALE_VALIDATE_REQUEST}
setting.
+ *
+ * @param val New setting
+ *
+ * @since 6.11.0
+ */
+ @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.
*
@@ -950,7 +967,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) {
@@ -961,7 +978,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();
@@ -970,6 +987,33 @@ 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.
+ *
+ * @param request the current request
+ * @return the locale to use for this request
+ *
+ * @since 6.11.0
+ */
+ 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/resources/org/apache/struts2/default.properties
b/core/src/main/resources/org/apache/struts2/default.properties
index 2ab573b87..bdf85cdff 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
@@ -240,6 +243,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/com/opensymphony/xwork2/ognl/OgnlCacheRemoveTest.java
b/core/src/test/java/com/opensymphony/xwork2/ognl/OgnlCacheRemoveTest.java
new file mode 100644
index 000000000..c637bc41f
--- /dev/null
+++ b/core/src/test/java/com/opensymphony/xwork2/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 com.opensymphony.xwork2.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/com/opensymphony/xwork2/util/CacheFixture.java
b/core/src/test/java/com/opensymphony/xwork2/util/CacheFixture.java
new file mode 100644
index 000000000..027aec163
--- /dev/null
+++ b/core/src/test/java/com/opensymphony/xwork2/util/CacheFixture.java
@@ -0,0 +1,28 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package com.opensymphony.xwork2.util;
+
+/**
+ * Simple fixture whose class-associated bundle ({@code
CacheFixture.properties}) backs the
+ * localized-text caching tests.
+ *
+ * @since 6.11.0
+ */
+public class CacheFixture {
+}
diff --git
a/core/src/test/java/com/opensymphony/xwork2/util/StrutsLocalizedTextProviderTest.java
b/core/src/test/java/com/opensymphony/xwork2/util/StrutsLocalizedTextProviderTest.java
index 7bb8af612..dea14bec8 100644
---
a/core/src/test/java/com/opensymphony/xwork2/util/StrutsLocalizedTextProviderTest.java
+++
b/core/src/test/java/com/opensymphony/xwork2/util/StrutsLocalizedTextProviderTest.java
@@ -34,6 +34,11 @@ import com.opensymphony.xwork2.test.TestBean2;
import org.apache.struts2.config.StrutsXmlConfigurationProvider;
import org.apache.struts2.interceptor.parameter.StrutsParameter;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import java.lang.reflect.Field;
import java.text.DateFormat;
import java.text.ParseException;
import java.util.Date;
@@ -563,6 +568,109 @@ public class StrutsLocalizedTextProviderTest extends
XWorkTestCase {
assertEquals("Result of bean2.name lookup not as expected ?", "Okay!
You found Me!", messageResult);
}
+ 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("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 {
+ StrutsLocalizedTextProvider provider = new
StrutsLocalizedTextProvider();
+ ValueStack valueStack = ActionContext.getContext().getValueStack();
+ provider.findText(CacheFixture.class, "cache.static", Locale.ENGLISH,
null, null, valueStack);
+
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ try (ObjectOutputStream oos = new ObjectOutputStream(baos)) {
+ oos.writeObject(provider);
+ }
+ Object restored;
+ try (ObjectInputStream ois = new ObjectInputStream(new
ByteArrayInputStream(baos.toByteArray()))) {
+ restored = ois.readObject();
+ }
+ StrutsLocalizedTextProvider deserialized =
(StrutsLocalizedTextProvider) restored;
+ // Caches were transient (null right after defaultReadObject) but
readObject rebuilds them:
+ assertEquals("Deserialized caches not rebuilt empty", 0,
deserialized.bundlesMapSize());
+ String result = deserialized.findText(CacheFixture.class,
"cache.static", Locale.ENGLISH, null, null, valueStack);
+ assertEquals("Static cached value", result);
+ }
+
+ /**
+ * A stream written before the i18n cache settings existed carries no
value for them, and field
+ * initialisers do not run during deserialization, so they arrive as
null/0. The provider must still
+ * come back usable rather than failing while rebuilding its caches.
+ */
+ public void testProviderIsUsableAfterDeserializingLegacyStream() throws
Exception {
+ StrutsLocalizedTextProvider provider = new
StrutsLocalizedTextProvider();
+ ValueStack valueStack = ActionContext.getContext().getValueStack();
+ provider.findText(CacheFixture.class, "cache.static", Locale.ENGLISH,
null, null, valueStack);
+
+ // Simulate the absent-field state an older stream produces.
+ Field cacheType =
AbstractLocalizedTextProvider.class.getDeclaredField("i18nCacheType");
+ cacheType.setAccessible(true);
+ cacheType.set(provider, null);
+ Field maxSize =
AbstractLocalizedTextProvider.class.getDeclaredField("i18nCacheMaxSize");
+ maxSize.setAccessible(true);
+ maxSize.setInt(provider, 0);
+
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ try (ObjectOutputStream oos = new ObjectOutputStream(baos)) {
+ oos.writeObject(provider);
+ }
+ Object restored;
+ try (ObjectInputStream ois = new ObjectInputStream(new
ByteArrayInputStream(baos.toByteArray()))) {
+ restored = ois.readObject();
+ }
+
+ StrutsLocalizedTextProvider deserialized =
(StrutsLocalizedTextProvider) restored;
+ 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("bundlesMap should populate", provider.bundlesMapSize() >=
1);
+ }
+
@Override
protected void setUp() throws Exception {
super.setUp();
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 6cce6e601..96e6bd2d1 100644
--- a/core/src/test/java/org/apache/struts2/dispatcher/DispatcherTest.java
+++ b/core/src/test/java/org/apache/struts2/dispatcher/DispatcherTest.java
@@ -570,6 +570,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/resources/com/opensymphony/xwork2/util/CacheFixture.properties
b/core/src/test/resources/com/opensymphony/xwork2/util/CacheFixture.properties
new file mode 100644
index 000000000..0e0511b92
--- /dev/null
+++
b/core/src/test/resources/com/opensymphony/xwork2/util/CacheFixture.properties
@@ -0,0 +1,19 @@
+#
+# 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.
+#
+cache.static=Static cached value