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