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-collections.git


The following commit(s) were added to refs/heads/master by this push:
     new 491fc7758 [COLLECTIONS-776] Fix expiration bypass in 
PassiveExpiringMap when wrapped in SynchronizedMap (#694)
491fc7758 is described below

commit 491fc77589f1b262d19f5c109fc42794edfcb36e
Author: sankalp suman <[email protected]>
AuthorDate: Mon Jul 6 01:20:01 2026 +0530

    [COLLECTIONS-776] Fix expiration bypass in PassiveExpiringMap when wrapped 
in SynchronizedMap (#694)
    
    * COLLECTIONS-776: Wrapping PassiveExpiringMap in a SynchronizedMap breaks 
expiration
    
    * COLLECTIONS-776: Update regression test to cache views before expiration 
to satisfy TDD
    
    * Remove unused.
    
    * COLLECTIONS-776: Fix tests to use isEmpty(), clean blank lines, and cover 
null removeAll/retainAll
    
    * Better test assertions.
    
    * COLLECTIONS-776: Add test case for cached view iterator expiration
    
    * COLLECTIONS-776: Align collection views removeAll/retainAll with JDK 
semantics and test expirationMap cleanup
    
    * Potential fix for pull request finding
    
    Co-authored-by: Copilot Autofix powered by AI 
<[email protected]>
    
    * COLLECTIONS-776: Update comments in 
testCollectionsSynchronizedMapExpiration to reflect view access triggers 
expiration
    
    ---------
    
    Co-authored-by: Gary Gregory <[email protected]>
    Co-authored-by: Copilot Autofix powered by AI 
<[email protected]>
---
 src/changes/changes.xml                            |   1 +
 .../collections4/map/PassiveExpiringMap.java       | 424 ++++++++++++++++++++-
 .../collections4/map/PassiveExpiringMapTest.java   | 184 +++++++++
 3 files changed, 606 insertions(+), 3 deletions(-)

diff --git a/src/changes/changes.xml b/src/changes/changes.xml
index 972914bbd..24535388a 100644
--- a/src/changes/changes.xml
+++ b/src/changes/changes.xml
@@ -24,6 +24,7 @@
   <body>
   <release version="4.6.0" date="YYYY-MM-DD" description="This is a feature 
and maintenance release. Java 8 or later is required.">
     <!-- FIX -->
+    <action type="fix" dev="ggregory" due-to="Gary Gregory" 
issue="COLLECTIONS-776">Wrapping PassiveExpiringMap in 
Collections.synchronizedMap breaks expiration.</action>
     <action type="fix" dev="ggregory" due-to="Naveed Khan, Gary 
Gregory">[javadoc] Document that BloomFilter merge operations are non-atomic 
and a filter that throws during a merge should be considered invalid.</action>
     <action type="fix" dev="ggregory" due-to="Gary 
Gregory">org.apache.commons.collections4.map.AbstractReferenceMap.ReferenceEntry.toReference(ReferenceStrength,
 T, int) now throws IllegalArgumentException instead of Error.</action>
     <action type="fix" dev="ggregory" due-to="Eric Hubert, Gary 
Gregory">Remove deprecation annotation of 
org.apache.commons.collections4.Factory; this will be deprecated in 5.0 in 
favor of java.util.function.Supplier.</action>
diff --git 
a/src/main/java/org/apache/commons/collections4/map/PassiveExpiringMap.java 
b/src/main/java/org/apache/commons/collections4/map/PassiveExpiringMap.java
index 4de0ef9a3..eb1eb1c7f 100644
--- a/src/main/java/org/apache/commons/collections4/map/PassiveExpiringMap.java
+++ b/src/main/java/org/apache/commons/collections4/map/PassiveExpiringMap.java
@@ -27,6 +27,11 @@ import java.util.Map;
 import java.util.Objects;
 import java.util.Set;
 import java.util.concurrent.TimeUnit;
+import java.util.function.Predicate;
+
+import org.apache.commons.collections4.collection.AbstractCollectionDecorator;
+import org.apache.commons.collections4.iterators.AbstractIteratorDecorator;
+import org.apache.commons.collections4.set.AbstractSetDecorator;
 
 /**
  * Decorates a {@code Map} to evict expired entries once their expiration
@@ -359,7 +364,7 @@ public class PassiveExpiringMap<K, V>
     @Override
     public Set<Entry<K, V>> entrySet() {
         removeAllExpired(now());
-        return super.entrySet();
+        return new EntrySet(super.entrySet());
     }
 
     /**
@@ -408,7 +413,7 @@ public class PassiveExpiringMap<K, V>
     @Override
     public Set<K> keySet() {
         removeAllExpired(now());
-        return super.keySet();
+        return new KeySet(super.keySet());
     }
 
     /**
@@ -519,7 +524,7 @@ public class PassiveExpiringMap<K, V>
     @Override
     public Collection<V> values() {
         removeAllExpired(now());
-        return super.values();
+        return new ValuesCollection(super.values());
     }
 
     /**
@@ -533,4 +538,417 @@ public class PassiveExpiringMap<K, V>
         out.defaultWriteObject();
         out.writeObject(map);
     }
+
+    private final class EntrySet extends AbstractSetDecorator<Entry<K, V>> {
+
+        /** Generated serial version ID. */
+        private static final long serialVersionUID = 1L;
+
+        private EntrySet(final Set<Entry<K, V>> set) {
+            super(set);
+        }
+
+        @Override
+        public Iterator<Entry<K, V>> iterator() {
+            
PassiveExpiringMap.this.removeAllExpired(PassiveExpiringMap.this.now());
+            return new EntrySetIterator(super.iterator());
+        }
+
+        @Override
+        public int size() {
+            
PassiveExpiringMap.this.removeAllExpired(PassiveExpiringMap.this.now());
+            return super.size();
+        }
+
+        @Override
+        public boolean isEmpty() {
+            
PassiveExpiringMap.this.removeAllExpired(PassiveExpiringMap.this.now());
+            return super.isEmpty();
+        }
+
+        @Override
+        public boolean contains(final Object object) {
+            
PassiveExpiringMap.this.removeAllExpired(PassiveExpiringMap.this.now());
+            return super.contains(object);
+        }
+
+        @Override
+        public boolean remove(final Object object) {
+            if (object instanceof Map.Entry) {
+                final Map.Entry<?, ?> entry = (Map.Entry<?, ?>) object;
+                final Object key = entry.getKey();
+                if (PassiveExpiringMap.this.containsKey(key)) {
+                    final Object value = PassiveExpiringMap.this.get(key);
+                    if (Objects.equals(value, entry.getValue())) {
+                        PassiveExpiringMap.this.remove(key);
+                        return true;
+                    }
+                }
+            }
+            return false;
+        }
+
+        @Override
+        public boolean removeAll(final Collection<?> coll) {
+            Objects.requireNonNull(coll);
+            boolean changed = false;
+            if (size() > coll.size()) {
+                for (final Object obj : coll) {
+                    changed |= remove(obj);
+                }
+            } else {
+                final Iterator<?> it = iterator();
+                while (it.hasNext()) {
+                    if (coll.contains(it.next())) {
+                        it.remove();
+                        changed = true;
+                    }
+                }
+            }
+            return changed;
+        }
+
+        @Override
+        public boolean retainAll(final Collection<?> coll) {
+            Objects.requireNonNull(coll);
+            boolean changed = false;
+            final Iterator<?> it = iterator();
+            while (it.hasNext()) {
+                if (!coll.contains(it.next())) {
+                    it.remove();
+                    changed = true;
+                }
+            }
+            return changed;
+        }
+
+        @Override
+        public boolean removeIf(final Predicate<? super Entry<K, V>> filter) {
+            Objects.requireNonNull(filter);
+            boolean changed = false;
+            final Iterator<Entry<K, V>> it = iterator();
+            while (it.hasNext()) {
+                if (filter.test(it.next())) {
+                    it.remove();
+                    changed = true;
+                }
+            }
+            return changed;
+        }
+
+        @Override
+        public void clear() {
+            PassiveExpiringMap.this.clear();
+        }
+
+        @Override
+        public Object[] toArray() {
+            
PassiveExpiringMap.this.removeAllExpired(PassiveExpiringMap.this.now());
+            return super.toArray();
+        }
+
+        @Override
+        public <T> T[] toArray(final T[] array) {
+            
PassiveExpiringMap.this.removeAllExpired(PassiveExpiringMap.this.now());
+            return super.toArray(array);
+        }
+
+        @Override
+        public boolean containsAll(final Collection<?> coll) {
+            
PassiveExpiringMap.this.removeAllExpired(PassiveExpiringMap.this.now());
+            return super.containsAll(coll);
+        }
+    }
+
+    private final class EntrySetIterator extends 
AbstractIteratorDecorator<Entry<K, V>> {
+        private Entry<K, V> lastReturned;
+
+        private EntrySetIterator(final Iterator<Entry<K, V>> iterator) {
+            super(iterator);
+        }
+
+        @Override
+        public Entry<K, V> next() {
+            lastReturned = super.next();
+            return lastReturned;
+        }
+
+        @Override
+        public void remove() {
+            super.remove();
+            if (lastReturned != null) {
+                
PassiveExpiringMap.this.expirationMap.remove(lastReturned.getKey());
+                lastReturned = null;
+            }
+        }
+    }
+
+    private final class KeySet extends AbstractSetDecorator<K> {
+
+        /** Generated serial version ID. */
+        private static final long serialVersionUID = 1L;
+
+        private KeySet(final Set<K> set) {
+            super(set);
+        }
+
+        @Override
+        public Iterator<K> iterator() {
+            return new 
KeySetIterator(PassiveExpiringMap.this.entrySet().iterator());
+        }
+
+        @Override
+        public int size() {
+            
PassiveExpiringMap.this.removeAllExpired(PassiveExpiringMap.this.now());
+            return super.size();
+        }
+
+        @Override
+        public boolean isEmpty() {
+            
PassiveExpiringMap.this.removeAllExpired(PassiveExpiringMap.this.now());
+            return super.isEmpty();
+        }
+
+        @Override
+        public boolean contains(final Object key) {
+            PassiveExpiringMap.this.removeIfExpired(key, 
PassiveExpiringMap.this.now());
+            return super.contains(key);
+        }
+
+        @Override
+        public boolean remove(final Object key) {
+            final boolean hasKey = contains(key);
+            if (hasKey) {
+                PassiveExpiringMap.this.remove(key);
+            }
+            return hasKey;
+        }
+
+        @Override
+        public boolean removeAll(final Collection<?> coll) {
+            Objects.requireNonNull(coll);
+            boolean changed = false;
+            if (size() > coll.size()) {
+                for (final Object obj : coll) {
+                    changed |= remove(obj);
+                }
+            } else {
+                final Iterator<?> it = iterator();
+                while (it.hasNext()) {
+                    if (coll.contains(it.next())) {
+                        it.remove();
+                        changed = true;
+                    }
+                }
+            }
+            return changed;
+        }
+
+        @Override
+        public boolean retainAll(final Collection<?> coll) {
+            Objects.requireNonNull(coll);
+            boolean changed = false;
+            final Iterator<?> it = iterator();
+            while (it.hasNext()) {
+                if (!coll.contains(it.next())) {
+                    it.remove();
+                    changed = true;
+                }
+            }
+            return changed;
+        }
+
+        @Override
+        public boolean removeIf(final Predicate<? super K> filter) {
+            Objects.requireNonNull(filter);
+            boolean changed = false;
+            final Iterator<K> it = iterator();
+            while (it.hasNext()) {
+                if (filter.test(it.next())) {
+                    it.remove();
+                    changed = true;
+                }
+            }
+            return changed;
+        }
+
+        @Override
+        public void clear() {
+            PassiveExpiringMap.this.clear();
+        }
+
+        @Override
+        public Object[] toArray() {
+            
PassiveExpiringMap.this.removeAllExpired(PassiveExpiringMap.this.now());
+            return super.toArray();
+        }
+
+        @Override
+        public <T> T[] toArray(final T[] array) {
+            
PassiveExpiringMap.this.removeAllExpired(PassiveExpiringMap.this.now());
+            return super.toArray(array);
+        }
+
+        @Override
+        public boolean containsAll(final Collection<?> coll) {
+            
PassiveExpiringMap.this.removeAllExpired(PassiveExpiringMap.this.now());
+            return super.containsAll(coll);
+        }
+    }
+
+    private final class KeySetIterator implements Iterator<K> {
+        private final Iterator<Entry<K, V>> iterator;
+
+        private KeySetIterator(final Iterator<Entry<K, V>> iterator) {
+            this.iterator = iterator;
+        }
+
+        @Override
+        public boolean hasNext() {
+            return iterator.hasNext();
+        }
+
+        @Override
+        public K next() {
+            return iterator.next().getKey();
+        }
+
+        @Override
+        public void remove() {
+            iterator.remove();
+        }
+    }
+
+    private final class ValuesCollection extends 
AbstractCollectionDecorator<V> {
+
+        /** Generated serial version ID. */
+        private static final long serialVersionUID = 1L;
+
+        private ValuesCollection(final Collection<V> coll) {
+            super(coll);
+        }
+
+        @Override
+        public Iterator<V> iterator() {
+            return new 
ValuesIterator(PassiveExpiringMap.this.entrySet().iterator());
+        }
+
+        @Override
+        public int size() {
+            
PassiveExpiringMap.this.removeAllExpired(PassiveExpiringMap.this.now());
+            return super.size();
+        }
+
+        @Override
+        public boolean isEmpty() {
+            
PassiveExpiringMap.this.removeAllExpired(PassiveExpiringMap.this.now());
+            return super.isEmpty();
+        }
+
+        @Override
+        public boolean contains(final Object value) {
+            
PassiveExpiringMap.this.removeAllExpired(PassiveExpiringMap.this.now());
+            return super.contains(value);
+        }
+
+        @Override
+        public boolean remove(final Object value) {
+            final Iterator<V> it = iterator();
+            while (it.hasNext()) {
+                if (Objects.equals(it.next(), value)) {
+                    it.remove();
+                    return true;
+                }
+            }
+            return false;
+        }
+
+        @Override
+        public boolean removeAll(final Collection<?> coll) {
+            Objects.requireNonNull(coll);
+            boolean changed = false;
+            final Iterator<?> it = iterator();
+            while (it.hasNext()) {
+                if (coll.contains(it.next())) {
+                    it.remove();
+                    changed = true;
+                }
+            }
+            return changed;
+        }
+
+        @Override
+        public boolean retainAll(final Collection<?> coll) {
+            Objects.requireNonNull(coll);
+            boolean changed = false;
+            final Iterator<?> it = iterator();
+            while (it.hasNext()) {
+                if (!coll.contains(it.next())) {
+                    it.remove();
+                    changed = true;
+                }
+            }
+            return changed;
+        }
+
+        @Override
+        public boolean removeIf(final Predicate<? super V> filter) {
+            Objects.requireNonNull(filter);
+            boolean changed = false;
+            final Iterator<V> it = iterator();
+            while (it.hasNext()) {
+                if (filter.test(it.next())) {
+                    it.remove();
+                    changed = true;
+                }
+            }
+            return changed;
+        }
+
+        @Override
+        public void clear() {
+            PassiveExpiringMap.this.clear();
+        }
+
+        @Override
+        public Object[] toArray() {
+            
PassiveExpiringMap.this.removeAllExpired(PassiveExpiringMap.this.now());
+            return super.toArray();
+        }
+
+        @Override
+        public <T> T[] toArray(final T[] array) {
+            
PassiveExpiringMap.this.removeAllExpired(PassiveExpiringMap.this.now());
+            return super.toArray(array);
+        }
+
+        @Override
+        public boolean containsAll(final Collection<?> coll) {
+            
PassiveExpiringMap.this.removeAllExpired(PassiveExpiringMap.this.now());
+            return super.containsAll(coll);
+        }
+    }
+
+    private final class ValuesIterator implements Iterator<V> {
+        private final Iterator<Entry<K, V>> iterator;
+
+        private ValuesIterator(final Iterator<Entry<K, V>> iterator) {
+            this.iterator = iterator;
+        }
+
+        @Override
+        public boolean hasNext() {
+            return iterator.hasNext();
+        }
+
+        @Override
+        public V next() {
+            return iterator.next().getValue();
+        }
+
+        @Override
+        public void remove() {
+            iterator.remove();
+        }
+    }
 }
diff --git 
a/src/test/java/org/apache/commons/collections4/map/PassiveExpiringMapTest.java 
b/src/test/java/org/apache/commons/collections4/map/PassiveExpiringMapTest.java
index df889d2e9..436d6cfdc 100644
--- 
a/src/test/java/org/apache/commons/collections4/map/PassiveExpiringMapTest.java
+++ 
b/src/test/java/org/apache/commons/collections4/map/PassiveExpiringMapTest.java
@@ -23,7 +23,10 @@ import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
+import java.util.Collection;
+import java.util.Collections;
 import java.util.HashMap;
+import java.util.Iterator;
 import java.util.Map;
 import java.util.concurrent.TimeUnit;
 
@@ -255,4 +258,185 @@ public class PassiveExpiringMapTest<K, V> extends 
AbstractMapTest<PassiveExpirin
         assertNull(map.get("a"));
     }
 
+    @Test
+    void testCollectionsSynchronizedMapExpiration() throws 
InterruptedException {
+        final Map<String, String> map = Collections.synchronizedMap(new 
PassiveExpiringMap<>(50L));
+        map.put("a", "b");
+        map.put("c", "d");
+        assertEquals(2, map.size());
+        // Cache the views in SynchronizedMap before they expire
+        final Collection<Map.Entry<String, String>> entrySet = map.entrySet();
+        final Collection<String> keySet = map.keySet();
+        final Collection<String> values = map.values();
+        Thread.sleep(100L);
+        // entrySet view access triggers expiration
+        synchronized (map) {
+            assertTrue(entrySet.isEmpty());
+            assertTrue(keySet.isEmpty());
+            assertTrue(values.isEmpty());
+        }
+        map.put("a", "b");
+        map.put("c", "d");
+        assertEquals(2, map.size());
+        Thread.sleep(100L);
+        // keySet view access triggers expiration
+        synchronized (map) {
+            assertTrue(entrySet.isEmpty());
+            assertTrue(keySet.isEmpty());
+            assertTrue(values.isEmpty());
+        }
+        map.put("a", "b");
+        map.put("c", "d");
+        assertEquals(2, map.size());
+        Thread.sleep(100L);
+        // values view access triggers expiration
+        synchronized (map) {
+            assertTrue(entrySet.isEmpty());
+            assertTrue(keySet.isEmpty());
+            assertTrue(values.isEmpty());
+        }
+    }
+
+    @Test
+    void testCollectionViewRemoval() {
+        final PassiveExpiringMap<String, String> map = new 
PassiveExpiringMap<>(10000L);
+        map.put("a", "b");
+        map.put("c", "d");
+        map.put("e", "f");
+        // Remove via entrySet iterator
+        final Iterator<Map.Entry<String, String>> entryIter = 
map.entrySet().iterator();
+        assertTrue(entryIter.hasNext());
+        final Map.Entry<String, String> entry = entryIter.next();
+        final String removedKey = entry.getKey();
+        entryIter.remove();
+        assertFalse(map.containsKey(removedKey));
+        // Remove via keySet iterator
+        final Iterator<String> keyIter = map.keySet().iterator();
+        assertTrue(keyIter.hasNext());
+        final String key = keyIter.next();
+        keyIter.remove();
+        assertFalse(map.containsKey(key));
+        // Remove via values iterator
+        final Iterator<String> valIter = map.values().iterator();
+        assertTrue(valIter.hasNext());
+        final String val = valIter.next();
+        valIter.remove();
+        assertFalse(map.containsValue(val));
+    }
+
+    @Test
+    void testCollectionViewNullInputs() {
+        final PassiveExpiringMap<String, String> map = new 
PassiveExpiringMap<>(10000L);
+        map.put("a", "b");
+        // entrySet
+        assertThrows(NullPointerException.class, () -> 
map.entrySet().removeAll(null));
+        assertThrows(NullPointerException.class, () -> 
map.entrySet().retainAll(null));
+        // keySet
+        assertThrows(NullPointerException.class, () -> 
map.keySet().removeAll(null));
+        assertThrows(NullPointerException.class, () -> 
map.keySet().retainAll(null));
+        // values
+        assertThrows(NullPointerException.class, () -> 
map.values().removeAll(null));
+        assertThrows(NullPointerException.class, () -> 
map.values().retainAll(null));
+    }
+
+    @SuppressWarnings("unchecked")
+    private Map<Object, Long> getExpirationMap(final PassiveExpiringMap<?, ?> 
map) {
+        try {
+            final java.lang.reflect.Field field = 
PassiveExpiringMap.class.getDeclaredField("expirationMap");
+            field.setAccessible(true);
+            return (Map<Object, Long>) field.get(map);
+        } catch (final Exception e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    @Test
+    void testExpirationMapCleanup() {
+        final PassiveExpiringMap<String, String> map = new 
PassiveExpiringMap<>(10000L);
+        final Map<Object, Long> expirationMap = getExpirationMap(map);
+
+        // Verify initial size
+        assertEquals(0, expirationMap.size());
+
+        // Verify cleanup on put and remove
+        map.put("a", "b");
+        map.put("c", "d");
+        assertEquals(2, expirationMap.size());
+        map.remove("a");
+        assertEquals(1, expirationMap.size());
+        assertFalse(expirationMap.containsKey("a"));
+
+        // Verify cleanup on clear
+        map.put("a", "b");
+        assertEquals(2, expirationMap.size());
+        map.clear();
+        assertEquals(0, expirationMap.size());
+
+        // Verify cleanup on entrySet remove
+        map.put("a", "b");
+        map.put("c", "d");
+        assertEquals(2, expirationMap.size());
+        map.entrySet().remove(map.entrySet().iterator().next());
+        assertEquals(1, expirationMap.size());
+
+        // Verify cleanup on keySet remove
+        map.put("e", "f");
+        assertEquals(2, expirationMap.size());
+        map.keySet().remove("e");
+        assertEquals(1, expirationMap.size());
+        assertFalse(expirationMap.containsKey("e"));
+
+        // Verify cleanup on values remove
+        map.put("g", "h");
+        assertEquals(2, expirationMap.size());
+        map.values().remove("h");
+        assertEquals(1, expirationMap.size());
+        assertFalse(expirationMap.containsKey("g"));
+
+        // Verify cleanup on removeAll
+        map.put("i", "j");
+        map.put("k", "l");
+        assertEquals(3, expirationMap.size());
+        map.keySet().removeAll(Collections.singleton("i"));
+        assertEquals(2, expirationMap.size());
+        assertFalse(expirationMap.containsKey("i"));
+
+        // Verify cleanup on retainAll
+        map.keySet().retainAll(Collections.singleton("k"));
+        assertEquals(1, expirationMap.size());
+        assertTrue(expirationMap.containsKey("k"));
+
+        // Verify cleanup on removeIf
+        map.keySet().removeIf(k -> k.equals("k"));
+        assertEquals(0, expirationMap.size());
+
+        // Verify cleanup on iterator remove
+        map.put("a", "b");
+        map.put("c", "d");
+        assertEquals(2, expirationMap.size());
+        final Iterator<String> iterator = map.keySet().iterator();
+        assertTrue(iterator.hasNext());
+        final String removedKey = iterator.next();
+        iterator.remove();
+        assertEquals(1, expirationMap.size());
+        assertFalse(expirationMap.containsKey(removedKey));
+    }
+
+    @Test
+    void testCollectionViewIteratorExpiration() throws InterruptedException {
+        final PassiveExpiringMap<String, String> map = new 
PassiveExpiringMap<>(50L);
+        map.put("a", "b");
+
+        final Collection<Map.Entry<String, String>> entrySet = map.entrySet();
+        final Collection<String> keySet = map.keySet();
+        final Collection<String> values = map.values();
+
+        Thread.sleep(100L);
+
+        // The iterators should trigger expiration and not return any elements
+        assertFalse(entrySet.iterator().hasNext());
+        assertFalse(keySet.iterator().hasNext());
+        assertFalse(values.iterator().hasNext());
+    }
+
 }

Reply via email to