You are viewing a plain text version of this content. The canonical link for it is here.
Posted to java-commits@axis.apache.org by ro...@apache.org on 2026/04/13 18:56:20 UTC

(axis-axis2-java-core) branch master updated (29d29c9887 -> c6058355a0)

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

robertlazarski pushed a change to branch master
in repository https://gitbox.apache.org/repos/asf/axis-axis2-java-core.git


    from 29d29c9887 AXIS2-6101 Implement getResponseContentEncoding() in H2 transports
     new 905b30e4fd AXIS2-5788 Add non-touching overload of getServiceGroupContext
     new e4923cde53 AXIS2-5788 Unit tests for getServiceGroupContext touch semantics
     new c6058355a0 AXIS2-5862 Guard Phase.flowComplete against stale currentPhaseIndex

The 3 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 .../apache/axis2/context/ConfigurationContext.java |  49 +++--
 .../kernel/src/org/apache/axis2/engine/Phase.java  |  11 ++
 ...ConfigurationContextServiceGroupLookupTest.java | 162 +++++++++++++++++
 .../apache/axis2/engine/PhaseFlowCompleteTest.java | 199 +++++++++++++++++++++
 4 files changed, 410 insertions(+), 11 deletions(-)
 create mode 100644 modules/kernel/test/org/apache/axis2/context/ConfigurationContextServiceGroupLookupTest.java
 create mode 100644 modules/kernel/test/org/apache/axis2/engine/PhaseFlowCompleteTest.java


(axis-axis2-java-core) 02/03: AXIS2-5788 Unit tests for getServiceGroupContext touch semantics

Posted by ro...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

robertlazarski pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/axis-axis2-java-core.git

commit e4923cde53ee6978adb56d8df3a6a79872568aca
Author: Robert Lazarski <ro...@gmail.com>
AuthorDate: Mon Apr 13 08:09:05 2026 -1000

    AXIS2-5788 Unit tests for getServiceGroupContext touch semantics
    
    Covers all branches of the two overloads:
    
    - null id returns null from both overloads (no NPE, no mutation)
    - unknown id returns null from both overloads
    - legacy single-arg lookup touches lastTouchedTime (back-compat)
    - two-arg lookup with touch=true matches the legacy behaviour
    - two-arg lookup with touch=false LEAVES lastTouchedTime alone -- the
      actual AXIS2-5788 fix
    - the no-touch contract holds for both SOAP-session-scoped contexts
      (serviceGroupContextMap) and application-session-scoped contexts
      (applicationSessionServiceGroupContexts)
    - repeated no-touch lookups stay read-only (guard against a future
      refactor that re-introduces the observer effect for only the first
      call)
    
    The mutation check is done by forcing lastTouchedTime to 0L before the
    lookup and asserting it is either still 0L (no touch) or > 0L (touched)
    after -- avoids flakiness from the millisecond resolution of
    System.currentTimeMillis() that a "before vs after" comparison would
    have.
---
 ...ConfigurationContextServiceGroupLookupTest.java | 162 +++++++++++++++++++++
 1 file changed, 162 insertions(+)

diff --git a/modules/kernel/test/org/apache/axis2/context/ConfigurationContextServiceGroupLookupTest.java b/modules/kernel/test/org/apache/axis2/context/ConfigurationContextServiceGroupLookupTest.java
new file mode 100644
index 0000000000..b8a3a5dbea
--- /dev/null
+++ b/modules/kernel/test/org/apache/axis2/context/ConfigurationContextServiceGroupLookupTest.java
@@ -0,0 +1,162 @@
+/*
+ * 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.apache.axis2.context;
+
+import junit.framework.TestCase;
+
+import org.apache.axis2.description.AxisService;
+import org.apache.axis2.description.AxisServiceGroup;
+import org.apache.axis2.engine.AxisConfiguration;
+
+/**
+ * Covers the AXIS2-5788 fix: ConfigurationContext.getServiceGroupContext must
+ * no longer unconditionally touch the returned context -- callers must be
+ * able to opt out of the touch via the new
+ * {@code getServiceGroupContext(String, boolean)} overload.
+ */
+public class ConfigurationContextServiceGroupLookupTest extends TestCase {
+
+    private static final String SOAP_SESSION_ID = "soap-session-sgc";
+    private static final String APP_SESSION_SG_NAME = "app-session-sg";
+
+    private ConfigurationContext configurationContext;
+    private ServiceGroupContext soapSessionContext;
+    private ServiceGroupContext appSessionContext;
+
+    @Override
+    protected void setUp() throws Exception {
+        AxisConfiguration axisConfiguration = new AxisConfiguration();
+
+        // A SOAP-session-scoped group (ends up in serviceGroupContextMap).
+        AxisServiceGroup soapSessionGroup = new AxisServiceGroup(axisConfiguration);
+        soapSessionGroup.setServiceGroupName("soap-session-group");
+        soapSessionGroup.addService(new AxisService("soap-svc"));
+        axisConfiguration.addServiceGroup(soapSessionGroup);
+
+        // An application-session-scoped group (ends up in
+        // applicationSessionServiceGroupContexts).
+        AxisServiceGroup appSessionGroup = new AxisServiceGroup(axisConfiguration);
+        appSessionGroup.setServiceGroupName(APP_SESSION_SG_NAME);
+        appSessionGroup.addService(new AxisService("app-svc"));
+        axisConfiguration.addServiceGroup(appSessionGroup);
+
+        configurationContext = new ConfigurationContext(axisConfiguration);
+
+        soapSessionContext = new ServiceGroupContext(configurationContext, soapSessionGroup);
+        soapSessionContext.setId(SOAP_SESSION_ID);
+        configurationContext.addServiceGroupContextIntoSoapSessionTable(soapSessionContext);
+
+        appSessionContext = new ServiceGroupContext(configurationContext, appSessionGroup);
+        configurationContext.addServiceGroupContextIntoApplicationScopeTable(appSessionContext);
+    }
+
+    /** Null id must short-circuit to null without mutation or NPE. */
+    public void testNullIdReturnsNull() {
+        assertNull(configurationContext.getServiceGroupContext(null));
+        assertNull(configurationContext.getServiceGroupContext(null, true));
+        assertNull(configurationContext.getServiceGroupContext(null, false));
+    }
+
+    /** Unknown id returns null from both overloads. */
+    public void testUnknownIdReturnsNull() {
+        assertNull(configurationContext.getServiceGroupContext("no-such-id"));
+        assertNull(configurationContext.getServiceGroupContext("no-such-id", true));
+        assertNull(configurationContext.getServiceGroupContext("no-such-id", false));
+    }
+
+    /** Legacy single-arg lookup must still touch (back-compat). */
+    public void testSingleArgLookupTouchesSoapSessionContext() {
+        soapSessionContext.setLastTouchedTime(0L);
+
+        ServiceGroupContext found = configurationContext.getServiceGroupContext(SOAP_SESSION_ID);
+
+        assertSame(soapSessionContext, found);
+        assertTrue("single-arg lookup must update lastTouchedTime",
+                   found.getLastTouchedTime() > 0L);
+    }
+
+    /** Two-arg lookup with touch=true matches the legacy behaviour. */
+    public void testTwoArgLookupWithTouchTrueTouchesSoapSessionContext() {
+        soapSessionContext.setLastTouchedTime(0L);
+
+        ServiceGroupContext found =
+                configurationContext.getServiceGroupContext(SOAP_SESSION_ID, true);
+
+        assertSame(soapSessionContext, found);
+        assertTrue("touch=true must update lastTouchedTime",
+                   found.getLastTouchedTime() > 0L);
+    }
+
+    /**
+     * The AXIS2-5788 fix proper: touch=false must NOT mutate lastTouchedTime
+     * on the returned context.
+     */
+    public void testTwoArgLookupWithTouchFalseDoesNotTouchSoapSessionContext() {
+        soapSessionContext.setLastTouchedTime(0L);
+
+        ServiceGroupContext found =
+                configurationContext.getServiceGroupContext(SOAP_SESSION_ID, false);
+
+        assertSame(soapSessionContext, found);
+        assertEquals("touch=false must leave lastTouchedTime unchanged",
+                     0L, found.getLastTouchedTime());
+    }
+
+    /** The same no-touch contract must hold for application-session contexts. */
+    public void testTwoArgLookupWithTouchFalseDoesNotTouchAppSessionContext() {
+        appSessionContext.setLastTouchedTime(0L);
+
+        ServiceGroupContext found =
+                configurationContext.getServiceGroupContext(APP_SESSION_SG_NAME, false);
+
+        assertSame(appSessionContext, found);
+        assertEquals("touch=false must leave lastTouchedTime unchanged",
+                     0L, found.getLastTouchedTime());
+    }
+
+    /** And the legacy-touching contract must also hold for app-session contexts. */
+    public void testSingleArgLookupTouchesAppSessionContext() {
+        appSessionContext.setLastTouchedTime(0L);
+
+        ServiceGroupContext found =
+                configurationContext.getServiceGroupContext(APP_SESSION_SG_NAME);
+
+        assertSame(appSessionContext, found);
+        assertTrue("single-arg lookup must update lastTouchedTime for app-session SGC",
+                   found.getLastTouchedTime() > 0L);
+    }
+
+    /**
+     * Calling the no-touch variant repeatedly must continue to leave
+     * lastTouchedTime alone -- guards against a future refactor that
+     * re-introduces the observer effect for only the first call.
+     */
+    public void testRepeatedNoTouchLookupsNeverMutate() {
+        soapSessionContext.setLastTouchedTime(0L);
+
+        for (int i = 0; i < 5; i++) {
+            ServiceGroupContext found =
+                    configurationContext.getServiceGroupContext(SOAP_SESSION_ID, false);
+            assertSame(soapSessionContext, found);
+            assertEquals("iteration " + i + ": touch=false must stay read-only",
+                         0L, found.getLastTouchedTime());
+        }
+    }
+}


(axis-axis2-java-core) 01/03: AXIS2-5788 Add non-touching overload of getServiceGroupContext

Posted by ro...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

robertlazarski pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/axis-axis2-java-core.git

commit 905b30e4fd2efdc78c3dfa1e310f453e0839cc3d
Author: Robert Lazarski <ro...@gmail.com>
AuthorDate: Mon Apr 13 07:53:33 2026 -1000

    AXIS2-5788 Add non-touching overload of getServiceGroupContext
    
    ConfigurationContext.getServiceGroupContext(id) always calls
    ServiceGroupContext.touch() on a hit, which resets lastTouchedTime.
    That is an "observer effect": any caller that wants to inspect a
    context (e.g. a session-cleanup sweep evaluating staleness against
    lastTouchedTime) mutates the very field it is trying to read and
    ends up keeping the context alive forever.
    
    Add an overload that takes an explicit touchServiceGroupContext flag:
    
        ServiceGroupContext getServiceGroupContext(String id,
                                                   boolean touchServiceGroupContext)
    
    The existing single-arg method is preserved and delegates with
    touch=true, so legacy callers see no behaviour change. Callers that
    want a read-only peek pass touch=false.
    
    Also fix the stale Javadoc on getServiceGroupContextIDs() noted in
    the same ticket (it claimed to return a hashmap; it returns a
    String[]).
    
    No functional change to the lookup itself beyond collapsing the
    duplicated touch() call at the two hit sites into a single branch.
---
 .../apache/axis2/context/ConfigurationContext.java | 49 +++++++++++++++++-----
 1 file changed, 38 insertions(+), 11 deletions(-)

diff --git a/modules/kernel/src/org/apache/axis2/context/ConfigurationContext.java b/modules/kernel/src/org/apache/axis2/context/ConfigurationContext.java
index 69ebc4eb8a..1365a582f0 100644
--- a/modules/kernel/src/org/apache/axis2/context/ConfigurationContext.java
+++ b/modules/kernel/src/org/apache/axis2/context/ConfigurationContext.java
@@ -500,12 +500,36 @@ public class ConfigurationContext extends AbstractContext {
 
     /**
      * Returns a ServiceGroupContext object associated with the specified ID from the internal
-     * table.
+     * table. The returned context's {@code lastTouchedTime} is updated as a side effect of the
+     * lookup; use {@link #getServiceGroupContext(String, boolean)} with {@code touch=false}
+     * if the caller needs to inspect the context (for example to evaluate staleness against
+     * {@code lastTouchedTime}) without mutating it. See AXIS2-5788.
      *
      * @param serviceGroupCtxId The ID string associated with the ServiceGroupContext object
      * @return The ServiceGroupContext object, or null if not found
      */
     public ServiceGroupContext getServiceGroupContext(String serviceGroupCtxId) {
+        return getServiceGroupContext(serviceGroupCtxId, true);
+    }
+
+    /**
+     * Returns a ServiceGroupContext object associated with the specified ID from the internal
+     * table, optionally updating its {@code lastTouchedTime}.
+     * <p>
+     * Passing {@code touchServiceGroupContext=false} lets external code read the context
+     * without the "observer effect" described in
+     * <a href="https://issues.apache.org/jira/browse/AXIS2-5788">AXIS2-5788</a>:
+     * for example, session-cleanup code that wants to evaluate staleness against
+     * {@code lastTouchedTime} would otherwise reset the clock by the very act of looking.
+     *
+     * @param serviceGroupCtxId         The ID string associated with the ServiceGroupContext object.
+     * @param touchServiceGroupContext  {@code true} to update {@code lastTouchedTime} on a hit
+     *                                  (legacy behaviour preserved for back-compat), {@code false}
+     *                                  to leave it unchanged.
+     * @return The ServiceGroupContext object, or null if not found.
+     */
+    public ServiceGroupContext getServiceGroupContext(String serviceGroupCtxId,
+                                                      boolean touchServiceGroupContext) {
 
         if (serviceGroupCtxId == null) {
             // Hashtables require non-null key-value pairs
@@ -515,15 +539,13 @@ public class ConfigurationContext extends AbstractContext {
         ServiceGroupContext serviceGroupContext = null;
 
         if (serviceGroupContextMap != null) {
-            serviceGroupContext =serviceGroupContextMap.get(serviceGroupCtxId);
-            if (serviceGroupContext != null) {
-                serviceGroupContext.touch();
-            } else {
-                serviceGroupContext =applicationSessionServiceGroupContexts
+            serviceGroupContext = serviceGroupContextMap.get(serviceGroupCtxId);
+            if (serviceGroupContext == null) {
+                serviceGroupContext = applicationSessionServiceGroupContexts
                                 .get(serviceGroupCtxId);
-                if (serviceGroupContext != null) {
-                    serviceGroupContext.touch();
-                }
+            }
+            if (serviceGroupContext != null && touchServiceGroupContext) {
+                serviceGroupContext.touch();
             }
         }
 
@@ -532,9 +554,14 @@ public class ConfigurationContext extends AbstractContext {
     }
 
     /**
-     * Gets all service groups in the system.
+     * Returns the IDs of all service groups currently held by this
+     * {@code ConfigurationContext} (both SOAP-session and application-session scoped).
+     * <p>
+     * Fixes the stale Javadoc noted in
+     * <a href="https://issues.apache.org/jira/browse/AXIS2-5788">AXIS2-5788</a>: the return
+     * type is a {@code String[]}, not a hashmap of {@code ServiceGroupContext} instances.
      *
-     * @return Returns hashmap of ServiceGroupContexts.
+     * @return an array of service group context IDs; never {@code null}, but may be empty.
      */
     public String[] getServiceGroupContextIDs() {
         String[] ids = new String[serviceGroupContextMap.size() +


(axis-axis2-java-core) 03/03: AXIS2-5862 Guard Phase.flowComplete against stale currentPhaseIndex

Posted by ro...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

robertlazarski pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/axis-axis2-java-core.git

commit c6058355a0a31aaaad40282440fb79bfdb417aed
Author: Robert Lazarski <ro...@gmail.com>
AuthorDate: Mon Apr 13 08:48:50 2026 -1000

    AXIS2-5862 Guard Phase.flowComplete against stale currentPhaseIndex
    
    Cap the unwind index at handlers.size() at the top of the
    flowComplete() loop:
    
        if (currentHandlerIndex > handlers.size()) {
            currentHandlerIndex = handlers.size();
        }
    
    Without this guard, flowComplete() reads handlers.get(N - 1) for
    some N > handlers.size() and throws ArrayIndexOutOfBoundsException.
    That throw aborts flowComplete() for every earlier phase on the
    executed-phase chain, so a faulted request gets a partial unwind
    instead of the documented reverse-order callback sequence.
    
    The reported reproducer is the degenerate one -- an empty phase
    reached with a stale non-zero currentPhaseIndex -- but the same
    failure mode applies any time the inbound index exceeds the
    current phase's handler count.
    
    This change is strictly do-no-harm:
    
      * When the inbound index is in range (the common case), the cap
        is a no-op and behaviour is identical to today.
      * When the index is out of range, we clamp to handlers.size() so
        every real handler in the current phase still receives
        flowComplete() in reverse order, AND earlier phases on the
        chain still get their flowComplete() delivered instead of
        being skipped by a thrown AIOOBE.
    
    Deliberately NOT done:
    
      * The original ticket proposed swapping
        MessageContext.getCurrentPhaseIndex/setCurrentPhaseIndex for
        their get/setCurrentHandlerIndex counterparts. Those two
        fields have historically misleading names: currentPhaseIndex
        tracks the handler index *within the current Phase*, while
        currentHandlerIndex tracks the phase index *within the
        execution chain* (see AxisEngine.invoke()). Swapping them
        would be a regression on the common path, not a fix. The
        cap addresses the crash without touching the field semantics.
      * No investigation of *how* a stale currentPhaseIndex leaks
        across phases -- after 9 years the ticket still has no
        reproducer, so we harden the boundary rather than chase a
        ghost. If a reproducer surfaces later the root-cause fix is
        additive on top of this guard.
    
    PhaseFlowCompleteTest adds five kernel-level unit tests covering:
    
      - the happy path (reverse unwind on normal phase completion)
      - empty phase with stale index (the AXIS2-5862 symptom)
      - phase with real handlers but over-run inbound index
      - exact-boundary inbound index (== handlers.size())
      - mid-phase failure with a legitimate non-zero index
    
    All 393 kernel tests continue to pass.
---
 .../kernel/src/org/apache/axis2/engine/Phase.java  |  11 ++
 .../apache/axis2/engine/PhaseFlowCompleteTest.java | 199 +++++++++++++++++++++
 2 files changed, 210 insertions(+)

diff --git a/modules/kernel/src/org/apache/axis2/engine/Phase.java b/modules/kernel/src/org/apache/axis2/engine/Phase.java
index 8fe59fc3cf..f780dfc022 100644
--- a/modules/kernel/src/org/apache/axis2/engine/Phase.java
+++ b/modules/kernel/src/org/apache/axis2/engine/Phase.java
@@ -352,6 +352,17 @@ public class Phase implements Handler {
             msgContext.setCurrentPhaseIndex(0);
         }
 
+        // AXIS2-5862: guard against a stale currentPhaseIndex that exceeds this
+        // phase's handler count. The documented symptom is an empty phase being
+        // reached via flowComplete() with a non-zero inbound index, which makes
+        // handlers.get(currentHandlerIndex - 1) throw ArrayIndexOutOfBoundsException
+        // and silently abort flowComplete() for every earlier phase. Capping at
+        // handlers.size() turns that crash into a safe no-op / truncated unwind,
+        // and has no effect on the normal path where the index is already valid.
+        if (currentHandlerIndex > handlers.size()) {
+            currentHandlerIndex = handlers.size();
+        }
+
         for (; currentHandlerIndex > 0; currentHandlerIndex--) {
             Handler handler = (Handler) handlers.get(currentHandlerIndex - 1);
 
diff --git a/modules/kernel/test/org/apache/axis2/engine/PhaseFlowCompleteTest.java b/modules/kernel/test/org/apache/axis2/engine/PhaseFlowCompleteTest.java
new file mode 100644
index 0000000000..8bd4939d5e
--- /dev/null
+++ b/modules/kernel/test/org/apache/axis2/engine/PhaseFlowCompleteTest.java
@@ -0,0 +1,199 @@
+/*
+ * 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.apache.axis2.engine;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import junit.framework.TestCase;
+
+import org.apache.axis2.AxisFault;
+import org.apache.axis2.context.ConfigurationContext;
+import org.apache.axis2.context.MessageContext;
+import org.apache.axis2.description.HandlerDescription;
+import org.apache.axis2.handlers.AbstractHandler;
+
+/**
+ * Covers the AXIS2-5862 defensive cap in {@link Phase#flowComplete(MessageContext)}.
+ * <p>
+ * The ticket reports that {@code flowComplete} can throw
+ * {@code ArrayIndexOutOfBoundsException} when {@code MessageContext.currentPhaseIndex}
+ * holds a value that exceeds the current phase's handler count -- most visibly when
+ * the phase has zero handlers but the inbound index is non-zero. That throw aborts
+ * {@code flowComplete} and prevents earlier phases from receiving the callback.
+ * <p>
+ * The fix is a single-line cap at the top of the unwind loop: if the index exceeds
+ * {@code handlers.size()} it is clamped to {@code handlers.size()}. The tests below
+ * verify:
+ * <ul>
+ *   <li>The happy path (index within range) is unchanged and calls handlers in
+ *       reverse order.</li>
+ *   <li>An empty phase with a stale non-zero index no longer throws.</li>
+ *   <li>A non-empty phase with an over-run index completes every real handler in
+ *       reverse order and stops there (does not read past the end of the list).</li>
+ *   <li>The side effect of resetting {@code currentPhaseIndex} to 0 on entry is
+ *       preserved in the broken-index path, so earlier phases still see a clean
+ *       slate.</li>
+ * </ul>
+ */
+public class PhaseFlowCompleteTest extends TestCase {
+
+    /** Handler that records whether its {@code flowComplete} was invoked, and when. */
+    private static final class RecordingHandler extends AbstractHandler {
+        private final String name;
+        private final List<String> log;
+
+        RecordingHandler(String name, List<String> log) {
+            this.name = name;
+            this.log = log;
+            HandlerDescription desc = new HandlerDescription(name);
+            init(desc);
+        }
+
+        @Override
+        public InvocationResponse invoke(MessageContext msgContext) throws AxisFault {
+            // Not exercised by flowComplete tests.
+            return InvocationResponse.CONTINUE;
+        }
+
+        @Override
+        public void flowComplete(MessageContext msgContext) {
+            log.add(name);
+        }
+    }
+
+    private MessageContext newMessageContext() throws AxisFault {
+        // flowComplete() only reads/writes currentPhaseIndex on the MessageContext,
+        // so a bare ConfigurationContext-backed MessageContext is enough.
+        ConfigurationContext cc = new ConfigurationContext(new AxisConfiguration());
+        return cc.createMessageContext();
+    }
+
+    /** Happy path: index in range, all handlers get flowComplete in reverse order. */
+    public void testFlowCompleteNormalPathUnchanged() throws AxisFault {
+        List<String> calls = new ArrayList<String>();
+        Phase phase = new Phase("p");
+        phase.addHandler(new RecordingHandler("h0", calls));
+        phase.addHandler(new RecordingHandler("h1", calls));
+        phase.addHandler(new RecordingHandler("h2", calls));
+
+        MessageContext mc = newMessageContext();
+        mc.setCurrentPhaseIndex(0);        // phase completed normally
+
+        phase.flowComplete(mc);
+
+        // Reverse order: h2, h1, h0
+        assertEquals(3, calls.size());
+        assertEquals("h2", calls.get(0));
+        assertEquals("h1", calls.get(1));
+        assertEquals("h0", calls.get(2));
+    }
+
+    /**
+     * The documented AXIS2-5862 symptom: empty phase reached with a stale
+     * non-zero currentPhaseIndex. Must not throw; must still reset the index.
+     */
+    public void testFlowCompleteOnEmptyPhaseWithStaleHandlerIndex() throws AxisFault {
+        Phase empty = new Phase("empty");  // no handlers added
+
+        MessageContext mc = newMessageContext();
+        mc.setCurrentPhaseIndex(1);        // stale carry-over from a prior phase
+
+        // Before the fix this threw ArrayIndexOutOfBoundsException: 0.
+        empty.flowComplete(mc);
+
+        // The reset side effect must still happen so earlier phases see a
+        // clean slate.
+        assertEquals("currentPhaseIndex must be reset to 0 even on the"
+                     + " empty-phase / stale-index path",
+                     0, mc.getCurrentPhaseIndex());
+    }
+
+    /**
+     * Phase with real handlers but an inbound index that exceeds handlers.size().
+     * Every real handler must still receive flowComplete, in reverse order, and
+     * the loop must not read past the end of the list.
+     */
+    public void testFlowCompleteCapsOverrunHandlerIndex() throws AxisFault {
+        List<String> calls = new ArrayList<String>();
+        Phase phase = new Phase("p");
+        phase.addHandler(new RecordingHandler("h0", calls));
+        phase.addHandler(new RecordingHandler("h1", calls));
+
+        MessageContext mc = newMessageContext();
+        mc.setCurrentPhaseIndex(99);       // pathological over-run
+
+        // Before the fix this threw ArrayIndexOutOfBoundsException: 98.
+        phase.flowComplete(mc);
+
+        // All real handlers should still flowComplete, reverse order, exactly once.
+        assertEquals(2, calls.size());
+        assertEquals("h1", calls.get(0));
+        assertEquals("h0", calls.get(1));
+        assertEquals(0, mc.getCurrentPhaseIndex());
+    }
+
+    /**
+     * Edge case: inbound index exactly equals handlers.size(). Today that path
+     * already works; the cap must not alter it (all handlers should still be
+     * unwound in reverse order).
+     */
+    public void testFlowCompleteAtExactBoundaryUnchanged() throws AxisFault {
+        List<String> calls = new ArrayList<String>();
+        Phase phase = new Phase("p");
+        phase.addHandler(new RecordingHandler("h0", calls));
+        phase.addHandler(new RecordingHandler("h1", calls));
+
+        MessageContext mc = newMessageContext();
+        mc.setCurrentPhaseIndex(2);        // == handlers.size()
+
+        phase.flowComplete(mc);
+
+        assertEquals(2, calls.size());
+        assertEquals("h1", calls.get(0));
+        assertEquals("h0", calls.get(1));
+    }
+
+    /**
+     * Mid-phase failure: currentPhaseIndex points at the handler where the fault
+     * occurred. flowComplete should roll back just the handlers that ran (in
+     * reverse order), not handlers that never got to invoke.
+     */
+    public void testFlowCompleteMidPhaseFailurePathUnchanged() throws AxisFault {
+        List<String> calls = new ArrayList<String>();
+        Phase phase = new Phase("p");
+        phase.addHandler(new RecordingHandler("h0", calls));
+        phase.addHandler(new RecordingHandler("h1", calls));
+        phase.addHandler(new RecordingHandler("h2", calls));
+
+        MessageContext mc = newMessageContext();
+        // h0 and h1 ran, fault was raised from h2 before it set the index
+        // onward; the engine left currentPhaseIndex at 2.
+        mc.setCurrentPhaseIndex(2);
+
+        phase.flowComplete(mc);
+
+        // Only h1 then h0 should unwind; h2 never invoke()'d successfully.
+        assertEquals(2, calls.size());
+        assertEquals("h1", calls.get(0));
+        assertEquals("h0", calls.get(1));
+        assertEquals(0, mc.getCurrentPhaseIndex());
+    }
+}