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 6829de72c WW-5724 fix(core): hand out a clone of the cached
MessageFormat (#1914)
6829de72c is described below
commit 6829de72ce6a9b332066c96f9c8cb3f091f1952e
Author: Lukasz Lenart <[email protected]>
AuthorDate: Sat Sep 12 18:37:56 2026 +0200
WW-5724 fix(core): hand out a clone of the cached MessageFormat (#1914)
AbstractLocalizedTextProvider caches MessageFormat instances by pattern
and locale and returned the cached instance itself from
buildMessageFormat. MessageFormat is not thread-safe, so concurrent
callers rendering the same message formatted through one shared
instance; with a date or time sub-format that produced output for the
wrong argument or an exception.
buildMessageFormat now returns a clone of the cached instance, so the
cached entry is only ever a template and is never formatted directly.
MessageFormat.clone() deep-copies the sub-formats. The pattern-parsing
cache is kept; measured against a fresh instance per call and against
synchronizing on the shared instance, cloning is the cheapest of the
three.
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
---
.../text/AbstractLocalizedTextProvider.java | 3 +-
.../text/StrutsLocalizedTextProviderTest.java | 60 ++++++++++++++++++++++
2 files changed, 62 insertions(+), 1 deletion(-)
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 6d4a88ea8..9d8b7b851 100644
---
a/core/src/main/java/org/apache/struts2/text/AbstractLocalizedTextProvider.java
+++
b/core/src/main/java/org/apache/struts2/text/AbstractLocalizedTextProvider.java
@@ -416,7 +416,8 @@ abstract class AbstractLocalizedTextProvider implements
LocalizedTextProvider {
messageFormats.put(key, format);
}
- return format;
+ // MessageFormat is not thread-safe; the cached instance is a template
that is never formatted directly
+ return (MessageFormat) format.clone();
}
protected String formatWithNullDetection(MessageFormat mf, Object[] args) {
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 dd836c115..a63c331b2 100644
---
a/core/src/test/java/org/apache/struts2/text/StrutsLocalizedTextProviderTest.java
+++
b/core/src/test/java/org/apache/struts2/text/StrutsLocalizedTextProviderTest.java
@@ -36,11 +36,15 @@ import org.apache.struts2.util.Bar;
import org.apache.struts2.util.ValueStack;
import java.text.DateFormat;
+import java.text.MessageFormat;
import java.text.ParseException;
import java.util.Date;
import java.util.HashMap;
import java.util.Locale;
import java.util.ResourceBundle;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
public class StrutsLocalizedTextProviderTest extends XWorkTestCase {
@@ -574,6 +578,62 @@ public class StrutsLocalizedTextProviderTest extends
XWorkTestCase {
assertEquals("Miss cache grew on repeat ?", 1,
provider.classHierarchyCacheSize());
}
+ public void testBuildMessageFormatDoesNotHandOutTheCachedInstance() {
+ TestStrutsLocalizedTextProvider provider = new
TestStrutsLocalizedTextProvider();
+
+ MessageFormat first = provider.buildMessageFormat("{0,date,short}",
Locale.US);
+ MessageFormat second = provider.buildMessageFormat("{0,date,short}",
Locale.US);
+
+ assertNotSame("cached MessageFormat shared between callers ?", first,
second);
+ assertEquals("pattern not cached once ?", 1,
provider.messageFormatsSize());
+ }
+
+ public void testConcurrentDateFormattingDoesNotMixArguments() throws
Exception {
+
localizedTextProvider.addDefaultResourceBundle("org/apache/struts2/util/LocalizedTextUtilTest");
+ Date[] dates = {
+ DateFormat.getDateInstance(DateFormat.SHORT,
Locale.US).parse("01/01/2015"),
+ DateFormat.getDateInstance(DateFormat.SHORT,
Locale.US).parse("02/02/2020"),
+ };
+ String[] expected = {"1/1/15", "2/2/20"};
+ assertEquals(expected[0],
localizedTextProvider.findDefaultText("test.format.date", Locale.US, new
Object[]{dates[0]}));
+ assertEquals(expected[1],
localizedTextProvider.findDefaultText("test.format.date", Locale.US, new
Object[]{dates[1]}));
+
+ int threads = 4;
+ int iterations = 20000;
+ AtomicInteger wrong = new AtomicInteger();
+ AtomicReference<String> sample = new AtomicReference<>();
+ AtomicReference<Throwable> thrown = new AtomicReference<>();
+ CountDownLatch start = new CountDownLatch(1);
+ Thread[] workers = new Thread[threads];
+ for (int t = 0; t < threads; t++) {
+ final int which = t % 2;
+ workers[t] = new Thread(() -> {
+ try {
+ start.await();
+ for (int i = 0; i < iterations; i++) {
+ String out =
localizedTextProvider.findDefaultText("test.format.date", Locale.US, new
Object[]{dates[which]});
+ if (!expected[which].equals(out)) {
+ wrong.incrementAndGet();
+ sample.compareAndSet(null, "expected <" +
expected[which] + "> but was <" + out + ">");
+ }
+ }
+ } catch (Throwable e) {
+ thrown.compareAndSet(null, e);
+ }
+ });
+ }
+ for (Thread worker : workers) {
+ worker.start();
+ }
+ start.countDown();
+ for (Thread worker : workers) {
+ worker.join();
+ }
+
+ assertNull("formatting threw under concurrency ?", thrown.get());
+ assertEquals("another caller's argument rendered: " + sample.get(), 0,
wrong.get());
+ }
+
public void testFormattingIsPerCallNotCached() {
TestStrutsLocalizedTextProvider provider = new
TestStrutsLocalizedTextProvider();
ValueStack valueStack = ActionContext.getContext().getValueStack();