jjazzboss opened a new issue, #9270:
URL: https://github.com/apache/netbeans/issues/9270

   ### Apache NetBeans version
   
   Apache NetBeans 29
   
   ### What happened
   
   This is probably an old bug (present in NB7). I experienced it some years 
ago and could not reproduce. I avoided it by using immutable objects in my 
lookup and creating a custom identity-based AbstractLookup.Content. 
   
   Today I asked copilot to investigate and it seems to have found the issue. I 
did not check NB code myself but I did check the generated unit tests that 
reproduce the issue. 
   
   ### Language / Project Type / NetBeans Component
   
   Lookup API
   
   ### How to reproduce
   
   ```
   /*
    * Standalone JUnit 5 test demonstrating the NetBeans AbstractLookup 
mutation bug (NB28 but way older than that).
    *
    * Bug summary:
    *   AbstractLookup switches from ArrayStorage (linear scan) to 
InheritanceTree (hash-based
    *   LinkedHashSet) once the total number of registered pairs exceeds 11 
(the default threshold).
    *   If a stored object mutates and its hashCode() changes while in the 
InheritanceTree,
    *   InstanceContent.remove() silently fails because it looks in the wrong 
hash bucket.
    *
    * Fix: use IdentityInstanceContent which relies on 
System.identityHashCode() and == instead of hashCode()/equals(), 
    * making remove() immune to any mutation of the stored objects.
    */
   package org.jjazz.chordleadsheet;
   
   import java.util.Collection;
   import java.util.concurrent.Executor;
   import org.junit.jupiter.api.Test;
   import static org.junit.jupiter.api.Assertions.*;
   import org.openide.util.lookup.AbstractLookup;
   import org.openide.util.lookup.InstanceContent;
   
   /**
    * Tests that expose a silent remove() failure in NetBeans AbstractLookup 
when a stored mutable object's hashCode() changes after being added to the 
lookup.
    */
   public class LookupMutationTest
   {
   
       // 
---------------------------------------------------------------------------
       // Helper: a mutable object with field-based hashCode(). 
       // 
---------------------------------------------------------------------------
       static class Mutable
       {
   
           int value;
   
           Mutable(int v)
           {
               this.value = v;
           }
   
           /**
            * Purely field-based — no identity shortcut.
            */
           @Override
           public boolean equals(Object o)
           {
               if (!(o instanceof Mutable other))
               {
                   return false;
               }
               return this.value == other.value;
           }
   
           @Override
           public int hashCode()
           {
               return value;
           }
   
           @Override
           public String toString()
           {
               return "Mutable(" + value + ")";
           }
       }
   
       // 
---------------------------------------------------------------------------
       // Helper: dummy type used as filler to push the total pair count above 
the
       // ArrayStorage threshold (11), forcing AbstractLookup to switch to 
InheritanceTree.
       // 
---------------------------------------------------------------------------
       static class Filler
       {
       }
   
       // 
---------------------------------------------------------------------------
       // Test 1 — lookup() always works after mutation.
       //
       // lookup() searches by type using Pair.instanceOf(), which calls
       // Class.isInstance(obj). This is independent of hashCode(), so mutation 
never
       // breaks it.
       // 
---------------------------------------------------------------------------
       @Test
       public void testLookupAlwaysWorksAfterMutation()
       {
           var obj = new Mutable(42);
           var ic = new InstanceContent();
           var lookup = new AbstractLookup(ic);
           ic.add(obj);
   
           assertSame(obj, lookup.lookup(Mutable.class), "lookup() should 
return obj before mutation");
   
           int hashBefore = obj.hashCode();
           obj.value = 99;
           assertNotEquals(hashBefore, obj.hashCode(), "hashCode() should have 
changed after mutation");
   
           // lookup() is unaffected — it finds the object by type, not by hash
           assertSame(obj, lookup.lookup(Mutable.class), "lookup() should still 
return obj after mutation");
       }
   
       // 
---------------------------------------------------------------------------
       // Test 2 — remove() is safe with ArrayStorage (≤ 11 items total).
       //
       // With ≤ 11 total pairs, AbstractLookup uses ArrayStorage, which 
removes items
       // by iterating a plain Object[] and calling arr[i].equals(removalPair).
       // Because both the stored pair and the removal pair wrap the same object
       // reference, equals() compares the object's current value to itself — 
always
       // true — so remove() succeeds regardless of hash changes.
       // 
---------------------------------------------------------------------------
       @Test
       public void testRemoveWorksWithArrayStorage()
       {
           var obj1 = new Mutable(10);
           var obj2 = new Mutable(20);
           var obj3 = new Mutable(30);
   
           var ic = new InstanceContent();
           var lookup = new AbstractLookup(ic);
   
           // 3 items — well within the 11-item ArrayStorage threshold
           ic.add(obj1);
           ic.add(obj2);
           ic.add(obj3);
   
           obj2.value = 99; // mutate: hashCode changes
   
           ic.remove(obj2); // ArrayStorage: linear scan, equals() comparison → 
succeeds
   
           Collection<? extends Mutable> result = 
lookup.lookupAll(Mutable.class);
           assertFalse(result.contains(obj2), "obj2 should have been removed 
(ArrayStorage path)");
           assertEquals(2, result.size(), "Only obj1 and obj3 should remain");
       }
   
       // 
---------------------------------------------------------------------------
       // Test 3 — remove() silently FAILS with InheritanceTree storage (> 11 
items).
       //          THIS IS THE BUG.
       //
       // When the total pair count exceeds 11, AbstractLookup upgrades to
       // InheritanceTree, which stores pairs in a LinkedHashSet<Pair> (backed 
by a
       // LinkedHashMap). Java's HashMap caches the hash of each key at 
insertion time
       // in Node.hash. After mutation, the removal pair's hashCode() returns a 
new
       // value, so HashMap looks in the wrong bucket and silently returns 
false.
       // The object stays stuck in the lookup.
       // 
---------------------------------------------------------------------------
       @Test
       public void testRemoveFailsWithInheritanceTreeStorage()
       {
           var obj1 = new Mutable(10);
           var obj2 = new Mutable(20);
           var obj3 = new Mutable(30);
   
           var ic = new InstanceContent();
           var lookup = new AbstractLookup(ic);
   
           // 9 fillers + 3 Mutables = 12 total → exceeds threshold → 
InheritanceTree
           for (int i = 0; i < 9; i++)
           {
               ic.add(new Filler());
           }
           ic.add(obj1);
           ic.add(obj2);
           ic.add(obj3);
   
           // Trigger type-node creation in InheritanceTree (moves pairs to a 
typed LinkedHashSet node)
           lookup.lookupAll(Mutable.class);
   
           obj2.value = 99; // hashCode: hash(20) → hash(99) — stored node is 
still in bucket hash(20)!
   
           ic.remove(obj2); // InheritanceTree: looks in bucket hash(99) → miss 
→ returns false silently
   
           Collection<? extends Mutable> result = 
lookup.lookupAll(Mutable.class);
           assertTrue(result.contains(obj2),
                   "BUG: obj2 is stuck in the lookup — InstanceContent.remove() 
failed silently after mutation "
                   + "because InheritanceTree uses a hash-based LinkedHashSet 
whose cached bucket hash is now stale.");
       }
   
       // 
---------------------------------------------------------------------------
       // Test 4 — IdentityInstanceContent fixes the bug.
       //
       // By using System.identityHashCode() and == instead of 
hashCode()/equals(),
       // the hash bucket computed for add() and remove() is always the same 
(the JVM
       // identity hash is stable for the lifetime of the object), regardless 
of any
       // field mutation.
       // 
---------------------------------------------------------------------------
       @Test
       public void testIdentityInstanceContentFixesBug()
       {
           var obj1 = new Mutable(10);
           var obj2 = new Mutable(20);
           var obj3 = new Mutable(30);
   
           var iic = new IdentityInstanceContent();
           var lookup = new AbstractLookup(iic);
   
           for (int i = 0; i < 9; i++)
           {
               iic.add(new Filler()); // push past threshold
           }
           iic.add(obj1);
           iic.add(obj2);
           iic.add(obj3);
   
           lookup.lookupAll(Mutable.class);
   
           obj2.value = 99; // same mutation as test 3
   
           iic.remove(obj2); // identity hash is stable → correct bucket → 
found and removed
   
           Collection<? extends Mutable> result = 
lookup.lookupAll(Mutable.class);
           assertFalse(result.contains(obj2),
                   "IdentityInstanceContent.remove() should succeed even after 
mutation");
           assertEquals(2, result.size(), "Only obj1 and obj3 should remain");
       }
   
       // 
===========================================================================
       // IdentityInstanceContent
       //
       // A drop-in replacement for InstanceContent that uses object identity
       // (System.identityHashCode / ==) instead of the object's own 
hashCode/equals.
       // This makes add/remove immune to mutations that change the object's 
hash.
       //
       // Equivalent to JJazzLab's MutableInstanceContent.
       // 
===========================================================================
   
       static final class IdentityInstanceContent extends AbstractLookup.Content
       {
   
           public IdentityInstanceContent()
           {
           }
   
           public IdentityInstanceContent(Executor notifyIn)
           {
               super(notifyIn);
           }
   
           /**
            * Add an object to the lookup.
            */
           public final void add(Object inst)
           {
               addPair(new IdentityPair<>(inst));
           }
   
           /**
            * Remove an object from the lookup.
            */
           public final void remove(Object inst)
           {
               removePair(new IdentityPair<>(inst));
           }
       }
   
       /**
        * A Lookup Pair that uses object identity for equals() and hashCode().
        */
       private static final class IdentityPair<T> extends AbstractLookup.Pair<T>
       {
   
           private final T obj;
   
           IdentityPair(T obj)
           {
               if (obj == null)
               {
                   throw new NullPointerException();
               }
               this.obj = obj;
           }
   
           @Override public boolean instanceOf(Class<?> c)
           {
               return c.isInstance(obj);
           }
   
           @Override public T getInstance()
           {
               return obj;
           }
   
           /**
            * Identity equality — immune to any mutation of obj.
            */
           @Override
           public boolean equals(Object o)
           {
               return (o instanceof IdentityPair<?> other) && obj == other.obj;
           }
   
           /**
            * Identity hash — stable for the lifetime of obj regardless of 
mutation.
            */
           @Override
           public int hashCode()
           {
               return System.identityHashCode(obj);
           }
   
           @Override public String getId()
           {
               return "Identity[" + obj + "]";
           }
   
           @Override public String getDisplayName()
           {
               return obj.toString();
           }
   
           @Override protected boolean creatorOf(Object o)
           {
               return obj == o;
           }
   
           @SuppressWarnings("unchecked")
           @Override public Class<? extends T> getType()
           {
               return (Class<? extends T>) obj.getClass();
           }
       }
   }
   
   ```
   
   ### Did this work correctly in an earlier version?
   
   No / Don't know
   
   ### Operating System
   
   Win 11
   
   ### JDK
   
   Adoptium 23
   
   ### Apache NetBeans packaging
   
   Apache NetBeans binary zip
   
   ### Anything else
   
   _No response_
   
   ### Are you willing to submit a pull request?
   
   No


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

For further information about the NetBeans mailing lists, visit:
https://cwiki.apache.org/confluence/display/NETBEANS/Mailing+lists

Reply via email to