-
Notifications
You must be signed in to change notification settings - Fork 921
Open
Labels
JavaDoc[ci] enable java/javadoc tests and build-javadoc target[ci] enable java/javadoc tests and build-javadoc targetPlatform[ci] enable platform tests (platform/*)[ci] enable platform tests (platform/*)kind:bugBug report or fixBug report or fixneeds:triageRequires attention from one of the committersRequires attention from one of the committers
Description
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
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
JavaDoc[ci] enable java/javadoc tests and build-javadoc target[ci] enable java/javadoc tests and build-javadoc targetPlatform[ci] enable platform tests (platform/*)[ci] enable platform tests (platform/*)kind:bugBug report or fixBug report or fixneeds:triageRequires attention from one of the committersRequires attention from one of the committers