Skip to content

InstanceContent.remove(obj) fails if obj has mutated and lookup contains 12+ items #9270

@jjazzboss

Description

@jjazzboss

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    JavaDoc[ci] enable java/javadoc tests and build-javadoc targetPlatform[ci] enable platform tests (platform/*)kind:bugBug report or fixneeds:triageRequires attention from one of the committers

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions