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 <noreply@anthropic.com>
This commit is contained in:
Lukasz Lenart
2026-08-01 11:55:17 +02:00
committed by GitHub
parent a6570b769b
commit 687436f9bf
4 changed files with 51 additions and 4 deletions
@@ -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();
}
@@ -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);
@@ -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;
@@ -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");