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 687436f9b WW-5668 Keep the localized-text providers deserializable
across a version upgrade (#1824)
687436f9b is described below
commit 687436f9bfbb3112e3d8af7c5130c99afcd163f9
Author: Lukasz Lenart <[email protected]>
AuthorDate: Sat Aug 1 11:55:17 2026 +0200
WW-5668 Keep the localized-text providers deserializable across a version
upgrade (#1824)
Follow-up to #1821. Pins serialVersionUID to the value implicitly computed
for the
Struts 7.2.1 class shape instead of 1L, so a session serialized by a 7.2.1
node still
loads on a 7.3.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]>
---
.../text/AbstractLocalizedTextProvider.java | 17 ++++++++++--
.../struts2/text/GlobalLocalizedTextProvider.java | 3 +-
.../struts2/text/StrutsLocalizedTextProvider.java | 3 +-
.../text/StrutsLocalizedTextProviderTest.java | 32 ++++++++++++++++++++++
4 files changed, 51 insertions(+), 4 deletions(-)
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 2aaebaee4..6d4a88ea8 100644
---
a/core/src/main/java/org/apache/struts2/text/AbstractLocalizedTextProvider.java
+++
b/core/src/main/java/org/apache/struts2/text/AbstractLocalizedTextProvider.java
@@ -50,7 +50,10 @@ import java.util.concurrent.CopyOnWriteArrayList;
abstract class AbstractLocalizedTextProvider implements LocalizedTextProvider {
- private static final long serialVersionUID = 1L;
+ // Pinned to the value implicitly computed for the Struts 7.2.1 class
shape, so sessions serialized by
+ // an older node still deserialize here during a rolling upgrade. The
caches that became transient are
+ // simply discarded from such a stream and rebuilt by readObject.
+ private static final long serialVersionUID = -4455624669971032217L;
private static final Logger LOG =
LogManager.getLogger(AbstractLocalizedTextProvider.class);
@@ -77,8 +80,10 @@ abstract class AbstractLocalizedTextProvider implements
LocalizedTextProvider {
// 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 = 10000;
+ private volatile int i18nCacheMaxSize = DEFAULT_I18N_CACHE_MAX_SIZE;
private <K, V> OgnlCache<K, V> buildI18nCache() {
return new DefaultOgnlCacheFactory<K, V>(i18nCacheMaxSize,
i18nCacheType).buildOgnlCache();
@@ -473,6 +478,14 @@ abstract class AbstractLocalizedTextProvider implements
LocalizedTextProvider {
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();
}
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 72383de5e..ad252f66c 100644
---
a/core/src/main/java/org/apache/struts2/text/GlobalLocalizedTextProvider.java
+++
b/core/src/main/java/org/apache/struts2/text/GlobalLocalizedTextProvider.java
@@ -34,7 +34,8 @@ import java.util.ResourceBundle;
*/
public class GlobalLocalizedTextProvider extends AbstractLocalizedTextProvider
{
- private static final long serialVersionUID = 1L;
+ // Pinned to the value implicitly computed for the Struts 7.2.1 class
shape, see AbstractLocalizedTextProvider.
+ private static final long serialVersionUID = 3777960740495792359L;
private static final Logger LOG =
LogManager.getLogger(GlobalLocalizedTextProvider.class);
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 1a0976889..5ec88bd65 100644
---
a/core/src/main/java/org/apache/struts2/text/StrutsLocalizedTextProvider.java
+++
b/core/src/main/java/org/apache/struts2/text/StrutsLocalizedTextProvider.java
@@ -38,7 +38,8 @@ import java.util.ResourceBundle;
*/
public class StrutsLocalizedTextProvider extends AbstractLocalizedTextProvider
{
- private static final long serialVersionUID = 1L;
+ // Pinned to the value implicitly computed for the Struts 7.2.1 class
shape, see AbstractLocalizedTextProvider.
+ private static final long serialVersionUID = 1939638936989370989L;
private static final Logger LOG =
LogManager.getLogger(StrutsLocalizedTextProvider.class);
private transient ReflectionProvider reflectionProvider;
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 076403dc2..dd836c115 100644
---
a/core/src/test/java/org/apache/struts2/text/StrutsLocalizedTextProviderTest.java
+++
b/core/src/test/java/org/apache/struts2/text/StrutsLocalizedTextProviderTest.java
@@ -812,6 +812,38 @@ public class StrutsLocalizedTextProviderTest extends
XWorkTestCase {
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 {
+ TestStrutsLocalizedTextProvider provider = new
TestStrutsLocalizedTextProvider();
+ 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.
+ java.lang.reflect.Field cacheType =
AbstractLocalizedTextProvider.class.getDeclaredField("i18nCacheType");
+ cacheType.setAccessible(true);
+ cacheType.set(provider, null);
+ java.lang.reflect.Field maxSize =
AbstractLocalizedTextProvider.class.getDeclaredField("i18nCacheMaxSize");
+ maxSize.setAccessible(true);
+ maxSize.setInt(provider, 0);
+
+ 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;
+ 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");