This is an automated email from the ASF dual-hosted git repository.

garydgregory pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/commons-lang.git


The following commit(s) were added to refs/heads/master by this push:
     new eae89fc69 FastDateFormat static caches grow without bound per distinct 
pattern/zone/locale, untrusted patterns pin several KB each, forever (f033).
eae89fc69 is described below

commit eae89fc698f9a7acbba1903b836451041b417618
Author: Gary Gregory <[email protected]>
AuthorDate: Sun Sep 6 17:06:56 2026 -0400

    FastDateFormat static caches grow without bound per distinct
    pattern/zone/locale, untrusted patterns pin several KB each, forever
    (f033).
---
 src/changes/changes.xml                            |  1 +
 .../commons/lang3/time/AbstractFormatCache.java    | 14 ++++++++++
 .../apache/commons/lang3/time/FastDateFormat.java  | 29 +++++++++++++++++++--
 .../apache/commons/lang3/time/FastDatePrinter.java |  5 ++++
 .../commons/lang3/time/FastDateFormatTest.java     | 30 ++++++++++++++++++++++
 5 files changed, 77 insertions(+), 2 deletions(-)

diff --git a/src/changes/changes.xml b/src/changes/changes.xml
index ff1854cc8..00defba20 100644
--- a/src/changes/changes.xml
+++ b/src/changes/changes.xml
@@ -279,6 +279,7 @@ java.lang.NullPointerException: Cannot invoke
     <action                   type="fix" dev="ggregory" due-to="Gary 
Gregory">ThresholdCircuitBreaker accepts negative increments and overflows its 
accumulator, silently keeping the breaker closed (f027).</action>
     <action                   type="fix" dev="ggregory" due-to="Gary 
Gregory">ExceptionUtils.getThrowableList() cycle check is O(n^2) over deep 
cause chains; all chain-walking consumers inherit it (f031).</action>
     <action                   type="fix" dev="ggregory" due-to="Gary 
Gregory">Reflection builders' cycle registry unregisters per-visit: cycle-SAFE 
but DAG-exponential, a 40-level reference diamond drives ~2^40 traversals and 
unbounded output (f032).</action>
+    <action                   type="fix" dev="ggregory" due-to="Gary 
Gregory">FastDateFormat static caches grow without bound per distinct 
pattern/zone/locale, untrusted patterns pin several KB each, forever 
(f033).</action>
     <!-- ADD -->
     <action                   type="add" dev="ggregory" due-to="Gary 
Gregory">Add JavaVersion.JAVA_27.</action>
     <action                   type="add" dev="ggregory" due-to="Gary 
Gregory">Add SystemUtils.IS_JAVA_27.</action>
diff --git 
a/src/main/java/org/apache/commons/lang3/time/AbstractFormatCache.java 
b/src/main/java/org/apache/commons/lang3/time/AbstractFormatCache.java
index d897893d3..914d22fb3 100644
--- a/src/main/java/org/apache/commons/lang3/time/AbstractFormatCache.java
+++ b/src/main/java/org/apache/commons/lang3/time/AbstractFormatCache.java
@@ -79,6 +79,15 @@ public int hashCode() {
      */
     static final int NONE = -1;
 
+    /**
+     * Maximum number of entries a static cache in this package may hold 
before it is flushed.
+     * These caches live for the lifetime of the JVM and are keyed by 
caller-supplied values
+     * (pattern, time zone, locale), so without a bound, unbounded-cardinality 
inputs would pin
+     * memory forever. The bound is approximate under concurrency; flushing 
only costs
+     * re-creation of evicted entries.
+     */
+    static final int MAX_CACHE_SIZE = 1024;
+
     private static final ConcurrentMap<ArrayKey, String> dateTimeInstanceCache 
= new ConcurrentHashMap<>(7);
 
     /**
@@ -213,6 +222,11 @@ public F getInstance(final String pattern, final TimeZone 
timeZone, final Locale
         final TimeZone actualTimeZone = TimeZones.toTimeZone(timeZone);
         final Locale actualLocale = LocaleUtils.toLocale(locale);
         final ArrayKey key = new ArrayKey(pattern, actualTimeZone, 
actualLocale);
+        // Bound the cache: it is static and process-lifetime, so 
unbounded-cardinality keys
+        // (for example patterns derived from caller input) would otherwise 
pin memory forever.
+        if (instanceCache.size() >= MAX_CACHE_SIZE && 
!instanceCache.containsKey(key)) {
+            instanceCache.clear();
+        }
         return instanceCache.computeIfAbsent(key, k -> createInstance(pattern, 
actualTimeZone, actualLocale));
     }
 
diff --git a/src/main/java/org/apache/commons/lang3/time/FastDateFormat.java 
b/src/main/java/org/apache/commons/lang3/time/FastDateFormat.java
index 224b4a578..0a08f38fd 100644
--- a/src/main/java/org/apache/commons/lang3/time/FastDateFormat.java
+++ b/src/main/java/org/apache/commons/lang3/time/FastDateFormat.java
@@ -50,6 +50,13 @@
  * </p>
  *
  * <p>
+ * Note on memory retention: unlike {@code new SimpleDateFormat(pattern)}, 
instances obtained from the static factory methods are held in a static cache 
keyed
+ * by (pattern, time zone, locale). The cache is bounded (it is flushed when 
it exceeds an internal limit) and can be flushed explicitly with
+ * {@link #clear()}, but each distinct key retains its instance for the 
lifetime of the JVM until then. Prefer fixed, application-defined patterns; do 
not
+ * pass unvalidated caller-supplied pattern, time zone, or locale values to 
the factory methods.
+ * </p>
+ *
+ * <p>
  * All patterns are compatible with SimpleDateFormat (except time zones and 
some year patterns - see below).
  * </p>
  *
@@ -113,12 +120,30 @@ protected FastDateFormat createInstance(final String 
pattern, final TimeZone tim
     };
 
     /**
-     * Clears the cache.
+     * Clears the caches.
+     * <p>
+     * Clears the static caches used by {@link FastDateFormat}: the (pattern, 
time zone, locale) to instance cache and the time zone display-name cache.
+     * Cached instances already obtained by callers remain valid; subsequent 
factory calls simply create and cache new instances. This can be used for
+     * operational relief if many distinct patterns, time zones, or locales 
have been used.
+     * </p>
      */
     static void clear() {
         AbstractFormatCache.clear();
         CACHE.clearInstance();
-    }
+        FastDatePrinter.clear();
+    }
+
+//    /**
+//     * Clears the static caches used by {@link FastDateFormat}: the 
(pattern, time zone, locale) to instance cache and the time zone display-name 
cache.
+//     * Cached instances already obtained by callers remain valid; subsequent 
factory calls simply create and cache new instances. This can be used for
+//     * operational relief if many distinct patterns, time zones, or locales 
have been used.
+//     *
+//     * @since 3.21.0
+//     */
+//    public static void clearCache() {
+//        clear();
+//        FastDatePrinter.clear();
+//    }
 
     /**
      * Gets a date formatter instance using the specified style in the default 
time zone and locale.
diff --git a/src/main/java/org/apache/commons/lang3/time/FastDatePrinter.java 
b/src/main/java/org/apache/commons/lang3/time/FastDatePrinter.java
index 2163ab26d..876704fef 100644
--- a/src/main/java/org/apache/commons/lang3/time/FastDatePrinter.java
+++ b/src/main/java/org/apache/commons/lang3/time/FastDatePrinter.java
@@ -1021,6 +1021,11 @@ static void clear() {
      */
     static String getTimeZoneDisplay(final TimeZone tz, final boolean 
daylight, final int style, final Locale locale) {
         final TimeZoneDisplayKey key = new TimeZoneDisplayKey(tz, daylight, 
style, locale);
+        // Bound the cache: it is static and process-lifetime, and custom time 
zone IDs give the
+        // key unbounded cardinality, which would otherwise pin memory forever.
+        if (timeZoneDisplayCache.size() >= AbstractFormatCache.MAX_CACHE_SIZE 
&& !timeZoneDisplayCache.containsKey(key)) {
+            timeZoneDisplayCache.clear();
+        }
         // This is a very slow call, so cache the results.
         return timeZoneDisplayCache.computeIfAbsent(key, k -> 
tz.getDisplayName(daylight, style, locale));
     }
diff --git 
a/src/test/java/org/apache/commons/lang3/time/FastDateFormatTest.java 
b/src/test/java/org/apache/commons/lang3/time/FastDateFormatTest.java
index 4023b8e30..09b937626 100644
--- a/src/test/java/org/apache/commons/lang3/time/FastDateFormatTest.java
+++ b/src/test/java/org/apache/commons/lang3/time/FastDateFormatTest.java
@@ -219,6 +219,36 @@ void test_getInstance_String_TimeZone_Locale() {
         assertEquals(Locale.GERMANY, format3.getLocale());
     }
 
+    /**
+     * The instance cache is bounded: once it reaches its size limit, it is 
flushed rather than
+     * growing without bound per distinct pattern.
+     */
+    @Test
+    void test_instanceCacheIsBounded() {
+        FastDateFormat.clear();
+        final FastDateFormat first = FastDateFormat.getInstance("yyyy-MM-dd");
+        assertSame(first, FastDateFormat.getInstance("yyyy-MM-dd"));
+        // Exceed the cache bound with distinct patterns; the cache must 
flush, not grow forever.
+        for (int i = 0; i <= AbstractFormatCache.MAX_CACHE_SIZE; i++) {
+            FastDateFormat.getInstance("'p" + i + "'yyyy");
+        }
+        assertNotSame(first, FastDateFormat.getInstance("yyyy-MM-dd"), "Cache 
was not flushed at its bound");
+        FastDateFormat.clear();
+    }
+
+    /**
+     * Tests the public cache-flush entry point.
+     */
+    @Test
+    void test_publicClearCache() {
+        final FastDateFormat format1 = 
FastDateFormat.getInstance("yyyy-MM-dd'T'HH");
+        assertSame(format1, FastDateFormat.getInstance("yyyy-MM-dd'T'HH"));
+        FastDateFormat.clear();
+        final FastDateFormat format2 = 
FastDateFormat.getInstance("yyyy-MM-dd'T'HH");
+        assertNotSame(format1, format2);
+        assertEquals(format1, format2);
+    }
+
     @Test
     @ReadsDefaultLocale
     @ReadsDefaultTimeZone

Reply via email to