This is an automated email from the ASF dual-hosted git repository.

yesamer pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/incubator-kie-drools.git


The following commit(s) were added to refs/heads/main by this push:
     new b4bef4fe5f [incubator-kie-drools-6422] Fix NPE in 
RuleExecutor.removeDormantTuple (#6707)
b4bef4fe5f is described below

commit b4bef4fe5f2bf44217c9577c471823fb0cc73975
Author: Toni Rikkola <[email protected]>
AuthorDate: Fri May 29 12:43:56 2026 +0300

    [incubator-kie-drools-6422] Fix NPE in RuleExecutor.removeDormantTuple 
(#6707)
    
    Add a guard in removeDormantTuple to verify the tuple is actually in
    the dormant list before attempting removal. The NPE likely occurs when a
    tuple is removed from active matches with stagedType DELETE (skipping
    dormant addition), then later processed by doLeftDelete or
    modifyActiveTuple which unconditionally call removeDormantTuple.
    
    Pin the fix with a focused unit test in drools-core that constructs
    the exact list state the guard protects against: a tuple unlinked
    from dormantMatches (previous == null, not the head) passed back to
    removeDormantTuple. Without the guard this NPEs at
    LinkedList.remove:178; with it the call is a no-op.
    
    Coverage also includes modifyActiveTuple as a second entry point that
    routes through removeDormantTuple, and the upstream precondition that
    removeActiveTuple with stagedType=DELETE must skip the dormant
    transition (plus its non-DELETE baseline).
    
    Assited-By: Claude Opus 4.6 (1M context) <[email protected]>
---
 .../java/org/drools/core/phreak/RuleExecutor.java  |   7 +-
 .../core/phreak/RuleExecutorDormantTupleTest.java  | 142 +++++++++++++++++++++
 2 files changed, 148 insertions(+), 1 deletion(-)

diff --git a/drools-core/src/main/java/org/drools/core/phreak/RuleExecutor.java 
b/drools-core/src/main/java/org/drools/core/phreak/RuleExecutor.java
index 431e966c07..7839ea8813 100644
--- a/drools-core/src/main/java/org/drools/core/phreak/RuleExecutor.java
+++ b/drools-core/src/main/java/org/drools/core/phreak/RuleExecutor.java
@@ -311,7 +311,12 @@ public class RuleExecutor {
                 throw new IllegalStateException();
             }
         }
-        dormantMatches.remove(tuple);
+        if (tuple.getPrevious() != null || dormantMatches.getFirst() == tuple) 
{
+            dormantMatches.remove(tuple);
+        } else {
+            log.debug("Skipping removeDormantTuple for orphan tuple {} on rule 
\"{}\" — tuple is not present in dormantMatches",
+                      tuple, ruleAgendaItem.getRule().getName());
+        }
         if (DEBUG_DORMANT_TUPLE) {
             tuple.setDormant(false);
         }
diff --git 
a/drools-core/src/test/java/org/drools/core/phreak/RuleExecutorDormantTupleTest.java
 
b/drools-core/src/test/java/org/drools/core/phreak/RuleExecutorDormantTupleTest.java
new file mode 100644
index 0000000000..29e3a8450a
--- /dev/null
+++ 
b/drools-core/src/test/java/org/drools/core/phreak/RuleExecutorDormantTupleTest.java
@@ -0,0 +1,142 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.drools.core.phreak;
+
+import org.drools.base.base.SalienceInteger;
+import org.drools.base.definitions.rule.impl.RuleImpl;
+import org.drools.core.reteoo.RuleTerminalNodeLeftTuple;
+import org.drools.core.reteoo.Tuple;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatNoException;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * Targeted regression test for the guard in {@link 
RuleExecutor#removeDormantTuple} added for
+ * issue 6422. A tuple passed to {@code removeDormantTuple} may already have 
been unlinked from
+ * {@code dormantMatches} by another path (e.g. {@code modifyActiveTuple}), in 
which case its
+ * {@code previous} pointer is null and the unguarded {@code 
LinkedList.remove()} dereferences it.
+ */
+public class RuleExecutorDormantTupleTest {
+
+    @Test
+    public void 
removeDormantTuple_whenTupleAlreadyUnlinkedFromList_doesNotNPE() {
+        final RuleExecutor executor = newRuleExecutor();
+        final RuleTerminalNodeLeftTuple t1 = new RuleTerminalNodeLeftTuple();
+        final RuleTerminalNodeLeftTuple t2 = new RuleTerminalNodeLeftTuple();
+
+        executor.addDormantTuple(t1);
+        executor.addDormantTuple(t2);
+
+        // First remove takes t1 out cleanly via the firstNode path. After 
this,
+        // t1.previous is null and dormantMatches.getFirst() is t2 — the exact 
state
+        // the bug fix guards against.
+        executor.removeDormantTuple(t1);
+        assertThat(executor.getDormantMatches().size()).isEqualTo(1);
+        assertThat(t1.getPrevious()).isNull();
+        assertThat(executor.getDormantMatches().getFirst()).isNotSameAs(t1);
+
+        // Without the guard, LinkedList.remove() would fall into the 
middle-node
+        // branch and NPE on t1.getPrevious().setNext(...).
+        assertThatNoException().isThrownBy(() -> 
executor.removeDormantTuple(t1));
+
+        // The list is unchanged: t2 is still the sole dormant tuple.
+        assertThat(executor.getDormantMatches().size()).isEqualTo(1);
+        assertThat(executor.getDormantMatches().getFirst()).isSameAs(t2);
+    }
+
+    @Test
+    public void removeDormantTuple_whenTupleIsInList_unlinksIt() {
+        final RuleExecutor executor = newRuleExecutor();
+        final RuleTerminalNodeLeftTuple t1 = new RuleTerminalNodeLeftTuple();
+
+        executor.addDormantTuple(t1);
+        assertThat(executor.getDormantMatches().size()).isEqualTo(1);
+
+        executor.removeDormantTuple(t1);
+        assertThat(executor.getDormantMatches().size()).isZero();
+    }
+
+    /**
+     * {@link RuleExecutor#modifyActiveTuple} routes through {@code 
removeDormantTuple}, so the
+     * same NPE could surface from this second entry point. Pins the guard 
from both call sites.
+     */
+    @Test
+    public void 
modifyActiveTuple_whenTupleAlreadyUnlinkedFromDormantList_doesNotNPE() {
+        final RuleExecutor executor = newRuleExecutor();
+        final RuleTerminalNodeLeftTuple t1 = new RuleTerminalNodeLeftTuple();
+        final RuleTerminalNodeLeftTuple t2 = new RuleTerminalNodeLeftTuple();
+
+        executor.addDormantTuple(t1);
+        executor.addDormantTuple(t2);
+        executor.removeDormantTuple(t1);
+
+        assertThatNoException().isThrownBy(() -> 
executor.modifyActiveTuple(t1));
+
+        assertThat(executor.getActiveMatches().size()).isEqualTo(1);
+        assertThat(executor.getDormantMatches().size()).isEqualTo(1);
+        assertThat(executor.getDormantMatches().getFirst()).isSameAs(t2);
+        assertThat(t1.isQueued()).isTrue();
+    }
+
+    /**
+     * Pins the upstream half of the bug: {@link 
RuleExecutor#removeActiveTuple} must skip
+     * {@code addDormantTuple} when the staged type is DELETE. This is the 
precondition that
+     * leaves a tuple unlinked from {@code dormantMatches} while later paths 
still try to
+     * remove it.
+     */
+    @Test
+    public void removeActiveTuple_whenStagedForDelete_skipsDormantTransition() 
{
+        final RuleExecutor executor = newRuleExecutor();
+        final RuleTerminalNodeLeftTuple t1 = new RuleTerminalNodeLeftTuple();
+
+        executor.addActiveTuple(t1);
+        t1.setStagedType(Tuple.DELETE);
+
+        executor.removeActiveTuple(t1);
+
+        assertThat(executor.getActiveMatches().size()).isZero();
+        assertThat(executor.getDormantMatches().size()).isZero();
+    }
+
+    @Test
+    public void 
removeActiveTuple_whenNotStagedForDelete_transitionsToDormant() {
+        final RuleExecutor executor = newRuleExecutor();
+        final RuleTerminalNodeLeftTuple t1 = new RuleTerminalNodeLeftTuple();
+
+        executor.addActiveTuple(t1);
+        // Default stagedType is NONE (0); leave it as-is.
+
+        executor.removeActiveTuple(t1);
+
+        assertThat(executor.getActiveMatches().size()).isZero();
+        assertThat(executor.getDormantMatches().size()).isEqualTo(1);
+        assertThat(executor.getDormantMatches().getFirst()).isSameAs(t1);
+    }
+
+    private static RuleExecutor newRuleExecutor() {
+        final RuleImpl rule = mock(RuleImpl.class);
+        when(rule.getSalience()).thenReturn(SalienceInteger.DEFAULT_SALIENCE);
+        final RuleAgendaItem item = mock(RuleAgendaItem.class);
+        when(item.getRule()).thenReturn(rule);
+        return new RuleExecutor(null, item, false);
+    }
+}


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

Reply via email to